@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,156 @@
1
+ # Mode Awareness
2
+
3
+ Pi runs in different modes. Extensions must handle all of them gracefully.
4
+
5
+ ## Modes
6
+
7
+ | Mode | `ctx.hasUI` | Description |
8
+ |---|---|---|
9
+ | **Interactive** | `true` | Full TUI. Normal terminal usage. |
10
+ | **RPC** (`--mode rpc`) | `true` | JSON protocol. A host application handles UI. Dialogs work via request/response. |
11
+ | **Print** (`-p`, `--mode json`) | `false` | No UI. Extensions run but cannot prompt the user. |
12
+
13
+ Important nuance: in RPC mode, `ctx.hasUI` is `true`, but TUI-only APIs are degraded.
14
+
15
+ ## Method Behavior by Mode
16
+
17
+ ### Dialog Methods (return a value)
18
+
19
+ These methods prompt the user and return a result. Their behavior varies by mode.
20
+
21
+ | Method | Interactive | RPC | Print |
22
+ |---|---|---|---|
23
+ | `ctx.ui.select()` | TUI picker | JSON request to host | Returns `undefined` |
24
+ | `ctx.ui.confirm()` | TUI dialog | JSON request to host | Returns `false` |
25
+ | `ctx.ui.input()` | TUI text input | JSON request to host | Returns `undefined` |
26
+ | `ctx.ui.editor()` | TUI editor | JSON request to host | Returns `undefined` |
27
+ | `ctx.ui.custom()` | TUI component | Returns `undefined` | Returns `undefined` |
28
+
29
+ Key observation: `custom()` returns `undefined` in both RPC and Print modes. All other dialog methods work in RPC (the host presents them to the user).
30
+
31
+ Second key observation: `custom()` can also resolve to `undefined` in Interactive mode if your component calls `done(undefined)`. So `result === undefined` is not a reliable mode detector by itself.
32
+
33
+ ### Fire-and-Forget Methods (no return value)
34
+
35
+ These methods are safe to call unconditionally in any mode. In modes that do not support them, they are silently ignored.
36
+
37
+ | Method | Interactive | RPC | Print |
38
+ |---|---|---|---|
39
+ | `ctx.ui.notify()` | TUI notification | JSON event to host | No-op |
40
+ | `ctx.ui.setStatus()` | Status bar | JSON event to host | No-op |
41
+ | `ctx.ui.setWidget()` | Widget area | JSON event to host (string arrays only) | No-op |
42
+ | `ctx.ui.setTitle()` | Window title | JSON event to host | No-op |
43
+ | `ctx.ui.setEditorText()` | Sets editor content | JSON event to host | No-op |
44
+ | `ctx.ui.setFooter()` | Footer area | No-op | No-op |
45
+ | `ctx.ui.setHeader()` | Header area | No-op | No-op |
46
+ | `ctx.ui.setWorkingMessage()` | Loader text | No-op | No-op |
47
+ | `ctx.ui.setEditorComponent()` | Custom editor | No-op | No-op |
48
+
49
+ You never need to check `ctx.hasUI` before calling fire-and-forget methods.
50
+
51
+ ## When to Check ctx.hasUI
52
+
53
+ Check `ctx.hasUI` when a dialog method gates behavior. If the dialog result determines what happens next (for example, blocking a tool call or cancelling a session switch), you must handle the case where the dialog cannot run.
54
+
55
+ ```typescript
56
+ // tool_call handler: must decide to block or allow
57
+ pi.on("tool_call", async (event, ctx) => {
58
+ if (isDangerous(event)) {
59
+ if (!ctx.hasUI) {
60
+ // Print mode: no way to ask the user, block by default
61
+ return { block: true, reason: "Dangerous command blocked (no UI)" };
62
+ }
63
+
64
+ const choice = await ctx.ui.select("Dangerous command detected", ["Allow", "Block"]);
65
+ if (choice !== "Allow") {
66
+ return { block: true, reason: "Blocked by user" };
67
+ }
68
+ }
69
+ return undefined;
70
+ });
71
+ ```
72
+
73
+ You do not need to check `ctx.hasUI` for:
74
+ - Fire-and-forget calls (`notify`, `setStatus`, `setWidget`, etc.).
75
+ - Dialogs where the default return value is acceptable (for example, a non-critical confirm that defaults to `false`).
76
+
77
+ ## The Three-Tier Pattern for Custom Components
78
+
79
+ When a command uses `ctx.ui.custom()` for a rich TUI display, handle three tiers:
80
+
81
+ ```typescript
82
+ pi.registerCommand("quotas", {
83
+ description: "Show API quotas",
84
+ handler: async (_args, ctx) => {
85
+ const data = await fetchQuotas();
86
+
87
+ // Tier 1: Print mode -- no UI at all
88
+ if (!ctx.hasUI) {
89
+ console.log(formatPlain(data));
90
+ return;
91
+ }
92
+
93
+ // Tier 2: Interactive mode -- full TUI component.
94
+ // Use an explicit non-undefined sentinel for close/cancel.
95
+ const result = await ctx.ui.custom<"closed">((tui, theme, _kb, done) => {
96
+ return new QuotasDisplay(theme, data, () => done("closed"));
97
+ });
98
+
99
+ // Tier 3: RPC mode -- custom() returns undefined by design.
100
+ if (result === undefined) {
101
+ ctx.ui.notify(formatPlain(data), "info");
102
+ }
103
+ },
104
+ });
105
+ ```
106
+
107
+ Since `select`, `confirm`, `input`, and `notify` all work in RPC mode (forwarded to the host via JSON protocol), use them as the RPC fallback. Choose based on UX:
108
+
109
+ - **`notify`**: Transient feedback or displaying data. Best for most display-only commands.
110
+ - **`select`**: When the custom component is a picker/selector. The RPC host presents a list.
111
+ - **`confirm`**: When the custom component is a confirmation dialog (for example, permission gate).
112
+ - **Notify "requires interactive mode"**: When the custom component is too complex to reduce (for example, settings editor, process manager).
113
+
114
+ Use `sendMessage` + `registerMessageRenderer` only when the result must persist in session history. See `references/messages.md`.
115
+
116
+ ### Example: Selector Fallback
117
+
118
+ ```typescript
119
+ const result = await ctx.ui.custom<string | null>((_tui, _theme, _kb, done) => {
120
+ return new FancyPicker(items, done); // done(value) or done(null)
121
+ });
122
+
123
+ // RPC fallback: use select dialog
124
+ if (result === undefined) {
125
+ const selected = await ctx.ui.select("Pick an item", items.map((i) => i.label));
126
+ // ... handle selected
127
+ }
128
+ ```
129
+
130
+ ### Example: Confirmation Fallback
131
+
132
+ ```typescript
133
+ // In a tool_call handler:
134
+ if (!ctx.hasUI) {
135
+ return { block: true, reason: "No UI to confirm" };
136
+ }
137
+
138
+ const proceed = await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
139
+ return new ConfirmDialog(theme, message, done); // done(true|false)
140
+ });
141
+
142
+ // RPC fallback: custom() returns undefined, so treat as "not approved".
143
+ if (proceed !== true) {
144
+ return { block: true, reason: "Blocked" };
145
+ }
146
+ ```
147
+
148
+ ## Guidelines
149
+
150
+ 1. Never assume Interactive mode. Always consider what happens in RPC and Print.
151
+ 2. Fire-and-forget methods are always safe. Use them freely.
152
+ 3. Guard dialog methods that gate behavior with `ctx.hasUI` checks.
153
+ 4. Always provide a fallback for `ctx.ui.custom()` because it returns `undefined` in RPC and Print.
154
+ 5. Do not use `done(undefined)` for normal interactive close paths if you need to detect RPC fallback.
155
+ 6. For `tool_call` handlers, decide a safe default when there is no UI (usually block).
156
+ 7. Test your extension in at least Interactive and Print modes. If you use `custom()`, test RPC fallback explicitly.
@@ -0,0 +1,134 @@
1
+ # Providers
2
+
3
+ Providers add LLM backends to pi. They connect pi to model APIs (OpenAI-compatible or custom).
4
+
5
+ ## Registration
6
+
7
+ ```typescript
8
+ import { Type, type ExtensionAPI, type ProviderDefinition } from "@mariozechner/pi-coding-agent";
9
+
10
+ const myProvider: ProviderDefinition = {
11
+ name: "my-provider",
12
+ models: () => {
13
+ const apiKey = process.env.MY_API_KEY;
14
+ if (!apiKey) return [];
15
+
16
+ return [
17
+ {
18
+ id: "my-provider/model-name",
19
+ name: "Model Name",
20
+ provider: "my-provider",
21
+ canStream: true,
22
+ contextLength: 128000,
23
+ maxOutputTokens: 8192,
24
+ pricing: { inputPerMillion: 3.0, outputPerMillion: 15.0 },
25
+ compat: {
26
+ type: "openai-completions",
27
+ maxTokensField: "max_tokens",
28
+ supportsDeveloperRole: false,
29
+ },
30
+ },
31
+ ];
32
+ },
33
+ apiKey: () => process.env.MY_API_KEY,
34
+ baseUrl: () => "https://api.my-provider.com/v1",
35
+ };
36
+
37
+ export default function (pi: ExtensionAPI) {
38
+ pi.registerProvider(myProvider);
39
+ }
40
+ ```
41
+
42
+ ## Provider Definition
43
+
44
+ | Field | Type | Description |
45
+ |---|---|---|
46
+ | `name` | `string` | Unique provider identifier. Used as prefix in model IDs. |
47
+ | `models` | `() => ProviderModelConfig[]` | Returns available models. Called when pi needs the model list. Return `[]` if the API key is missing. |
48
+ | `apiKey` | `() => string \| undefined` | Returns the API key. Pi calls this when making requests. |
49
+ | `baseUrl` | `() => string \| undefined` | Returns the base URL for the API. |
50
+
51
+ The `models` function is the right place to check for API key presence. If the key is missing, return an empty array and the provider will appear registered but offer no models.
52
+
53
+ ## Model Definition
54
+
55
+ | Field | Type | Description |
56
+ |---|---|---|
57
+ | `id` | `string` | Unique model ID. Convention: `provider/model-name`. |
58
+ | `name` | `string` | Display name shown in model picker. |
59
+ | `provider` | `string` | Must match the provider's `name`. |
60
+ | `canStream` | `boolean` | Whether the model supports streaming responses. |
61
+ | `contextLength` | `number` | Maximum context window in tokens. |
62
+ | `maxOutputTokens` | `number` | Maximum output tokens per response. |
63
+ | `pricing` | `object` | `{ inputPerMillion, outputPerMillion }` in USD. Used for cost display. |
64
+ | `compat` | `object` | OpenAI compatibility settings. See below. |
65
+
66
+ ## Compat Field
67
+
68
+ The `compat` field tells pi how to talk to the model's API. Most third-party APIs are OpenAI-compatible but differ in which features they support.
69
+
70
+ ```typescript
71
+ compat: {
72
+ type: "openai-completions",
73
+
74
+ // Which field name the API uses for max output tokens
75
+ maxTokensField: "max_tokens" | "max_completion_tokens",
76
+
77
+ // Whether the API supports the 'developer' role (vs 'system')
78
+ supportsDeveloperRole: boolean,
79
+
80
+ // Whether the API supports the 'store' parameter
81
+ supportsStore: boolean,
82
+
83
+ // Whether the API supports reasoning_effort parameter
84
+ supportsReasoningEffort: boolean,
85
+
86
+ // Whether usage stats are included in streaming responses
87
+ supportsUsageInStreaming: boolean,
88
+
89
+ // Whether tool results must include a 'name' field
90
+ requiresToolResultName: boolean,
91
+
92
+ // Whether an assistant message is required after tool results
93
+ requiresAssistantAfterToolResult: boolean,
94
+
95
+ // Whether thinking/reasoning must be sent as text content
96
+ requiresThinkingAsText: boolean,
97
+
98
+ // Mistral-specific tool ID requirements
99
+ requiresMistralToolIds: boolean,
100
+
101
+ // Format for thinking/reasoning blocks
102
+ thinkingFormat: "openai" | "zai" | "qwen",
103
+
104
+ // OpenRouter-specific routing hints
105
+ openRouterRouting: object,
106
+
107
+ // Vercel AI Gateway routing
108
+ vercelGatewayRouting: object,
109
+ }
110
+ ```
111
+
112
+ All fields in `compat` are optional except `type`. Start with the minimum and add fields as needed based on API behavior.
113
+
114
+ There is also `type: "openai-responses"` for providers using the OpenAI Responses API, which currently has no additional compat fields.
115
+
116
+ ## Provider with API Key Gate
117
+
118
+ Register the provider unconditionally but gate tools/commands on the API key:
119
+
120
+ ```typescript
121
+ export default function (pi: ExtensionAPI) {
122
+ // Provider always registered -- models() returns [] if no key
123
+ pi.registerProvider(myProvider);
124
+
125
+ const apiKey = process.env.MY_API_KEY;
126
+ if (!apiKey) return;
127
+
128
+ // Only register tools that need the key
129
+ pi.registerTool(createSearchTool(apiKey));
130
+ pi.registerCommand(createQuotasCommand(apiKey));
131
+ }
132
+ ```
133
+
134
+ This way the provider appears in pi's provider list even without a key, and users see a clear "no models available" state rather than a missing provider.
@@ -0,0 +1,139 @@
1
+ # Publishing
2
+
3
+ Extensions are published to npm and installed with `pi install`.
4
+
5
+ ## Package Setup
6
+
7
+ The `package.json` must have the `pi` key declaring extension resources. See `references/structure.md` for the full template.
8
+
9
+ Key fields for publishing:
10
+
11
+ ```json
12
+ {
13
+ "name": "@scope/pi-my-extension",
14
+ "version": "0.1.0",
15
+ "type": "module",
16
+ "license": "MIT",
17
+ "private": false,
18
+ "publishConfig": { "access": "public" },
19
+ "files": ["src", "README.md"],
20
+ "pi": {
21
+ "extensions": ["./src/index.ts"]
22
+ },
23
+ "peerDependencies": {
24
+ "@mariozechner/pi-coding-agent": ">=0.51.0"
25
+ }
26
+ }
27
+ ```
28
+
29
+ ## Versioning with Changesets
30
+
31
+ Use [changesets](https://github.com/changesets/changesets) for versioning and changelogs.
32
+
33
+ ### `.changeset/config.json`
34
+
35
+ ```json
36
+ {
37
+ "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
38
+ "changelog": "@changesets/cli/changelog",
39
+ "commit": false,
40
+ "fixed": [],
41
+ "linked": [],
42
+ "access": "public",
43
+ "baseBranch": "main",
44
+ "updateInternalDependencies": "patch",
45
+ "ignore": []
46
+ }
47
+ ```
48
+
49
+ ### Creating a changeset
50
+
51
+ The interactive CLI (`pnpm changeset`) is not available in headless environments. Write the file directly instead. Create `.changeset/<descriptive-name>.md`:
52
+
53
+ ```md
54
+ ---
55
+ "@scope/pi-my-extension": patch
56
+ ---
57
+
58
+ Description of what changed and why it matters to users.
59
+ ```
60
+
61
+ Bump types: `patch` for fixes and internal changes, `minor` for new user-facing features, `major` for breaking changes.
62
+
63
+ Commit the changeset file alongside the changes it describes. Multiple changeset files can coexist — they are all consumed together on the next release.
64
+
65
+ ### Releasing manually (no CI)
66
+
67
+ ```bash
68
+ pnpm changeset version # consumes .changeset/*.md, bumps version, updates CHANGELOG.md
69
+ pnpm changeset publish # publishes to npm
70
+ ```
71
+
72
+ ## GitHub Actions Automation
73
+
74
+ The recommended setup uses a single `publish.yml` workflow that runs on every push to `main`. It handles two cases automatically:
75
+
76
+ - **Pending changesets present**: opens (or updates) a version PR titled `Updating @scope/pi-my-extension to version X.Y.Z`.
77
+ - **Version PR merged**: publishes the package to npm and creates a GitHub release with a matching git tag.
78
+
79
+ Copy `.github/workflows/publish.yml` from `pi-extension-template` into the new repo. It uses `changesets/action@v1` under the hood.
80
+
81
+ The workflow requires two secrets, configured in the repo's GitHub settings under **Settings → Secrets and variables → Actions**:
82
+
83
+ - `GITHUB_TOKEN` — automatically provided by GitHub Actions, no setup needed.
84
+ - `NPM_TOKEN` — an npm automation token with publish access to the `@scope` org. Create one at npmjs.com under **Access Tokens → Generate New Token → Automation**. Add it as a repository secret named `NPM_TOKEN`. Without this, the publish step will fail silently on the version PR merge.
85
+
86
+ The workflow also sets `NPM_CONFIG_PROVENANCE=true`, which links the published package to the GitHub Actions run for supply chain transparency (requires the `id-token: write` permission, already included in the template).
87
+
88
+ ## First-time Setup for a New Package
89
+
90
+ Before the workflow can publish a package that has never been on npm:
91
+
92
+ 1. Make sure `"private": false` and `"publishConfig": { "access": "public" }` are in `package.json`.
93
+ 2. Add the `NPM_TOKEN` secret to the repo (see above).
94
+ 3. The first time the version PR is merged, the workflow publishes the package. npm will create the package entry automatically — no manual `npm publish` needed.
95
+
96
+ If the package name is scoped (e.g., `@aliou/pi-my-extension`) and the scope is new to your npm account, you may need to create the scope first at npmjs.com or run `npm publish --access public` once manually to register it.
97
+
98
+ ## Installation
99
+
100
+ Users install extensions with:
101
+
102
+ ```bash
103
+ pi install @scope/pi-my-extension
104
+ ```
105
+
106
+ Pi reads the `pi` key from the package's `package.json` to discover extensions, skills, themes, and prompts.
107
+
108
+ ## Dependency Management in Monorepos
109
+
110
+ If publishing from a monorepo that contains both public and private packages:
111
+
112
+ **Critical rule**: Public packages cannot depend on private workspace packages. This will break when users try to install your package from npm.
113
+
114
+ In the pi-extensions monorepo, this is enforced by:
115
+ - Pre-commit hook that blocks commits with invalid dependencies
116
+ - CI check that prevents merging bad dependencies
117
+ - `pnpm run check:public-deps` validates all dependencies
118
+
119
+ When adding a workspace dependency to a `package.json`:
120
+ 1. Check if the dependency is public (`"private": false` or `"publishConfig": { "access": "public" }`).
121
+ 2. If the dependency is private, either make it public, make your package private, or remove the dependency.
122
+
123
+ ## Pre-publish Checklist
124
+
125
+ - [ ] `"private": false` is set.
126
+ - [ ] `"publishConfig": { "access": "public" }` is set.
127
+ - [ ] `"files"` lists only what users need (`["src", "README.md"]`).
128
+ - [ ] `peerDependencies` version range is correct (`>=` minimum supported version).
129
+ - [ ] `@mariozechner/pi-tui` is in `peerDependencies` with `optional: true` in `peerDependenciesMeta` if imported at runtime.
130
+ - [ ] `prepare` script is `[ -d .git ] && husky || true`, not bare `husky`.
131
+ - [ ] `check:lockfile` script is present.
132
+ - [ ] `description` is clear and concise.
133
+ - [ ] `pi.extensions` paths are correct.
134
+ - [ ] `NPM_TOKEN` secret is set on the GitHub repo.
135
+ - [ ] `.github/workflows/publish.yml` is present.
136
+ - [ ] If in a monorepo: no dependency on private workspace packages (`pnpm run check:public-deps` if available).
137
+ - [ ] README documents what the extension does, required environment variables, and available tools/commands.
138
+ - [ ] If wrapping a third-party API: extension handles missing API key gracefully (notification, not crash).
139
+ - [ ] `pnpm typecheck` and `pnpm lint` pass.
@@ -0,0 +1,56 @@
1
+ # State Management
2
+
3
+ Extensions can persist state in the session history using `appendEntry`. State is reconstructed by replaying entries when a session is loaded.
4
+
5
+ ## appendEntry
6
+
7
+ Adds an entry to the session conversation. Unlike `sendMessage`, entries from `appendEntry` are explicitly for state tracking and are rendered via the tool result rendering system.
8
+
9
+ ```typescript
10
+ pi.appendEntry({
11
+ toolName: "todo",
12
+ toolCallId: `todo-${Date.now()}`,
13
+ input: { action: "add", text: "Buy groceries" },
14
+ output: "Added: Buy groceries",
15
+ display: true,
16
+ details: { items: ["Buy groceries"] },
17
+ });
18
+ ```
19
+
20
+ | Field | Type | Description |
21
+ |---|---|---|
22
+ | `toolName` | `string` | Which tool this entry is associated with. Used for rendering. |
23
+ | `toolCallId` | `string` | Unique ID for this entry. |
24
+ | `input` | `object` | The "input" shown in the entry (as if the tool was called with these params). |
25
+ | `output` | `string` | The text output (what the LLM sees). |
26
+ | `display` | `boolean` | Whether to show in TUI. |
27
+ | `details` | `object` | Rich data for the tool's `renderResult`. |
28
+
29
+ ## Reconstructing State from Session
30
+
31
+ When a session loads, you can reconstruct state by iterating over existing entries. This is typically done in a `session_start` or `session_switch` handler:
32
+
33
+ ```typescript
34
+ pi.on("session_start", async (_event, ctx) => {
35
+ // Rebuild state from session entries
36
+ const entries = ctx.getEntries();
37
+ for (const entry of entries) {
38
+ if (entry.toolName === "todo" && entry.details) {
39
+ todoItems = entry.details.items;
40
+ }
41
+ }
42
+ });
43
+ ```
44
+
45
+ This pattern makes state survive session reloads, forks, and compactions (as long as the entries are included in the compaction summary).
46
+
47
+ ## When to Use appendEntry vs sendMessage
48
+
49
+ | | `appendEntry` | `sendMessage` |
50
+ |---|---|---|
51
+ | Rendered as | Tool call/result pair | Assistant message |
52
+ | Custom renderer | Tool's `renderResult` | `registerMessageRenderer` |
53
+ | Use for | State changes, action logs | Information display, command results |
54
+ | LLM sees | The `output` field | The `content` field |
55
+
56
+ Use `appendEntry` when you are tracking state changes that need to be replayed. Use `sendMessage` when you are displaying a one-time result.