diff --git a/src/resources/skills/create-gsd-extension/SKILL.md b/src/resources/skills/create-gsd-extension/SKILL.md new file mode 100644 index 000000000..e233c0229 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/SKILL.md @@ -0,0 +1,87 @@ +--- +name: create-gsd-extension +description: Create, debug, and iterate on GSD extensions (TypeScript modules that add tools, commands, event hooks, custom UI, and providers to GSD). Use when asked to build an extension, add a tool the LLM can call, register a slash command, hook into GSD events, create custom TUI components, or modify GSD behavior. Triggers on "create extension", "build extension", "add a tool", "register command", "hook into gsd", "custom tool", "gsd plugin", "gsd extension". +--- + + + +**Extensions are TypeScript modules** that hook into GSD's runtime (built on pi). They export a default function receiving `ExtensionAPI` and use it to subscribe to events, register tools/commands/shortcuts, and interact with the session. + +**GSD extension paths:** +- Global extensions: `~/.gsd/agent/extensions/*.ts` or `~/.gsd/agent/extensions/*/index.ts` +- Project-local extensions: `.gsd/extensions/*.ts` or `.gsd/extensions/*/index.ts` + +**The three primitives:** +1. **Events** — Listen and react (`pi.on("event", handler)`). Can block tool calls, modify messages, inject context. +2. **Tools** — Give the LLM new abilities (`pi.registerTool()`). LLM calls them autonomously. +3. **Commands** — Give users slash commands (`pi.registerCommand()`). Users type `/mycommand`. + +**Non-negotiable rules:** +- Use `StringEnum` from `@mariozechner/pi-ai` for string enum params (NOT `Type.Union`/`Type.Literal` — breaks Google's API) +- Truncate tool output to 50KB / 2000 lines max (use `truncateHead`/`truncateTail` from `@mariozechner/pi-coding-agent`) +- Store stateful tool state in `details` for branching support +- Check `signal?.aborted` in long-running tool executions +- Use `pi.exec()` not `child_process` for shell commands +- Check `ctx.hasUI` before dialog methods (non-interactive modes exist) +- Session control methods (`waitForIdle`, `newSession`, `fork`, `navigateTree`, `reload`) are ONLY available in command handlers — they deadlock in event handlers +- Lines from `render()` must not exceed `width` — use `truncateToWidth()` +- Use theme from callback params, never import directly +- Strip leading `@` from path params in custom tools (some models add it) + +**Available imports:** + +| Package | Purpose | +|---------|---------| +| `@mariozechner/pi-coding-agent` | `ExtensionAPI`, `ExtensionContext`, `Theme`, event types, tool utilities, `DynamicBorder`, `BorderedLoader`, `CustomEditor`, `highlightCode` | +| `@sinclair/typebox` | `Type.Object`, `Type.String`, `Type.Number`, `Type.Optional`, `Type.Boolean`, `Type.Array` | +| `@mariozechner/pi-ai` | `StringEnum` (required for string enums), `Type` re-export | +| `@mariozechner/pi-tui` | `Text`, `Box`, `Container`, `Spacer`, `Markdown`, `SelectList`, `Input`, `matchesKey`, `Key`, `truncateToWidth`, `visibleWidth` | +| Node.js built-ins | `node:fs`, `node:path`, `node:child_process`, etc. | + + + + +Based on user intent, route to the appropriate workflow: + +**Building a new extension:** +- "Create an extension", "build a tool", "I want to add a command" → `workflows/create-extension.md` + +**Adding capabilities to an existing extension:** +- "Add a tool to my extension", "add event hook", "add custom rendering" → `workflows/add-capability.md` + +**Debugging an extension:** +- "My extension doesn't work", "tool not showing up", "event not firing" → `workflows/debug-extension.md` + +**If user intent is clear from context, skip the question and go directly to the workflow.** + + + +All domain knowledge in `references/`: + +**Core architecture:** extension-lifecycle.md, events-reference.md +**API surface:** extensionapi-reference.md, extensioncontext-reference.md +**Capabilities:** custom-tools.md, custom-commands.md, custom-ui.md, custom-rendering.md +**Patterns:** state-management.md, system-prompt-modification.md, compaction-session-control.md +**Infrastructure:** model-provider-management.md, remote-execution-overrides.md, packaging-distribution.md, mode-behavior.md +**Gotchas:** key-rules-gotchas.md + + + +| Workflow | Purpose | +|----------|---------| +| create-extension.md | Build a new extension from scratch | +| add-capability.md | Add tools, commands, hooks, UI to an existing extension | +| debug-extension.md | Diagnose and fix extension issues | + + + +Extension is complete when: +- TypeScript compiles without errors (jiti handles this at runtime) +- Extension loads on GSD startup or `/reload` without errors +- Tools appear in the LLM's system prompt and are callable +- Commands respond to `/command` input +- Event hooks fire at the expected lifecycle points +- Custom UI renders correctly within terminal width +- State persists correctly across session restarts (if stateful) +- Output is truncated to safe limits (if tools produce variable output) + diff --git a/src/resources/skills/create-gsd-extension/references/compaction-session-control.md b/src/resources/skills/create-gsd-extension/references/compaction-session-control.md new file mode 100644 index 000000000..9826955fb --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/compaction-session-control.md @@ -0,0 +1,77 @@ + +Custom compaction hooks, triggering compaction, and session control methods available only in command handlers. + + + +Override default compaction behavior: + +```typescript +pi.on("session_before_compact", async (event, ctx) => { + const { preparation, branchEntries, customInstructions, signal } = event; + + // Option 1: Cancel + return { cancel: true }; + + // Option 2: Custom summary + return { + compaction: { + summary: "Custom summary of conversation so far...", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + } + }; +}); +``` + + + +Trigger compaction programmatically from any handler: + +```typescript +ctx.compact({ + customInstructions: "Focus on the authentication changes", + onComplete: (result) => ctx.ui.notify("Compacted!", "info"), + onError: (error) => ctx.ui.notify(`Failed: ${error.message}`, "error"), +}); +``` + + + +**Only available in command handlers** (deadlocks in event handlers): + +```typescript +pi.registerCommand("handoff", { + handler: async (args, ctx) => { + await ctx.waitForIdle(); + + // Create new session with initial context + const result = await ctx.newSession({ + parentSession: ctx.sessionManager.getSessionFile(), + setup: async (sm) => { + sm.appendMessage({ + role: "user", + content: [{ type: "text", text: `Context: ${args}` }], + timestamp: Date.now(), + }); + }, + }); + + if (result.cancelled) { /* extension cancelled via session_before_switch */ } + }, +}); +``` + +| Method | Purpose | +|--------|---------| +| `ctx.waitForIdle()` | Wait for agent to finish streaming | +| `ctx.newSession(options?)` | Create a new session | +| `ctx.fork(entryId)` | Fork from a specific entry | +| `ctx.navigateTree(targetId, options?)` | Navigate session tree (with optional summary) | +| `ctx.reload()` | Hot-reload everything (treat as terminal — code after runs pre-reload version) | + +`navigateTree` options: +- `summarize: boolean` — generate summary of abandoned branch +- `customInstructions: string` — instructions for summarizer +- `replaceInstructions: boolean` — replace default prompt entirely +- `label: string` — label to attach to branch summary + diff --git a/src/resources/skills/create-gsd-extension/references/custom-commands.md b/src/resources/skills/create-gsd-extension/references/custom-commands.md new file mode 100644 index 000000000..43a6c0676 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/custom-commands.md @@ -0,0 +1,139 @@ + +Custom slash commands — registration, argument completions, subcommand patterns, and the extended command context. + + + +```typescript +pi.registerCommand("deploy", { + description: "Deploy to an environment", + handler: async (args, ctx) => { + // args = everything after "/deploy " + // ctx = ExtensionCommandContext (has session control methods) + ctx.ui.notify(`Deploying to ${args || "production"}`, "info"); + }, +}); +``` + + + +Add tab-completion for command arguments: + +```typescript +import type { AutocompleteItem } from "@mariozechner/pi-tui"; + +pi.registerCommand("deploy", { + description: "Deploy to an environment", + getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { + const envs = ["dev", "staging", "prod"]; + const items = envs.map(e => ({ value: e, label: e })); + const filtered = items.filter(i => i.value.startsWith(prefix)); + return filtered.length > 0 ? filtered : null; + }, + handler: async (args, ctx) => { + ctx.ui.notify(`Deploying to ${args}`, "info"); + }, +}); +``` + + + +Fake nested commands via first-argument parsing. Used by `/wt new|ls|switch|merge|rm`. + +```typescript +pi.registerCommand("foo", { + description: "Manage foo items: /foo new|list|delete [name]", + + getArgumentCompletions: (prefix: string) => { + const parts = prefix.trim().split(/\s+/); + + // First arg: subcommand + if (parts.length <= 1) { + return ["new", "list", "delete"] + .filter(cmd => cmd.startsWith(parts[0] ?? "")) + .map(cmd => ({ value: cmd, label: cmd })); + } + + // Second arg: depends on subcommand + if (parts[0] === "delete") { + const items = getItemsSomehow(); + return items + .filter(name => name.startsWith(parts[1] ?? "")) + .map(name => ({ value: `delete ${name}`, label: name })); + } + + return []; + }, + + handler: async (args, ctx) => { + const parts = args.trim().split(/\s+/); + const sub = parts[0]; + + switch (sub) { + case "new": /* ... */ return; + case "list": /* ... */ return; + case "delete": /* handle parts[1] */ return; + default: + ctx.ui.notify("Usage: /foo [name]", "info"); + } + }, +}); +``` + +**Gotcha:** `"".trim().split(/\s+/)` produces `['']`, not `[]`. That's why `parts.length <= 1` handles both empty and partial first arg. + + + +Command handlers get `ExtensionCommandContext` which extends `ExtensionContext` with session control methods: + +| Method | Purpose | +|--------|---------| +| `ctx.waitForIdle()` | Wait for agent to finish streaming | +| `ctx.newSession(options?)` | Create a new session | +| `ctx.fork(entryId)` | Fork from an entry | +| `ctx.navigateTree(targetId, options?)` | Navigate session tree | +| `ctx.reload()` | Hot-reload everything | + +**⚠️ These methods are ONLY available in command handlers.** Calling them from event handlers causes deadlocks. + +```typescript +pi.registerCommand("handoff", { + handler: async (args, ctx) => { + await ctx.waitForIdle(); + await ctx.newSession({ + setup: async (sm) => { + sm.appendMessage({ + role: "user", + content: [{ type: "text", text: `Context: ${args}` }], + timestamp: Date.now(), + }); + }, + }); + }, +}); +``` + + + +Expose reload as both a command and a tool the LLM can call: + +```typescript +pi.registerCommand("reload-runtime", { + description: "Reload extensions, skills, prompts, and themes", + handler: async (_args, ctx) => { + await ctx.reload(); + return; // Treat reload as terminal + }, +}); + +pi.registerTool({ + name: "reload_runtime", + label: "Reload Runtime", + description: "Reload extensions, skills, prompts, and themes", + parameters: Type.Object({}), + async execute() { + pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" }); + return { content: [{ type: "text", text: "Queued /reload-runtime as follow-up." }] }; + }, +}); +``` + diff --git a/src/resources/skills/create-gsd-extension/references/custom-rendering.md b/src/resources/skills/create-gsd-extension/references/custom-rendering.md new file mode 100644 index 000000000..8572af6d2 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/custom-rendering.md @@ -0,0 +1,108 @@ + +Custom rendering for tools and messages — control how they appear in the TUI. + + + +Tools can provide `renderCall` (how the call looks) and `renderResult` (how the result looks): + +```typescript +import { Text } from "@mariozechner/pi-tui"; +import { keyHint } from "@mariozechner/pi-coding-agent"; + +pi.registerTool({ + name: "my_tool", + // ... + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("my_tool ")); + text += theme.fg("muted", args.action); + if (args.text) text += " " + theme.fg("dim", `"${args.text}"`); + return new Text(text, 0, 0); // 0,0 padding — Box handles it + }, + + renderResult(result, { expanded, isPartial }, theme) { + // isPartial = true during streaming (onUpdate was called) + if (isPartial) { + return new Text(theme.fg("warning", "Processing..."), 0, 0); + } + + // expanded = user toggled expand (Ctrl+O) + if (result.details?.error) { + return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0); + } + + let text = theme.fg("success", "✓ Done"); + if (!expanded) { + text += ` (${keyHint("expandTools", "to expand")})`; + } + if (expanded && result.details?.items) { + for (const item of result.details.items) { + text += "\n " + theme.fg("dim", item); + } + } + return new Text(text, 0, 0); + }, +}); +``` + +If you omit `renderCall`/`renderResult`, the built-in renderer is used. Useful for tool overrides where you just wrap logic without reimplementing UI. + +**Fallback:** If render methods throw, `renderCall` shows tool name, `renderResult` shows raw `content` text. + + + +Key hint helpers for showing keybinding info in render output: + +```typescript +import { keyHint, appKeyHint, editorKey, rawKeyHint } from "@mariozechner/pi-coding-agent"; + +// Editor action hint (respects user keybinding config) +keyHint("expandTools", "to expand") // e.g., "Ctrl+O to expand" +keyHint("selectConfirm", "to select") + +// Raw key hint (always shows literal key) +rawKeyHint("Ctrl+O", "to expand") +``` + + + +Register a renderer for custom message types: + +```typescript +import { Text } from "@mariozechner/pi-tui"; + +pi.registerMessageRenderer("my-extension", (message, options, theme) => { + const { expanded } = options; + let text = theme.fg("accent", `[${message.customType}] `) + message.content; + if (expanded && message.details) { + text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2)); + } + return new Text(text, 0, 0); +}); + +// Send messages that use this renderer: +pi.sendMessage({ + customType: "my-extension", // Matches renderer name + content: "Status update", + display: true, + details: { foo: "bar" }, +}); +``` + + + +```typescript +import { highlightCode, getLanguageFromPath } from "@mariozechner/pi-coding-agent"; + +const lang = getLanguageFromPath("/path/to/file.rs"); // "rust" +const highlighted = highlightCode(code, lang, theme); +``` + + + +- Return `Text` with padding `(0, 0)` — the wrapping `Box` handles padding +- Support `expanded` for detail on demand +- Handle `isPartial` for streaming progress +- Keep collapsed view compact +- Use `\n` for multi-line content within a single `Text` + diff --git a/src/resources/skills/create-gsd-extension/references/custom-tools.md b/src/resources/skills/create-gsd-extension/references/custom-tools.md new file mode 100644 index 000000000..acc1b4361 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/custom-tools.md @@ -0,0 +1,183 @@ + +Complete custom tools reference — registration, parameters, execution, output truncation, overrides, rendering, and dynamic registration. + + + +```typescript +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; + +pi.registerTool({ + name: "my_tool", // Unique identifier (snake_case) + label: "My Tool", // Display name in TUI + description: "What this does", // Full description shown to LLM + + // Optional: one-liner for system prompt "Available tools" section + promptSnippet: "Manage project todo items", + + // Optional: bullets added to system prompt "Guidelines" when tool is active + promptGuidelines: [ + "Use my_tool for task management instead of file edits." + ], + + // Parameter schema (MUST use TypeBox) + parameters: Type.Object({ + action: StringEnum(["list", "add", "remove"] as const), + text: Type.Optional(Type.String({ description: "Item text" })), + id: Type.Optional(Type.Number({ description: "Item ID" })), + }), + + async execute(toolCallId, params, signal, onUpdate, ctx) { + // 1. Check cancellation + if (signal?.aborted) { + return { content: [{ type: "text", text: "Cancelled" }] }; + } + + // 2. Stream progress (optional) + onUpdate?.({ + content: [{ type: "text", text: "Working..." }], + details: { progress: 50 }, + }); + + // 3. Do the work + const result = await doWork(params); + + // 4. Return result + return { + content: [{ type: "text", text: "Result text for LLM" }], // Sent to LLM context + details: { data: result }, // For rendering & state + }; + }, + + // Optional: custom TUI rendering + renderCall(args, theme) { ... }, + renderResult(result, { expanded, isPartial }, theme) { ... }, +}); +``` + + + +**⚠️ MUST use `StringEnum` for string enum parameters:** + +```typescript +import { StringEnum } from "@mariozechner/pi-ai"; + +// ✅ Correct — works with all providers including Google +action: StringEnum(["list", "add", "remove"] as const) + +// ❌ BROKEN with Google's API +action: Type.Union([Type.Literal("list"), Type.Literal("add")]) +``` + + + +Tools MUST truncate output to avoid context overflow. Built-in limit: 50KB / 2000 lines. + +```typescript +import { + truncateHead, truncateTail, formatSize, + DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, +} from "@mariozechner/pi-coding-agent"; + +async execute(toolCallId, params, signal, onUpdate, ctx) { + const output = await runCommand(); + const truncation = truncateHead(output, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let result = truncation.content; + if (truncation.truncated) { + const tempFile = writeTempFile(output); + result += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines`; + result += ` (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}).`; + result += ` Full output: ${tempFile}]`; + } + return { content: [{ type: "text", text: result }] }; +} +``` + +Use `truncateHead` when beginning matters (search results, file reads). Use `truncateTail` when end matters (logs, command output). + + + +Throw to signal an error (sets `isError: true`). Returning a value never sets error flag. + +```typescript +async execute(toolCallId, params) { + if (!isValid(params.input)) { + throw new Error(`Invalid input: ${params.input}`); + } + return { content: [{ type: "text", text: "OK" }], details: {} }; +} +``` + + + +Tools can be registered at any time — during load, in `session_start`, in command handlers. Available immediately without `/reload`. + +```typescript +pi.on("session_start", async (_event, ctx) => { + pi.registerTool({ name: "dynamic_tool", ... }); +}); +``` + +Use `pi.setActiveTools(names)` to enable/disable tools at runtime. + + + +Register a tool with the same name as a built-in (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) to override it. **Must match exact result shape including `details` type.** + +```typescript +import { createReadTool } from "@mariozechner/pi-coding-agent"; + +pi.registerTool({ + name: "read", + label: "Read (Logged)", + description: "Read file contents with logging", + parameters: Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Number()), + limit: Type.Optional(Type.Number()), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + console.log(`[AUDIT] Reading: ${params.path}`); + const builtIn = createReadTool(ctx.cwd); + return builtIn.execute(toolCallId, params, signal, onUpdate); + }, + // Omit renderCall/renderResult to use built-in renderer +}); +``` + +Start with no built-in tools: `gsd --no-tools -e ./my-extension.ts` + + + +One extension can register multiple tools with shared state: + +```typescript +export default function (pi: ExtensionAPI) { + let connection = null; + + pi.registerTool({ name: "db_connect", ... }); + pi.registerTool({ name: "db_query", ... }); + pi.registerTool({ name: "db_close", ... }); + + pi.on("session_shutdown", async () => { + connection?.close(); + }); +} +``` + + + +Some models add `@` prefix to path arguments. Strip it: + +```typescript +async execute(toolCallId, params, signal, onUpdate, ctx) { + let path = params.path; + if (path.startsWith("@")) path = path.slice(1); + // ... +} +``` + diff --git a/src/resources/skills/create-gsd-extension/references/custom-ui.md b/src/resources/skills/create-gsd-extension/references/custom-ui.md new file mode 100644 index 000000000..7eeaadc2a --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/custom-ui.md @@ -0,0 +1,490 @@ + +Complete custom UI reference — dialogs, persistent elements, custom components, overlays, custom editors, built-in components, keyboard input, performance, theming, and common mistakes. + + + +``` +┌─────────────────────────────────────────────────┐ +│ Custom Header (ctx.ui.setHeader) │ +├─────────────────────────────────────────────────┤ +│ Message Area │ +│ - User/assistant messages │ +│ - Tool calls ◄── renderCall/renderResult │ +│ - Custom messages ◄── registerMessageRenderer │ +├─────────────────────────────────────────────────┤ +│ Widgets (above editor) ◄── ctx.ui.setWidget │ +├─────────────────────────────────────────────────┤ +│ Editor ◄── ctx.ui.custom() / setEditorComponent│ +├─────────────────────────────────────────────────┤ +│ Widgets (below editor) ◄── ctx.ui.setWidget │ +├─────────────────────────────────────────────────┤ +│ Footer ◄── ctx.ui.setFooter / setStatus │ +└─────────────────────────────────────────────────┘ + ┌─────────────────────┐ + │ Overlay (floating) │ ◄── ctx.ui.custom({ overlay }) + └─────────────────────┘ +``` + +**11 ways to get UI on screen:** + +| Method | Blocks? | Replaces editor? | +|--------|---------|-------------------| +| `ctx.ui.select/confirm/input/editor` | Yes | Temporarily | +| `ctx.ui.notify` | No | No | +| `ctx.ui.setStatus` | No | No (footer) | +| `ctx.ui.setWidget` | No | No | +| `ctx.ui.setFooter` | No | No (replaces footer) | +| `ctx.ui.setHeader` | No | No (replaces header) | +| `ctx.ui.custom()` | Yes | Temporarily | +| `ctx.ui.custom({overlay})` | Yes | No (renders on top) | +| `ctx.ui.setEditorComponent` | No | Yes (permanently) | +| `renderCall/renderResult` | No | No (inline in messages) | +| `registerMessageRenderer` | No | No (inline in messages) | + + + +Every visual element implements: + +```typescript +interface Component { + render(width: number): string[]; // Required — each line ≤ width visible chars + handleInput?(data: string): void; // Optional — receive keyboard input + wantsKeyRelease?: boolean; // Optional — receive key release events (Kitty protocol) + invalidate(): void; // Required — clear cached render state +} +``` + +**Render contract:** +- Return array of strings, one per line +- Each string MUST NOT exceed `width` in visible characters +- ANSI escape codes don't count toward visible width +- **Styles are reset at end of each line** — reapply per line +- Return `[]` for zero-height component + +**Invalidation contract:** +- Clear ALL cached render output +- Clear any pre-baked themed strings +- Call `super.invalidate()` if extending a built-in component + + + +Blocking dialog methods on `ctx.ui`: + +```typescript +const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); // string | undefined +const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); // boolean +const name = await ctx.ui.input("Name:", "placeholder"); // string | undefined +const text = await ctx.ui.editor("Edit:", "prefilled text"); // string | undefined + +// Timed auto-dismiss with countdown +const ok = await ctx.ui.confirm("Proceed?", "Auto-continues in 5s", { timeout: 5000 }); +// Returns false on timeout, undefined for select/input + +// Manual dismissal with AbortSignal (distinguish timeout from cancel) +const controller = new AbortController(); +const timeoutId = setTimeout(() => controller.abort(), 5000); +const ok = await ctx.ui.confirm("Timed", "Auto-cancels in 5s", { signal: controller.signal }); +clearTimeout(timeoutId); +if (controller.signal.aborted) { /* timed out */ } +``` + + + +```typescript +// Footer status (multiple extensions can set independent entries) +ctx.ui.setStatus("my-ext", "● Active"); +ctx.ui.setStatus("my-ext", undefined); // Clear + +// Widgets +ctx.ui.setWidget("my-id", ["Line 1", "Line 2"]); // Above editor +ctx.ui.setWidget("my-id", ["Below"], { placement: "belowEditor" }); // Below editor +ctx.ui.setWidget("my-id", (_tui, theme) => ({ // Themed + render: () => [theme.fg("accent", "Styled")], + invalidate: () => {}, +})); +ctx.ui.setWidget("my-id", undefined); // Clear + +// Working message during streaming +ctx.ui.setWorkingMessage("Analyzing code..."); +ctx.ui.setWorkingMessage(); // Restore default + +// Custom footer (full replacement) +ctx.ui.setFooter((tui, theme, footerData) => ({ + render(width) { + const branch = footerData.getGitBranch(); // Only available here + const statuses = footerData.getExtensionStatuses(); // All setStatus values + return [truncateToWidth(`${branch} | model`, width)]; + }, + invalidate() {}, + dispose: footerData.onBranchChange(() => tui.requestRender()), // Reactive +})); +ctx.ui.setFooter(undefined); // Restore default + +// Custom header +ctx.ui.setHeader((tui, theme) => ({ + render(width) { return [theme.fg("accent", theme.bold("My Header"))]; }, + invalidate() {}, +})); + +// Editor control +ctx.ui.setEditorText("Prefill"); +const current = ctx.ui.getEditorText(); +ctx.ui.pasteToEditor("pasted content"); // Triggers paste handling + +// Tool expansion +ctx.ui.setToolsExpanded(true); +const expanded = ctx.ui.getToolsExpanded(); + +// Theme management +const themes = ctx.ui.getAllThemes(); +ctx.ui.setTheme("light"); +ctx.ui.theme.fg("accent", "text"); // Access current theme +``` + + + +`ctx.ui.custom()` temporarily replaces the editor. Returns a value when `done()` is called. + +**Factory callback args:** + +| Argument | Type | Purpose | +|----------|------|---------| +| `tui` | `TUI` | `tui.requestRender()` triggers re-render after state changes | +| `theme` | `Theme` | Current theme for styling | +| `keybindings` | `KeybindingsManager` | App keybinding config | +| `done` | `(value: T) => void` | Close component and return value | + +**Inline pattern:** +```typescript +const result = await ctx.ui.custom((tui, theme, keybindings, done) => ({ + render(width: number): string[] { + return [truncateToWidth("Press Enter to confirm, Escape to cancel", width)]; + }, + handleInput(data: string) { + if (matchesKey(data, Key.enter)) done("confirmed"); + if (matchesKey(data, Key.escape)) done(null); + }, + invalidate() {}, +})); +``` + +**Class-based pattern (recommended for complex UI):** +```typescript +class MyComponent { + private selected = 0; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor( + private tui: { requestRender: () => void }, + private theme: Theme, + private items: string[], + private done: (value: string | null) => void, + ) {} + + handleInput(data: string) { + if (matchesKey(data, Key.up) && this.selected > 0) this.selected--; + else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) this.selected++; + else if (matchesKey(data, Key.enter)) { this.done(this.items[this.selected]); return; } + else if (matchesKey(data, Key.escape)) { this.done(null); return; } + else return; + this.invalidate(); + this.tui.requestRender(); + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; + this.cachedLines = this.items.map((item, i) => + truncateToWidth((i === this.selected ? "> " : " ") + item, width) + ); + this.cachedWidth = width; + return this.cachedLines; + } + + invalidate() { this.cachedWidth = undefined; this.cachedLines = undefined; } +} + +const result = await ctx.ui.custom((tui, theme, _kb, done) => + new MyComponent(tui, theme, ["A", "B", "C"], done) +); +``` + +**Composing with built-in components:** +```typescript +const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const container = new Container(); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0)); + + const selectList = new SelectList(items, 10, { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }); + selectList.onSelect = (item) => done(item.value); + selectList.onCancel = () => done(null); + container.addChild(selectList); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); }, + }; +}); +``` + + + +Floating modals rendered on top of everything: + +```typescript +const result = await ctx.ui.custom( + (tui, theme, _kb, done) => new MyDialog({ onClose: done }), + { + overlay: true, + overlayOptions: { + anchor: "center", // 9 positions (see below) + width: "50%", // number = columns, string = percentage + minWidth: 40, + maxHeight: "80%", + margin: 2, // All sides, or { top, right, bottom, left } + offsetX: 0, offsetY: 0, // Fine-tune position + visible: (w, h) => w >= 80, // Hide on narrow terminals + }, + onHandle: (handle) => { + // handle.setHidden(true/false) — temporarily hide + // handle.hide() — permanently remove + }, + } +); +``` + +**Anchor positions:** +``` +top-left top-center top-right +left-center center right-center +bottom-left bottom-center bottom-right +``` + +**Stacked overlays:** Multiple overlays stack (newest on top). Closing one gives focus to the one below. + +**⚠️ Overlay lifecycle:** Components are disposed when closed. Never reuse references — create fresh instances each time. + + + +Replace the main input editor permanently: + +```typescript +import { CustomEditor } from "@mariozechner/pi-coding-agent"; + +class VimEditor extends CustomEditor { + private mode: "normal" | "insert" = "insert"; + + handleInput(data: string): void { + if (matchesKey(data, "escape") && this.mode === "insert") { + this.mode = "normal"; return; + } + if (this.mode === "insert") { super.handleInput(data); return; } + switch (data) { + case "i": this.mode = "insert"; return; + case "h": super.handleInput("\x1b[D"); return; // Left + case "j": super.handleInput("\x1b[B"); return; // Down + case "k": super.handleInput("\x1b[A"); return; // Up + case "l": super.handleInput("\x1b[C"); return; // Right + } + if (data.length === 1 && data.charCodeAt(0) >= 32) return; // Block printable in normal + super.handleInput(data); + } +} + +ctx.ui.setEditorComponent((_tui, theme, keybindings) => new VimEditor(theme, keybindings)); +ctx.ui.setEditorComponent(undefined); // Restore default +``` + +**Critical:** Extend `CustomEditor` (NOT `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching). + + + +**From `@mariozechner/pi-tui`:** + +| Component | Constructor | Purpose | +|-----------|-------------|---------| +| `Text` | `new Text(content, paddingX, paddingY, bgFn?)` | Multi-line text with word wrap | +| `Box` | `new Box(paddingX, paddingY, bgFn)` | Container with padding+background, `.addChild()` | +| `Container` | `new Container()` | Vertical stack, `.addChild()`, `.removeChild()`, `.clear()` | +| `Spacer` | `new Spacer(lines)` | Empty vertical space | +| `Markdown` | `new Markdown(content, padX, padY, getMarkdownTheme())` | Rendered markdown with syntax highlighting | +| `Image` | `new Image(base64, mimeType, theme, opts?)` | Image rendering (Kitty, iTerm2) | +| `SelectList` | `new SelectList(items, maxVisible, themeOpts)` | Interactive selection with search and scrolling | +| `SettingsList` | `new SettingsList(items, maxVisible, theme, onChange, onClose, opts?)` | Toggle settings with left/right arrows | +| `Input` | `new Input()` | Text input field | +| `Editor` | `new Editor(tui, editorTheme)` | Multi-line editor with undo | + +**SelectList usage:** +```typescript +const items: SelectItem[] = [ + { value: "opt1", label: "Option 1", description: "First option" }, + { value: "opt2", label: "Option 2" }, +]; +const selectList = new SelectList(items, 10, { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), +}); +selectList.onSelect = (item) => { /* item.value */ }; +selectList.onCancel = () => { /* escape pressed */ }; +``` + +**SettingsList usage:** +```typescript +const items: SettingItem[] = [ + { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] }, + { id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light", "auto"] }, +]; +const settings = new SettingsList(items, 15, getSettingsListTheme(), + (id, newValue) => { /* setting changed */ }, + () => { /* close requested */ }, + { enableSearch: true }, +); +``` + +**From `@mariozechner/pi-coding-agent`:** + +| Component | Constructor | Purpose | +|-----------|-------------|---------| +| `DynamicBorder` | `new DynamicBorder((s: string) => theme.fg("accent", s))` | Border line | +| `BorderedLoader` | — | Spinner with cancel support | +| `CustomEditor` | `new CustomEditor(theme, keybindings)` | Base class for custom editors | + + + +```typescript +import { matchesKey, Key } from "@mariozechner/pi-tui"; + +handleInput(data: string) { + // Basic keys + if (matchesKey(data, Key.up)) {} + if (matchesKey(data, Key.down)) {} + if (matchesKey(data, Key.enter)) {} + if (matchesKey(data, Key.escape)) {} + if (matchesKey(data, Key.tab)) {} + if (matchesKey(data, Key.space)) {} + if (matchesKey(data, Key.backspace)) {} + if (matchesKey(data, Key.home)) {} + if (matchesKey(data, Key.end)) {} + + // With modifiers + if (matchesKey(data, Key.ctrl("c"))) {} + if (matchesKey(data, Key.shift("tab"))) {} + if (matchesKey(data, Key.alt("left"))) {} + if (matchesKey(data, Key.ctrlShift("p"))) {} + + // String format also works: "enter", "ctrl+c", "shift+tab" + + // Printable character detection + if (data.length === 1 && data.charCodeAt(0) >= 32) { + // Letter, number, symbol + } +} +``` + +**handleInput contract:** +1. Check for your keys +2. Update state +3. Call `this.invalidate()` if render output changes +4. Call `tui.requestRender()` to trigger re-render + + + +**Cardinal rule: each line from render() must not exceed `width` visible characters.** + +```typescript +import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; + +visibleWidth("\x1b[32mHello\x1b[0m"); // Returns 5 (ignores ANSI codes) +truncateToWidth("Very long text here", 10); // "Very lo..." +truncateToWidth("Very long text here", 10, ""); // "Very long " (no ellipsis) +wrapTextWithAnsi("\x1b[32mLong green text\x1b[0m", 10); // Word wrap preserving ANSI +``` + +If lines exceed `width`, terminal wraps cause visual corruption. + + + +Always cache render output: + +```typescript +class CachedComponent { + private cachedWidth?: number; + private cachedLines?: string[]; + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; + const lines = this.computeLines(width); + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate() { this.cachedWidth = undefined; this.cachedLines = undefined; } +} +``` + +**Update cycle:** State changes → `invalidate()` → `tui.requestRender()` → `render(width)` called + +**Game loop pattern** (real-time updates): +```typescript +this.interval = setInterval(() => { + this.tick(); + this.version++; + this.tui.requestRender(); +}, 100); // 10 FPS + +// Clean up in dispose() +clearInterval(this.interval); +``` + + + +Always use theme from callback params, never import directly. + +**All foreground colors:** + +| Category | Colors | +|----------|--------| +| General | `text`, `accent`, `muted`, `dim` | +| Status | `success`, `error`, `warning` | +| Borders | `border`, `borderAccent`, `borderMuted` | +| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` | +| Tools | `toolTitle`, `toolOutput` | +| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` | +| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` | +| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` | +| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` | + +**All background colors:** `selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg` + +**Syntax highlighting:** +```typescript +import { highlightCode, getLanguageFromPath } from "@mariozechner/pi-coding-agent"; +const lang = getLanguageFromPath("/file.rs"); // "rust" +const highlighted = highlightCode(code, lang, theme); +``` + + + +1. **Lines exceed width** → Visual corruption. Use `truncateToWidth()` on every line. +2. **Forgetting `tui.requestRender()`** → UI doesn't update. Call after invalidate(). +3. **Importing theme directly** → Wrong colors after theme switch. Use theme from callback. +4. **Not typing DynamicBorder param** → `new DynamicBorder((s: string) => theme.fg("accent", s))`. +5. **Reusing disposed overlay components** → Create fresh instances each time. +6. **Styles bleeding across lines** → TUI resets per line. Reapply styles, or use `wrapTextWithAnsi()`. +7. **Not implementing invalidate()** → Theme changes don't take effect. +8. **Forgetting super.invalidate()** → `override invalidate() { super.invalidate(); /* cleanup */ }` +9. **Timer not cleaned up** → Call `clearInterval` before `done()`. +10. **Using ctx.ui in non-interactive mode** → Check `ctx.hasUI` first. + diff --git a/src/resources/skills/create-gsd-extension/references/events-reference.md b/src/resources/skills/create-gsd-extension/references/events-reference.md new file mode 100644 index 000000000..82028f5de --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/events-reference.md @@ -0,0 +1,126 @@ + +Complete event reference with handler signatures, return types, and type narrowing utilities. + + + + +**Session events:** `session_start`, `session_before_switch`, `session_switch`, `session_before_fork`, `session_fork`, `session_before_compact`, `session_compact`, `session_before_tree`, `session_tree`, `session_shutdown` + +**Agent events:** `before_agent_start`, `agent_start`, `agent_end`, `turn_start`, `turn_end`, `context`, `before_provider_request`, `message_start`, `message_update`, `message_end` + +**Tool events:** `tool_call`, `tool_execution_start`, `tool_execution_update`, `tool_execution_end`, `tool_result` + +**Input events:** `input` + +**Model events:** `model_select` + +**User bash events:** `user_bash` + +**Special:** `session_directory` (CLI startup only, no `ctx` — receives only event) + + + + +```typescript +pi.on("event_name", async (event, ctx: ExtensionContext) => { + // event — typed payload for this event + // ctx — access to UI, session, model, control flow + // Return undefined for no action, or a typed response +}); +``` + + + + +**before_agent_start** — Fired after user prompt, before agent loop. Primary hook for context injection and system prompt modification. +```typescript +pi.on("before_agent_start", async (event, ctx) => { + // event.prompt — user's prompt text + // event.images — attached images + // event.systemPrompt — current system prompt + return { + message: { customType: "my-ext", content: "Extra context", display: true }, + systemPrompt: event.systemPrompt + "\n\nExtra instructions...", + }; +}); +``` + +**tool_call** — Fired before tool executes. Can block. +```typescript +import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; + +pi.on("tool_call", async (event, ctx) => { + if (isToolCallEventType("bash", event)) { + // event.input is typed as { command: string; timeout?: number } + if (event.input.command.includes("rm -rf")) { + return { block: true, reason: "Dangerous command" }; + } + } +}); +``` + +**tool_result** — Fired after tool executes. Can modify result. Handlers chain like middleware. +```typescript +import { isBashToolResult } from "@mariozechner/pi-coding-agent"; + +pi.on("tool_result", async (event, ctx) => { + if (isBashToolResult(event)) { + // event.details is typed as BashToolDetails + } + // Return partial patch: { content, details, isError } + // Omitted fields keep current values +}); +``` + +**context** — Fired before each LLM call. Modify messages non-destructively. +```typescript +pi.on("context", async (event, ctx) => { + // event.messages is a deep copy — safe to modify + const filtered = event.messages.filter(m => !shouldPrune(m)); + return { messages: filtered }; +}); +``` + +**input** — Fired when user input is received, before skill/template expansion. +```typescript +pi.on("input", async (event, ctx) => { + // event.text — raw input + // event.source — "interactive", "rpc", or "extension" + if (event.text.startsWith("?quick ")) + return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` }; + return { action: "continue" }; +}); +``` + +**model_select** — Fired when model changes. +```typescript +pi.on("model_select", async (event, ctx) => { + // event.model, event.previousModel, event.source ("set"|"cycle"|"restore") +}); +``` + + + + +Built-in type guards for tool events: + +```typescript +import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent"; + +// Tool calls — narrows event.input type +if (isToolCallEventType("bash", event)) { /* event.input: { command, timeout? } */ } +if (isToolCallEventType("read", event)) { /* event.input: { path, offset?, limit? } */ } +if (isToolCallEventType("write", event)) { /* event.input: { path, content } */ } +if (isToolCallEventType("edit", event)) { /* event.input: { path, oldText, newText } */ } + +// Tool results — narrows event.details type +if (isBashToolResult(event)) { /* event.details: BashToolDetails */ } +``` + +For custom tools, export your input type and use explicit type params: +```typescript +if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) { + event.input.action; // typed +} +``` + diff --git a/src/resources/skills/create-gsd-extension/references/extension-lifecycle.md b/src/resources/skills/create-gsd-extension/references/extension-lifecycle.md new file mode 100644 index 000000000..d6ab71c42 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/extension-lifecycle.md @@ -0,0 +1,64 @@ + +The extension lifecycle from load to shutdown, including the full event flow. + + + +Extensions load when GSD starts (or on `/reload`). The default export function runs synchronously — subscribe to events and register tools/commands during this call. + +``` +GSD starts + └─► Extension default function runs + ├── pi.on("event", handler) ← Subscribe + ├── pi.registerTool({...}) ← Register tools + ├── pi.registerCommand(...) ← Register commands + └── pi.registerShortcut(...) ← Register shortcuts + └─► session_start fires +``` + + + +Full event flow per user prompt: + +``` +user sends prompt + ├─► Extension commands checked (bypass if match) + ├─► input event (can intercept/transform/handle) + ├─► Skill/template expansion + ├─► before_agent_start (inject message, modify system prompt) + ├─► agent_start + │ + │ ┌── Turn loop (repeats while LLM calls tools) ──┐ + │ │ turn_start │ + │ │ context (can modify messages sent to LLM) │ + │ │ before_provider_request (inspect/replace payload)│ + │ │ LLM responds → may call tools: │ + │ │ tool_call (can BLOCK) │ + │ │ tool_execution_start/update/end │ + │ │ tool_result (can MODIFY) │ + │ │ turn_end │ + │ └────────────────────────────────────────────────┘ + │ + └─► agent_end +``` + + + +| Event | When | Can Return | +|-------|------|------------| +| `session_start` | Session loads | — | +| `session_before_switch` | Before `/new` or `/resume` | `{ cancel: true }` | +| `session_switch` | After switch | — | +| `session_before_fork` | Before `/fork` | `{ cancel: true }`, `{ skipConversationRestore: true }` | +| `session_fork` | After fork | — | +| `session_before_compact` | Before compaction | `{ cancel: true }`, `{ compaction: {...} }` | +| `session_compact` | After compaction | — | +| `session_shutdown` | On exit | — | + + + +Extensions in auto-discovered locations hot-reload with `/reload`: +- `session_shutdown` fires for old runtime +- Resources re-scanned +- `session_start` fires for new runtime +- Code after `await ctx.reload()` still runs from the pre-reload version — treat as terminal + diff --git a/src/resources/skills/create-gsd-extension/references/extensionapi-reference.md b/src/resources/skills/create-gsd-extension/references/extensionapi-reference.md new file mode 100644 index 000000000..7e5f458df --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/extensionapi-reference.md @@ -0,0 +1,75 @@ + +ExtensionAPI methods — the `pi` object received in the default export function. + + + +| Method | Purpose | +|--------|---------| +| `pi.on(event, handler)` | Subscribe to events | +| `pi.registerTool(definition)` | Register LLM-callable tool | +| `pi.registerCommand(name, options)` | Register `/command` | +| `pi.registerShortcut(key, options)` | Register keyboard shortcut | +| `pi.registerFlag(name, options)` | Register CLI flag | +| `pi.registerMessageRenderer(customType, renderer)` | Custom message rendering | +| `pi.registerProvider(name, config)` | Register/override model provider | +| `pi.unregisterProvider(name)` | Remove a provider | + + + +| Method | Purpose | +|--------|---------| +| `pi.sendMessage(message, options?)` | Inject custom message into session | +| `pi.sendUserMessage(content, options?)` | Send user message (triggers turn) | + +**Delivery modes for `sendMessage`:** +- `"steer"` (default) — Interrupts streaming after current tool +- `"followUp"` — Waits for agent to finish all tools +- `"nextTurn"` — Queued for next user prompt + +```typescript +pi.sendMessage({ + customType: "my-extension", + content: "Additional context", + display: true, + details: { ... }, +}, { deliverAs: "steer", triggerTurn: true }); +``` + + + +| Method | Purpose | +|--------|---------| +| `pi.appendEntry(customType, data?)` | Persist state (NOT sent to LLM) | +| `pi.setSessionName(name)` | Set session display name | +| `pi.getSessionName()` | Get session name | +| `pi.setLabel(entryId, label)` | Bookmark entry for `/tree` | + + + +```typescript +const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"] +const all = pi.getAllTools(); // [{ name, description }, ...] +pi.setActiveTools(["read", "bash"]); // Enable/disable tools +``` + + + +```typescript +const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5"); +if (model) { + const success = await pi.setModel(model); // Returns false if no API key +} + +pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" +pi.setThinkingLevel("high"); +``` + + + +| Method | Purpose | +|--------|---------| +| `pi.exec(cmd, args, opts?)` | Shell command (prefer over child_process) | +| `pi.events` | Shared event bus for inter-extension communication | +| `pi.getFlag(name)` | Get CLI flag value | +| `pi.getCommands()` | All available slash commands | + diff --git a/src/resources/skills/create-gsd-extension/references/extensioncontext-reference.md b/src/resources/skills/create-gsd-extension/references/extensioncontext-reference.md new file mode 100644 index 000000000..ab04ad711 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/extensioncontext-reference.md @@ -0,0 +1,53 @@ + +ExtensionContext (`ctx`) — available in all event handlers (except `session_directory`). + + + +**Dialogs (blocking — wait for user response):** +```typescript +const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); +const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); +const name = await ctx.ui.input("Name:", "placeholder"); +const text = await ctx.ui.editor("Edit:", "prefilled text"); + +// Timed dialog — auto-dismiss after timeout +const ok = await ctx.ui.confirm("Auto-confirm?", "Proceeds in 5s", { timeout: 5000 }); +``` + +**Non-blocking UI:** +```typescript +ctx.ui.notify("Done!", "info"); // Toast: "info" | "warning" | "error" +ctx.ui.setStatus("my-ext", "● Active"); // Footer status +ctx.ui.setStatus("my-ext", undefined); // Clear +ctx.ui.setWidget("my-id", ["Line 1", "Line 2"]); // Widget above editor +ctx.ui.setWidget("my-id", ["Below!"], { placement: "belowEditor" }); +ctx.ui.setTitle("gsd - my project"); // Terminal title +ctx.ui.setEditorText("Prefill"); // Set editor content +ctx.ui.setWorkingMessage("Analyzing..."); // Working message during streaming +ctx.ui.setToolsExpanded(true); // Expand tool output +``` + + + +| Property/Method | Purpose | +|----------------|---------| +| `ctx.hasUI` | `false` in print/JSON mode — check before dialogs | +| `ctx.cwd` | Current working directory | +| `ctx.sessionManager` | Read-only session state | +| `ctx.modelRegistry` / `ctx.model` | Model access | +| `ctx.isIdle()` / `ctx.abort()` / `ctx.hasPendingMessages()` | Agent state | +| `ctx.shutdown()` | Request graceful exit (deferred until idle) | +| `ctx.getContextUsage()` | Current context token usage | +| `ctx.compact(options?)` | Trigger compaction | +| `ctx.getSystemPrompt()` | Current effective system prompt | + + + +```typescript +ctx.sessionManager.getEntries() // All entries +ctx.sessionManager.getBranch() // Current branch +ctx.sessionManager.getLeafId() // Current leaf entry ID +ctx.sessionManager.getSessionFile() // Session JSONL path +ctx.sessionManager.getLabel(entryId) // Entry label +``` + diff --git a/src/resources/skills/create-gsd-extension/references/key-rules-gotchas.md b/src/resources/skills/create-gsd-extension/references/key-rules-gotchas.md new file mode 100644 index 000000000..75f73f2c8 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/key-rules-gotchas.md @@ -0,0 +1,36 @@ + +Non-negotiable rules and common gotchas when building GSD extensions. + + + +1. **Use `StringEnum` for string enums** — `Type.Union`/`Type.Literal` breaks Google's API. +2. **Truncate tool output** — Large output causes context overflow, compaction failures, degraded performance. Limit: 50KB / 2000 lines. +3. **Use theme from callback** — Don't import theme directly. Use the `theme` parameter from `ctx.ui.custom()` or render functions. +4. **`DynamicBorder` color param** — Type as `(s: string) => theme.fg("accent", s)`. +5. **Call `tui.requestRender()` after state changes** in `handleInput`. +6. **Return `{ render, invalidate, handleInput }`** from custom components. +7. **Lines must not exceed `width`** in `render()` — use `truncateToWidth()`. +8. **Session control methods ONLY in commands** — `waitForIdle()`, `newSession()`, `fork()`, `navigateTree()`, `reload()` will **deadlock** in event handlers. +9. **Strip leading `@` from path arguments** — some models add it. +10. **Store state in tool result `details`** for proper branching support. + + + +- Rebuild component on `invalidate()` when pre-baking theme colors +- Check `signal?.aborted` in long-running tool executions +- Use `pi.exec()` instead of `child_process` for shell commands +- Overlay components are **disposed when closed** — create fresh instances each time +- Treat `ctx.reload()` as terminal — code after runs from pre-reload version +- Check `ctx.hasUI` before dialog methods (false in print/JSON mode) +- Extension errors are logged but don't crash GSD — tool_call handler errors fail-safe (block the tool) + + + +**GSD extension paths:** +- Global: `~/.gsd/agent/extensions/*.ts` +- Global (subdir): `~/.gsd/agent/extensions/*/index.ts` +- Project-local: `.gsd/extensions/*.ts` +- Project-local (subdir): `.gsd/extensions/*/index.ts` + +The upstream pi docs reference `~/.pi` paths — GSD uses `~/.gsd` everywhere instead. + diff --git a/src/resources/skills/create-gsd-extension/references/mode-behavior.md b/src/resources/skills/create-gsd-extension/references/mode-behavior.md new file mode 100644 index 000000000..3e8db5822 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/mode-behavior.md @@ -0,0 +1,32 @@ + +Mode behavior determines which UI methods work. Extensions may run in non-interactive modes where dialogs are unavailable. + + + +| Mode | UI Methods | Notes | +|------|-----------|-------| +| **Interactive** (default) | Full TUI | Normal operation — all UI works | +| **RPC** (`--mode rpc`) | JSON protocol | Host handles UI, dialogs work via sub-protocol | +| **JSON** (`--mode json`) | No-op | Event stream to stdout, no UI | +| **Print** (`-p`) | No-op | Extensions run but can't prompt users | + + + +**Always check `ctx.hasUI`** before calling dialog methods: + +```typescript +if (ctx.hasUI) { + const ok = await ctx.ui.confirm("Delete?", "Sure?"); + if (!ok) return; +} else { + // Default behavior for non-interactive mode + // Or just proceed without confirmation +} +``` + +`ctx.hasUI` is `false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode. + + + +Non-blocking methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) are safe in all modes — they're no-ops when no UI is available. + diff --git a/src/resources/skills/create-gsd-extension/references/model-provider-management.md b/src/resources/skills/create-gsd-extension/references/model-provider-management.md new file mode 100644 index 000000000..c57d5fb22 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/model-provider-management.md @@ -0,0 +1,89 @@ + +Model and provider management — switching models, registering custom providers with OAuth, and reacting to model changes. + + + +```typescript +const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5"); +if (model) { + const success = await pi.setModel(model); + if (!success) ctx.ui.notify("No API key for this model", "error"); +} + +// Thinking level +pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" +pi.setThinkingLevel("high"); // Clamped to model capabilities +``` + + + +```typescript +pi.registerProvider("my-proxy", { + baseUrl: "https://proxy.example.com", + apiKey: "PROXY_API_KEY", // Env var name or literal + api: "anthropic-messages", // or "openai-completions", "openai-responses" + headers: { "X-Custom": "value" }, // Optional custom headers + authHeader: true, // Auto-add Authorization: Bearer header + models: [ + { + id: "claude-sonnet-4-20250514", + name: "Claude 4 Sonnet (proxy)", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 16384, + } + ], +}); + +// Override just baseUrl for an existing provider (keeps all models) +pi.registerProvider("anthropic", { + baseUrl: "https://proxy.example.com", +}); + +// Remove a provider (restores any overridden built-in models) +pi.unregisterProvider("my-proxy"); +``` + +Takes effect immediately after initial load phase — no `/reload` required. + + + +Register a provider with OAuth support for `/login`: + +```typescript +pi.registerProvider("corporate-ai", { + baseUrl: "https://ai.corp.com", + api: "openai-responses", + models: [/* ... */], + oauth: { + name: "Corporate AI (SSO)", + async login(callbacks) { + callbacks.onAuth({ url: "https://sso.corp.com/..." }); + const code = await callbacks.onPrompt({ message: "Enter code:" }); + return { refresh: code, access: code, expires: Date.now() + 3600000 }; + }, + async refreshToken(credentials) { + return credentials; // Refresh logic + }, + getApiKey(credentials) { + return credentials.access; + }, + }, +}); +``` + + + +React to model changes: + +```typescript +pi.on("model_select", async (event, ctx) => { + // event.model — newly selected model + // event.previousModel — previous model (undefined if first) + // event.source — "set" | "cycle" | "restore" + ctx.ui.setStatus("model", `${event.model.provider}/${event.model.id}`); +}); +``` + diff --git a/src/resources/skills/create-gsd-extension/references/packaging-distribution.md b/src/resources/skills/create-gsd-extension/references/packaging-distribution.md new file mode 100644 index 000000000..a3d603182 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/packaging-distribution.md @@ -0,0 +1,55 @@ + +Packaging extensions for distribution via npm, git, or local paths. Creating GSD/pi packages. + + + +Add a `pi` manifest to `package.json`: + +```json +{ + "name": "my-gsd-package", + "keywords": ["pi-package"], + "pi": { + "extensions": ["./extensions"], + "skills": ["./skills"], + "prompts": ["./prompts"], + "themes": ["./themes"] + } +} +``` + + + +```bash +gsd install npm:@foo/bar@1.0.0 +gsd install git:github.com/user/repo@v1 +gsd install ./local/path + +# Try without installing: +gsd -e npm:@foo/bar +``` + + + +If no `pi` manifest exists, auto-discovers: +- `extensions/` → `.ts` and `.js` files +- `skills/` → `SKILL.md` folders +- `prompts/` → `.md` files +- `themes/` → `.json` files + + + +- List `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`, `@sinclair/typebox` in `peerDependencies` with `"*"` — they're bundled by the runtime. +- Other npm deps go in `dependencies`. The runtime runs `npm install` on package installation. + + + +```json +{ + "pi": { + "video": "https://example.com/demo.mp4", + "image": "https://example.com/screenshot.png" + } +} +``` + diff --git a/src/resources/skills/create-gsd-extension/references/remote-execution-overrides.md b/src/resources/skills/create-gsd-extension/references/remote-execution-overrides.md new file mode 100644 index 000000000..1945fb80e --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/remote-execution-overrides.md @@ -0,0 +1,90 @@ + +Remote execution via pluggable operations, spawnHook for bash, and tool override patterns. + + + +Built-in tools support pluggable operations for SSH, containers, etc.: + +```typescript +import { createReadTool, createBashTool, createWriteTool } from "@mariozechner/pi-coding-agent"; + +// Create tool with custom remote operations +const remoteBash = createBashTool(cwd, { + operations: { + execute: (cmd) => sshExec(remote, cmd), + }, +}); +``` + +**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` + + + +The bash tool supports a `spawnHook` to modify commands before execution: + +```typescript +const bashTool = createBashTool(cwd, { + spawnHook: ({ command, cwd, env }) => ({ + command: `source ~/.profile\n${command}`, + cwd: `/mnt/sandbox${cwd}`, + env: { ...env, CI: "1" }, + }), +}); +``` + + + +Full SSH pattern with flag-based switching: + +```typescript +import { createBashTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerFlag("ssh", { description: "SSH target", type: "string" }); + + const localBash = createBashTool(process.cwd()); + + pi.registerTool({ + ...localBash, + async execute(id, params, signal, onUpdate, ctx) { + const sshTarget = pi.getFlag("--ssh"); + if (sshTarget) { + const remoteBash = createBashTool(process.cwd(), { + operations: createSSHOperations(sshTarget), + }); + return remoteBash.execute(id, params, signal, onUpdate); + } + return localBash.execute(id, params, signal, onUpdate); + }, + }); +} +``` + + + +Override built-in tools for logging/access control — omit renderCall/renderResult to keep built-in rendering: + +```typescript +import { createReadTool } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +pi.registerTool({ + name: "read", // Same name = overrides built-in + label: "Read (Logged)", + description: "Read file contents with logging", + parameters: Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Number()), + limit: Type.Optional(Type.Number()), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + console.log(`[AUDIT] Reading: ${params.path}`); + const builtIn = createReadTool(ctx.cwd); + return builtIn.execute(toolCallId, params, signal, onUpdate); + }, + // Omit renderCall/renderResult → built-in renderer used automatically +}); +``` + +**Must match exact result shape** including `details` type. + diff --git a/src/resources/skills/create-gsd-extension/references/state-management.md b/src/resources/skills/create-gsd-extension/references/state-management.md new file mode 100644 index 000000000..717293cb0 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/state-management.md @@ -0,0 +1,70 @@ + +State management patterns for extensions — tool result details (branch-safe) and appendEntry (private). + + + +**Recommended for stateful tools.** State in `details` works correctly with branching/forking. + +```typescript +export default function (pi: ExtensionAPI) { + let items: string[] = []; + + // Reconstruct state from session on load + pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_fork", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + const reconstructState = (ctx: ExtensionContext) => { + items = []; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "toolResult") { + if (entry.message.toolName === "my_tool") { + items = entry.message.details?.items ?? []; + } + } + } + }; + + pi.registerTool({ + name: "my_tool", + // ... + async execute(toolCallId, params, signal, onUpdate, ctx) { + items.push(params.text); + return { + content: [{ type: "text", text: "Added" }], + details: { items: [...items] }, // ← Snapshot full state + }; + }, + }); +} +``` + +**Key:** Reconstruct on ALL session change events: `session_start`, `session_switch`, `session_fork`, `session_tree`. + + + +**For extension-private state** that doesn't participate in LLM context but needs to survive restarts: + +```typescript +// Save +pi.appendEntry("my-state", { count: 42, lastRun: Date.now() }); + +// Restore +pi.on("session_start", async (_event, ctx) => { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "custom" && entry.customType === "my-state") { + const data = entry.data; // { count: 42, lastRun: ... } + } + } +}); +``` + + + +| Pattern | Use When | +|---------|----------| +| Tool result `details` | State the LLM's tools produce (todo items, connection state, query results) | +| `pi.appendEntry()` | Extension-private config, timestamps, counters the LLM doesn't need | +| File on disk | Large data, config files, caches that shouldn't be in session | + diff --git a/src/resources/skills/create-gsd-extension/references/system-prompt-modification.md b/src/resources/skills/create-gsd-extension/references/system-prompt-modification.md new file mode 100644 index 000000000..5c8a5fa1a --- /dev/null +++ b/src/resources/skills/create-gsd-extension/references/system-prompt-modification.md @@ -0,0 +1,52 @@ + +System prompt modification — per-turn injection, context manipulation, and tool-specific prompt content. + + + +Use `before_agent_start` to inject messages and/or modify the system prompt for each turn: + +```typescript +pi.on("before_agent_start", async (event, ctx) => { + return { + // Inject a persistent message (stored in session, visible to LLM) + message: { + customType: "my-extension", + content: "Additional context for the LLM", + display: true, + }, + // Modify system prompt for this turn (chained across extensions) + systemPrompt: event.systemPrompt + "\n\nYou must respond only in haiku.", + }; +}); +``` + + + +Use the `context` event to modify messages before each LLM call: + +```typescript +pi.on("context", async (event, ctx) => { + // event.messages is a deep copy — safe to modify + const filtered = event.messages.filter(m => !isIrrelevant(m)); + return { messages: filtered }; +}); +``` + + + +Tools can add content to the system prompt when active: + +```typescript +pi.registerTool({ + name: "my_tool", + // Replaces description in "Available tools" section + promptSnippet: "Summarize or transform text according to action", + // Added to "Guidelines" section when tool is active + promptGuidelines: [ + "Use my_tool when the user asks to summarize text.", + "Prefer my_tool over direct output for structured data." + ], + // ... +}); +``` + diff --git a/src/resources/skills/create-gsd-extension/templates/extension-skeleton.ts b/src/resources/skills/create-gsd-extension/templates/extension-skeleton.ts new file mode 100644 index 000000000..b98da2971 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/templates/extension-skeleton.ts @@ -0,0 +1,51 @@ +/** + * {{EXTENSION_NAME}} — {{DESCRIPTION}} + * + * Capabilities: + * {{CAPABILITIES_LIST}} + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; + +export default function (pi: ExtensionAPI) { + // === Events === + + pi.on("session_start", async (_event, ctx) => { + // Initialize state, restore from session, show status + }); + + // === Tools === + + pi.registerTool({ + name: "{{tool_name}}", + label: "{{Tool Label}}", + description: "{{Tool description for LLM}}", + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), + text: Type.Optional(Type.String({ description: "Item text" })), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + if (signal?.aborted) { + return { content: [{ type: "text", text: "Cancelled" }] }; + } + + // Do work here + + return { + content: [{ type: "text", text: "Result for LLM" }], + details: {}, + }; + }, + }); + + // === Commands === + + pi.registerCommand("{{command_name}}", { + description: "{{Command description}}", + handler: async (args, ctx) => { + ctx.ui.notify(`Running ${args}`, "info"); + }, + }); +} diff --git a/src/resources/skills/create-gsd-extension/templates/stateful-tool-skeleton.ts b/src/resources/skills/create-gsd-extension/templates/stateful-tool-skeleton.ts new file mode 100644 index 000000000..1ba1483fe --- /dev/null +++ b/src/resources/skills/create-gsd-extension/templates/stateful-tool-skeleton.ts @@ -0,0 +1,143 @@ +/** + * {{EXTENSION_NAME}} — Stateful tool with persistence + * + * State is stored in tool result details for proper branching support. + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Text, truncateToWidth, matchesKey, Key } from "@mariozechner/pi-tui"; + +interface {{ItemType}} { + id: number; + // Add fields +} + +interface {{ToolDetails}} { + action: string; + items: {{ItemType}}[]; + nextId: number; + error?: string; +} + +export default function (pi: ExtensionAPI) { + let items: {{ItemType}}[] = []; + let nextId = 1; + + // Reconstruct state from session + const reconstructState = (ctx: ExtensionContext) => { + items = []; + nextId = 1; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "toolResult") { + if (entry.message.toolName === "{{tool_name}}") { + const details = entry.message.details as {{ToolDetails}} | undefined; + if (details) { + items = details.items; + nextId = details.nextId; + } + } + } + } + }; + + // Reconstruct on ALL session change events + pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_fork", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + // Register the tool + pi.registerTool({ + name: "{{tool_name}}", + label: "{{Tool Label}}", + description: "{{Description for LLM}}", + parameters: Type.Object({ + action: StringEnum(["list", "add", "remove"] as const), + text: Type.Optional(Type.String({ description: "Item text" })), + id: Type.Optional(Type.Number({ description: "Item ID" })), + }), + + async execute(toolCallId, params, signal, onUpdate, ctx) { + if (signal?.aborted) { + return { content: [{ type: "text", text: "Cancelled" }] }; + } + + switch (params.action) { + case "list": + return { + content: [{ type: "text", text: items.length ? JSON.stringify(items) : "No items" }], + details: { action: "list", items: [...items], nextId } as {{ToolDetails}}, + }; + + case "add": { + if (!params.text) throw new Error("text required for add"); + const item: {{ItemType}} = { id: nextId++ /* , ... */ }; + items.push(item); + return { + content: [{ type: "text", text: `Added #${item.id}` }], + details: { action: "add", items: [...items], nextId } as {{ToolDetails}}, + }; + } + + case "remove": { + if (params.id === undefined) throw new Error("id required for remove"); + const idx = items.findIndex(i => i.id === params.id); + if (idx === -1) throw new Error(`Item #${params.id} not found`); + items.splice(idx, 1); + return { + content: [{ type: "text", text: `Removed #${params.id}` }], + details: { action: "remove", items: [...items], nextId } as {{ToolDetails}}, + }; + } + + default: + throw new Error(`Unknown action: ${params.action}`); + } + }, + + // Custom rendering + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("{{tool_name}} ")); + text += theme.fg("muted", args.action); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as {{ToolDetails}} | undefined; + if (!details) return new Text("", 0, 0); + if (details.error) return new Text(theme.fg("error", details.error), 0, 0); + return new Text(theme.fg("success", `✓ ${details.action} (${details.items.length} items)`), 0, 0); + }, + }); + + // User command to view state + pi.registerCommand("{{command_name}}", { + description: "View {{items}}", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("Requires interactive mode", "error"); + return; + } + await ctx.ui.custom((_tui, theme, _kb, done) => ({ + render(width: number): string[] { + const lines = [ + "", + truncateToWidth(theme.fg("accent", ` {{Items}} (${items.length}) `), width), + "", + ]; + for (const item of items) { + lines.push(truncateToWidth(` #${item.id}`, width)); + } + lines.push("", truncateToWidth(theme.fg("dim", " Press Escape to close"), width), ""); + return lines; + }, + handleInput(data: string) { + if (matchesKey(data, Key.escape)) done(); + }, + invalidate() {}, + })); + }, + }); +} diff --git a/src/resources/skills/create-gsd-extension/workflows/add-capability.md b/src/resources/skills/create-gsd-extension/workflows/add-capability.md new file mode 100644 index 000000000..a069e4570 --- /dev/null +++ b/src/resources/skills/create-gsd-extension/workflows/add-capability.md @@ -0,0 +1,57 @@ + +Read the reference file for the specific capability being added: +- Tools → references/custom-tools.md +- Commands → references/custom-commands.md +- Events → references/events-reference.md +- UI → references/custom-ui.md +- Rendering → references/custom-rendering.md +- State → references/state-management.md +- System prompt → references/system-prompt-modification.md + + + + +## Step 1: Identify the Extension + +Locate the existing extension file. Check: +- `~/.gsd/agent/extensions/` (global) +- `.gsd/extensions/` (project-local) + +Read the current extension code to understand its structure. + +## Step 2: Add the Capability + +Add the new registration/hook inside the existing `export default function (pi: ExtensionAPI)` body. Follow the patterns in the relevant reference file. + +If the extension needs new imports, add them at the top of the file. + +## Step 3: Handle Structural Changes + +**Single file → Directory**: If the extension is outgrowing a single file: +1. Create `~/.gsd/agent/extensions/my-extension/` +2. Move the file to `index.ts` +3. Extract helpers to separate files + +**Adding npm dependencies**: If new packages are needed: +1. Create `package.json` in the extension directory +2. Add dependencies +3. Run `npm install` +4. Add `"pi": { "extensions": ["./index.ts"] }` to package.json + +## Step 4: Test + +```bash +/reload +``` + +Verify the new capability works alongside existing ones. + + + + +Capability addition is complete when: +- [ ] New capability added without breaking existing functionality +- [ ] All new imports resolve +- [ ] `/reload` succeeds +- [ ] New tool/command/hook tested with real invocation + diff --git a/src/resources/skills/create-gsd-extension/workflows/create-extension.md b/src/resources/skills/create-gsd-extension/workflows/create-extension.md new file mode 100644 index 000000000..817efa13b --- /dev/null +++ b/src/resources/skills/create-gsd-extension/workflows/create-extension.md @@ -0,0 +1,156 @@ + +**Read these reference files before proceeding:** +1. references/extension-lifecycle.md +2. references/custom-tools.md (if building tools) +3. references/custom-commands.md (if building commands) +4. references/events-reference.md (if building event hooks) +5. references/key-rules-gotchas.md (always) + + + + +## Step 1: Determine Scope and Placement + +Ask the user: +- **Global** (`~/.gsd/agent/extensions/`) — Available in all GSD sessions +- **Project-local** (`.gsd/extensions/`) — Available only in this project + +## Step 2: Determine Extension Capabilities + +Identify what the extension needs from the user's description: + +| Capability | API | When | +|------------|-----|------| +| Custom tool (LLM-callable) | `pi.registerTool()` | LLM needs to perform new actions | +| Slash command | `pi.registerCommand()` | User needs direct actions | +| Event interception | `pi.on("event", ...)` | Block/modify tool calls, inject context, react to lifecycle | +| Custom UI | `ctx.ui.custom()` | Complex interactive displays | +| System prompt modification | `before_agent_start` event | Add per-turn instructions | +| Context filtering | `context` event | Modify messages sent to LLM | +| State persistence | `details` in tool results or `pi.appendEntry()` | Stateful behavior | +| Custom rendering | `renderCall` / `renderResult` | Control how tools appear in TUI | +| Provider management | `pi.registerProvider()` | Custom model endpoints | +| Keyboard shortcut | `pi.registerShortcut()` | Hotkey triggers | + +## Step 3: Choose Extension Structure + +**Single file** — for small extensions (1-2 tools/commands, simple hooks): +``` +~/.gsd/agent/extensions/my-extension.ts +``` + +**Directory with index.ts** — for multi-file extensions: +``` +~/.gsd/agent/extensions/my-extension/ +├── index.ts +├── tools.ts +└── utils.ts +``` + +**Package with dependencies** — when npm packages are needed: +``` +~/.gsd/agent/extensions/my-extension/ +├── package.json +├── src/index.ts +└── node_modules/ +``` + +For packages, `package.json` needs: +```json +{ + "name": "my-extension", + "dependencies": { ... }, + "pi": { "extensions": ["./src/index.ts"] } +} +``` + +## Step 4: Write the Extension + +Start with the skeleton: + +```typescript +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // Register events, tools, commands here +} +``` + +Then add capabilities based on Step 2. Reference the appropriate reference files for each capability. + +**Tool registration pattern:** +```typescript +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; + +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does (shown to LLM)", + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), + text: Type.Optional(Type.String({ description: "Item text" })), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }] }; + return { + content: [{ type: "text", text: "Result for LLM" }], + details: { data: "for rendering and state" }, + }; + }, +}); +``` + +**Command registration pattern:** +```typescript +pi.registerCommand("mycommand", { + description: "What this command does", + handler: async (args, ctx) => { + ctx.ui.notify(`Running with args: ${args}`, "info"); + }, +}); +``` + +**Event hook pattern:** +```typescript +pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + return { block: true, reason: "Blocked dangerous command" }; + } +}); +``` + +## Step 5: Test the Extension + +```bash +# Quick test without installing +gsd -e ./path/to/my-extension.ts + +# Or place in extensions dir and reload +/reload +``` + +Verify: +- Extension loads without errors (check GSD startup output) +- Tools appear when LLM is asked to use them +- Commands respond to `/mycommand` +- Event hooks trigger at expected points + +## Step 6: Iterate + +Fix issues, add features, refine. Use `/reload` for hot-reload during development. + + + + +Extension creation is complete when: +- [ ] Extension file(s) written to correct location +- [ ] All imports resolve (TypeBox, pi-ai, pi-coding-agent, pi-tui as needed) +- [ ] Tools use `StringEnum` for string enums (not `Type.Union`/`Type.Literal`) +- [ ] Tool output is truncated if variable-length +- [ ] State stored in `details` if extension is stateful +- [ ] `ctx.hasUI` checked before dialog methods +- [ ] Extension loads on `/reload` without errors +- [ ] Tools callable by LLM, commands by user +- [ ] Tested with at least one real invocation + diff --git a/src/resources/skills/create-gsd-extension/workflows/debug-extension.md b/src/resources/skills/create-gsd-extension/workflows/debug-extension.md new file mode 100644 index 000000000..ceef023ee --- /dev/null +++ b/src/resources/skills/create-gsd-extension/workflows/debug-extension.md @@ -0,0 +1,74 @@ + +1. references/key-rules-gotchas.md +2. references/extension-lifecycle.md + + + + +## Step 1: Identify the Symptom + +| Symptom | Likely Cause | +|---------|--------------| +| Extension not loading | File not in discovery path, syntax error, missing export default | +| Tool not appearing for LLM | Tool not registered, `pi.setActiveTools()` excluding it, tool name conflict | +| Command not responding | Command not registered, name collision with built-in | +| Event not firing | Wrong event name, handler returning too early, handler error (logged but swallowed) | +| UI not rendering | `ctx.hasUI` is false (print mode), render lines exceed width, component not returning lines | +| State lost on restart | State not stored in `details` or `appendEntry`, not reconstructing on `session_start` | +| Google API errors | Using `Type.Union`/`Type.Literal` instead of `StringEnum` | +| Context overflow | Tool output not truncated | +| Deadlock/hang | Session control methods called from event handler (must be in command handler only) | +| Render garbage | Theme imported directly instead of from callback, missing `truncateToWidth()` | + +## Step 2: Check Extension Loading + +```bash +# Test in isolation +gsd -e ./path/to/extension.ts + +# Check GSD startup output for errors +# Extension errors are logged but don't crash GSD +``` + +## Step 3: Verify File Location + +Extensions must be in auto-discovery paths: +- `~/.gsd/agent/extensions/*.ts` +- `~/.gsd/agent/extensions/*/index.ts` +- `.gsd/extensions/*.ts` +- `.gsd/extensions/*/index.ts` + +The file must `export default function(pi: ExtensionAPI) { ... }`. + +## Step 4: Check for Common Mistakes + +Read `references/key-rules-gotchas.md` and verify each rule against the extension code. + +## Step 5: Add Debugging + +```typescript +// Temporary: log to stderr (visible in GSD output) +console.error("[my-ext] Loading..."); + +pi.on("session_start", async (_event, ctx) => { + console.error("[my-ext] Session started"); + ctx.ui.notify("Extension loaded", "info"); +}); +``` + +## Step 6: Fix and Reload + +Apply the fix and test: +``` +/reload +``` + + + + +Debugging is complete when: +- [ ] Root cause identified +- [ ] Fix applied +- [ ] Extension loads and functions correctly after `/reload` +- [ ] No regression in existing functionality +