@cokefenta/pi-switch 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,341 @@
1
+ <div align="center">
2
+
3
+ # pi-switch
4
+
5
+ [![Version](https://img.shields.io/badge/version-0.2.0-blue.svg)](https://github.com/user/pi-switch/releases)
6
+ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/user/pi-switch/releases)
7
+ [![Built with Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg)](https://www.rust-lang.org/)
8
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
9
+
10
+ **TUI + CLI dual-mode profile switcher for pi agent**
11
+
12
+ Manage provider profiles, switch models.json, and run a local proxy with failover. Interactive TUI with full CRUD, Dracula theme, and bilingual support.
13
+
14
+ [English](#) | [δΈ­ζ–‡](README_ZH.md)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ## πŸ“– About
21
+
22
+ pi-switch is a lightweight profile switcher for [pi coding agent](https://pi.dev). It manages `~/.pi/agent/models.json` provider profiles β€” add, edit, remove, and switch between them via CLI or an interactive terminal UI.
23
+
24
+ Built with Rust (napi-rs) as a native Node.js addon. The interactive TUI is modeled after cc-switch.
25
+
26
+ ---
27
+
28
+ ## πŸ“Έ Screenshots
29
+
30
+ <div align="center">
31
+ <h3>Profiles</h3>
32
+ <img src="assets/screenshots/profiles.png" alt="Profiles" width="70%"/>
33
+ </div>
34
+
35
+ <br/>
36
+
37
+ <table>
38
+ <tr>
39
+ <th>Home</th>
40
+ <th>Settings</th>
41
+ </tr>
42
+ <tr>
43
+ <td><img src="assets/screenshots/home.png" alt="Home" width="100%"/></td>
44
+ <td><img src="assets/screenshots/settings.png" alt="Settings" width="100%"/></td>
45
+ </tr>
46
+ </table>
47
+
48
+ ## πŸš€ Quick Start
49
+
50
+ **TUI Mode (Recommended)**
51
+ ```bash
52
+ pi-switch tui
53
+ ```
54
+ Use the full-screen interface to manage providers, browse presets, inspect proxy status, and configure settings.
55
+
56
+ **Command-Line Mode**
57
+ ```bash
58
+ pi-switch provider list # List all provider profiles
59
+ pi-switch provider add <name> [--preset <id>] [--api-key <key>] # Add a profile
60
+ pi-switch use <name> # Switch pi to this profile
61
+ pi-switch provider show <name> # Show profile details
62
+ pi-switch provider delete <name> # Delete a profile
63
+ pi-switch presets list # List built-in provider presets
64
+ pi-switch config show # Display current config
65
+ pi-switch config backups # List backup files
66
+ pi-switch stats # View proxy request statistics
67
+ pi-switch doctor # Run environment diagnostics
68
+ ```
69
+
70
+ ---
71
+
72
+ ## πŸ“₯ Installation
73
+
74
+ ### npm (Recommended)
75
+
76
+ ```bash
77
+ npm install -g pi-switch
78
+ ```
79
+
80
+ ### Pi Package
81
+
82
+ ```bash
83
+ pi install npm:pi-switch
84
+ ```
85
+
86
+ ### Build from Source
87
+
88
+ **Prerequisites:**
89
+ - Node.js >= 20
90
+ - Rust 1.80+ ([install via rustup](https://rustup.rs/))
91
+
92
+ **Build:**
93
+ ```bash
94
+ git clone https://github.com/user/pi-switch.git
95
+ cd pi-switch
96
+ npm install
97
+ npm run build:native
98
+ node bin/pi-switch.js tui
99
+ ```
100
+
101
+ ---
102
+
103
+ ## ✨ Features
104
+
105
+ ### πŸ”Œ Provider Management
106
+
107
+ Manage provider configurations for pi agent. Built-in presets: OpenRouter, Anthropic, DeepSeek, SiliconFlow, OpenAI.
108
+
109
+ **Features:** add, edit, delete, duplicate, current marker, proxy badge, provider ID display, search/filter.
110
+
111
+ ```bash
112
+ pi-switch provider list # List all provider profiles
113
+ pi-switch provider show <name> # Show profile details
114
+ pi-switch provider add <name> [--preset <preset>]
115
+ pi-switch provider delete <name> # Delete profile
116
+ pi-switch provider duplicate <name> [--as <new-name>]
117
+ pi-switch use <name> [--mode merge|exclusive] # Switch pi to profile
118
+ ```
119
+
120
+ ### πŸ’‘ Built-in Presets
121
+
122
+ Ready-to-use provider templates with pre-configured API endpoints and models.
123
+
124
+ ```bash
125
+ pi-switch presets list # List all presets
126
+ pi-switch presets show <id> # Show preset detail
127
+ ```
128
+
129
+ In the TUI: Presets β†’ Enter to create a profile from a preset template.
130
+
131
+ ### βš™οΈ Configuration Management
132
+
133
+ Manage config backups, imports, and exports with encryption.
134
+
135
+ ```bash
136
+ pi-switch config show # Display full config
137
+ pi-switch config path # Show config file path
138
+ pi-switch config backups # List backup files
139
+ pi-switch config export <passphrase> # Encrypted export (AES-256-CBC)
140
+ pi-switch config import <path> <passphrase> # Encrypted import
141
+ ```
142
+
143
+ ### πŸŒ‰ Local Proxy
144
+
145
+ OpenAI-compatible proxy with Anthropic auto-conversion, failover chain, and circuit breaker.
146
+
147
+ ```bash
148
+ pi-switch proxy start [--host <ip>] [--port <port>] [--profile <name>]
149
+ pi-switch proxy stop
150
+ pi-switch proxy status
151
+ ```
152
+
153
+ Endpoints:
154
+ - `GET /health`
155
+ - `GET /v1/models`
156
+ - `POST /v1/chat/completions` (OpenAI β†’ Anthropic auto-conversion)
157
+ - `POST /v1/messages` (Anthropic native forwarding)
158
+
159
+ ### πŸ“Š Usage Statistics
160
+
161
+ Request metrics aggregated from proxy logs.
162
+
163
+ ```bash
164
+ pi-switch stats
165
+ ```
166
+
167
+ Displays: total requests, success rate, per-provider breakdown, per-model breakdown, average latency.
168
+
169
+ ### 🩺 Diagnostics
170
+
171
+ ```bash
172
+ pi-switch doctor
173
+ ```
174
+
175
+ Checks: config file existence, models.json validity, JSON structure, profile count, backup directory.
176
+
177
+ ### 🌐 Multi-language Support
178
+
179
+ Interactive TUI supports English and Chinese. Language is persisted to config.
180
+
181
+ - Default language: English (set `PI_SWITCH_LANG=zh` for initial Chinese)
182
+ - In TUI: βš™οΈ Settings β†’ Language β†’ `←→/Space` to switch
183
+
184
+ ### πŸ–₯️ Interactive TUI
185
+
186
+ ```bash
187
+ pi-switch tui
188
+ ```
189
+
190
+ Full interactive terminal UI built with ratatui:
191
+
192
+ - **Profiles**: table with proxy badge, provider ID, current marker, add/edit/delete/duplicate/switch/search
193
+ - **Presets**: browse preset templates, create profile from preset
194
+ - **Proxy**: start/stop daemon, view status with target/failover/listen info
195
+ - **Stats**: request metrics by provider and model
196
+ - **Backups**: browse config backup history
197
+ - **Settings**: language (English / δΈ­ζ–‡), proxy host/port/target/failover editing
198
+
199
+ Key bindings:
200
+ - `←→` switch between menu and content
201
+ - `↑↓ / j k` move selection
202
+ - `Enter` open detail / confirm
203
+ - `?` help overlay
204
+ - `/` filter
205
+ - `q` quit
206
+
207
+ ---
208
+
209
+ ## πŸ—οΈ Architecture
210
+
211
+ ### Core Design
212
+
213
+ - **napi-rs native addon**: Rust core compiled to `.node` binary for Node.js
214
+ - **pi-switch config**: `~/.pi-switch/config.json` with profiles, proxy settings, backup metadata
215
+ - **pi models.json**: `~/.pi/agent/models.json` β€” the file pi reads for provider definitions
216
+ - **Atomic writes**: Temp file + rename pattern prevents corruption
217
+ - **Backup rotation**: Auto-backup on every mutation, stored in `~/.pi-switch/backups/`
218
+
219
+ ### Config Files
220
+
221
+ - `~/.pi-switch/config.json` β€” Profiles, current selection, proxy settings
222
+ - `~/.pi-switch/backups/` β€” Timestamped config backups
223
+ - `~/.pi/agent/models.json` β€” pi agent provider registry (written by `pi-switch use`)
224
+
225
+ ### Code Structure
226
+
227
+ ```
228
+ pi-switch/
229
+ β”œβ”€β”€ bin/pi-switch.js # CLI entry point
230
+ β”œβ”€β”€ index.js # NAPI re-exports
231
+ β”œβ”€β”€ pi-switch-native.cjs # NAPI loader (auto platform detection)
232
+ β”œβ”€β”€ src-rust/
233
+ β”‚ β”œβ”€β”€ lib.rs # NAPI function exports
234
+ β”‚ β”œβ”€β”€ config.rs # Config load/save, types
235
+ β”‚ β”œβ”€β”€ ops.rs # Core operations (use/upsert/remove/duplicate)
236
+ β”‚ β”œβ”€β”€ presets.rs # Built-in provider presets
237
+ β”‚ β”œβ”€β”€ daemon.rs # Proxy daemon management
238
+ β”‚ β”œβ”€β”€ stats.rs # Request log aggregation
239
+ β”‚ └── tui/ # Interactive terminal UI
240
+ β”‚ β”œβ”€β”€ app.rs # State machine + key handler
241
+ β”‚ β”œβ”€β”€ form.rs # Provider form state
242
+ β”‚ β”œβ”€β”€ text_edit.rs # Readline-style text input
243
+ β”‚ β”œβ”€β”€ theme.rs # Dracula theme + color fallback
244
+ β”‚ β”œβ”€β”€ route.rs # Navigation routes
245
+ β”‚ β”œβ”€β”€ i18n.rs # Bilingual text (English / δΈ­ζ–‡)
246
+ β”‚ └── ui/ # Rendering (chrome/pages/profiles/overlay)
247
+ β”œβ”€β”€ package.json
248
+ └── Cargo.toml
249
+ ```
250
+
251
+ ---
252
+
253
+ ## ❓ FAQ
254
+
255
+ <details>
256
+ <summary><b>How do I switch pi to a different provider?</b></summary>
257
+
258
+ <br>
259
+
260
+ ```bash
261
+ pi-switch use <name>
262
+ ```
263
+
264
+ This updates `~/.pi/agent/models.json` so pi picks up the new provider. If pi is already running, use `/model` inside pi to refresh.
265
+
266
+ Alternatively: open the TUI, navigate to Profiles, and press `Space` on any profile.
267
+
268
+ </details>
269
+
270
+ <details>
271
+ <summary><b>How do I add a custom provider?</b></summary>
272
+
273
+ <br>
274
+
275
+ **CLI:**
276
+ ```bash
277
+ pi-switch provider add my-provider --api openai-completions --base-url https://api.example.com/v1 --api-key '$MY_API_KEY' --model gpt-4
278
+ ```
279
+
280
+ **TUI:** Profiles β†’ `a` β†’ fill in form β†’ `Ctrl+S`
281
+
282
+ </details>
283
+
284
+ <details>
285
+ <summary><b>What does the [proxy] badge mean?</b></summary>
286
+
287
+ <br>
288
+
289
+ A profile with the `[proxy]` badge has `"proxy": true` in its config. This means it's configured to route through the local proxy. The proxy can auto-convert between OpenAI and Anthropic formats and apply failover/circuit-breaker policies.
290
+
291
+ </details>
292
+
293
+ <details>
294
+ <summary><b>How do I set up failover?</b></summary>
295
+
296
+ <br>
297
+
298
+ In the TUI: βš™οΈ Settings β†’ Failover chain β†’ `Enter` β†’ enter comma-separated profile names β†’ `Enter` to save.
299
+
300
+ Or directly in `~/.pi-switch/config.json` under `settings.proxy.failover`.
301
+
302
+ </details>
303
+
304
+ <details>
305
+ <summary><b>Where is my data stored?</b></summary>
306
+
307
+ <br>
308
+
309
+ All pi-switch data is under `~/.pi-switch/`. pi's own provider registry is `~/.pi/agent/models.json`. No data is sent anywhere.
310
+
311
+ </details>
312
+
313
+ ---
314
+
315
+ ## πŸ› οΈ Development
316
+
317
+ ### Requirements
318
+
319
+ - **Node.js**: >= 20
320
+ - **Rust**: 1.80+ ([rustup](https://rustup.rs/))
321
+ - **npm**: bundled with Node.js
322
+
323
+ ### Commands
324
+
325
+ ```bash
326
+ cd pi-switch
327
+
328
+ npm run build:native:debug # Build Rust native addon (debug)
329
+ npm run build:native # Build Rust native addon (release)
330
+ node bin/pi-switch.js tui # Run TUI
331
+
332
+ cargo build # Rust-only build
333
+ cargo clippy # Lint
334
+ cargo fmt # Format
335
+ ```
336
+
337
+ ---
338
+
339
+ ## πŸ“œ License
340
+
341
+ MIT
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ initConfig, addProvider, listProfiles, showProfile, useProfile, removeProfile,
4
+ listPresets, showPreset, listBackups, doctor,
5
+ daemonStartNative, daemonStopNative, daemonStatusNative,
6
+ getUsageStats, exportConfig, importConfig,
7
+ runNativeTui,
8
+ } from "../index.js";
9
+
10
+ function usage() {
11
+ console.log(`pi-switch v0.2.0 β€” lightweight profile switcher for pi agent
12
+
13
+ Usage:
14
+ pi-switch provider list
15
+ pi-switch provider show <name>
16
+ pi-switch provider add <name> [--preset <preset>] [--api <api>] [--base-url <url>] [--api-key <key>] [--model <id>...]
17
+ pi-switch provider edit <name>
18
+ pi-switch provider delete <name>
19
+ pi-switch provider duplicate <name> [--as <new-name>]
20
+ pi-switch use <name> [--mode merge|exclusive]
21
+ pi-switch presets [list]
22
+ pi-switch presets show <id>
23
+ pi-switch config show
24
+ pi-switch config path
25
+ pi-switch config export <passphrase>
26
+ pi-switch config import <path> <passphrase>
27
+ pi-switch config backups
28
+ pi-switch proxy start [--host <ip>] [--port <port>] [--profile <name>]
29
+ pi-switch proxy stop
30
+ pi-switch proxy status
31
+ pi-switch stats
32
+ pi-switch doctor
33
+ pi-switch tui
34
+
35
+ Aliases: remove β†’ provider delete, rm β†’ provider delete, interactive/ui β†’ tui
36
+ `);
37
+ }
38
+
39
+ function fail(message, code = 1) {
40
+ const clean = message.startsWith("Error: ") ? message : `Error: ${message}`;
41
+ console.error(clean);
42
+ process.exit(code);
43
+ }
44
+
45
+ function parseModel(modelArg) {
46
+ const idx = modelArg.indexOf("=");
47
+ const id = idx === -1 ? modelArg.trim() : modelArg.slice(0, idx).trim();
48
+ const name = idx === -1 ? undefined : modelArg.slice(idx + 1).trim();
49
+ if (!id) throw new Error(`invalid --model '${modelArg}'`);
50
+ return { id, ...(name ? { name } : {}), input: ["text"], contextWindow: 128000, maxTokens: 16384, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } };
51
+ }
52
+
53
+ function parseArgs(argv) {
54
+ const out = { _: [] };
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const arg = argv[i];
57
+ if (!arg.startsWith("--")) { out._.push(arg); continue; }
58
+ const eq = arg.indexOf("=");
59
+ const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
60
+ const raw = eq === -1 ? argv[++i] : arg.slice(eq + 1);
61
+ if (raw === undefined || raw.startsWith("--")) throw new Error(`missing value for --${key}`);
62
+ if (out[key] === undefined) out[key] = raw;
63
+ else if (Array.isArray(out[key])) out[key].push(raw);
64
+ else out[key] = [out[key], raw];
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function asArray(value) {
70
+ if (value === undefined) return [];
71
+ return Array.isArray(value) ? value : [value];
72
+ }
73
+
74
+ async function main() {
75
+ const [cmd, ...rest] = process.argv.slice(2);
76
+ try {
77
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") return usage();
78
+
79
+ // ─── Aliases ─────────────────────────────────────
80
+
81
+ const effectiveCmd = (() => {
82
+ if (cmd === "remove" || cmd === "rm") return "provider-delete";
83
+ if (cmd === "interactive" || cmd === "ui") return "tui";
84
+ return cmd;
85
+ })();
86
+
87
+ // ─── Init ────────────────────────────────────────
88
+
89
+ if (effectiveCmd === "init") {
90
+ for (const line of initConfig()) console.log(line);
91
+ return;
92
+ }
93
+
94
+ // ─── Provider subcommands ────────────────────────
95
+
96
+ if (effectiveCmd === "provider" || effectiveCmd === "provider-delete") {
97
+ const sub = effectiveCmd === "provider-delete" ? "delete" : (rest[0] || "list");
98
+
99
+ if (sub === "list") {
100
+ const data = JSON.parse(listProfiles());
101
+ const names = Object.keys(data.profiles);
102
+ if (names.length === 0) { console.log("No profiles. Add one with: pi-switch provider add <name> ..."); return; }
103
+ for (const name of names) {
104
+ const p = data.profiles[name];
105
+ const mark = data.current === name ? "*" : " ";
106
+ const models = (p.models || []).map(m => m.id).join(", ");
107
+ console.log(`${mark} ${name}`);
108
+ console.log(` api: ${p.api} baseUrl: ${p.baseUrl}`);
109
+ if (models) console.log(` models: ${models}`);
110
+ }
111
+ return;
112
+ }
113
+
114
+ if (sub === "show" || sub === "info") {
115
+ const name = rest[1];
116
+ if (!name) fail("provider name required");
117
+ const result = JSON.parse(showProfile(name));
118
+ console.log(JSON.stringify(result, null, 2));
119
+ return;
120
+ }
121
+
122
+ if (sub === "add") {
123
+ const args = parseArgs(rest.slice(1));
124
+ const name = args._[0];
125
+ if (!name) fail("provider name required");
126
+ const modelArgs = asArray(args.model);
127
+ const result = addProvider({
128
+ name,
129
+ preset: args.preset || undefined,
130
+ api: args.api || undefined,
131
+ baseUrl: args["base-url"] || args.baseUrl || undefined,
132
+ apiKey: args["api-key"] || args.apiKey || undefined,
133
+ models: modelArgs.length ? modelArgs.map(m => typeof m === 'string' ? parseModel(m).id : m) : undefined,
134
+ });
135
+ console.log(`Saved profile '${result.name}' to ~/.pi-switch/config.json`);
136
+ if (result.backup) console.log(`Backup: ${result.backup}`);
137
+ return;
138
+ }
139
+
140
+ if (sub === "edit") {
141
+ // provider edit opens TUI at the edit form for the given provider
142
+ const name = rest[1];
143
+ if (!name) fail("provider name required");
144
+ const data = JSON.parse(showProfile(name));
145
+ console.log(`Current config for '${name}':`);
146
+ console.log(JSON.stringify(data, null, 2));
147
+ console.log(`\nTo edit, use: pi-switch tui β†’ Profiles β†’ ${name} β†’ e (edit)`);
148
+ return;
149
+ }
150
+
151
+ if (sub === "delete" || sub === "remove" || sub === "rm") {
152
+ const name = rest[1];
153
+ if (!name) fail("provider name required");
154
+ const result = removeProfile(name);
155
+ console.log(`Removed profile '${result.name}'`);
156
+ if (result.backup) console.log(`Backup: ${result.backup}`);
157
+ return;
158
+ }
159
+
160
+ if (sub === "duplicate" || sub === "copy") {
161
+ const name = rest[1];
162
+ if (!name) fail("provider name required");
163
+ const args = parseArgs(rest.slice(2));
164
+ const dst = args.as || args._[0] || `${name}-copy`;
165
+ // Reuse addProvider with the source's config
166
+ const data = JSON.parse(showProfile(name));
167
+ const p = data.profile;
168
+ const result = addProvider({
169
+ name: dst,
170
+ api: p.api,
171
+ baseUrl: p.baseUrl,
172
+ apiKey: p.apiKey,
173
+ models: (p.models || []).map(m => m.id),
174
+ });
175
+ console.log(`Duplicated '${name}' as '${dst}'`);
176
+ if (result.backup) console.log(`Backup: ${result.backup}`);
177
+ return;
178
+ }
179
+
180
+ fail(`unknown provider subcommand: '${sub}'`);
181
+ }
182
+
183
+ // ─── Use (shortcut) ──────────────────────────────
184
+
185
+ if (effectiveCmd === "use") {
186
+ const args = parseArgs(rest);
187
+ const name = args._[0];
188
+ if (!name) fail("profile name required");
189
+ const result = useProfile(name, args.mode || undefined);
190
+ console.log(`Activated '${result.name}' as provider '${result.providerId}'`);
191
+ if (result.modelsBackup) console.log(`Backup: ${result.modelsBackup}`);
192
+ console.log("Open /model in pi to refresh model list if pi is already running.");
193
+ return;
194
+ }
195
+
196
+ // ─── Presets ─────────────────────────────────────
197
+
198
+ if (effectiveCmd === "presets" || effectiveCmd === "preset") {
199
+ const sub = rest[0] || "list";
200
+ if (sub === "list") {
201
+ let first = true;
202
+ for (const p of listPresets()) {
203
+ if (!first) console.log("");
204
+ first = false;
205
+ console.log(`${p.id} β€” ${p.description}`);
206
+ console.log(` api: ${p.api} baseUrl: ${p.baseUrl}`);
207
+ console.log(` models: ${p.models.join(", ")}`);
208
+ }
209
+ return;
210
+ }
211
+ if (sub === "show") {
212
+ const id = rest[1];
213
+ if (!id) fail("preset id required");
214
+ console.log(showPreset(id));
215
+ return;
216
+ }
217
+ fail("usage: pi-switch presets [list|show <id>]");
218
+ }
219
+
220
+ // ─── Config subcommands ──────────────────────────
221
+
222
+ if (effectiveCmd === "config") {
223
+ const sub = rest[0] || "show";
224
+ if (sub === "show" || sub === "list") {
225
+ const data = JSON.parse(listProfiles());
226
+ console.log(JSON.stringify(data, null, 2));
227
+ return;
228
+ }
229
+ if (sub === "path") {
230
+ console.log(JSON.parse(listProfiles())._configPath || "~/.pi-switch/config.json");
231
+ return;
232
+ }
233
+ if (sub === "export") {
234
+ const pw = rest[1];
235
+ if (!pw) fail("passphrase required");
236
+ console.log(exportConfig(pw));
237
+ return;
238
+ }
239
+ if (sub === "import") {
240
+ const filePath = rest[1];
241
+ const passphrase = rest[2];
242
+ if (!filePath || !passphrase) fail("usage: pi-switch config import <path> <passphrase>");
243
+ console.log(importConfig(filePath, passphrase));
244
+ return;
245
+ }
246
+ if (sub === "backups" || sub === "backup") {
247
+ const result = listBackups();
248
+ if (result.length === 0) console.log("No backups found.");
249
+ else for (const file of result) console.log(file);
250
+ return;
251
+ }
252
+ fail(`unknown config subcommand: '${sub}'`);
253
+ }
254
+
255
+ // ─── Proxy subcommands ───────────────────────────
256
+
257
+ if (effectiveCmd === "proxy") {
258
+ const sub = rest[0] || "status";
259
+
260
+ if (sub === "start") {
261
+ const args = {};
262
+ for (let i = 1; i < rest.length; i++) {
263
+ if (rest[i] === "--host") args.host = rest[++i];
264
+ else if (rest[i] === "--port") args.port = parseInt(rest[++i], 10);
265
+ else if (rest[i] === "--profile") args.profile = rest[++i];
266
+ }
267
+ const result = JSON.parse(daemonStartNative(args.host || null, args.port || null));
268
+ console.log(result.message);
269
+ if (result.pid) console.log(`PID: ${result.pid}`);
270
+ if (args.profile) {
271
+ const useResult = useProfile(args.profile);
272
+ console.log(`Using profile '${useResult.name}' as provider '${useResult.providerId}'`);
273
+ }
274
+ return;
275
+ }
276
+
277
+ if (sub === "stop") {
278
+ const result = JSON.parse(daemonStopNative());
279
+ console.log(result.message);
280
+ return;
281
+ }
282
+
283
+ if (sub === "status") {
284
+ const result = JSON.parse(daemonStatusNative());
285
+ if (result.running) {
286
+ console.log(`Proxy daemon is running (PID ${result.pid})`);
287
+ console.log(`Listen: http://${result.host}:${result.port}`);
288
+ if (result.target) console.log(`Target: ${result.target}`);
289
+ if (result.failover?.length) console.log(`Failover: ${result.failover.join(" β†’ ")}`);
290
+ } else {
291
+ console.log(result.message);
292
+ }
293
+ return;
294
+ }
295
+
296
+ fail("usage: pi-switch proxy [start|stop|status]");
297
+ }
298
+
299
+ // ─── Stats ───────────────────────────────────────
300
+
301
+ if (effectiveCmd === "stats") {
302
+ const stats = JSON.parse(getUsageStats());
303
+ if (stats.totalRequests === 0) {
304
+ console.log("No request data. Start the proxy and make some requests first.");
305
+ return;
306
+ }
307
+ console.log(`Total: ${stats.totalRequests} | OK: ${stats.okRequests} | Fail: ${stats.failedRequests} | Rate: ${stats.successRate}`);
308
+ if (stats.avgLatencyMs) console.log(`Avg latency: ${stats.avgLatencyMs}ms`);
309
+ if (Object.keys(stats.byProvider).length) {
310
+ console.log("\nBy Provider:");
311
+ for (const [name, ps] of Object.entries(stats.byProvider)) {
312
+ const rate = ps.total > 0 ? ((ps.ok / ps.total) * 100).toFixed(0) + "%" : "0%";
313
+ console.log(` ${name}: ${ps.total} req, ${ps.ok} ok (${rate})`);
314
+ }
315
+ }
316
+ return;
317
+ }
318
+
319
+ // ─── Doctor ──────────────────────────────────────
320
+
321
+ if (effectiveCmd === "doctor") {
322
+ const checks = doctor();
323
+ let failed = 0;
324
+ for (const c of checks) {
325
+ console.log(`${c.ok ? "βœ“" : "βœ—"} ${c.msg}`);
326
+ if (!c.ok) failed++;
327
+ }
328
+ if (failed) process.exit(1);
329
+ return;
330
+ }
331
+
332
+ // ─── TUI ─────────────────────────────────────────
333
+
334
+ if (effectiveCmd === "tui") {
335
+ runNativeTui();
336
+ return;
337
+ }
338
+
339
+ // ─── Legacy flat commands (backward compat) ──────
340
+
341
+ if (cmd === "list") {
342
+ const data = JSON.parse(listProfiles());
343
+ const names = Object.keys(data.profiles);
344
+ if (names.length === 0) { console.log("No profiles. Add one with: pi-switch provider add <name> ..."); return; }
345
+ for (const name of names) {
346
+ const p = data.profiles[name];
347
+ const mark = data.current === name ? "*" : " ";
348
+ const models = (p.models || []).map(m => m.id).join(", ");
349
+ console.log(`${mark} ${name}`);
350
+ console.log(` api: ${p.api} baseUrl: ${p.baseUrl}`);
351
+ if (models) console.log(` models: ${models}`);
352
+ }
353
+ return;
354
+ }
355
+
356
+ if (cmd === "add") {
357
+ const args = parseArgs(rest);
358
+ const name = args._[0];
359
+ if (!name) fail("provider name required");
360
+ const modelArgs = asArray(args.model);
361
+ const result = addProvider({
362
+ name,
363
+ preset: args.preset || undefined,
364
+ api: args.api || undefined,
365
+ baseUrl: args["base-url"] || args.baseUrl || undefined,
366
+ apiKey: args["api-key"] || args.apiKey || undefined,
367
+ models: modelArgs.length ? modelArgs.map(m => typeof m === 'string' ? parseModel(m).id : m) : undefined,
368
+ });
369
+ console.log(`Saved profile '${result.name}' to ~/.pi-switch/config.json`);
370
+ if (result.backup) console.log(`Backup: ${result.backup}`);
371
+ return;
372
+ }
373
+
374
+ fail(`unknown command '${cmd}'. Run 'pi-switch help' for usage.`);
375
+ } catch (err) {
376
+ fail(err.stack || err.message);
377
+ }
378
+ }
379
+
380
+ main();
@@ -0,0 +1,559 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { add as addProfile, doctor, installProxyProvider, list, preset, remove as removeProfile, setProxyTarget, update as updateProfile, use } from "../src/commands.js";
3
+ import { loadConfig, profileToPiProvider, providerIdFor, saveConfig } from "../src/core.js";
4
+ import { getCircuitState, resetCircuitState, daemonStart, daemonStop, daemonStatus } from "../src/proxy.js";
5
+ import { getStats, formatStatsText } from "../src/stats.js";
6
+ import { exportConfig, importConfig, getProfileInfo, openProvider } from "../src/sync.js";
7
+
8
+ async function registerConfiguredProviders(pi: ExtensionAPI): Promise<string[]> {
9
+ const config = await loadConfig();
10
+ const registered: string[] = [];
11
+
12
+ for (const [name, profile] of Object.entries(config.profiles || {})) {
13
+ const providerId = providerIdFor(config, name);
14
+ pi.registerProvider(providerId, profileToPiProvider(profile as any) as any);
15
+ registered.push(providerId);
16
+ }
17
+
18
+ return registered;
19
+ }
20
+
21
+ function formatProfileList(result: Awaited<ReturnType<typeof list>>): string {
22
+ const names = Object.keys(result.profiles);
23
+ if (names.length === 0) return "No profiles. Add one with /piswitch add.";
24
+ return names
25
+ .map((name) => {
26
+ const p = result.profiles[name];
27
+ const mark = result.current === name ? "*" : " ";
28
+ const models = (p.models || []).map((m: any) => m.id).join(", ");
29
+ return `${mark} ${name}\n api: ${p.api}\n baseUrl: ${p.baseUrl}\n models: ${models}`;
30
+ })
31
+ .join("\n");
32
+ }
33
+
34
+ async function showDoctor(ctx: ExtensionContext): Promise<void> {
35
+ const checks = await doctor();
36
+ const text = checks.map((c) => `${c.ok ? "βœ“" : "βœ—"} ${c.msg}`).join("\n");
37
+ ctx.ui.notify(text, checks.some((c) => !c.ok) ? "warning" : "info");
38
+ }
39
+
40
+ async function chooseProfile(ctx: ExtensionContext): Promise<string | null> {
41
+ const result = await list();
42
+ const names = Object.keys(result.profiles);
43
+ if (names.length === 0) {
44
+ ctx.ui.notify("No profiles. Add one with /piswitch add.", "warning");
45
+ return null;
46
+ }
47
+ const selected = await ctx.ui.select(
48
+ "pi-switch profiles",
49
+ names.map((name) => {
50
+ const p = result.profiles[name];
51
+ const mark = result.current === name ? "* " : " ";
52
+ return `${mark}${name} (${p.api})`;
53
+ }),
54
+ );
55
+ if (!selected) return null;
56
+ return selected.replace(/^\*?\s*/, "").split(/\s+/)[0] || null;
57
+ }
58
+
59
+ async function promptAddProvider(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
60
+ const presetResult = await preset(["list"]);
61
+ const presetItems = presetResult.presets.map((p: any) => `${p.id} β€” ${p.description}`);
62
+ const selected = await ctx.ui.select("Choose provider preset", [
63
+ ...presetItems,
64
+ "custom-openai β€” Custom OpenAI-compatible endpoint",
65
+ "custom-anthropic β€” Custom Anthropic-compatible endpoint",
66
+ ]);
67
+ if (!selected) return;
68
+
69
+ const presetId = selected.split(" β€” ")[0];
70
+ const isCustomOpenAI = presetId === "custom-openai";
71
+ const isCustomAnthropic = presetId === "custom-anthropic";
72
+ const isCustom = isCustomOpenAI || isCustomAnthropic;
73
+
74
+ const defaultName = isCustom ? (isCustomOpenAI ? "custom-openai" : "custom-anthropic") : presetId;
75
+ const name = (await ctx.ui.input("Provider name", defaultName))?.trim();
76
+ if (!name) return;
77
+
78
+ let baseUrl: string | undefined;
79
+ let api: string | undefined;
80
+ let model: string | undefined;
81
+
82
+ if (isCustom) {
83
+ api = isCustomOpenAI ? "openai" : "anthropic";
84
+ const defaultUrl = isCustomOpenAI ? "https://api.example.com/v1" : "https://api.example.com";
85
+ baseUrl = (await ctx.ui.input("Base URL", defaultUrl))?.trim();
86
+ if (!baseUrl) return;
87
+ const defaultModel = isCustomOpenAI ? "model-id" : "claude-sonnet-4-5";
88
+ model = (await ctx.ui.input("Model id", defaultModel))?.trim();
89
+ if (!model) return;
90
+ }
91
+
92
+ const defaultKey = isCustom ? "$MY_API_KEY" : `$${presetId.toUpperCase().replace(/-/g, "_")}_API_KEY`;
93
+ const apiKey = (await ctx.ui.input("API key or env var", defaultKey))?.trim();
94
+ if (!apiKey) return;
95
+
96
+ const summary = isCustom
97
+ ? `Name: ${name}\nAPI: ${api}\nBase URL: ${baseUrl}\nAPI Key: ${apiKey}\nModel: ${model}`
98
+ : `Name: ${name}\nPreset: ${presetId}\nAPI Key: ${apiKey}`;
99
+ const ok = await ctx.ui.confirm("Create pi-switch provider?", summary);
100
+ if (!ok) return;
101
+
102
+ const argv = isCustom
103
+ ? [name, "--api", api!, "--base-url", baseUrl!, "--api-key", apiKey, "--model", model!]
104
+ : [name, "--preset", presetId, "--api-key", apiKey];
105
+
106
+ const result = await addProfile(argv);
107
+ const registered = await registerConfiguredProviders(pi);
108
+ const activate = await ctx.ui.confirm(
109
+ "Provider created",
110
+ `Created '${result.name}'.\nRegistered ${registered.length} provider(s).\n\nActivate it now?`,
111
+ );
112
+ if (activate) {
113
+ const activated = await use([result.name]);
114
+ await registerConfiguredProviders(pi);
115
+ ctx.ui.notify(`Activated '${activated.name}' as '${activated.providerId}'. Open /model to select it.`, "info");
116
+ } else {
117
+ ctx.ui.notify(`Created '${result.name}'. Use /piswitch use ${result.name} when ready.`, "info");
118
+ }
119
+ }
120
+
121
+ async function promptEditProvider(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
122
+ const name = await chooseProfile(ctx);
123
+ if (!name) return;
124
+ const result = await list();
125
+ const profile = result.profiles[name];
126
+ if (!profile) return;
127
+
128
+ const action = await ctx.ui.select(`Edit ${name}`, [
129
+ "Edit API key",
130
+ "Edit Base URL",
131
+ "Edit models",
132
+ "Delete profile",
133
+ ]);
134
+ if (!action) return;
135
+
136
+ if (action === "Edit API key") {
137
+ const apiKey = (await ctx.ui.input("API key or env var", profile.apiKey || "$API_KEY"))?.trim();
138
+ if (!apiKey) return;
139
+ await updateProfile(name, { apiKey });
140
+ await registerConfiguredProviders(pi);
141
+ ctx.ui.notify(`Updated API key for '${name}'.`, "info");
142
+ return;
143
+ }
144
+
145
+ if (action === "Edit Base URL") {
146
+ const baseUrl = (await ctx.ui.input("Base URL", profile.baseUrl || "https://api.example.com/v1"))?.trim();
147
+ if (!baseUrl) return;
148
+ await updateProfile(name, { baseUrl });
149
+ await registerConfiguredProviders(pi);
150
+ ctx.ui.notify(`Updated Base URL for '${name}'.`, "info");
151
+ return;
152
+ }
153
+
154
+ if (action === "Edit models") {
155
+ const current = (profile.models || []).map((m: any) => (m.name ? `${m.id}=${m.name}` : m.id)).join(", ");
156
+ const text = (await ctx.ui.input("Models, comma separated. Use id or id=name", current))?.trim();
157
+ if (!text) return;
158
+ const models = text.split(",").map((s) => s.trim()).filter(Boolean).map((item) => {
159
+ const idx = item.indexOf("=");
160
+ const id = idx === -1 ? item : item.slice(0, idx).trim();
161
+ const modelName = idx === -1 ? undefined : item.slice(idx + 1).trim();
162
+ return {
163
+ id,
164
+ ...(modelName ? { name: modelName } : {}),
165
+ input: ["text"],
166
+ contextWindow: 128000,
167
+ maxTokens: 16384,
168
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
169
+ };
170
+ });
171
+ await updateProfile(name, { models });
172
+ await registerConfiguredProviders(pi);
173
+ ctx.ui.notify(`Updated models for '${name}'.`, "info");
174
+ return;
175
+ }
176
+
177
+ if (action === "Delete profile") {
178
+ const ok = await ctx.ui.confirm("Delete provider?", `Delete '${name}' from pi-switch config?`);
179
+ if (!ok) return;
180
+ await removeProfile(name);
181
+ await registerConfiguredProviders(pi);
182
+ ctx.ui.notify(`Deleted '${name}'.`, "info");
183
+ }
184
+ }
185
+
186
+ async function promptShowProvider(ctx: ExtensionContext): Promise<void> {
187
+ const name = await chooseProfile(ctx);
188
+ if (!name) return;
189
+ const result = await list();
190
+ const profile = result.profiles[name];
191
+ if (!profile) return;
192
+ const models = (profile.models || []).map((m: any) => `- ${m.id}${m.name ? ` (${m.name})` : ""}`).join("\n");
193
+ ctx.ui.notify(
194
+ `Name: ${name}\nAPI: ${profile.api}\nBase URL: ${profile.baseUrl}\nAPI Key: ${profile.apiKey}\nPreset: ${profile.preset || "custom"}\nModels:\n${models || "- none"}`,
195
+ "info",
196
+ );
197
+ }
198
+
199
+ async function promptRemoveProvider(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
200
+ const name = await chooseProfile(ctx);
201
+ if (!name) return;
202
+ const ok = await ctx.ui.confirm("Delete provider?", `Delete '${name}' from pi-switch config?`);
203
+ if (!ok) return;
204
+ await removeProfile(name);
205
+ await registerConfiguredProviders(pi);
206
+ ctx.ui.notify(`Deleted '${name}'.`, "info");
207
+ }
208
+
209
+ async function promptRawConfig(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
210
+ const config = await loadConfig();
211
+ const edited = await ctx.ui.editor("Edit ~/.pi-switch/config.json", JSON.stringify(config, null, 2));
212
+ if (edited === undefined) return;
213
+ let parsed: any;
214
+ try {
215
+ parsed = JSON.parse(edited);
216
+ } catch (err: any) {
217
+ ctx.ui.notify(`Invalid JSON: ${err.message}`, "error");
218
+ return;
219
+ }
220
+ const ok = await ctx.ui.confirm("Save raw config?", "This will overwrite ~/.pi-switch/config.json.");
221
+ if (!ok) return;
222
+ await saveConfig(parsed);
223
+ const registered = await registerConfiguredProviders(pi);
224
+ ctx.ui.notify(`Saved raw config. Registered ${registered.length} provider(s).`, "info");
225
+ }
226
+
227
+ async function promptInstallProxy(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
228
+ const action = await ctx.ui.select("Proxy", ["Install proxy provider", "Set target profile", "Circuit status", "Reset circuit", "Daemon start", "Daemon stop", "Daemon status"]);
229
+ if (!action) return;
230
+
231
+ if (action === "Daemon start") {
232
+ try {
233
+ const result = await daemonStart({});
234
+ ctx.ui.notify(result.message + (result.pid ? `\nPID: ${result.pid}\nLog: ${result.logPath}` : ""), "info");
235
+ } catch (err: any) {
236
+ ctx.ui.notify(`Daemon start failed: ${err.message}`, "error");
237
+ }
238
+ return;
239
+ }
240
+
241
+ if (action === "Daemon stop") {
242
+ const result = await daemonStop();
243
+ ctx.ui.notify(result.message, "info");
244
+ return;
245
+ }
246
+
247
+ if (action === "Daemon status") {
248
+ const result = await daemonStatus();
249
+ if (result.running) {
250
+ ctx.ui.notify(`Proxy daemon running\nPID: ${result.pid}\nListen: http://${result.host}:${result.port}\nTarget: ${result.target || "none"}\nFailover: ${result.failover?.join(" -> ") || "none"}`, "info");
251
+ } else {
252
+ ctx.ui.notify(result.message, "warning");
253
+ }
254
+ return;
255
+ }
256
+
257
+ if (action === "Set target profile") {
258
+ const target = await chooseNonProxyProfile(ctx);
259
+ if (!target) return;
260
+ const result = await list();
261
+ const others = Object.keys(result.profiles).filter((name) => name !== target && !result.profiles[name]?.proxy);
262
+ const failoverText = others.length
263
+ ? await ctx.ui.input("Failover profiles, comma separated (optional)", others.join(","))
264
+ : undefined;
265
+ const failover = failoverText?.split(",").map((s) => s.trim()).filter(Boolean);
266
+ const saved = await setProxyTarget(target, failover);
267
+ ctx.ui.notify(
268
+ `Proxy target set to '${saved.target}'.${saved.failover.length ? `\nFailover: ${saved.failover.join(" -> ")}` : ""}`,
269
+ "info",
270
+ );
271
+ return;
272
+ }
273
+
274
+ if (action === "Circuit status") {
275
+ ctx.ui.notify(JSON.stringify(await getCircuitState(), null, 2), "info");
276
+ return;
277
+ }
278
+
279
+ if (action === "Reset circuit") {
280
+ await resetCircuitState();
281
+ ctx.ui.notify("Circuit breaker state reset.", "info");
282
+ return;
283
+ }
284
+
285
+ const name = (await ctx.ui.input("Proxy provider name", "proxy"))?.trim();
286
+ if (!name) return;
287
+ const host = (await ctx.ui.input("Proxy host", "127.0.0.1"))?.trim();
288
+ if (!host) return;
289
+ const port = (await ctx.ui.input("Proxy port", "43112"))?.trim();
290
+ if (!port) return;
291
+ const result = await installProxyProvider([name, "--host", host, "--port", port]);
292
+ await registerConfiguredProviders(pi);
293
+ const activate = await ctx.ui.confirm(
294
+ "Proxy provider installed",
295
+ `Installed '${result.name}' -> http://${host}:${port}/v1.\n\nActivate it now?`,
296
+ );
297
+ if (activate) {
298
+ const activated = await use([result.name]);
299
+ ctx.ui.notify(`Activated '${activated.name}'. Start the proxy with: pi-switch proxy start`, "info");
300
+ } else {
301
+ ctx.ui.notify(`Installed '${result.name}'. Start proxy with: pi-switch proxy start`, "info");
302
+ }
303
+ }
304
+
305
+ async function chooseNonProxyProfile(ctx: ExtensionContext): Promise<string | null> {
306
+ const result = await list();
307
+ const names = Object.keys(result.profiles).filter((name) => !result.profiles[name]?.proxy);
308
+ if (names.length === 0) {
309
+ ctx.ui.notify("No non-proxy profiles. Add an upstream provider first with /piswitch add.", "warning");
310
+ return null;
311
+ }
312
+ const selected = await ctx.ui.select(
313
+ "Proxy target profile",
314
+ names.map((name) => `${name} (${result.profiles[name].api})`),
315
+ );
316
+ return selected ? selected.split(/\s+/)[0] || null : null;
317
+ }
318
+
319
+ export default async function piSwitchExtension(pi: ExtensionAPI) {
320
+ try {
321
+ await registerConfiguredProviders(pi);
322
+ } catch {
323
+ // Avoid breaking pi startup if pi-switch has not been initialized yet.
324
+ }
325
+
326
+ pi.registerCommand("piswitch", {
327
+ description: "Manage pi-switch profiles",
328
+ getArgumentCompletions: (prefix) => {
329
+ const commands = ["add", "edit", "show", "remove", "raw", "proxy", "list", "use", "doctor", "reload", "presets", "stats", "info", "open", "export", "import", "menu"];
330
+ const matches = commands.filter((c) => c.startsWith(prefix.trim()));
331
+ return matches.length ? matches.map((value) => ({ value, label: value })) : null;
332
+ },
333
+ handler: async (args, ctx) => {
334
+ const [subcommand, ...rest] = args.trim().split(/\s+/).filter(Boolean);
335
+ const cmd = subcommand || "menu";
336
+
337
+ if (cmd === "add") {
338
+ await promptAddProvider(pi, ctx);
339
+ return;
340
+ }
341
+
342
+ if (cmd === "edit") {
343
+ await promptEditProvider(pi, ctx);
344
+ return;
345
+ }
346
+
347
+ if (cmd === "show") {
348
+ await promptShowProvider(ctx);
349
+ return;
350
+ }
351
+
352
+ if (cmd === "remove" || cmd === "rm") {
353
+ await promptRemoveProvider(pi, ctx);
354
+ return;
355
+ }
356
+
357
+ if (cmd === "raw") {
358
+ await promptRawConfig(pi, ctx);
359
+ return;
360
+ }
361
+
362
+ if (cmd === "proxy") {
363
+ await promptInstallProxy(pi, ctx);
364
+ return;
365
+ }
366
+
367
+ if (cmd === "list") {
368
+ ctx.ui.notify(formatProfileList(await list()), "info");
369
+ return;
370
+ }
371
+
372
+ if (cmd === "doctor") {
373
+ await showDoctor(ctx);
374
+ return;
375
+ }
376
+
377
+ if (cmd === "presets" || cmd === "preset") {
378
+ const result = await preset(["list"]);
379
+ ctx.ui.notify(
380
+ result.presets.map((p: any) => `${p.id}: ${p.description}\n ${p.baseUrl}`).join("\n"),
381
+ "info",
382
+ );
383
+ return;
384
+ }
385
+
386
+ if (cmd === "reload") {
387
+ const registered = await registerConfiguredProviders(pi);
388
+ ctx.ui.notify(`Registered ${registered.length} provider(s): ${registered.join(", ") || "none"}`, "info");
389
+ return;
390
+ }
391
+
392
+ if (cmd === "stats") {
393
+ const format = rest[0] || "summary";
394
+ const valid = ["summary", "by-provider", "by-model", "hourly", "full"];
395
+ if (!valid.includes(format)) {
396
+ ctx.ui.notify(`Invalid format '${format}'. Use: ${valid.join("|")}`, "warning");
397
+ return;
398
+ }
399
+ const stats = await getStats();
400
+ ctx.ui.notify(formatStatsText(stats, format), "info");
401
+ return;
402
+ }
403
+
404
+ if (cmd === "info") {
405
+ const name = rest[0] || (await chooseProfile(ctx));
406
+ if (!name) return;
407
+ try {
408
+ const result = await getProfileInfo(name);
409
+ const links = [];
410
+ if (result.links.manageKeys) links.push(`Manage keys: ${result.links.manageKeys}`);
411
+ links.push(`API docs: ${result.links.docs}`);
412
+ ctx.ui.notify(
413
+ `Name: ${result.name}\nAPI: ${result.api}\nBase URL: ${result.baseUrl}\nModels: ${result.models.join(", ")}\n\n${links.join("\n")}`,
414
+ "info",
415
+ );
416
+ } catch (err: any) {
417
+ ctx.ui.notify(err.message, "error");
418
+ }
419
+ return;
420
+ }
421
+
422
+ if (cmd === "open") {
423
+ const target = rest[0] || (await chooseProfile(ctx));
424
+ if (!target) return;
425
+ const result = await openProvider(target);
426
+ if (result?.opened) {
427
+ ctx.ui.notify(`Opened ${result.label}`, "info");
428
+ } else if (result?.url) {
429
+ ctx.ui.notify(`${result.label}: ${result.url}`, "info");
430
+ } else {
431
+ ctx.ui.notify(`No link for '${target}'`, "warning");
432
+ }
433
+ return;
434
+ }
435
+
436
+ if (cmd === "export") {
437
+ const passphrase = await ctx.ui.input("Passphrase (min 8 chars)", "");
438
+ if (!passphrase) return;
439
+ try {
440
+ const result = await exportConfig(passphrase.trim());
441
+ ctx.ui.notify(result.message, "info");
442
+ } catch (err: any) {
443
+ ctx.ui.notify(`Export failed: ${err.message}`, "error");
444
+ }
445
+ return;
446
+ }
447
+
448
+ if (cmd === "import") {
449
+ const filePath = await ctx.ui.input("Export file path", "");
450
+ if (!filePath) return;
451
+ const passphrase = await ctx.ui.input("Passphrase", "");
452
+ if (!passphrase) return;
453
+ try {
454
+ const result = await importConfig(filePath.trim(), passphrase.trim());
455
+ ctx.ui.notify(result.message + (result.sanitizedKeys ? `\n${result.sanitizedKeys} key(s) sanitized β†’ env vars` : ""), "info");
456
+ await registerConfiguredProviders(pi);
457
+ } catch (err: any) {
458
+ ctx.ui.notify(`Import failed: ${err.message}`, "error");
459
+ }
460
+ return;
461
+ }
462
+
463
+ if (cmd === "use") {
464
+ const name = rest[0] || (await chooseProfile(ctx));
465
+ if (!name) return;
466
+ const result = await use([name]);
467
+ await registerConfiguredProviders(pi);
468
+ ctx.ui.notify(
469
+ `Activated '${result.name}' as '${result.providerId}'. Open /model to refresh/select the model.`,
470
+ "info",
471
+ );
472
+ return;
473
+ }
474
+
475
+ if (cmd !== "menu") {
476
+ ctx.ui.notify("Usage: /piswitch [add|edit|show|remove|raw|list|use|doctor|presets|reload|menu]", "warning");
477
+ return;
478
+ }
479
+
480
+ const selected = await ctx.ui.select("pi-switch", [
481
+ "Add provider",
482
+ "Edit provider",
483
+ "Show provider",
484
+ "Remove provider",
485
+ "Raw config editor",
486
+ "Install proxy provider",
487
+ "Use profile",
488
+ "List profiles",
489
+ "Preset list",
490
+ "Doctor",
491
+ "Reload registered providers",
492
+ "Usage stats",
493
+ "Open dashboard",
494
+ "Export config",
495
+ "Import config",
496
+ ]);
497
+ if (selected === "Add provider") {
498
+ await promptAddProvider(pi, ctx);
499
+ } else if (selected === "Edit provider") {
500
+ await promptEditProvider(pi, ctx);
501
+ } else if (selected === "Show provider") {
502
+ await promptShowProvider(ctx);
503
+ } else if (selected === "Remove provider") {
504
+ await promptRemoveProvider(pi, ctx);
505
+ } else if (selected === "Raw config editor") {
506
+ await promptRawConfig(pi, ctx);
507
+ } else if (selected === "Install proxy provider") {
508
+ await promptInstallProxy(pi, ctx);
509
+ } else if (selected === "Use profile") {
510
+ const name = await chooseProfile(ctx);
511
+ if (!name) return;
512
+ const result = await use([name]);
513
+ await registerConfiguredProviders(pi);
514
+ ctx.ui.notify(`Activated '${result.name}' as '${result.providerId}'. Open /model to refresh/select it.`, "info");
515
+ } else if (selected === "List profiles") {
516
+ ctx.ui.notify(formatProfileList(await list()), "info");
517
+ } else if (selected === "Preset list") {
518
+ const result = await preset(["list"]);
519
+ ctx.ui.notify(result.presets.map((p: any) => `${p.id}: ${p.description}\n ${p.baseUrl}`).join("\n"), "info");
520
+ } else if (selected === "Doctor") {
521
+ await showDoctor(ctx);
522
+ } else if (selected === "Reload registered providers") {
523
+ const registered = await registerConfiguredProviders(pi);
524
+ ctx.ui.notify(`Registered ${registered.length} provider(s): ${registered.join(", ") || "none"}`, "info");
525
+ } else if (selected === "Usage stats") {
526
+ const stats = await getStats();
527
+ ctx.ui.notify(formatStatsText(stats, "summary"), "info");
528
+ } else if (selected === "Open dashboard") {
529
+ const name = await chooseProfile(ctx);
530
+ if (!name) return;
531
+ const result = await openProvider(name);
532
+ if (result?.opened) ctx.ui.notify(`Opened ${result.label}`, "info");
533
+ else if (result?.url) ctx.ui.notify(`${result.label}: ${result.url}`, "info");
534
+ else ctx.ui.notify(`No link for '${name}'`, "warning");
535
+ } else if (selected === "Export config") {
536
+ const passphrase = await ctx.ui.input("Passphrase (min 8 chars)", "");
537
+ if (!passphrase) return;
538
+ try {
539
+ const result = await exportConfig(passphrase.trim());
540
+ ctx.ui.notify(result.message, "info");
541
+ } catch (err: any) {
542
+ ctx.ui.notify(`Export failed: ${err.message}`, "error");
543
+ }
544
+ } else if (selected === "Import config") {
545
+ const filePath = await ctx.ui.input("Export file path", "");
546
+ if (!filePath) return;
547
+ const passphrase = await ctx.ui.input("Passphrase", "");
548
+ if (!passphrase) return;
549
+ try {
550
+ const result = await importConfig(filePath.trim(), passphrase.trim());
551
+ ctx.ui.notify(result.message + (result.sanitizedKeys ? `\n${result.sanitizedKeys} key(s) sanitized` : ""), "info");
552
+ await registerConfiguredProviders(pi);
553
+ } catch (err: any) {
554
+ ctx.ui.notify(`Import failed: ${err.message}`, "error");
555
+ }
556
+ }
557
+ },
558
+ });
559
+ }
package/index.d.ts ADDED
File without changes
package/index.js ADDED
@@ -0,0 +1,28 @@
1
+ // ESM wrapper for pi-switch native addon
2
+ import { createRequire } from 'module';
3
+ const require = createRequire(import.meta.url);
4
+ const native = require('./pi-switch-native.cjs');
5
+
6
+ // Re-export all native functions
7
+ export const {
8
+ initConfig,
9
+ listPresets,
10
+ showPreset,
11
+ addProvider,
12
+ listProfiles,
13
+ showProfile,
14
+ useProfile,
15
+ removeProfile,
16
+ listBackups,
17
+ doctor,
18
+ daemonStartNative,
19
+ daemonStopNative,
20
+ daemonStatusNative,
21
+ getUsageStats,
22
+ exportConfig,
23
+ importConfig,
24
+ exportDir,
25
+ runNativeTui,
26
+ } = native;
27
+
28
+ export default native;
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@cokefenta/pi-switch",
3
+ "version": "0.2.0",
4
+ "description": "Lightweight profile switcher for pi models.json",
5
+ "type": "module",
6
+ "bin": {
7
+ "pi-switch": "./bin/pi-switch.js"
8
+ },
9
+ "main": "./index.js",
10
+ "napi": {
11
+ "name": "pi-switch-native",
12
+ "targets": [
13
+ "x86_64-apple-darwin",
14
+ "aarch64-apple-darwin",
15
+ "x86_64-unknown-linux-gnu",
16
+ "x86_64-pc-windows-msvc"
17
+ ]
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "index.js",
22
+ "index.d.ts",
23
+ "pi-switch-native.cjs",
24
+ "pi-switch-native.darwin-arm64.node",
25
+ "pi-switch-native.darwin-x64.node",
26
+ "pi-switch-native.linux-x64-gnu.node",
27
+ "pi-switch-native.win32-x64-msvc.node",
28
+ "extensions"
29
+ ],
30
+ "scripts": {
31
+ "artifacts": "napi artifacts",
32
+ "build:native": "napi build --platform --release",
33
+ "build:native:debug": "napi build --platform",
34
+ "doctor": "node ./bin/pi-switch.js doctor",
35
+ "test": "node --test"
36
+ },
37
+ "keywords": [
38
+ "pi",
39
+ "pi-package",
40
+ "models",
41
+ "switch"
42
+ ],
43
+ "pi": {
44
+ "extensions": [
45
+ "./extensions"
46
+ ]
47
+ },
48
+ "license": "MIT",
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
52
+ "devDependencies": {
53
+ "@napi-rs/cli": "^3.7.1"
54
+ }
55
+ }
@@ -0,0 +1,22 @@
1
+ // Auto-generated loader for pi-switch-native
2
+ const { existsSync } = require('fs');
3
+ const { join, resolve } = require('path');
4
+
5
+ const { platform, arch } = process;
6
+
7
+ function getBinaryName() {
8
+ const parts = [platform];
9
+ parts.push(arch);
10
+ if (platform === 'linux') parts.push('gnu');
11
+ else if (platform === 'win32') parts.push('msvc');
12
+ return `pi-switch-native.${parts.join('-')}.node`;
13
+ }
14
+
15
+ const binaryName = getBinaryName();
16
+ const localPath = resolve(__dirname, binaryName);
17
+
18
+ if (!existsSync(localPath)) {
19
+ throw new Error(`Native binding not found: ${localPath}. Run: npm run build:native`);
20
+ }
21
+
22
+ module.exports = require(localPath);