@aliou/pi-dev-kit 0.4.9 → 0.5.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,408 @@
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
15
+ commands/
16
+ my-command.ts # One file per command
17
+ components/
18
+ my-renderer.ts # Shared TUI components
19
+ providers/
20
+ index.ts # Provider registration
21
+ models.ts # Model definitions
22
+ utils/ # Internal helpers (matching, parsing, etc.)
23
+ my-helper.ts
24
+ package.json
25
+ tsconfig.json
26
+ biome.json # Linting/formatting
27
+ shell.nix # Nix dev environment
28
+ .changeset/
29
+ config.json # Changeset config for versioning
30
+ README.md
31
+ ```
32
+
33
+ Not every extension needs every directory. A simple extension with one tool might only have `src/index.ts` and `src/tools/my-tool.ts`.
34
+
35
+ ### Organization principles
36
+
37
+ - **`index.ts` and `config.ts`** stay at root. These are the two core files every non-trivial extension has.
38
+ - **Tools, commands, components, providers, hooks** each get their own directory. One file per tool/command/component.
39
+ - **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.
40
+ - **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.
41
+ - **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`.
42
+
43
+ ## package.json
44
+
45
+ ```json
46
+ {
47
+ "name": "@scope/pi-my-extension",
48
+ "version": "0.1.0",
49
+ "description": "Description of the extension",
50
+ "type": "module",
51
+ "license": "MIT",
52
+ "private": false,
53
+ "keywords": ["pi-package", "pi-extension", "pi"],
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "https://github.com/your-org/pi-my-extension"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "files": ["src", "README.md"],
62
+ "pi": {
63
+ "extensions": ["./src/index.ts"],
64
+ "skills": ["./skills"],
65
+ "themes": ["./themes"],
66
+ "prompts": ["./prompts"],
67
+ "video": "https://example.com/demo.mp4"
68
+ },
69
+ "peerDependencies": {
70
+ "@mariozechner/pi-coding-agent": ">=CURRENT_VERSION",
71
+ "@mariozechner/pi-tui": ">=CURRENT_VERSION"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@mariozechner/pi-coding-agent": { "optional": true },
75
+ "@mariozechner/pi-tui": { "optional": true }
76
+ },
77
+ "devDependencies": {
78
+ "@aliou/biome-plugins": "^0.3.0",
79
+ "@biomejs/biome": "^2.0.0",
80
+ "@changesets/cli": "^2.27.0",
81
+ "@mariozechner/pi-coding-agent": "CURRENT_VERSION",
82
+ "@mariozechner/pi-tui": "CURRENT_VERSION",
83
+ "@types/node": "^25.0.0",
84
+ "husky": "^9.0.0",
85
+ "typescript": "^5.8.0"
86
+ },
87
+ "scripts": {
88
+ "typecheck": "tsc --noEmit",
89
+ "lint": "biome check",
90
+ "format": "biome check --write",
91
+ "check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
92
+ "prepare": "[ -d .git ] && husky || true",
93
+ "changeset": "changeset",
94
+ "version": "changeset version",
95
+ "release": "pnpm changeset publish"
96
+ },
97
+ "pnpm": {
98
+ "overrides": {
99
+ "@mariozechner/pi-ai": "$@mariozechner/pi-coding-agent",
100
+ "@mariozechner/pi-tui": "$@mariozechner/pi-coding-agent"
101
+ }
102
+ },
103
+ "packageManager": "pnpm@10.26.1"
104
+ }
105
+ ```
106
+
107
+ Replace `CURRENT_VERSION` with the actual installed version of pi (e.g., `0.52.7`).
108
+
109
+ Only include `pi` sub-fields that are actually used. `skills`, `themes`, `prompts`, and `video` are optional.
110
+
111
+ ### Fields
112
+
113
+ **`pi` key**: Declares extension resources. All paths are relative to the package root.
114
+
115
+ | Field | Description |
116
+ |---|---|
117
+ | `extensions` | Array of entry point paths. Each is a TypeScript file with a default export function. |
118
+ | `skills` | Array of directories containing skill definitions. Optional. |
119
+ | `themes` | Array of directories containing theme files. Optional. |
120
+ | `prompts` | Array of directories containing prompt files. Optional. |
121
+ | `video` | URL to an `.mp4` demo video. Displayed on the pi website package listing. Not used by pi itself. Optional. |
122
+
123
+ **`peerDependencies`**: Declares the minimum pi version required. Both `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` must be listed here as optional peers if your extension imports from either at runtime. Pi already ships these packages, so marking them as optional peers prevents npm from installing duplicate copies when a user installs your extension. Use `>=` with the current version when creating.
124
+
125
+ **`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.
126
+
127
+ **`devDependencies`**: Same packages at exact pinned versions for local type checking. `pnpm install` in your repo installs peerDependencies automatically, so local development is unaffected.
128
+
129
+ **`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.
130
+
131
+ **`scripts.check:lockfile`**: Verifies the lockfile is in sync with `package.json`. Run in CI to catch accidental lockfile drift.
132
+
133
+ **`pnpm.overrides`**: Ensures pi sub-packages resolve to the version bundled with pi-coding-agent, avoiding duplicate installations.
134
+
135
+ ## tsconfig.json
136
+
137
+ ```json
138
+ {
139
+ "compilerOptions": {
140
+ "target": "ES2022",
141
+ "module": "ESNext",
142
+ "moduleResolution": "bundler",
143
+ "strict": true,
144
+ "esModuleInterop": true,
145
+ "skipLibCheck": true,
146
+ "forceConsistentCasingInFileNames": true,
147
+ "resolveJsonModule": true,
148
+ "noEmit": true
149
+ },
150
+ "include": ["src/**/*"],
151
+ "exclude": ["node_modules"]
152
+ }
153
+ ```
154
+
155
+ Extensions are loaded directly by pi (no build step). `noEmit: true` means TypeScript is only used for type checking.
156
+
157
+ **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.
158
+
159
+ ## biome.json
160
+
161
+ All extensions use Biome for linting and formatting. Canonical config:
162
+
163
+ ```json
164
+ {
165
+ "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
166
+ "plugins": [
167
+ "./node_modules/@aliou/biome-plugins/plugins/no-inline-imports.grit",
168
+ "./node_modules/@aliou/biome-plugins/plugins/no-js-import-extension.grit",
169
+ "./node_modules/@aliou/biome-plugins/plugins/no-emojis.grit"
170
+ ],
171
+ "vcs": {
172
+ "enabled": true,
173
+ "clientKind": "git",
174
+ "useIgnoreFile": true
175
+ },
176
+ "files": {
177
+ "includes": ["**/*.ts", "**/*.json"],
178
+ "ignoreUnknown": true
179
+ },
180
+ "assist": {
181
+ "actions": {
182
+ "source": {
183
+ "organizeImports": "on"
184
+ }
185
+ }
186
+ },
187
+ "linter": {
188
+ "enabled": true,
189
+ "rules": {
190
+ "recommended": true
191
+ }
192
+ },
193
+ "formatter": {
194
+ "enabled": true,
195
+ "indentStyle": "space",
196
+ "indentWidth": 2
197
+ }
198
+ }
199
+ ```
200
+
201
+ The `plugins` field requires Biome 2.x for GritQL plugin support. The `@aliou/biome-plugins` package has five plugins; three apply to pi extensions:
202
+
203
+ - `no-inline-imports`: Disallows `await import()` and `require()` inside functions. All imports must be static.
204
+ - `no-js-import-extension`: Disallows `.js` extensions in import paths (enforces the rule in Critical Rules).
205
+ - `no-emojis`: Disallows emoji characters in code and strings.
206
+
207
+ The other two (`no-interpolated-classname`, `phosphor-icon-suffix`) are specific to React and Phosphor icons and are not applicable.
208
+
209
+ ## config.ts
210
+
211
+ 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.
212
+
213
+ ```typescript
214
+ import { ConfigLoader } from "@aliou/pi-utils-settings";
215
+
216
+ /**
217
+ * Raw config shape (what gets saved to disk).
218
+ * All fields optional -- only overrides are stored.
219
+ */
220
+ export interface MyExtensionConfig {
221
+ enabled?: boolean;
222
+ myOption?: string;
223
+ }
224
+
225
+ /**
226
+ * Resolved config (defaults merged in).
227
+ * All fields required.
228
+ */
229
+ export interface ResolvedMyExtensionConfig {
230
+ enabled: boolean;
231
+ myOption: string;
232
+ }
233
+
234
+ const DEFAULTS: ResolvedMyExtensionConfig = {
235
+ enabled: true,
236
+ myOption: "default-value",
237
+ };
238
+
239
+ /**
240
+ * Config loader instance.
241
+ * Config is stored at ~/.pi/agent/extensions/<name>.json
242
+ */
243
+ export const configLoader = new ConfigLoader<
244
+ MyExtensionConfig,
245
+ ResolvedMyExtensionConfig
246
+ >("my-extension", DEFAULTS);
247
+ ```
248
+
249
+ `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.
250
+
251
+ The name passed to `ConfigLoader` determines the filename: `"my-extension"` → `~/.pi/agent/extensions/my-extension.json`.
252
+
253
+ ### Reading config
254
+
255
+ After calling `load()`, use `getConfig()` for the resolved config (defaults merged in) or `getRawConfig(scope)` for the raw config at a specific scope.
256
+
257
+ ```typescript
258
+ await configLoader.load();
259
+ const config = configLoader.getConfig(); // ResolvedMyExtensionConfig
260
+ const raw = configLoader.getRawConfig("global"); // MyExtensionConfig | null
261
+ ```
262
+
263
+ ### Saving config
264
+
265
+ 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.
266
+
267
+ ```typescript
268
+ await configLoader.save("global", { myOption: "new-value" });
269
+ // configLoader.getConfig() now reflects the saved change
270
+ ```
271
+
272
+ Memory scope is ephemeral -- it resets on reload and is not written to disk.
273
+
274
+ ### Scopes and merge order
275
+
276
+ 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.
277
+
278
+ For extensions with migrations or multi-scope config (global + local + in-memory), pass an options object:
279
+
280
+ ```typescript
281
+ export const configLoader = new ConfigLoader<MyExtensionConfig, ResolvedMyExtensionConfig>(
282
+ "my-extension",
283
+ DEFAULTS,
284
+ {
285
+ scopes: ["global", "local", "memory"],
286
+ migrations: [...],
287
+ },
288
+ );
289
+ ```
290
+
291
+ ## Entry Point (src/index.ts)
292
+
293
+ The entry point is a default export function that receives the `ExtensionAPI` object.
294
+
295
+ ### Standard Pattern
296
+
297
+ ```typescript
298
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
299
+ import { configLoader } from "./config";
300
+ import { registerCommands } from "./commands";
301
+ import { registerHooks } from "./hooks";
302
+ import { registerTools } from "./tools";
303
+
304
+ export default async function (pi: ExtensionAPI) {
305
+ await configLoader.load();
306
+ const config = configLoader.getConfig();
307
+ if (!config.enabled) return;
308
+
309
+ registerTools(pi);
310
+ registerCommands(pi);
311
+ registerHooks(pi);
312
+ }
313
+ ```
314
+
315
+ ### Acceptable Exceptions
316
+
317
+ Not all extensions follow the standard pattern exactly. These deviations are valid:
318
+
319
+ **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.
320
+
321
+ **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.
322
+
323
+ **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`.
324
+
325
+ When deviating from the standard pattern, note the reason in the extension's `AGENTS.md`.
326
+
327
+ ## API Key Pattern
328
+
329
+ If your extension wraps a third-party API that requires an API key:
330
+
331
+ ```typescript
332
+ export default function (pi: ExtensionAPI) {
333
+ const apiKey = process.env.MY_API_KEY;
334
+
335
+ // Register provider unconditionally if it exists
336
+ // (provider handles missing key internally for model registration)
337
+ pi.registerProvider(myProvider);
338
+
339
+ // Only register tools that need the key
340
+ if (!apiKey) {
341
+ pi.on("session_start", async (_event, ctx) => {
342
+ ctx.ui.notify("MY_API_KEY not set. Tools disabled.", "warning");
343
+ });
344
+ return;
345
+ }
346
+
347
+ pi.registerTool(createMyTool(apiKey));
348
+ pi.registerCommand(createMyCommand(apiKey));
349
+ }
350
+ ```
351
+
352
+ 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).
353
+
354
+ ## Imports
355
+
356
+ Do not use `.js` file extensions in imports. Use bare module paths:
357
+
358
+ ```typescript
359
+ // Correct
360
+ import { myTool } from "./tools/my-tool";
361
+ import type { MyType } from "./types";
362
+
363
+ // Wrong
364
+ import { myTool } from "./tools/my-tool.js";
365
+ ```
366
+
367
+ ## Monorepo Variant
368
+
369
+ 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.
370
+
371
+ ```
372
+ extensions/
373
+ my-extension/
374
+ index.ts # Entry point (no src/ directory)
375
+ config.ts # Config schema (types) + loader + defaults
376
+ commands/
377
+ settings-command.ts
378
+ hooks/
379
+ my-hook.ts
380
+ components/
381
+ my-editor.ts
382
+ utils/
383
+ matching.ts
384
+ shell-utils.ts
385
+ package.json
386
+ ```
387
+
388
+ Key differences from standalone:
389
+ - Entry point directly in the package root (no `src/` directory).
390
+ - `"pi": { "extensions": ["./index.ts"] }` instead of `["./src/index.ts"]`.
391
+ - Uses `peerDependencies` (resolved by workspace root).
392
+ - Shared `tsconfig` from a workspace package.
393
+ - Same organization principles apply: config types in `config.ts`, helpers in `utils/`, one directory per feature category.
394
+
395
+ ### Workspace dependencies
396
+
397
+ 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:
398
+
399
+ ```json
400
+ {
401
+ "dependencies": {
402
+ "@aliou/pi-utils-settings": "workspace:^",
403
+ "@aliou/sh": "^0.1.0"
404
+ }
405
+ }
406
+ ```
407
+
408
+ 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,54 @@
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
+ ## Debugging
46
+
47
+ Extension errors are logged to the pi log file. Check the output for stack traces:
48
+
49
+ ```bash
50
+ # View pi logs
51
+ pi --log-level debug
52
+ ```
53
+
54
+ If an extension fails to load, pi logs the error and continues without it.