@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.
- package/README.md +60 -0
- package/package.json +63 -2
- package/src/commands/index.ts +6 -0
- package/src/commands/update.ts +144 -0
- package/src/index.ts +8 -0
- package/src/prompts/setup-demo.md +35 -0
- package/src/skills/demo-setup/SKILL.md +217 -0
- package/src/skills/pi-extension/SKILL.md +154 -0
- package/src/skills/pi-extension/references/additional-apis.md +304 -0
- package/src/skills/pi-extension/references/commands.md +100 -0
- package/src/skills/pi-extension/references/components.md +166 -0
- package/src/skills/pi-extension/references/documentation.md +54 -0
- package/src/skills/pi-extension/references/hooks.md +244 -0
- package/src/skills/pi-extension/references/messages.md +169 -0
- package/src/skills/pi-extension/references/modes.md +156 -0
- package/src/skills/pi-extension/references/providers.md +134 -0
- package/src/skills/pi-extension/references/publish.md +139 -0
- package/src/skills/pi-extension/references/state.md +56 -0
- package/src/skills/pi-extension/references/structure.md +522 -0
- package/src/skills/pi-extension/references/testing.md +183 -0
- package/src/skills/pi-extension/references/tools.md +948 -0
- package/src/tools/changelog-tool.ts +484 -0
- package/src/tools/docs-tool.ts +181 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/package-manager-tool.ts +194 -0
- package/src/tools/utils.ts +38 -0
- package/src/tools/version-tool.ts +70 -0
|
@@ -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.
|