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 +145 -85
- package/package.json +1 -1
- package/src/custom-providers.ts +673 -0
- package/src/index.ts +2 -0
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
|
|
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
|
-
|
|
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
|
+

|
|
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
|
-

|
|
400
|
-
|
|
401
|
-
|
|
402
462
|
## Why moonpi?
|
|
403
463
|
|
|
404
464
|
moonpi is not trying to be a giant agent framework.
|
package/package.json
CHANGED
|
@@ -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",
|