pi-keyrouter 0.2.0 → 0.2.2

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.
Files changed (4) hide show
  1. package/README.md +9 -1
  2. package/config.ts +37 -31
  3. package/index.ts +66 -29
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -110,7 +110,15 @@ Every key switch notifies the user with a Box widget:
110
110
 
111
111
  ## 🔧 Config
112
112
 
113
- `~/.pi/keyrouter.json` (or `<cwd>/.soly/keyrouter.json`, `<cwd>/.pi/keyrouter.json`).
113
+ **`~/.pi/keyrouter.json` only** (user-level, never project-scoped).
114
+
115
+ - Windows: `%USERPROFILE%\.pi\keyrouter.json`
116
+ - macOS/Linux: `~/.pi/keyrouter.json`
117
+
118
+ Config is global because API keys are personal credentials — they do not
119
+ belong inside a project directory. Project-local `keyrouter.json` files are
120
+ **deliberately ignored** (security: prevents a malicious repo from overriding
121
+ your real keys).
114
122
 
115
123
  ```json5
116
124
  {
package/config.ts CHANGED
@@ -2,10 +2,13 @@
2
2
  // config.ts — load key router config from disk
3
3
  // =============================================================================
4
4
  //
5
- // Looks in this order (first hit wins):
6
- // 1. <cwd>/.pi/keyrouter.json — project override
7
- // 2. <cwd>/.soly/keyrouter.json — soly convention
8
- // 3. ~/.pi/keyrouter.json — user-level default
5
+ // Config is GLOBAL (user-level), never project-scoped. API keys are personal
6
+ // credentials that do not belong inside a project directory (risk of leaking
7
+ // via git, shared repos, etc.).
8
+ //
9
+ // Single location: ~/.pi/keyrouter.json
10
+ // - Windows: %USERPROFILE%\.pi\keyrouter.json
11
+ // - macOS/Linux: ~/.pi/keyrouter.json
9
12
  //
10
13
  // Schema:
11
14
  // {
@@ -28,8 +31,6 @@ import * as os from "node:os";
28
31
  import * as path from "node:path";
29
32
  import type { KeyRouterConfig } from "./types.ts";
30
33
 
31
- const CONFIG_FILENAMES = ["keyrouter.json"];
32
-
33
34
  export function defaultConfig(): KeyRouterConfig {
34
35
  return {
35
36
  providers: [],
@@ -38,33 +39,38 @@ export function defaultConfig(): KeyRouterConfig {
38
39
  };
39
40
  }
40
41
 
41
- export function loadConfig(cwd: string, home?: string): KeyRouterConfig {
42
+ /**
43
+ * Resolve the config path. Always under the user profile (~/.pi/), never
44
+ * project-scoped. The `cwd` argument is accepted for API symmetry but
45
+ * ignored — keys are global.
46
+ *
47
+ * @param _cwd ignored — config is always user-level
48
+ * @param home override home dir (for testing)
49
+ */
50
+ export function configPath(_cwd?: string, home?: string): string {
42
51
  const homeDir = home ?? os.homedir();
43
- const candidates: string[] = [];
44
- for (const dir of [
45
- path.join(cwd, ".soly"),
46
- path.join(cwd, ".pi"),
47
- cwd,
48
- path.join(homeDir, ".soly"),
49
- path.join(homeDir, ".pi"),
50
- homeDir,
51
- ]) {
52
- for (const name of CONFIG_FILENAMES) {
53
- candidates.push(path.join(dir, name));
54
- }
55
- }
56
- for (const file of candidates) {
57
- if (fs.existsSync(file)) {
58
- try {
59
- const raw = fs.readFileSync(file, "utf-8");
60
- const parsed = JSON.parse(raw) as Partial<KeyRouterConfig>;
61
- return normalize(parsed);
62
- } catch {
63
- // bad config — fall through to default
64
- }
65
- }
52
+ return path.join(homeDir, ".pi", "keyrouter.json");
53
+ }
54
+
55
+ /**
56
+ * Path displayed in error messages / /keyrouter status so the user can see
57
+ * exactly where we're looking.
58
+ */
59
+ export function configSearchPaths(): string[] {
60
+ return [configPath()];
61
+ }
62
+
63
+ export function loadConfig(_cwd?: string, home?: string): KeyRouterConfig {
64
+ const file = configPath(undefined, home);
65
+ if (!fs.existsSync(file)) return defaultConfig();
66
+ try {
67
+ const raw = fs.readFileSync(file, "utf-8");
68
+ const parsed = JSON.parse(raw) as Partial<KeyRouterConfig>;
69
+ return normalize(parsed);
70
+ } catch {
71
+ // bad config — fall through to default
72
+ return defaultConfig();
66
73
  }
67
- return defaultConfig();
68
74
  }
69
75
 
70
76
  function normalize(input: Partial<KeyRouterConfig>): KeyRouterConfig {
package/index.ts CHANGED
@@ -28,7 +28,7 @@
28
28
  // /reload
29
29
 
30
30
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
- import { loadConfig } from "./config.ts";
31
+ import { loadConfig, configPath } from "./config.ts";
32
32
  import {
33
33
  initKeyStates,
34
34
  isAvailable,
@@ -47,6 +47,7 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
47
47
  let config: KeyRouterConfig | undefined;
48
48
  const runtimes = new Map<string, ProviderRuntime>();
49
49
  let notify: ((text: string, level: "info" | "warning" | "error") => void) | undefined;
50
+ let activationNotified = false;
50
51
 
51
52
  function ensureRuntime(providerName: string, cfg: KeyRouterConfig): ProviderRuntime | undefined {
52
53
  let rt = runtimes.get(providerName);
@@ -61,6 +62,50 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
61
62
  return rt;
62
63
  }
63
64
 
65
+ /**
66
+ * Activate the router: load config (once), bootstrap all providers.
67
+ * Idempotent — safe to call on every before_agent_start. Only runs
68
+ * the bootstrap the FIRST time for each provider.
69
+ */
70
+ async function activate(ctx: {
71
+ cwd: string;
72
+ ui: { notify: (t: string, l?: "info" | "warning" | "error") => void };
73
+ modelRegistry: { authStorage: { setRuntimeApiKey: (p: string, k: string) => void } };
74
+ }): Promise<void> {
75
+ // Load config once (reload clears it)
76
+ if (!config) {
77
+ config = loadConfig(ctx.cwd);
78
+ }
79
+ if (config.providers.length === 0) return;
80
+ notify = (text, level) => ctx.ui.notify(text, level);
81
+
82
+ const authStorage = ctx.modelRegistry.authStorage;
83
+ let newlyBootstrapped = 0;
84
+ for (const p of config.providers) {
85
+ const providerName = resolveProviderName(p.name);
86
+ // Skip providers we've already bootstrapped
87
+ if (runtimes.has(providerName)) continue;
88
+ if (bootstrap(providerName)) {
89
+ const rt = runtimes.get(providerName);
90
+ if (rt && rt.currentIndex >= 0) {
91
+ const key = rt.keys[rt.currentIndex];
92
+ if (key) {
93
+ authStorage.setRuntimeApiKey(providerName, key.value);
94
+ newlyBootstrapped++;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ // Only notify on first activation (when we bootstrapped at least one)
100
+ if (newlyBootstrapped > 0 && !activationNotified) {
101
+ activationNotified = true;
102
+ ctx.ui.notify(
103
+ `🔑 keyrouter: active (${config.providers.length} provider(s), ${config.providers.reduce((a, p) => a + p.keys.length, 0)} keys)`,
104
+ "info",
105
+ );
106
+ }
107
+ }
108
+
64
109
  /** Set the initial key for a provider on first use. */
65
110
  function bootstrap(providerName: string): boolean {
66
111
  const cfg = config;
@@ -129,34 +174,14 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
129
174
  }
130
175
 
131
176
  pi.on("session_start", async (_event, ctx) => {
132
- config = loadConfig(ctx.cwd);
133
- if (config.providers.length === 0) {
134
- return; // nothing to do
135
- }
136
- notify = (text, level) => ctx.ui.notify(text, level);
177
+ await activate(ctx);
178
+ });
137
179
 
138
- // Bootstrap all providers: set the first key as runtime override
139
- const authStorage = ctx.modelRegistry.authStorage;
140
- let bootstrapped = 0;
141
- for (const p of config.providers) {
142
- const providerName = resolveProviderName(p.name);
143
- if (bootstrap(providerName)) {
144
- const rt = runtimes.get(providerName);
145
- if (rt && rt.currentIndex >= 0) {
146
- const key = rt.keys[rt.currentIndex];
147
- if (key) {
148
- authStorage.setRuntimeApiKey(providerName, key.value);
149
- bootstrapped++;
150
- }
151
- }
152
- }
153
- }
154
- if (bootstrapped > 0) {
155
- ctx.ui.notify(
156
- `🔑 keyrouter: active (${bootstrapped} provider(s), ${config.providers.reduce((a, p) => a + p.keys.length, 0)} keys)`,
157
- "info",
158
- );
159
- }
180
+ // Lazy bootstrap: also fire on every turn. This handles /reload (which
181
+ // does NOT re-fire session_start) and config changes mid-session.
182
+ // activate() is idempotent — only bootstraps once per provider.
183
+ pi.on("before_agent_start", async (_event, ctx) => {
184
+ await activate(ctx);
160
185
  });
161
186
 
162
187
  pi.on("after_provider_response", async (event, ctx) => {
@@ -196,6 +221,7 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
196
221
  runtimes.clear();
197
222
  config = undefined;
198
223
  notify = undefined;
224
+ activationNotified = false;
199
225
  });
200
226
 
201
227
  pi.registerCommand("keyrouter", {
@@ -203,8 +229,18 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
203
229
  handler: async (args, ctx) => {
204
230
  const sub = args.trim().split(/\s+/)[0] ?? "status";
205
231
  if (sub === "status") {
232
+ // On-demand activation in case session_start/before_agent_start
233
+ // haven't fired yet (e.g. user ran /keyrouter status right after
234
+ // /reload without sending a prompt).
235
+ if (!config || runtimes.size === 0) {
236
+ await activate(ctx);
237
+ }
206
238
  if (!config || runtimes.size === 0) {
207
- ctx.ui.notify("🔑 keyrouter: not active", "info");
239
+ ctx.ui.notify(
240
+ `🔑 keyrouter: not active — no ~/.pi/keyrouter.json found ` +
241
+ `(expected at ${configPath()}). Config is user-level only, never project-scoped.`,
242
+ "warning",
243
+ );
208
244
  return;
209
245
  }
210
246
  const lines: string[] = [`🔑 keyrouter: active`];
@@ -226,6 +262,7 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
226
262
  if (sub === "reload") {
227
263
  config = loadConfig(ctx.cwd);
228
264
  runtimes.clear();
265
+ activationNotified = false;
229
266
  ctx.ui.notify(
230
267
  `🔑 keyrouter: reloaded (${config.providers.length} provider(s))`,
231
268
  "info",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-keyrouter",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "API key rotation for pi-coding-agent. Multiple keys per provider, automatic 429/401 fallback, max-retries guard.",
5
5
  "type": "module",
6
6
  "main": "index.ts",