@aliou/pi-dev-kit 0.4.9 → 0.6.0

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.
@@ -0,0 +1,522 @@
1
+ # Extension Structure
2
+
3
+ This covers the standalone repository structure for a Pi extension. This is the recommended layout for new extensions.
4
+
5
+ ## Directory Layout
6
+
7
+ ```
8
+ my-extension/
9
+ src/
10
+ index.ts # Entry point (default export)
11
+ config.ts # Config schema (types) + loader + defaults
12
+ client.ts # API client (if wrapping a third-party API)
13
+ tools/
14
+ my-tool.ts # One file per tool (simple tool)
15
+ my-multi-tool/ # Multi-action tool
16
+ index.ts # Tool registration + renderCall/renderResult
17
+ actions/ # One file per action
18
+ create.ts
19
+ list.ts
20
+ show.ts
21
+ render.ts # Separate render module (when rendering is complex)
22
+ types.ts # Serialized types for tool details
23
+ commands/
24
+ my-command.ts # One file per command
25
+ components/
26
+ my-renderer.ts # Shared TUI components
27
+ providers/
28
+ index.ts # Provider registration
29
+ models.ts # Model definitions
30
+ utils/ # Internal helpers (matching, parsing, etc.)
31
+ my-helper.ts
32
+ package.json
33
+ tsconfig.json
34
+ biome.json # Linting/formatting
35
+ shell.nix # Nix dev environment
36
+ .changeset/
37
+ config.json # Changeset config for versioning
38
+ README.md
39
+ ```
40
+
41
+ Not every extension needs every directory. A simple extension with one tool might only have `src/index.ts` and `src/tools/my-tool.ts`.
42
+
43
+ ### Organization principles
44
+
45
+ - **`index.ts` and `config.ts`** stay at root. These are the two core files every non-trivial extension has.
46
+ - **Tools, commands, components, providers, hooks** each get their own directory. One file per tool/command/component.
47
+ - **Config types live in `config.ts`**, not a separate `types.ts` or `config-schema.ts`. The config file exports both the types (raw and resolved) and the config loader instance.
48
+ - **Utility/helper files** go in `utils/`. This includes pattern matching, shell parsing, event helpers, migrations, etc. Anything that is not a tool, command, component, provider, or hook.
49
+ - **No separate `types.ts`** unless the extension has shared types unrelated to config (rare). Config types are the most common shared types, and they belong in `config.ts`.
50
+ - **Multi-action tools** get their own directory under `tools/`. The tool registration + rendering lives in `index.ts`, each action gets its own file in `actions/`, and complex rendering logic goes in `render.ts`. Serialized types for tool details go in `types.ts`.
51
+ - **Core/domain logic** lives in dedicated modules at the `src/` root (`client.ts`, `manager.ts`). These contain the business logic, are testable without the Pi framework, and don't import from `@mariozechner/pi-coding-agent`. Tools are thin wrappers that call these modules and format results.
52
+
53
+ ## package.json
54
+
55
+ ```json
56
+ {
57
+ "name": "@scope/pi-my-extension",
58
+ "version": "0.1.0",
59
+ "description": "Description of the extension",
60
+ "type": "module",
61
+ "license": "MIT",
62
+ "private": false,
63
+ "keywords": ["pi-package", "pi-extension", "pi"],
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "https://github.com/your-org/pi-my-extension"
67
+ },
68
+ "publishConfig": {
69
+ "access": "public"
70
+ },
71
+ "files": ["src", "README.md"],
72
+ "pi": {
73
+ "extensions": ["./src/index.ts"],
74
+ "skills": ["./skills"],
75
+ "themes": ["./themes"],
76
+ "prompts": ["./prompts"],
77
+ "video": "https://example.com/demo.mp4"
78
+ },
79
+ "peerDependencies": {
80
+ "@mariozechner/pi-coding-agent": ">=CURRENT_VERSION",
81
+ "@mariozechner/pi-ai": ">=CURRENT_VERSION",
82
+ "@mariozechner/pi-tui": ">=CURRENT_VERSION"
83
+ },
84
+ "peerDependenciesMeta": {
85
+ "@mariozechner/pi-coding-agent": { "optional": true },
86
+ "@mariozechner/pi-ai": { "optional": true },
87
+ "@mariozechner/pi-tui": { "optional": true }
88
+ },
89
+ "devDependencies": {
90
+ "@aliou/biome-plugins": "^0.3.0",
91
+ "@biomejs/biome": "^2.0.0",
92
+ "@changesets/cli": "^2.27.0",
93
+ "@mariozechner/pi-ai": "CURRENT_VERSION",
94
+ "@mariozechner/pi-coding-agent": "CURRENT_VERSION",
95
+ "@mariozechner/pi-tui": "CURRENT_VERSION",
96
+ "@types/node": "^25.0.0",
97
+ "husky": "^9.0.0",
98
+ "typescript": "^5.8.0"
99
+ },
100
+ "scripts": {
101
+ "typecheck": "tsc --noEmit",
102
+ "lint": "biome check",
103
+ "format": "biome check --write",
104
+ "check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
105
+ "prepare": "[ -d .git ] && husky || true",
106
+ "changeset": "changeset",
107
+ "version": "changeset version",
108
+ "release": "pnpm changeset publish"
109
+ },
110
+ "pnpm": {
111
+ "overrides": {
112
+ "@mariozechner/pi-ai": "$@mariozechner/pi-coding-agent",
113
+ "@mariozechner/pi-tui": "$@mariozechner/pi-coding-agent"
114
+ }
115
+ },
116
+ "packageManager": "pnpm@10.26.1"
117
+ }
118
+ ```
119
+
120
+ Replace `CURRENT_VERSION` with the actual installed version of pi (e.g., `0.52.7`).
121
+
122
+ Only include `pi` sub-fields that are actually used. `skills`, `themes`, `prompts`, and `video` are optional.
123
+
124
+ ### Fields
125
+
126
+ **`pi` key**: Declares extension resources. All paths are relative to the package root.
127
+
128
+ | Field | Description |
129
+ |---|---|
130
+ | `extensions` | Array of entry point paths. Each is a TypeScript file with a default export function. |
131
+ | `skills` | Array of directories containing skill definitions. Optional. |
132
+ | `themes` | Array of directories containing theme files. Optional. |
133
+ | `prompts` | Array of directories containing prompt files. Optional. |
134
+ | `video` | URL to an `.mp4` demo video. Displayed on the pi website package listing. Not used by pi itself. Optional. |
135
+
136
+ **`peerDependencies`**: Declares the minimum pi version required. Pi ships these packages and injects them via jiti at runtime, so extensions never need to install them:
137
+
138
+ - `@mariozechner/pi-coding-agent` — core types, utilities, `Type` (re-exported from TypeBox)
139
+ - `@mariozechner/pi-tui` — TUI components
140
+ - `@mariozechner/pi-ai` — AI utilities (`StringEnum`, etc.)
141
+ - `@sinclair/typebox` — schema definitions (also re-exported from `pi-coding-agent`)
142
+
143
+ List any of these you import at runtime in `peerDependencies` as optional peers. This prevents npm from installing duplicate copies when a user installs your extension. Use `>=` with the current version when creating.
144
+
145
+ **`peerDependenciesMeta`**: Marks peer dependencies as optional. Without `optional: true`, npm 7+ auto-installs peers that are not already present, which defeats the purpose — Pi already provides them.
146
+
147
+ **`devDependencies`**: Same packages at exact pinned versions for local type checking. `pnpm install` in your repo installs peerDependencies automatically, so local development is unaffected.
148
+
149
+ **`scripts.prepare`**: The `[ -d .git ] && husky || true` guard prevents husky from running in consumer environments (including when Pi installs your package). Without this, `husky` runs on every `npm install` and fails with a non-zero exit code in environments without a `.git` directory.
150
+
151
+ **`scripts.check:lockfile`**: Verifies the lockfile is in sync with `package.json`. Run in CI to catch accidental lockfile drift.
152
+
153
+ **`pnpm.overrides`**: Ensures pi sub-packages resolve to the version bundled with pi-coding-agent, avoiding duplicate installations.
154
+
155
+ ## tsconfig.json
156
+
157
+ ```json
158
+ {
159
+ "compilerOptions": {
160
+ "target": "ES2022",
161
+ "module": "ESNext",
162
+ "moduleResolution": "bundler",
163
+ "strict": true,
164
+ "esModuleInterop": true,
165
+ "skipLibCheck": true,
166
+ "forceConsistentCasingInFileNames": true,
167
+ "resolveJsonModule": true,
168
+ "noEmit": true
169
+ },
170
+ "include": ["src/**/*"],
171
+ "exclude": ["node_modules"]
172
+ }
173
+ ```
174
+
175
+ Extensions are loaded directly by pi (no build step). `noEmit: true` means TypeScript is only used for type checking.
176
+
177
+ **Do not add `jsx` or `jsxImportSource` settings.** Although `src/components/` exists, pi-tui components are not React components. They implement the `Component` interface from `@mariozechner/pi-tui` and render to plain strings. No JSX transpilation is involved.
178
+
179
+ ## biome.json
180
+
181
+ All extensions use Biome for linting and formatting. Canonical config:
182
+
183
+ ```json
184
+ {
185
+ "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
186
+ "plugins": [
187
+ "./node_modules/@aliou/biome-plugins/plugins/no-inline-imports.grit",
188
+ "./node_modules/@aliou/biome-plugins/plugins/no-js-import-extension.grit",
189
+ "./node_modules/@aliou/biome-plugins/plugins/no-emojis.grit"
190
+ ],
191
+ "vcs": {
192
+ "enabled": true,
193
+ "clientKind": "git",
194
+ "useIgnoreFile": true
195
+ },
196
+ "files": {
197
+ "includes": ["**/*.ts", "**/*.json"],
198
+ "ignoreUnknown": true
199
+ },
200
+ "assist": {
201
+ "actions": {
202
+ "source": {
203
+ "organizeImports": "on"
204
+ }
205
+ }
206
+ },
207
+ "linter": {
208
+ "enabled": true,
209
+ "rules": {
210
+ "recommended": true
211
+ }
212
+ },
213
+ "formatter": {
214
+ "enabled": true,
215
+ "indentStyle": "space",
216
+ "indentWidth": 2
217
+ }
218
+ }
219
+ ```
220
+
221
+ The `plugins` field requires Biome 2.x for GritQL plugin support. The `@aliou/biome-plugins` package has five plugins; three apply to pi extensions:
222
+
223
+ - `no-inline-imports`: Disallows `await import()` and `require()` inside functions. All imports must be static.
224
+ - `no-js-import-extension`: Disallows `.js` extensions in import paths (enforces the rule in Critical Rules).
225
+ - `no-emojis`: Disallows emoji characters in code and strings.
226
+
227
+ The other two (`no-interpolated-classname`, `phosphor-icon-suffix`) are specific to React and Phosphor icons and are not applicable.
228
+
229
+ ## config.ts
230
+
231
+ Non-trivial extensions have a `config.ts` that defines the config schema, types, and loader instance. Use plain TypeScript interfaces with a raw/resolved two-type pattern. The raw type has all fields optional — only overrides are stored to disk. The resolved type has all fields required — defaults are merged in at load time.
232
+
233
+ ```typescript
234
+ import { ConfigLoader } from "@aliou/pi-utils-settings";
235
+
236
+ /**
237
+ * Raw config shape (what gets saved to disk).
238
+ * All fields optional -- only overrides are stored.
239
+ */
240
+ export interface MyExtensionConfig {
241
+ enabled?: boolean;
242
+ myOption?: string;
243
+ }
244
+
245
+ /**
246
+ * Resolved config (defaults merged in).
247
+ * All fields required.
248
+ */
249
+ export interface ResolvedMyExtensionConfig {
250
+ enabled: boolean;
251
+ myOption: string;
252
+ }
253
+
254
+ const DEFAULTS: ResolvedMyExtensionConfig = {
255
+ enabled: true,
256
+ myOption: "default-value",
257
+ };
258
+
259
+ /**
260
+ * Config loader instance.
261
+ * Config is stored at ~/.pi/agent/extensions/<name>.json
262
+ */
263
+ export const configLoader = new ConfigLoader<
264
+ MyExtensionConfig,
265
+ ResolvedMyExtensionConfig
266
+ >("my-extension", DEFAULTS);
267
+ ```
268
+
269
+ `ConfigLoader` comes from `@aliou/pi-utils-settings`, a standalone published package (source: `~/code/src/github.com/aliou/pi-extensions/packages/settings/`). It is listed as a regular dependency in `package.json`, not a peer dependency.
270
+
271
+ The name passed to `ConfigLoader` determines the filename: `"my-extension"` → `~/.pi/agent/extensions/my-extension.json`.
272
+
273
+ ### Reading config
274
+
275
+ After calling `load()`, use `getConfig()` for the resolved config (defaults merged in) or `getRawConfig(scope)` for the raw config at a specific scope.
276
+
277
+ ```typescript
278
+ await configLoader.load();
279
+ const config = configLoader.getConfig(); // ResolvedMyExtensionConfig
280
+ const raw = configLoader.getRawConfig("global"); // MyExtensionConfig | null
281
+ ```
282
+
283
+ ### Saving config
284
+
285
+ Use `save(scope, config)` to persist changes. The scope must be one of the enabled scopes (`"global"`, `"local"`, or `"memory"`). After saving, the loader automatically reloads and re-merges.
286
+
287
+ ```typescript
288
+ await configLoader.save("global", { myOption: "new-value" });
289
+ // configLoader.getConfig() now reflects the saved change
290
+ ```
291
+
292
+ Memory scope is ephemeral -- it resets on reload and is not written to disk.
293
+
294
+ ### Scopes and merge order
295
+
296
+ Default scopes are `["global", "local"]`. Merge priority (lowest to highest): defaults -> global -> local -> memory. Only overrides are stored to disk; missing fields fall back to defaults.
297
+
298
+ For extensions with migrations or multi-scope config (global + local + in-memory), pass an options object:
299
+
300
+ ```typescript
301
+ export const configLoader = new ConfigLoader<MyExtensionConfig, ResolvedMyExtensionConfig>(
302
+ "my-extension",
303
+ DEFAULTS,
304
+ {
305
+ scopes: ["global", "local", "memory"],
306
+ migrations: [...],
307
+ },
308
+ );
309
+ ```
310
+
311
+ ### Config Migrations
312
+
313
+ For evolving config shape across versions, pass named migrations to `ConfigLoader`:
314
+
315
+ ```typescript
316
+ import { ConfigLoader, type Migration, buildSchemaUrl } from "@aliou/pi-utils-settings";
317
+ import pkg from "../package.json" with { type: "json" };
318
+
319
+ const legacyMigration: Migration<MyExtensionConfig> = {
320
+ name: "legacy-flat-key-to-nested",
321
+ shouldRun: (config) => Boolean(config.apiKey && !config.workspaces),
322
+ run: (config) => {
323
+ const migrated = structuredClone(config);
324
+ migrated.workspaces = { default: { apiKey: config.apiKey } };
325
+ delete migrated.apiKey;
326
+ return migrated;
327
+ },
328
+ };
329
+
330
+ const schemaUrl = buildSchemaUrl(pkg.name, pkg.version);
331
+
332
+ export const configLoader = new ConfigLoader<MyConfig, ResolvedMyConfig>(
333
+ "my-extension",
334
+ DEFAULTS,
335
+ {
336
+ schemaUrl,
337
+ migrations: [legacyMigration],
338
+ },
339
+ );
340
+ ```
341
+
342
+ Each migration has:
343
+ - `name`: unique identifier for idempotency
344
+ - `shouldRun(config)`: predicate that returns true if migration is needed
345
+ - `run(config)`: returns the migrated config (must not mutate the input)
346
+
347
+ ### JSON Schema for Config Validation
348
+
349
+ Use `buildSchemaUrl(pkg.name, pkg.version)` from `@aliou/pi-utils-settings` to generate a schema URL. Config files get a `$schema` field pointing to the published schema, enabling editor validation and autocompletion.
350
+
351
+ ## Settings Command
352
+
353
+ Extensions with user-configurable settings use `registerSettingsCommand` from `@aliou/pi-utils-settings` to create a settings UI with Local/Global tabs:
354
+
355
+ ```typescript
356
+ import { registerSettingsCommand, type SettingsSection } from "@aliou/pi-utils-settings";
357
+
358
+ registerSettingsCommand<MyConfig, ResolvedMyConfig>(pi, {
359
+ commandName: "my-extension:settings",
360
+ commandDescription: "Configure my extension",
361
+ title: "My Extension Settings",
362
+ configStore: configLoader,
363
+ onSave: () => { /* invalidate caches */ },
364
+ buildSections: (tabConfig, resolved, ctx): SettingsSection[] => [
365
+ {
366
+ label: "General",
367
+ items: [
368
+ {
369
+ id: "enabled",
370
+ label: "Enabled",
371
+ description: "Enable or disable the extension",
372
+ currentValue: (tabConfig?.enabled ?? resolved.enabled) ? "enabled" : "disabled",
373
+ values: ["enabled", "disabled"],
374
+ },
375
+ ],
376
+ },
377
+ ],
378
+ });
379
+ ```
380
+
381
+ For complex nested config (workspaces, profiles), use `submenu` fields with `SettingsDetailEditor` or `FuzzySelector` components. See `pi-linear/src/commands/settings.ts` for a full example.
382
+
383
+ ### Auth Wizard
384
+
385
+ For extensions requiring API credentials, use the `Wizard` component from `@aliou/pi-utils-settings` for multi-step onboarding:
386
+
387
+ ```typescript
388
+ import { Wizard, FuzzySelector, type WizardStepContext } from "@aliou/pi-utils-settings";
389
+
390
+ const wizard = new Wizard({
391
+ title: "My Auth",
392
+ theme,
393
+ steps: [
394
+ { label: "Key", build: (ctx) => new ApiKeyStep(state, ctx) },
395
+ { label: "Validate", build: (ctx) => new ValidateStep(state, ctx) },
396
+ { label: "Scope", build: (ctx) => new ScopeStep(state, ctx) },
397
+ ],
398
+ onComplete: async () => { /* save config */ },
399
+ onCancel: () => done(false),
400
+ });
401
+ ```
402
+
403
+ Each step receives a `WizardStepContext` with `markComplete()`/`markIncomplete()` to control navigation gates. See `pi-linear/src/commands/auth-wizard.ts` for a full example with async validation and spinner.
404
+
405
+ ## Entry Point (src/index.ts)
406
+
407
+ The entry point is a default export function that receives the `ExtensionAPI` object.
408
+
409
+ ### Standard Pattern
410
+
411
+ ```typescript
412
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
413
+ import { configLoader } from "./config";
414
+ import { registerCommands } from "./commands";
415
+ import { registerHooks } from "./hooks";
416
+ import { registerTools } from "./tools";
417
+
418
+ export default async function (pi: ExtensionAPI) {
419
+ await configLoader.load();
420
+ const config = configLoader.getConfig();
421
+ if (!config.enabled) return;
422
+
423
+ registerTools(pi);
424
+ registerCommands(pi);
425
+ registerHooks(pi);
426
+ }
427
+ ```
428
+
429
+ ### Acceptable Exceptions
430
+
431
+ Not all extensions follow the standard pattern exactly. These deviations are valid:
432
+
433
+ **No config**: Extensions that use environment variables exclusively and have no user-configurable settings skip config loading entirely. The entry point reads the env var directly and gates registration on its presence.
434
+
435
+ **API-key-first**: Extensions wrapping a third-party API check for the API key before loading config or registering anything. If the key is missing, notify the user and return early. Config loads after the key check. See the API Key Pattern section.
436
+
437
+ **No `enabled` check**: Extensions that are always active by design omit the `enabled` field and the early-return check. The entry point still loads config for other settings. Document this decision in `AGENTS.md`.
438
+
439
+ When deviating from the standard pattern, note the reason in the extension's `AGENTS.md`.
440
+
441
+ ## API Key Pattern
442
+
443
+ If your extension wraps a third-party API that requires an API key:
444
+
445
+ ```typescript
446
+ export default function (pi: ExtensionAPI) {
447
+ const apiKey = process.env.MY_API_KEY;
448
+
449
+ // Register provider unconditionally if it exists
450
+ // (provider handles missing key internally for model registration)
451
+ pi.registerProvider(myProvider);
452
+
453
+ // Only register tools that need the key
454
+ if (!apiKey) {
455
+ pi.on("session_start", async (_event, ctx) => {
456
+ ctx.ui.notify("MY_API_KEY not set. Tools disabled.", "warning");
457
+ });
458
+ return;
459
+ }
460
+
461
+ pi.registerTool(createMyTool(apiKey));
462
+ pi.registerCommand(createMyCommand(apiKey));
463
+ }
464
+ ```
465
+
466
+ The principle: check for the API key before registering anything that requires it. If the extension also registers a provider, the provider can be registered regardless (it handles key presence internally for model listing).
467
+
468
+ ## Imports
469
+
470
+ Do not use `.js` file extensions in imports. Use bare module paths:
471
+
472
+ ```typescript
473
+ // Correct
474
+ import { myTool } from "./tools/my-tool";
475
+ import type { MyType } from "./types";
476
+
477
+ // Wrong
478
+ import { myTool } from "./tools/my-tool.js";
479
+ ```
480
+
481
+ ## Monorepo Variant
482
+
483
+ In a monorepo with pnpm workspaces, the structure differs slightly. There is no `src/` directory; the entry point and config live directly in the package root.
484
+
485
+ ```
486
+ extensions/
487
+ my-extension/
488
+ index.ts # Entry point (no src/ directory)
489
+ config.ts # Config schema (types) + loader + defaults
490
+ commands/
491
+ settings-command.ts
492
+ hooks/
493
+ my-hook.ts
494
+ components/
495
+ my-editor.ts
496
+ utils/
497
+ matching.ts
498
+ shell-utils.ts
499
+ package.json
500
+ ```
501
+
502
+ Key differences from standalone:
503
+ - Entry point directly in the package root (no `src/` directory).
504
+ - `"pi": { "extensions": ["./index.ts"] }` instead of `["./src/index.ts"]`.
505
+ - Uses `peerDependencies` (resolved by workspace root).
506
+ - Shared `tsconfig` from a workspace package.
507
+ - Same organization principles apply: config types in `config.ts`, helpers in `utils/`, one directory per feature category.
508
+
509
+ ### Workspace dependencies
510
+
511
+ When an extension depends on another workspace package (e.g., `@aliou/pi-utils-settings`, `@aliou/pi-agent-kit`), use the `workspace:^` protocol instead of a version range:
512
+
513
+ ```json
514
+ {
515
+ "dependencies": {
516
+ "@aliou/pi-utils-settings": "workspace:^",
517
+ "@aliou/sh": "^0.1.0"
518
+ }
519
+ }
520
+ ```
521
+
522
+ Use `workspace:^` only for packages that live in this monorepo (under `packages/` or `extensions/`). External published packages like `@aliou/sh` keep regular version ranges.
@@ -0,0 +1,183 @@
1
+ # Testing
2
+
3
+ Pi loads extensions directly from TypeScript source (no build step). Testing is done by running pi with the extension loaded.
4
+
5
+ ## Local Development
6
+
7
+ During development, the extension is loaded from the local filesystem. Point pi to your extension's `package.json` directory:
8
+
9
+ ```bash
10
+ # From within the extension directory
11
+ pi
12
+ ```
13
+
14
+ Pi reads the `pi.extensions` paths from `package.json` and loads the entry points.
15
+
16
+ ## Type Checking
17
+
18
+ Run TypeScript type checking to catch errors without building:
19
+
20
+ ```bash
21
+ pnpm tsc --noEmit
22
+ # or if configured in package.json:
23
+ pnpm typecheck
24
+ ```
25
+
26
+ ## Manual Testing Checklist
27
+
28
+ - [ ] Extension loads without errors.
29
+ - [ ] Tools appear in the tool list and work when called by the LLM.
30
+ - [ ] Commands appear in autocomplete and work when invoked.
31
+ - [ ] Custom renderers display correctly (both partial and final states).
32
+ - [ ] Missing API key shows a notification, not a crash.
33
+ - [ ] Works in Print mode (`pi -p "test message"`): no UI errors, graceful degradation.
34
+ - [ ] If using `ctx.ui.custom()`: RPC fallback is exercised (`custom()` returns undefined in RPC), and interactive close paths use explicit non-undefined sentinels (no accidental `done(undefined)` ambiguity).
35
+
36
+ ## Testing Hooks
37
+
38
+ Test event hooks by triggering the relevant actions:
39
+
40
+ - `tool_call`: Have the LLM call a tool that your hook intercepts.
41
+ - `session_before_switch`: Create a new session or switch sessions.
42
+ - `input`: Type a message that matches your transform pattern.
43
+ - `before_agent_start`: Start any agent turn and verify system prompt modifications.
44
+
45
+ ## Unit Testing Core Logic
46
+
47
+ The core/lib pattern makes domain logic testable without the Pi framework. Extract business logic into modules that don't import from `@mariozechner/pi-coding-agent` and test them directly.
48
+
49
+ ### Testable core modules
50
+
51
+ ```typescript
52
+ // src/manager.ts — no Pi imports
53
+ export class ProcessManager {
54
+ start(name: string, command: string, cwd: string): ProcessInfo { ... }
55
+ get(id: string): ProcessInfo | undefined { ... }
56
+ kill(id: string): Promise<KillResult> { ... }
57
+ }
58
+ ```
59
+
60
+ ```typescript
61
+ // src/manager.test.ts
62
+ import { describe, it, expect, afterEach } from "vitest";
63
+ import { ProcessManager } from "./manager";
64
+
65
+ describe("ProcessManager", () => {
66
+ let manager: ProcessManager;
67
+ afterEach(() => manager.cleanup());
68
+
69
+ it("starts a process and returns info", () => {
70
+ manager = new ProcessManager();
71
+ const info = manager.start("test", "echo hello", "/tmp");
72
+ expect(info.id).toMatch(/^proc_/);
73
+ expect(info.name).toBe("test");
74
+ });
75
+ });
76
+ ```
77
+
78
+ ### Testable execute functions
79
+
80
+ Export the execute logic as a pure function with injected dependencies:
81
+
82
+ ```typescript
83
+ // src/tools/read-url.ts
84
+ export async function executeReadUrlRequest(
85
+ input: string,
86
+ signal: AbortSignal | undefined,
87
+ handlers: ReadUrlHandler[],
88
+ fetchImpl: FetchLike = fetch,
89
+ ): Promise<ExecuteResult> {
90
+ // all logic here, no Pi imports
91
+ }
92
+
93
+ // In the tool registration:
94
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
95
+ return executeReadUrlRequest(params.url, signal, handlers, fetch);
96
+ }
97
+ ```
98
+
99
+ ```typescript
100
+ // src/tools/read-url.test.ts
101
+ import { describe, it, expect } from "vitest";
102
+ import { executeReadUrlRequest } from "./read-url";
103
+
104
+ const mockHandler = {
105
+ name: "mock",
106
+ matches: (url: URL) => url.hostname === "example.com",
107
+ fetchData: async () => ({ markdown: "# Hello", sourceUrl: "..." }),
108
+ };
109
+
110
+ describe("executeReadUrlRequest", () => {
111
+ it("routes to matching handler", async () => {
112
+ const result = await executeReadUrlRequest(
113
+ "https://example.com/page",
114
+ undefined,
115
+ [mockHandler],
116
+ );
117
+ expect(result.details.handler).toBe("mock");
118
+ });
119
+ });
120
+ ```
121
+
122
+ ### Handler pattern
123
+
124
+ For tools that route to different backends based on input, use an interface:
125
+
126
+ ```typescript
127
+ export interface ReadUrlHandler {
128
+ name: string;
129
+ matches(url: URL): boolean;
130
+ fetchData(url: URL, signal?: AbortSignal): Promise<HandlerResult>;
131
+ }
132
+ ```
133
+
134
+ Multiple handlers are tried in order. Each handler is independently testable.
135
+
136
+ ### Pi stub for hook testing
137
+
138
+ When testing hooks or tool registration, create a minimal Pi stub:
139
+
140
+ ```typescript
141
+ function createPiStub() {
142
+ const toolCallHandlers: Array<Parameters<ExtensionAPI["on"]>[1]> = [];
143
+ const registeredTools: unknown[] = [];
144
+
145
+ const pi = {
146
+ on(eventName: string, handler: Parameters<ExtensionAPI["on"]>[1]) {
147
+ if (eventName === "tool_call") toolCallHandlers.push(handler);
148
+ },
149
+ registerTool(tool: unknown) {
150
+ registeredTools.push(tool);
151
+ },
152
+ } as unknown as ExtensionAPI;
153
+
154
+ return { pi, toolCallHandlers, registeredTools };
155
+ }
156
+ ```
157
+
158
+ ### Test setup
159
+
160
+ Extensions use vitest. Add to `package.json`:
161
+
162
+ ```json
163
+ {
164
+ "devDependencies": {
165
+ "vitest": "^3.2.0"
166
+ },
167
+ "scripts": {
168
+ "test": "vitest run",
169
+ "test:watch": "vitest"
170
+ }
171
+ }
172
+ ```
173
+
174
+ ## Debugging
175
+
176
+ Extension errors are logged to the pi log file. Check the output for stack traces:
177
+
178
+ ```bash
179
+ # View pi logs
180
+ pi --log-level debug
181
+ ```
182
+
183
+ If an extension fails to load, pi logs the error and continues without it.