@pi-stef/superpowers-adapter 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @pi-stef/superpowers-adapter
2
+
3
+ A [pi](https://pi.dev) extension that bridges the [superpowers](https://github.com/obra/superpowers) skill system to pi's extension API.
4
+
5
+ ## Why This Extension Exists
6
+
7
+ Pi ships with 4 built-in tools: `read`, `bash`, `edit`, `write`. The superpowers skill system expects additional tools that pi doesn't provide natively:
8
+
9
+ | Tool | Pi Built-in | Superpowers Needs | Provided By |
10
+ |------|-------------|-------------------|-------------|
11
+ | TodoWrite | No | Yes | This extension |
12
+ | Skill | No | Yes | This extension |
13
+ | Agent | No | Yes | `@tintinweb/pi-subagents` |
14
+
15
+ The superpowers `using-superpowers` skill explicitly requires:
16
+ 1. **"Use the `Skill` tool"** to load skill instructions
17
+ 2. **"Never use the Read tool on skill files"** — the Skill tool must be used instead
18
+
19
+ While pi natively supports skill discovery (listing them in the system prompt), superpowers workflows depend on calling the `Skill` tool directly.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ # 1. Install superpowers (official skill pack)
25
+ pi install https://github.com/obra/superpowers
26
+
27
+ # 2. Install this extension
28
+ pi install git:github.com/sfiorini/pi-stef#packages/superpowers-adapter
29
+ ```
30
+
31
+ ## Tools
32
+
33
+ ### TodoWrite
34
+
35
+ Track implementation tasks with status progression.
36
+
37
+ **Parameters:**
38
+ - `todos` (array, required) — Array of todo items, each with:
39
+ - `id` (string) — Unique identifier
40
+ - `content` (string) — Task description
41
+ - `status` (string) — One of: `pending`, `in_progress`, `completed`
42
+ - `priority` (string, optional) — One of: `high`, `medium`, `low`
43
+
44
+ **Example:**
45
+ ```
46
+ TodoWrite({
47
+ todos: [
48
+ { id: "1", content: "Design API", status: "completed" },
49
+ { id: "2", content: "Implement", status: "in_progress", priority: "high" },
50
+ { id: "3", content: "Write tests", status: "pending" }
51
+ ]
52
+ })
53
+ ```
54
+
55
+ ### Skill
56
+
57
+ Load skill instructions by name. Discovers skills from standard pi skill directories.
58
+
59
+ **Parameters:**
60
+ - `skill` (string, required) — Skill name (e.g., `brainstorming`, `test-driven-development`)
61
+
62
+ **Discovery paths** (searched in order):
63
+ - `<cwd>/.pi/skills/`
64
+ - `<cwd>/.agents/skills/`
65
+ - `~/.pi/agent/skills/`
66
+ - `~/.agents/skills/`
67
+ - Recursively under `~/.pi/agent/git/` (depth 10)
68
+
69
+ **Limitation:** The YAML frontmatter parser handles simple `key: value` pairs only. Nested values, multi-line values, and quoted strings with complex escaping are not supported.
70
+
71
+ ## Commands
72
+
73
+ | Command | Description |
74
+ |---------|-------------|
75
+ | `/todos` | Display current todo list with progress |
76
+ | `/todo-clear` | Reset all todos |
77
+
78
+ ## Architecture
79
+
80
+ ```
81
+ src/
82
+ types.ts — Shared type definitions
83
+ tools/
84
+ todo-write.ts — TodoWrite tool + state management
85
+ skill.ts — Skill discovery, parsing, loading
86
+ commands.ts — /todos and /todo-clear
87
+ index.ts — Extension entry point + lifecycle hooks
88
+ ```
89
+
90
+ The extension auto-injects the `using-superpowers` skill content into the system prompt via the `before_agent_start` lifecycle hook. This ensures the LLM receives superpowers instructions without manual configuration.
91
+
92
+ ## Troubleshooting
93
+
94
+ **"using-superpowers skill not found"**
95
+ → Install superpowers: `pi install https://github.com/obra/superpowers`
96
+
97
+ **Skills not discovered**
98
+ → Check that skill directories contain `SKILL.md` files with valid YAML frontmatter.
99
+
100
+ ## Security
101
+
102
+ This extension has read-only filesystem access. It reads `SKILL.md` files from standard pi directories. No network calls, no process execution, no file writes.
103
+
104
+ ## License
105
+
106
+ MIT
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@pi-stef/superpowers-adapter",
3
+ "version": "0.2.2",
4
+ "description": "Bridges the superpowers skill system to pi's extension API with TodoWrite, Task, and Skill tools.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "superpowers"
9
+ ],
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "main": "./src/index.ts",
13
+ "files": [
14
+ "src/",
15
+ "README.md"
16
+ ],
17
+ "pi": {
18
+ "extensions": [
19
+ "./src/index.ts"
20
+ ]
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-ai": "*",
24
+ "@earendil-works/pi-coding-agent": "*",
25
+ "@earendil-works/pi-tui": "*",
26
+ "@sinclair/typebox": "*"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^6.0.3"
30
+ },
31
+ "scripts": {
32
+ "test": "vitest run",
33
+ "typecheck": "tsc --noEmit -p tsconfig.json"
34
+ }
35
+ }
@@ -0,0 +1,19 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { formatTodos, clearTodos } from "./tools/todo-write.js";
3
+
4
+ export function registerCommands(pi: ExtensionAPI): void {
5
+ pi.registerCommand("todos", {
6
+ description: "Show current todo list",
7
+ handler: async (_args: string, ctx: { ui: { notify: (msg: string, level: "info" | "warning" | "error") => void } }) => {
8
+ ctx.ui.notify(formatTodos(), "info");
9
+ },
10
+ });
11
+
12
+ pi.registerCommand("todo-clear", {
13
+ description: "Clear all todos",
14
+ handler: async (_args: string, ctx: { ui: { notify: (msg: string, level: "info" | "warning" | "error") => void } }) => {
15
+ clearTodos();
16
+ ctx.ui.notify("All todos cleared.", "info");
17
+ },
18
+ });
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { ExtensionAPI, BeforeAgentStartEvent, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { registerTodoWriteTool, clearTodos } from "./tools/todo-write.js";
3
+ import { registerSkillTool, resetSkillCache, readSkillContent, discoverSkills } from "./tools/skill.js";
4
+ import { registerCommands } from "./commands.js";
5
+
6
+ const USING_SUPERPOWERS_SKILL = "using-superpowers";
7
+
8
+ export default function (pi: ExtensionAPI): void {
9
+ registerTodoWriteTool(pi);
10
+ registerSkillTool(pi);
11
+ registerCommands(pi);
12
+
13
+ pi.on("session_start", async () => {
14
+ clearTodos();
15
+ resetSkillCache();
16
+ });
17
+
18
+ pi.on("resources_discover", async () => {
19
+ resetSkillCache();
20
+ });
21
+
22
+ pi.on(
23
+ "before_agent_start",
24
+ async (
25
+ event: BeforeAgentStartEvent,
26
+ ctx: ExtensionContext,
27
+ ) => {
28
+ const skills = discoverSkills(ctx.cwd);
29
+ const skill = skills.get(USING_SUPERPOWERS_SKILL);
30
+
31
+ if (!skill) {
32
+ if (ctx.hasUI && ctx.ui) {
33
+ ctx.ui.notify(
34
+ `[pi-superpowers-adapter] using-superpowers skill not found. Install superpowers: pi install https://github.com/obra/superpowers`,
35
+ "warning",
36
+ );
37
+ }
38
+ return;
39
+ }
40
+
41
+ const skillContent = readSkillContent(skill.path);
42
+ if (!skillContent || skillContent.startsWith("[pi-superpowers-adapter]")) {
43
+ if (ctx.hasUI && ctx.ui) {
44
+ ctx.ui.notify(
45
+ skillContent ?? `[pi-superpowers-adapter] Failed to read using-superpowers skill from ${skill.path}`,
46
+ "error",
47
+ );
48
+ }
49
+ return;
50
+ }
51
+
52
+ return {
53
+ systemPrompt: event.systemPrompt + "\n" + skillContent + "\n",
54
+ };
55
+ },
56
+ );
57
+ }
@@ -0,0 +1,275 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, basename } from "node:path";
4
+ import { Type, type Static } from "@sinclair/typebox";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { Theme } from "@earendil-works/pi-coding-agent";
7
+ import { Box, Text } from "@earendil-works/pi-tui";
8
+ import type { SkillMeta } from "../types.js";
9
+
10
+ const SkillSchema = Type.Object({
11
+ skill: Type.String({
12
+ description:
13
+ "Name of the skill to load (e.g., 'brainstorming', 'test-driven-development')",
14
+ }),
15
+ });
16
+
17
+ type SkillInput = Static<typeof SkillSchema>;
18
+
19
+ let skillCache: Map<string, SkillMeta> | null = null;
20
+
21
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n?---\n([\s\S]*)$/;
22
+
23
+ export function resetSkillCache(): void {
24
+ skillCache = null;
25
+ }
26
+
27
+ export function parseSkillFrontmatter(
28
+ content: string,
29
+ path: string,
30
+ ): SkillMeta | null {
31
+ const match = content.match(FRONTMATTER_RE);
32
+ if (!match) return null;
33
+
34
+ const frontmatter = match[1];
35
+ const skillDir = path.replace(/[\\/]?SKILL\.md$/, "");
36
+ const meta: SkillMeta = { name: basename(skillDir), path };
37
+
38
+ for (const line of frontmatter.split("\n")) {
39
+ const colonIdx = line.indexOf(":");
40
+ if (colonIdx > 0) {
41
+ const key = line.slice(0, colonIdx).trim();
42
+ const value = line
43
+ .slice(colonIdx + 1)
44
+ .trim()
45
+ .replace(/^["']|["']$/g, "");
46
+ if (key === "name") meta.name = value;
47
+ if (key === "description") meta.description = value;
48
+ }
49
+ }
50
+
51
+ return meta;
52
+ }
53
+
54
+ export function extractSkillContent(content: string): string {
55
+ const match = content.match(FRONTMATTER_RE);
56
+ return match ? match[2].trim() : content;
57
+ }
58
+
59
+ export function readSkillContent(skillPath: string): string | null {
60
+ try {
61
+ const content = readFileSync(skillPath, "utf-8");
62
+ return extractSkillContent(content);
63
+ } catch (err) {
64
+ return `[pi-superpowers-adapter] Failed to read skill file "${skillPath}": ${err instanceof Error ? err.message : String(err)}`;
65
+ }
66
+ }
67
+
68
+ function findSkillsDirs(
69
+ basePath: string,
70
+ results: string[],
71
+ depth = 0,
72
+ ): void {
73
+ const MAX_DEPTH = 10;
74
+ const MAX_BREADTH = 200;
75
+ if (depth > MAX_DEPTH) return;
76
+ let entries;
77
+ try {
78
+ entries = readdirSync(basePath, { withFileTypes: true });
79
+ } catch (err) {
80
+ throw new Error(
81
+ `[pi-superpowers-adapter] Failed to read directory "${basePath}": ${err instanceof Error ? err.message : String(err)}`,
82
+ );
83
+ }
84
+ let visited = 0;
85
+ for (const entry of entries) {
86
+ if (!entry.isDirectory()) continue;
87
+ if (++visited > MAX_BREADTH) break;
88
+ const fullPath = join(basePath, entry.name);
89
+ if (entry.name === "skills") {
90
+ results.push(fullPath);
91
+ } else {
92
+ findSkillsDirs(fullPath, results, depth + 1);
93
+ }
94
+ }
95
+ }
96
+
97
+ export function discoverSkills(cwd: string): Map<string, SkillMeta> {
98
+ if (skillCache) return skillCache;
99
+
100
+ const skills = new Map<string, SkillMeta>();
101
+ const home = homedir();
102
+
103
+ const skillPaths = [
104
+ join(cwd, ".pi", "skills"),
105
+ join(cwd, ".agents", "skills"),
106
+ join(home, ".pi", "agent", "skills"),
107
+ join(home, ".agents", "skills"),
108
+ ];
109
+
110
+ const gitPackagesDir = join(home, ".pi", "agent", "git");
111
+ if (existsSync(gitPackagesDir)) {
112
+ findSkillsDirs(gitPackagesDir, skillPaths);
113
+ }
114
+
115
+ for (const basePath of skillPaths) {
116
+ if (!existsSync(basePath)) continue;
117
+ try {
118
+ const entries = readdirSync(basePath, { withFileTypes: true });
119
+ for (const entry of entries) {
120
+ const entryPath = join(basePath, entry.name);
121
+ // Check if this entry is a skill directory (has SKILL.md) or a container (has subdirs)
122
+ const skillFile = join(entryPath, "SKILL.md");
123
+ if (existsSync(skillFile)) {
124
+ // Entry is a skill directory
125
+ try {
126
+ const content = readFileSync(skillFile, "utf-8");
127
+ const meta = parseSkillFrontmatter(content, skillFile);
128
+ if (meta?.name && !skills.has(meta.name)) {
129
+ skills.set(meta.name, meta);
130
+ }
131
+ } catch {
132
+ // Skip unreadable skill files
133
+ }
134
+ } else {
135
+ // Entry might be a container directory of skills (e.g., a symlink to a skills collection)
136
+ // Try to discover skills one level deeper
137
+ try {
138
+ const subEntries = readdirSync(entryPath, { withFileTypes: true });
139
+ for (const subEntry of subEntries) {
140
+ const subSkillFile = join(entryPath, subEntry.name, "SKILL.md");
141
+ if (!existsSync(subSkillFile)) continue;
142
+ try {
143
+ const content = readFileSync(subSkillFile, "utf-8");
144
+ const meta = parseSkillFrontmatter(content, subSkillFile);
145
+ if (meta?.name && !skills.has(meta.name)) {
146
+ skills.set(meta.name, meta);
147
+ }
148
+ } catch {
149
+ // Skip unreadable skill files
150
+ }
151
+ }
152
+ } catch {
153
+ // Not a directory or unreadable, skip
154
+ }
155
+ }
156
+ }
157
+ } catch {
158
+ // Skip unreadable directories
159
+ }
160
+ }
161
+
162
+ skillCache = skills;
163
+ return skills;
164
+ }
165
+
166
+ export function registerSkillTool(pi: ExtensionAPI): void {
167
+ pi.registerTool({
168
+ name: "Skill",
169
+ label: "Skill",
170
+ description:
171
+ "Load and invoke a skill by name. Skills provide specialized instructions for specific tasks like TDD, debugging, or brainstorming. IMPORTANT: Use this tool instead of read for skill files.",
172
+ promptSnippet: "Load specialized skill instructions for specific workflows",
173
+ promptGuidelines: [
174
+ "Use Skill tool to load skill instructions before starting a task that matches the skill's description.",
175
+ "Common skills: brainstorming, test-driven-development, systematic-debugging, writing-plans.",
176
+ "IMPORTANT: Always use Skill tool to load skills, never use read tool on skill files.",
177
+ ],
178
+ parameters: SkillSchema,
179
+ async execute(
180
+ _toolCallId: string,
181
+ params: SkillInput,
182
+ _signal: unknown,
183
+ _onUpdate: unknown,
184
+ ctx: { cwd: string },
185
+ ) {
186
+ const skills = discoverSkills(ctx.cwd);
187
+ const skill = skills.get(params.skill);
188
+
189
+ if (!skill) {
190
+ const availableSkills = Array.from(skills.keys()).sort();
191
+ return {
192
+ content: [
193
+ {
194
+ type: "text" as const,
195
+ text: `Skill "${params.skill}" not found.\n\nAvailable skills:\n${availableSkills.map((s) => ` - ${s}`).join("\n")}\n\nInstall superpowers: pi install https://github.com/obra/superpowers`,
196
+ },
197
+ ],
198
+ isError: true,
199
+ details: { requestedSkill: params.skill, availableSkills },
200
+ };
201
+ }
202
+
203
+ const skillContent = readSkillContent(skill.path);
204
+ if (!skillContent || skillContent.startsWith("[pi-superpowers-adapter]")) {
205
+ return {
206
+ content: [
207
+ {
208
+ type: "text" as const,
209
+ text: skillContent ?? `[pi-superpowers-adapter] Error loading skill "${params.skill}": Failed to read skill file`,
210
+ },
211
+ ],
212
+ isError: true,
213
+ details: { error: skillContent ?? "Failed to read skill file", skillPath: skill.path },
214
+ };
215
+ }
216
+
217
+ return {
218
+ content: [
219
+ {
220
+ type: "text" as const,
221
+ text: `Loaded skill: ${skill.name}\n${skill.description ? `\nDescription: ${skill.description}\n` : ""}\n\n${skillContent}`,
222
+ },
223
+ ],
224
+ details: {
225
+ skillName: skill.name,
226
+ skillPath: skill.path,
227
+ skillDescription: skill.description,
228
+ totalLines: skillContent.split("\n").length,
229
+ },
230
+ };
231
+ },
232
+ renderResult(
233
+ result: { content: { type: string; text?: string }[]; details?: Record<string, unknown> },
234
+ _options: unknown,
235
+ theme: Theme,
236
+ context: { isError?: boolean },
237
+ ) {
238
+ if (context.isError) {
239
+ const errorMsg =
240
+ result.content[0]?.type === "text" && result.content[0].text
241
+ ? result.content[0].text
242
+ : "Failed to load skill.";
243
+ return new Text(theme.fg("error", errorMsg), 0, 0);
244
+ }
245
+
246
+ const details = result.details as
247
+ | { skillName?: string; totalLines?: number }
248
+ | undefined;
249
+
250
+ if (!details?.skillName || !details?.totalLines) {
251
+ return new Text(
252
+ theme.fg("warning", "Skill loaded, but metadata is missing."),
253
+ 0,
254
+ 0,
255
+ );
256
+ }
257
+
258
+ const label = theme.fg(
259
+ "customMessageLabel",
260
+ "\x1b[1m[skill]\x1b[22m",
261
+ );
262
+ const name = theme.fg("customMessageText", details.skillName);
263
+ const lines = theme.fg("dim", ` (${details.totalLines} lines)`);
264
+ const line = `${label} ${name}${lines}`;
265
+
266
+ const box = new Box(
267
+ 1,
268
+ 0,
269
+ (t: string) => theme.bg("customMessageBg", t),
270
+ );
271
+ box.addChild(new Text(line, 0, 0));
272
+ return box;
273
+ },
274
+ });
275
+ }
@@ -0,0 +1,94 @@
1
+ import { Type, type Static } from "@sinclair/typebox";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { TodoItem, TodoStatus } from "../types.js";
4
+
5
+ const TodoWriteSchema = Type.Object({
6
+ todos: Type.Array(
7
+ Type.Object({
8
+ id: Type.String({ description: "Unique identifier for the todo item" }),
9
+ content: Type.String({ description: "The content/description of the todo item" }),
10
+ status: Type.Union(
11
+ [
12
+ Type.Literal("pending"),
13
+ Type.Literal("in_progress"),
14
+ Type.Literal("completed"),
15
+ ],
16
+ { description: "Status of the todo item" },
17
+ ),
18
+ priority: Type.Optional(
19
+ Type.Union(
20
+ [
21
+ Type.Literal("high"),
22
+ Type.Literal("medium"),
23
+ Type.Literal("low"),
24
+ ],
25
+ { description: "Priority level (optional)" },
26
+ ),
27
+ ),
28
+ }),
29
+ ),
30
+ });
31
+
32
+ type TodoWriteInput = Static<typeof TodoWriteSchema>;
33
+
34
+ let todos: TodoItem[] = [];
35
+
36
+ export function clearTodos(): void {
37
+ todos = [];
38
+ }
39
+
40
+ export function getTodos(): readonly TodoItem[] {
41
+ return todos;
42
+ }
43
+
44
+ const statusIcon = (s: TodoStatus): string => {
45
+ switch (s) {
46
+ case "completed":
47
+ return "✅";
48
+ case "in_progress":
49
+ return "🔄";
50
+ case "pending":
51
+ return "⭕";
52
+ }
53
+ };
54
+
55
+ const priorityLabel = (p?: "high" | "medium" | "low"): string =>
56
+ p ? `[${p.toUpperCase()}] ` : "";
57
+
58
+ export function formatTodos(): string {
59
+ if (todos.length === 0) return "No todos. Use TodoWrite to create tasks.";
60
+ const idWidth = todos.length >= 10 ? 2 : 1;
61
+ const lines = todos.map(
62
+ (t, i) =>
63
+ `${String(i + 1).padStart(idWidth)}. ${statusIcon(t.status)} ${priorityLabel(t.priority)}${t.content}`,
64
+ );
65
+ const completed = todos.filter((t) => t.status === "completed").length;
66
+ return `Todos (${completed}/${todos.length} completed):\n${lines.join("\n")}`;
67
+ }
68
+
69
+ export function registerTodoWriteTool(pi: ExtensionAPI): void {
70
+ pi.registerTool({
71
+ name: "TodoWrite",
72
+ label: "TodoWrite",
73
+ description:
74
+ "Create, update, or replace the todo list for tracking task progress. Use this to track implementation tasks from plans.",
75
+ promptSnippet: "Track tasks with status (pending, in_progress, completed)",
76
+ promptGuidelines: [
77
+ "Use TodoWrite when starting a multi-step task to track progress.",
78
+ "Update todo status as you work through tasks: mark in_progress when starting, completed when done.",
79
+ ],
80
+ parameters: TodoWriteSchema,
81
+ async execute(_toolCallId: string, params: TodoWriteInput) {
82
+ todos = params.todos.map((t) => ({
83
+ id: t.id,
84
+ content: t.content,
85
+ status: t.status,
86
+ priority: t.priority,
87
+ }));
88
+ return {
89
+ content: [{ type: "text" as const, text: formatTodos() }],
90
+ details: { todoCount: todos.length },
91
+ };
92
+ },
93
+ });
94
+ }
package/src/types.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type TodoStatus = "pending" | "in_progress" | "completed";
2
+
3
+ export interface TodoItem {
4
+ id: string;
5
+ content: string;
6
+ status: TodoStatus;
7
+ priority?: "high" | "medium" | "low";
8
+ }
9
+
10
+ export interface SkillMeta {
11
+ name: string;
12
+ description?: string;
13
+ path: string;
14
+ }