moonpi 0.4.3 → 0.4.4

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 CHANGED
@@ -54,7 +54,7 @@ npm install -g @mariozechner/pi-coding-agent
54
54
  Then, install the `moonpi` extension set directly via `pi`:
55
55
 
56
56
  ```bash
57
- pi install git:github.com/galatolofederico/moonpi@v0.4.1
57
+ pi install npm:moonpi
58
58
  ```
59
59
 
60
60
 
@@ -182,7 +182,150 @@ Example output:
182
182
 
183
183
  At startup, a notification shows which files are currently selected for injection.
184
184
 
185
- ### Configuration
185
+ ## Custom Providers
186
+
187
+ moonpi includes the support from some custom providers, and provides slash commands to manage custom providers in `~/.pi/agent/models.json`.
188
+
189
+
190
+ ### Synthetic Provider
191
+
192
+ Moonpi registers Synthetic as the `synthetic` provider using the OpenAI-compatible endpoint.
193
+
194
+ Configure credentials with either:
195
+
196
+ ```bash
197
+ export SYNTHETIC_API_KEY=...
198
+ ```
199
+
200
+ or run:
201
+
202
+ ```text
203
+ /login
204
+ ```
205
+
206
+ Use `/model` to select a `synthetic` model. Use `/synthetic:quotas` to show your weekly token and rolling 5h usage quotas:
207
+
208
+ ![Synthetic quotas output](assets/screenshots/moonpi-syn.png)
209
+
210
+
211
+ ### Managing Custom Providers
212
+
213
+ moonpi provides five slash commands to manage custom providers in `~/.pi/agent/models.json`:
214
+
215
+ #### `/custom-provider:add-provider`
216
+
217
+ Interactive wizard that adds a new custom provider. Prompts for:
218
+
219
+ 1. **Provider name** — a unique identifier (e.g. `my-vllm`)
220
+ 2. **API type** — select from all supported APIs (`openai-completions`, `anthropic-messages`, `google-generative-ai`, etc.)
221
+ 3. **Base URL** — the API endpoint (sensible defaults per API type)
222
+ 4. **API key** — your API key or an environment variable name
223
+
224
+ Example `models.json` result:
225
+
226
+ ```json
227
+ {
228
+ "providers": {
229
+ "my-vllm": {
230
+ "baseUrl": "http://127.0.0.1:8000/v1",
231
+ "api": "openai-completions",
232
+ "apiKey": "none",
233
+ "models": []
234
+ }
235
+ }
236
+ }
237
+ ```
238
+
239
+ #### `/custom-provider:add-model`
240
+
241
+ Adds a model to an existing custom provider. Prompts for:
242
+
243
+ 1. **Provider** — select from existing custom providers
244
+ 2. **Model ID** — the model identifier (e.g. `Qwen/Qwen3-27B`)
245
+ 3. **Display name** — optional human-readable name
246
+ 4. **Advanced options** — optional configuration for reasoning, context window, max tokens, image input, and API type override
247
+
248
+ #### `/custom-provider:scan-models`
249
+
250
+ Auto-detects models from an OpenAI-compatible provider endpoint. Works with providers using `openai-completions` or `openai-responses` API.
251
+
252
+ 1. **Select provider** — choose from OpenAI-compatible custom providers
253
+ 2. **Scan** — fetches `/v1/models` from the provider's base URL
254
+ 3. **Select models** — checkbox UI shows all discovered models; already-added models are greyed out
255
+ - `Space` to toggle individual models
256
+ - `a`/`A` to select/deselect all
257
+ - `Enter` to confirm (adds all new models if none selected)
258
+ 4. Auto-fills `contextWindow` from `max_model_len` when available
259
+
260
+ #### `/custom-provider:remove-provider`
261
+
262
+ Removes a custom provider and all its models from `models.json`. Asks for confirmation before deleting.
263
+
264
+ #### `/custom-provider:remove-model`
265
+
266
+ Removes a single model from a custom provider. Prompts for the provider, then the model to remove, with confirmation.
267
+
268
+ After any change, run `/reload` to refresh pi's model registry and make new models available in `/model`.
269
+
270
+ ## Moonpi loop
271
+
272
+ moonpi includes sprint-oriented loop for larger projects.
273
+
274
+ ### `/sprint:init`
275
+
276
+ Creates a new sprint for a larger project.
277
+
278
+ This command asks **one question**: the sprint objective.
279
+
280
+ It then delegates SPRINT.md and TASKS.md creation to the agent, which writes:
281
+
282
+ ```txt
283
+ ./sprints/<sprint_number>/SPRINT.md
284
+ ./sprints/<sprint_number>/TASKS.md
285
+ ```
286
+
287
+ The sprint is divided into phases.
288
+
289
+ Each phase includes tasks and verification steps that define when the phase is complete.
290
+
291
+ The goal is to turn a vague big project into a concrete, phased execution plan.
292
+
293
+ ### `/sprint:loop`
294
+
295
+ Runs the latest sprint phase-by-phase. Automatically picks the most recent sprint.
296
+
297
+ The loop works like this:
298
+
299
+ 1. complete one phase
300
+ 2. mark completed tasks in:
301
+
302
+ ```txt
303
+ ./sprints/<sprint_number>/TASKS.md
304
+ ```
305
+
306
+ 3. compact the conversation/context
307
+ 4. proceed to the next phase
308
+ 5. repeat until the sprint is complete
309
+
310
+ The model signals the end of a phase by calling a special `end_phase` tool.
311
+
312
+ This keeps long-running projects simple, resumable, and grounded in actual files.
313
+
314
+ ### Does it work?
315
+
316
+ Watch a drastically sped-up video of `Qwen/Qwen3.6-27B` working unattended for over an hour on this sprint prompt:
317
+
318
+ ```text
319
+ create WebOS a fully functional web-based operating system with apps, games and everything
320
+ ```
321
+
322
+ https://github.com/user-attachments/assets/92670a55-a3c4-4c31-a4a2-0afc449f0137
323
+
324
+
325
+ And judge the result yourself [here](https://qwen36-27b-moonpi-webos.netlify.app/).
326
+
327
+
328
+ ## Configuration
186
329
 
187
330
  Configure `.pi/moonpi.json` (project) or `~/.pi/agent/moonpi.json` (global):
188
331
 
@@ -316,89 +459,6 @@ If the model tries to write to a file without reading it first, the write tool r
316
459
 
317
460
  This prevents careless overwrites and forces the agent to inspect the current state of a file before modifying it.
318
461
 
319
- ## Moonpi loop
320
-
321
- moonpi includes sprint-oriented loop for larger projects.
322
-
323
- ### `/sprint:init`
324
-
325
- Creates a new sprint for a larger project.
326
-
327
- This command asks **one question**: the sprint objective.
328
-
329
- It then delegates SPRINT.md and TASKS.md creation to the agent, which writes:
330
-
331
- ```txt
332
- ./sprints/<sprint_number>/SPRINT.md
333
- ./sprints/<sprint_number>/TASKS.md
334
- ```
335
-
336
- The sprint is divided into phases.
337
-
338
- Each phase includes tasks and verification steps that define when the phase is complete.
339
-
340
- The goal is to turn a vague big project into a concrete, phased execution plan.
341
-
342
- ### `/sprint:loop`
343
-
344
- Runs the latest sprint phase-by-phase. Automatically picks the most recent sprint.
345
-
346
- The loop works like this:
347
-
348
- 1. complete one phase
349
- 2. mark completed tasks in:
350
-
351
- ```txt
352
- ./sprints/<sprint_number>/TASKS.md
353
- ```
354
-
355
- 3. compact the conversation/context
356
- 4. proceed to the next phase
357
- 5. repeat until the sprint is complete
358
-
359
- The model signals the end of a phase by calling a special `end_phase` tool.
360
-
361
- This keeps long-running projects simple, resumable, and grounded in actual files.
362
-
363
- ### Does it work?
364
-
365
- Watch a drastically sped-up video of `Qwen/Qwen3.6-27B` working unattended for over an hour on this sprint prompt:
366
-
367
- ```text
368
- create WebOS a fully functional web-based operating system with apps, games and everything
369
- ```
370
-
371
- https://github.com/user-attachments/assets/92670a55-a3c4-4c31-a4a2-0afc449f0137
372
-
373
-
374
- And judge the result yourself [here](https://qwen36-27b-moonpi-webos.netlify.app/).
375
-
376
- ## Custom Providers
377
-
378
- moonpi includes the support from some custom providers.
379
-
380
-
381
- ### Synthetic Provider
382
-
383
- Moonpi registers Synthetic as the `synthetic` provider using the OpenAI-compatible endpoint.
384
-
385
- Configure credentials with either:
386
-
387
- ```bash
388
- export SYNTHETIC_API_KEY=...
389
- ```
390
-
391
- or run:
392
-
393
- ```text
394
- /login
395
- ```
396
-
397
- Use `/model` to select a `synthetic` model. Use `/synthetic:quotas` to show your weekly token and rolling 5h usage quotas:
398
-
399
- ![Synthetic quotas output](assets/screenshots/moonpi-syn.png)
400
-
401
-
402
462
  ## Why moonpi?
403
463
 
404
464
  moonpi is not trying to be a giant agent framework.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moonpi",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Opinionated set of extensions for pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,673 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
5
+ import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
6
+
7
+ // =========================================================================
8
+ // models.json helpers
9
+ // =========================================================================
10
+
11
+ /** The API types that custom providers can use. */
12
+ const KNOWN_APIS = [
13
+ "openai-completions",
14
+ "openai-responses",
15
+ "anthropic-messages",
16
+ "google-generative-ai",
17
+ "google-vertex",
18
+ "mistral-conversations",
19
+ "bedrock-converse-stream",
20
+ "azure-openai-responses",
21
+ "openai-codex-responses",
22
+ ] as const;
23
+
24
+ /** APIs that are OpenAI-compatible and support the /v1/models endpoint. */
25
+ const OPENAI_COMPAT_APIS = new Set([
26
+ "openai-completions",
27
+ "openai-responses",
28
+ ]);
29
+
30
+ interface ModelDefinition {
31
+ id: string;
32
+ name?: string;
33
+ api?: string;
34
+ baseUrl?: string;
35
+ reasoning?: boolean;
36
+ input?: string[];
37
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
38
+ contextWindow?: number;
39
+ maxTokens?: number;
40
+ headers?: Record<string, string>;
41
+ compat?: Record<string, unknown>;
42
+ }
43
+
44
+ interface ProviderConfig {
45
+ name?: string;
46
+ baseUrl?: string;
47
+ apiKey?: string;
48
+ api?: string;
49
+ headers?: Record<string, string>;
50
+ compat?: Record<string, unknown>;
51
+ authHeader?: boolean;
52
+ models?: ModelDefinition[];
53
+ modelOverrides?: Record<string, unknown>;
54
+ }
55
+
56
+ interface ModelsConfig {
57
+ providers: Record<string, ProviderConfig>;
58
+ }
59
+
60
+ function getModelsJsonPath(): string {
61
+ return join(getAgentDir(), "models.json");
62
+ }
63
+
64
+ function readModelsJson(): ModelsConfig {
65
+ const filePath = getModelsJsonPath();
66
+ if (!existsSync(filePath)) {
67
+ return { providers: {} };
68
+ }
69
+ try {
70
+ const raw = readFileSync(filePath, "utf-8");
71
+ // Strip // line comments (same as pi's model-registry.ts)
72
+ const stripped = raw
73
+ .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
74
+ .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
75
+ return JSON.parse(stripped) as ModelsConfig;
76
+ } catch {
77
+ return { providers: {} };
78
+ }
79
+ }
80
+
81
+ function writeModelsJson(config: ModelsConfig): void {
82
+ const filePath = getModelsJsonPath();
83
+ const dir = join(filePath, "..");
84
+ if (!existsSync(dir)) {
85
+ mkdirSync(dir, { recursive: true });
86
+ }
87
+ writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
88
+ }
89
+
90
+ // =========================================================================
91
+ // Command: /custom-provider:add-provider
92
+ // =========================================================================
93
+
94
+ async function addProviderCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
95
+ const config = readModelsJson();
96
+
97
+ // 1. Provider name
98
+ const providerName = await ctx.ui.input("Provider name (e.g. my-vllm)", "my-provider");
99
+ if (!providerName?.trim()) {
100
+ ctx.ui.notify("Cancelled: no provider name given.", "warning");
101
+ return;
102
+ }
103
+ const name = providerName.trim();
104
+
105
+ if (config.providers[name]) {
106
+ const overwrite = await ctx.ui.confirm(
107
+ `Provider "${name}" already exists. Overwrite?`,
108
+ `Existing provider has baseUrl: ${config.providers[name]!.baseUrl ?? "none"}, api: ${config.providers[name]!.api ?? "none"}`,
109
+ );
110
+ if (!overwrite) {
111
+ ctx.ui.notify("Cancelled.", "info");
112
+ return;
113
+ }
114
+ }
115
+
116
+ // 2. API type
117
+ const selectedApi = await ctx.ui.select("Select API type", [...KNOWN_APIS]);
118
+ if (!selectedApi) {
119
+ ctx.ui.notify("Cancelled: no API type selected.", "warning");
120
+ return;
121
+ }
122
+
123
+ // 3. Base URL
124
+ const defaultBaseUrl = selectedApi === "anthropic-messages"
125
+ ? "https://api.anthropic.com"
126
+ : selectedApi === "google-generative-ai"
127
+ ? "https://generativelanguage.googleapis.com"
128
+ : "";
129
+ const baseUrl = await ctx.ui.input("Base URL", defaultBaseUrl || "http://localhost:8000/v1");
130
+ if (!baseUrl?.trim()) {
131
+ ctx.ui.notify("Cancelled: no base URL given.", "warning");
132
+ return;
133
+ }
134
+
135
+ // 4. API key
136
+ const apiKey = await ctx.ui.input("API key (or env var name like MY_API_KEY)", "none");
137
+ if (!apiKey?.trim()) {
138
+ ctx.ui.notify("Cancelled: no API key given.", "warning");
139
+ return;
140
+ }
141
+
142
+ // Build the provider config
143
+ const providerConfig: ProviderConfig = {
144
+ baseUrl: baseUrl.trim(),
145
+ api: selectedApi,
146
+ apiKey: apiKey.trim(),
147
+ models: [],
148
+ };
149
+
150
+ config.providers[name] = providerConfig;
151
+ writeModelsJson(config);
152
+
153
+ ctx.ui.notify(
154
+ `Provider "${name}" added to models.json.\n` +
155
+ ` API: ${selectedApi}\n` +
156
+ ` Base URL: ${baseUrl.trim()}\n` +
157
+ ` API Key: ${apiKey.trim()}\n\n` +
158
+ `Use /custom-provider:add-model to add models, or /custom-provider:scan-models to auto-detect them.\n` +
159
+ `Run /reload to refresh pi's model registry.`,
160
+ "info",
161
+ );
162
+ }
163
+
164
+ // =========================================================================
165
+ // Command: /custom-provider:add-model
166
+ // =========================================================================
167
+
168
+ async function addModelCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
169
+ const config = readModelsJson();
170
+ const providerNames = Object.keys(config.providers);
171
+
172
+ if (providerNames.length === 0) {
173
+ ctx.ui.notify(
174
+ "No custom providers found. Use /custom-provider:add-provider first.",
175
+ "warning",
176
+ );
177
+ return;
178
+ }
179
+
180
+ // 1. Select provider
181
+ const selectedProvider = await ctx.ui.select("Select provider to add model to", providerNames);
182
+ if (!selectedProvider) {
183
+ ctx.ui.notify("Cancelled: no provider selected.", "warning");
184
+ return;
185
+ }
186
+
187
+ const provider = config.providers[selectedProvider]!;
188
+
189
+ // 2. Model ID
190
+ const modelId = await ctx.ui.input("Model ID (e.g. Qwen/Qwen3-27B)", "");
191
+ if (!modelId?.trim()) {
192
+ ctx.ui.notify("Cancelled: no model ID given.", "warning");
193
+ return;
194
+ }
195
+
196
+ // Check for duplicates
197
+ const existingModels = provider.models ?? [];
198
+ if (existingModels.some((m) => m.id === modelId.trim())) {
199
+ ctx.ui.notify(`Model "${modelId.trim()}" already exists in provider "${selectedProvider}".`, "warning");
200
+ return;
201
+ }
202
+
203
+ // 3. Optional fields
204
+ const modelName = await ctx.ui.input("Model display name (optional, press Enter to skip)", modelId.trim());
205
+
206
+ const wantsAdvanced = await ctx.ui.confirm("Configure advanced options?", "Context window, max tokens, reasoning, input types");
207
+ const modelDef: ModelDefinition = {
208
+ id: modelId.trim(),
209
+ ...(modelName?.trim() && modelName.trim() !== modelId.trim() ? { name: modelName.trim() } : {}),
210
+ };
211
+
212
+ if (wantsAdvanced) {
213
+ // API override
214
+ const overrideApi = await ctx.ui.confirm("Override API type for this model?", `Provider default: ${provider.api ?? "unknown"}`);
215
+ if (overrideApi) {
216
+ const modelApi = await ctx.ui.select("Select model API type", [...KNOWN_APIS]);
217
+ if (modelApi) modelDef.api = modelApi;
218
+ }
219
+
220
+ // Reasoning
221
+ const reasoning = await ctx.ui.confirm("Does this model support reasoning/thinking?", "");
222
+ modelDef.reasoning = reasoning;
223
+
224
+ // Context window
225
+ const ctxWindow = await ctx.ui.input("Context window (tokens)", "128000");
226
+ if (ctxWindow?.trim()) {
227
+ const val = parseInt(ctxWindow.trim(), 10);
228
+ if (!Number.isNaN(val) && val > 0) modelDef.contextWindow = val;
229
+ }
230
+
231
+ // Max tokens
232
+ const maxTokens = await ctx.ui.input("Max output tokens", "16384");
233
+ if (maxTokens?.trim()) {
234
+ const val = parseInt(maxTokens.trim(), 10);
235
+ if (!Number.isNaN(val) && val > 0) modelDef.maxTokens = val;
236
+ }
237
+
238
+ // Input types
239
+ const hasImage = await ctx.ui.confirm("Supports image input?", "");
240
+ modelDef.input = hasImage ? ["text", "image"] : ["text"];
241
+ }
242
+
243
+ // Add model to provider
244
+ if (!provider.models) provider.models = [];
245
+ provider.models.push(modelDef);
246
+ writeModelsJson(config);
247
+
248
+ const summary = [
249
+ `Model "${modelDef.id}" added to provider "${selectedProvider}" in models.json.`,
250
+ ];
251
+ if (modelDef.name) summary.push(` Display name: ${modelDef.name}`);
252
+ if (modelDef.reasoning !== undefined) summary.push(` Reasoning: ${modelDef.reasoning}`);
253
+ if (modelDef.contextWindow) summary.push(` Context window: ${modelDef.contextWindow}`);
254
+ if (modelDef.maxTokens) summary.push(` Max tokens: ${modelDef.maxTokens}`);
255
+ summary.push("");
256
+ summary.push("Run /reload to refresh pi's model registry.");
257
+
258
+ ctx.ui.notify(summary.join("\n"), "info");
259
+ }
260
+
261
+ // =========================================================================
262
+ // Command: /custom-provider:scan-models
263
+ // =========================================================================
264
+
265
+ interface RemoteModel {
266
+ id: string;
267
+ object?: string;
268
+ created?: number;
269
+ owned_by?: string;
270
+ max_model_len?: number;
271
+ }
272
+
273
+ interface ModelsListResponse {
274
+ object: string;
275
+ data: RemoteModel[];
276
+ }
277
+
278
+ async function scanModelsCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
279
+ const config = readModelsJson();
280
+ const providerNames = Object.keys(config.providers);
281
+
282
+ if (providerNames.length === 0) {
283
+ ctx.ui.notify(
284
+ "No custom providers found. Use /custom-provider:add-provider first.",
285
+ "warning",
286
+ );
287
+ return;
288
+ }
289
+
290
+ // Filter to providers that are OpenAI-compatible
291
+ const compatProviders = providerNames.filter((name) => {
292
+ const api = config.providers[name]!.api;
293
+ return !api || OPENAI_COMPAT_APIS.has(api);
294
+ });
295
+
296
+ if (compatProviders.length === 0) {
297
+ ctx.ui.notify(
298
+ "No OpenAI-compatible providers found. Scan requires providers using openai-completions or openai-responses API.",
299
+ "warning",
300
+ );
301
+ return;
302
+ }
303
+
304
+ // 1. Select provider
305
+ const selectedProvider = await ctx.ui.select(
306
+ "Select provider to scan for models",
307
+ compatProviders,
308
+ );
309
+ if (!selectedProvider) {
310
+ ctx.ui.notify("Cancelled: no provider selected.", "warning");
311
+ return;
312
+ }
313
+
314
+ const provider = config.providers[selectedProvider]!;
315
+ const baseUrl = provider.baseUrl?.replace(/\/+$/, "");
316
+
317
+ if (!baseUrl) {
318
+ ctx.ui.notify(`Provider "${selectedProvider}" has no base URL configured.`, "error");
319
+ return;
320
+ }
321
+
322
+ // 2. Fetch models from /v1/models or /models
323
+ const modelsEndpoint = baseUrl.endsWith("/v1") || baseUrl.endsWith("/v1/")
324
+ ? `${baseUrl}/models`
325
+ : `${baseUrl}/v1/models`;
326
+
327
+ ctx.ui.notify(`Scanning ${modelsEndpoint}...`, "info");
328
+
329
+ let response: ModelsListResponse;
330
+ try {
331
+ const headers: Record<string, string> = {
332
+ "Accept": "application/json",
333
+ };
334
+ if (provider.apiKey && provider.apiKey !== "none") {
335
+ headers["Authorization"] = `Bearer ${provider.apiKey}`;
336
+ }
337
+
338
+ const res = await fetch(modelsEndpoint, { headers, signal: AbortSignal.timeout(15_000) });
339
+ if (!res.ok) {
340
+ ctx.ui.notify(
341
+ `Failed to fetch models: HTTP ${res.status} ${res.statusText}\n` +
342
+ `Endpoint: ${modelsEndpoint}`,
343
+ "error",
344
+ );
345
+ return;
346
+ }
347
+ const body = await res.json() as ModelsListResponse;
348
+ if (!body.data || !Array.isArray(body.data)) {
349
+ ctx.ui.notify(
350
+ `Unexpected response format. Expected { "data": [...] }.\n` +
351
+ `Got: ${JSON.stringify(body).slice(0, 500)}`,
352
+ "error",
353
+ );
354
+ return;
355
+ }
356
+ response = body;
357
+ } catch (err) {
358
+ ctx.ui.notify(
359
+ `Failed to connect to ${modelsEndpoint}: ${err instanceof Error ? err.message : String(err)}`,
360
+ "error",
361
+ );
362
+ return;
363
+ }
364
+
365
+ if (response.data.length === 0) {
366
+ ctx.ui.notify("No models found at the endpoint.", "info");
367
+ return;
368
+ }
369
+
370
+ // 3. Show discovered models and let user select which to add
371
+ const existingIds = new Set((provider.models ?? []).map((m) => m.id));
372
+ const modelOptions = response.data.map((m) => {
373
+ const exists = existingIds.has(m.id) ? " [already added]" : "";
374
+ const maxLen = m.max_model_len ? ` (ctx: ${m.max_model_len})` : "";
375
+ const owner = m.owned_by ? ` - ${m.owned_by}` : "";
376
+ return `${m.id}${maxLen}${owner}${exists}`;
377
+ });
378
+
379
+ // Select which models to add
380
+ const selectedModels = await ctx.ui.custom<string[]>((tui, theme, _kb, done) => {
381
+ let cursorIndex = 0;
382
+ const selected = new Set<number>();
383
+ let cachedLines: string[] | undefined;
384
+ // Only allow selecting models that aren't already added
385
+ const addableIndices = response.data
386
+ .map((m, i) => ({ i, alreadyAdded: existingIds.has(m.id) }))
387
+ .filter((x) => !x.alreadyAdded)
388
+ .map((x) => x.i);
389
+
390
+ function refresh() {
391
+ cachedLines = undefined;
392
+ tui.requestRender();
393
+ }
394
+
395
+ function handleInput(data: string) {
396
+ if (matchesKey(data, Key.up)) {
397
+ cursorIndex = Math.max(0, cursorIndex - 1);
398
+ refresh();
399
+ return;
400
+ }
401
+ if (matchesKey(data, Key.down)) {
402
+ cursorIndex = Math.min(modelOptions.length - 1, cursorIndex + 1);
403
+ refresh();
404
+ return;
405
+ }
406
+ if (matchesKey(data, Key.space)) {
407
+ if (addableIndices.includes(cursorIndex)) {
408
+ if (selected.has(cursorIndex)) {
409
+ selected.delete(cursorIndex);
410
+ } else {
411
+ selected.add(cursorIndex);
412
+ }
413
+ }
414
+ refresh();
415
+ return;
416
+ }
417
+ if (matchesKey(data, Key.enter)) {
418
+ // If nothing selected, select all addable models
419
+ const toAdd = selected.size > 0
420
+ ? [...selected]
421
+ : addableIndices;
422
+ const ids = toAdd
423
+ .sort((a, b) => a - b)
424
+ .map((i) => response.data[i]!.id);
425
+ done(ids);
426
+ return;
427
+ }
428
+ if (matchesKey(data, Key.escape)) {
429
+ done([]);
430
+ }
431
+ // a/A to select/deselect all
432
+ if (data === "a") {
433
+ for (const i of addableIndices) selected.add(i);
434
+ refresh();
435
+ return;
436
+ }
437
+ if (data === "A") {
438
+ selected.clear();
439
+ refresh();
440
+ return;
441
+ }
442
+ }
443
+
444
+ function render(width: number): string[] {
445
+ if (cachedLines) return cachedLines;
446
+ const lines: string[] = [];
447
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
448
+
449
+ add(theme.fg("accent", "─".repeat(width)));
450
+ add(theme.fg("text", ` Found ${response.data.length} models at ${selectedProvider}`));
451
+ lines.push("");
452
+
453
+ for (let i = 0; i < modelOptions.length; i++) {
454
+ const isCursor = i === cursorIndex;
455
+ const isAddable = addableIndices.includes(i);
456
+ const isChecked = selected.has(i);
457
+ const prefix = isCursor ? theme.fg("accent", "> ") : " ";
458
+ const box = isAddable
459
+ ? (isChecked ? theme.fg("success", "☑") : theme.fg("dim", "☐"))
460
+ : theme.fg("dim", "■");
461
+ const label = isAddable
462
+ ? theme.fg("text", modelOptions[i]!)
463
+ : theme.fg("dim", modelOptions[i]!);
464
+ add(`${prefix}${box} ${label}`);
465
+ }
466
+
467
+ lines.push("");
468
+ const selectedCount = selected.size;
469
+ if (selectedCount > 0) {
470
+ add(theme.fg("dim", ` ${selectedCount} selected • Space toggle • Enter confirm • a/A select/deselect all • Esc cancel`));
471
+ } else {
472
+ add(theme.fg("dim", ` Enter to add all new models • Space toggle • a/A select/deselect all • Esc cancel`));
473
+ }
474
+
475
+ add(theme.fg("accent", "─".repeat(width)));
476
+ cachedLines = lines;
477
+ return lines;
478
+ }
479
+
480
+ return {
481
+ render,
482
+ invalidate: () => { cachedLines = undefined; },
483
+ handleInput,
484
+ };
485
+ });
486
+
487
+ if (selectedModels.length === 0) {
488
+ ctx.ui.notify("No models selected. Cancelled.", "info");
489
+ return;
490
+ }
491
+
492
+ // 4. Add selected models to provider
493
+ if (!provider.models) provider.models = [];
494
+
495
+ let addedCount = 0;
496
+ let skippedCount = 0;
497
+ for (const modelId of selectedModels) {
498
+ if (existingIds.has(modelId)) {
499
+ skippedCount++;
500
+ continue;
501
+ }
502
+ const remoteModel = response.data.find((m) => m.id === modelId);
503
+ const modelDef: ModelDefinition = {
504
+ id: modelId,
505
+ ...(remoteModel?.max_model_len ? { contextWindow: remoteModel.max_model_len } : {}),
506
+ };
507
+ provider.models.push(modelDef);
508
+ existingIds.add(modelId);
509
+ addedCount++;
510
+ }
511
+
512
+ writeModelsJson(config);
513
+
514
+ ctx.ui.notify(
515
+ `Scan complete for "${selectedProvider}".\n` +
516
+ ` Added: ${addedCount} model(s)\n` +
517
+ (skippedCount > 0 ? ` Skipped (already existing): ${skippedCount}\n` : "") +
518
+ `\nRun /reload to refresh pi's model registry.`,
519
+ "info",
520
+ );
521
+ }
522
+
523
+ // =========================================================================
524
+ // Command: /custom-provider:remove-provider
525
+ // =========================================================================
526
+
527
+ async function removeProviderCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
528
+ const config = readModelsJson();
529
+ const providerNames = Object.keys(config.providers);
530
+
531
+ if (providerNames.length === 0) {
532
+ ctx.ui.notify(
533
+ "No custom providers found in models.json.",
534
+ "warning",
535
+ );
536
+ return;
537
+ }
538
+
539
+ // 1. Select provider to remove
540
+ const selectedProvider = await ctx.ui.select("Select provider to remove", providerNames);
541
+ if (!selectedProvider) {
542
+ ctx.ui.notify("Cancelled: no provider selected.", "warning");
543
+ return;
544
+ }
545
+
546
+ // 2. Confirm deletion
547
+ const provider = config.providers[selectedProvider]!;
548
+ const modelCount = provider.models?.length ?? 0;
549
+ const confirmMessage = modelCount > 0
550
+ ? `Remove provider "${selectedProvider}" and its ${modelCount} model(s)?`
551
+ : `Remove provider "${selectedProvider}"?`;
552
+
553
+ const confirmed = await ctx.ui.confirm(
554
+ confirmMessage,
555
+ `This will update models.json. Run /reload after to refresh pi's model registry.`,
556
+ );
557
+ if (!confirmed) {
558
+ ctx.ui.notify("Cancelled.", "info");
559
+ return;
560
+ }
561
+
562
+ // 3. Remove provider
563
+ delete config.providers[selectedProvider];
564
+ writeModelsJson(config);
565
+
566
+ ctx.ui.notify(
567
+ `Provider "${selectedProvider}" removed from models.json.\n` +
568
+ `Run /reload to refresh pi's model registry.`,
569
+ "info",
570
+ );
571
+ }
572
+
573
+ // =========================================================================
574
+ // Command: /custom-provider:remove-model
575
+ // =========================================================================
576
+
577
+ async function removeModelCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
578
+ const config = readModelsJson();
579
+ const providerNames = Object.keys(config.providers);
580
+
581
+ if (providerNames.length === 0) {
582
+ ctx.ui.notify(
583
+ "No custom providers found in models.json.",
584
+ "warning",
585
+ );
586
+ return;
587
+ }
588
+
589
+ // 1. Select provider
590
+ const selectedProvider = await ctx.ui.select("Select provider to remove a model from", providerNames);
591
+ if (!selectedProvider) {
592
+ ctx.ui.notify("Cancelled: no provider selected.", "warning");
593
+ return;
594
+ }
595
+
596
+ const provider = config.providers[selectedProvider]!;
597
+ const models = provider.models ?? [];
598
+
599
+ if (models.length === 0) {
600
+ ctx.ui.notify(
601
+ `Provider "${selectedProvider}" has no models.`,
602
+ "warning",
603
+ );
604
+ return;
605
+ }
606
+
607
+ // 2. Select model to remove
608
+ const modelLabels = models.map((m) => m.name ? `${m.id} (${m.name})` : m.id);
609
+ const selectedModelLabel = await ctx.ui.select("Select model to remove", modelLabels);
610
+ if (!selectedModelLabel) {
611
+ ctx.ui.notify("Cancelled: no model selected.", "warning");
612
+ return;
613
+ }
614
+
615
+ const selectedIndex = modelLabels.indexOf(selectedModelLabel);
616
+ if (selectedIndex === -1) {
617
+ ctx.ui.notify("Cancelled: could not resolve model.", "warning");
618
+ return;
619
+ }
620
+
621
+ const modelToRemove = models[selectedIndex]!;
622
+
623
+ // 3. Confirm
624
+ const confirmed = await ctx.ui.confirm(
625
+ `Remove model "${modelToRemove.id}" from provider "${selectedProvider}"?`,
626
+ "This will update models.json. Run /reload after to refresh pi's model registry.",
627
+ );
628
+ if (!confirmed) {
629
+ ctx.ui.notify("Cancelled.", "info");
630
+ return;
631
+ }
632
+
633
+ // 4. Remove model
634
+ provider.models = models.filter((_, i) => i !== selectedIndex);
635
+ writeModelsJson(config);
636
+
637
+ ctx.ui.notify(
638
+ `Model "${modelToRemove.id}" removed from provider "${selectedProvider}" in models.json.\n` +
639
+ `Run /reload to refresh pi's model registry.`,
640
+ "info",
641
+ );
642
+ }
643
+
644
+ // =========================================================================
645
+ // Install all custom-provider commands
646
+ // =========================================================================
647
+
648
+ export function installCustomProviderCommands(pi: ExtensionAPI): void {
649
+ pi.registerCommand("custom-provider:add-provider", {
650
+ description: "Add a custom provider to models.json (interactive wizard)",
651
+ handler: addProviderCommand,
652
+ });
653
+
654
+ pi.registerCommand("custom-provider:add-model", {
655
+ description: "Add a model to an existing custom provider in models.json",
656
+ handler: addModelCommand,
657
+ });
658
+
659
+ pi.registerCommand("custom-provider:scan-models", {
660
+ description: "Scan an OpenAI-compatible provider endpoint for available models and add them to models.json",
661
+ handler: scanModelsCommand,
662
+ });
663
+
664
+ pi.registerCommand("custom-provider:remove-provider", {
665
+ description: "Remove a custom provider and all its models from models.json",
666
+ handler: removeProviderCommand,
667
+ });
668
+
669
+ pi.registerCommand("custom-provider:remove-model", {
670
+ description: "Remove a model from an existing custom provider in models.json",
671
+ handler: removeModelCommand,
672
+ });
673
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { formatConfig } from "./config.js";
3
3
  import { installContextFiles } from "./context-files.js";
4
+ import { installCustomProviderCommands } from "./custom-providers.js";
4
5
  import { installGuards } from "./guards.js";
5
6
  import { MoonpiController } from "./modes.js";
6
7
  import { formatTodoList } from "./state.js";
@@ -22,6 +23,7 @@ export default async function moonpi(pi: ExtensionAPI): Promise<void> {
22
23
  installGuards(pi, controller);
23
24
  installContextFiles(pi, controller);
24
25
  installSprintWorkflow(pi, controller);
26
+ installCustomProviderCommands(pi);
25
27
 
26
28
  pi.registerCommand("moonpi:mode", {
27
29
  description: "Switch moonpi mode: plan, act, auto, fast",