@victor-software-house/pi-multicodex 1.1.0 → 2.0.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 CHANGED
@@ -45,46 +45,33 @@ Run the extension directly during local development:
45
45
  pi -e ./index.ts
46
46
  ```
47
47
 
48
- ## Current commands
48
+ ## Command family
49
49
 
50
- These commands reflect the current shipped implementation:
51
-
52
- - `/multicodex-use [identifier]`
53
- - Use an existing managed account, or start the Codex login flow when the account is missing or the stored auth is no longer valid.
54
- - With no argument, opens an account picker.
55
- - `/multicodex-status`
56
- - Show managed account state and cached usage information.
57
- - `/multicodex-footer`
58
- - Open an interactive panel to configure footer fields and ordering.
59
-
60
- ## Planned command migration
61
-
62
- The next user-facing milestone is a command-surface migration to one command family:
50
+ The extension now uses one command family:
63
51
 
64
52
  - `/multicodex`
65
53
  - open the main interactive UI
66
54
  - `/multicodex show`
67
- - show runtime state and active-account summary
55
+ - show managed account status and cached usage
68
56
  - `/multicodex use [identifier]`
69
- - choose or activate an account
57
+ - with an identifier, activate existing auth or trigger login
58
+ - with no identifier, open the account picker
59
+ - in the picker, `Backspace` removes the highlighted account after confirmation
70
60
  - `/multicodex footer`
71
- - open footer settings
61
+ - open footer settings in interactive mode
62
+ - show footer settings summary in non-interactive mode
72
63
  - `/multicodex rotation`
73
- - open rotation settings
64
+ - show current hard-coded rotation policy
74
65
  - `/multicodex verify`
75
- - verify runtime health and local storage access
66
+ - verify writable local paths and report runtime summary
76
67
  - `/multicodex path`
77
- - show config and storage paths
78
- - `/multicodex reset`
79
- - reset selected extension state
68
+ - show storage and settings file paths
69
+ - `/multicodex reset [manual|quota|all]`
70
+ - reset manual override state, quota cooldown state, or both
80
71
  - `/multicodex help`
81
72
  - print compact usage text
82
73
 
83
- Migration policy:
84
-
85
- - the old top-level commands will be removed once `/multicodex` is ready
86
- - no backward-compatibility aliases are planned
87
- - README, roadmap, tests, and release notes will move together in the same change
74
+ Dynamic autocomplete is available for subcommands and for `/multicodex use <identifier>`.
88
75
 
89
76
  ## Architecture overview
90
77
 
@@ -102,7 +89,7 @@ The implementation is currently organized around these modules:
102
89
  - `status.ts`
103
90
  - footer rendering, footer settings persistence, footer settings panel, and footer status refresh logic
104
91
  - `commands.ts`
105
- - current slash command registrations and account-selection flows
92
+ - `/multicodex` command-family routing, autocomplete, and account-selection flows
106
93
  - `hooks.ts`
107
94
  - session-start and session-switch refresh behavior
108
95
  - `storage.ts`
@@ -123,12 +110,10 @@ Current direction:
123
110
 
124
111
  Current next milestones:
125
112
 
126
- 1. Replace the split command surface with the `/multicodex` command family.
127
- 2. Add dynamic autocomplete for subcommands and managed account identifiers.
128
- 3. Make account inspection and selection consistently actionable.
129
- 4. Persist footer settings immediately instead of waiting for panel close.
130
- 5. Add configurable rotation settings and document the rotation behavior contract.
131
- 6. Broaden the current footer controller into a shared MultiCodex controller.
113
+ 1. Persist footer settings immediately instead of waiting for panel close.
114
+ 2. Add configurable rotation settings and document the rotation behavior contract.
115
+ 3. Broaden the current footer controller into a shared MultiCodex controller.
116
+ 4. Improve imported-account labels by deriving email identity safely when possible.
132
117
 
133
118
  ## Behavior contract
134
119
 
@@ -136,7 +121,7 @@ The current runtime behavior is:
136
121
 
137
122
  ### Account selection priority
138
123
 
139
- 1. Use the manual account selected with `/multicodex-use` when it is still available.
124
+ 1. Use the manual account selected with `/multicodex use` when it is still available.
140
125
  2. Otherwise clear the stale manual override and select the best available managed account.
141
126
  3. Best-account selection prefers:
142
127
  - untouched accounts with usage data
@@ -158,8 +143,8 @@ The current runtime behavior is:
158
143
 
159
144
  ### Manual override behavior
160
145
 
161
- - `/multicodex-use <identifier>` sets the manual account override immediately.
162
- - `/multicodex-use` with no argument opens the account picker and sets the selected manual override.
146
+ - `/multicodex use <identifier>` sets the manual account override immediately.
147
+ - `/multicodex use` with no argument opens the account picker and sets the selected manual override.
163
148
  - Manual override is session-local state.
164
149
  - Manual override clears automatically when the selected account is no longer available or when it hits quota during rotation.
165
150
 
@@ -195,6 +195,21 @@ export class AccountManager {
195
195
  }
196
196
  }
197
197
 
198
+ clearAllQuotaExhaustion(): number {
199
+ let cleared = 0;
200
+ for (const account of this.data.accounts) {
201
+ if (account.quotaExhaustedUntil) {
202
+ account.quotaExhaustedUntil = undefined;
203
+ cleared += 1;
204
+ }
205
+ }
206
+ if (cleared > 0) {
207
+ this.save();
208
+ this.notifyStateChanged();
209
+ }
210
+ return cleared;
211
+ }
212
+
198
213
  removeAccount(email: string): boolean {
199
214
  const index = this.data.accounts.findIndex(
200
215
  (account) => account.email === email,
package/commands.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { promises as fs, constants as fsConstants } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { loginOpenAICodex } from "@mariozechner/pi-ai/oauth";
2
5
  import type {
3
6
  ExtensionAPI,
@@ -5,6 +8,7 @@ import type {
5
8
  } from "@mariozechner/pi-coding-agent";
6
9
  import { getSelectListTheme } from "@mariozechner/pi-coding-agent";
7
10
  import {
11
+ type AutocompleteItem,
8
12
  Container,
9
13
  Key,
10
14
  matchesKey,
@@ -14,13 +18,164 @@ import {
14
18
  import type { AccountManager } from "./account-manager";
15
19
  import { openLoginInBrowser } from "./browser";
16
20
  import type { createUsageStatusController } from "./status";
21
+ import { STORAGE_FILE } from "./storage";
17
22
  import { formatResetAt, isUsageUntouched } from "./usage";
18
23
 
24
+ const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
25
+ const NO_ACCOUNTS_MESSAGE =
26
+ "No managed accounts found. Use /multicodex use <identifier> first.";
27
+ const HELP_TEXT =
28
+ "Usage: /multicodex [show|use [identifier]|footer|rotation|verify|path|reset [manual|quota|all]|help]";
29
+ const SUBCOMMANDS = [
30
+ "show",
31
+ "use",
32
+ "footer",
33
+ "rotation",
34
+ "verify",
35
+ "path",
36
+ "reset",
37
+ "help",
38
+ ] as const;
39
+ const RESET_TARGETS = ["manual", "quota", "all"] as const;
40
+
41
+ type Subcommand = (typeof SUBCOMMANDS)[number];
42
+ type ResetTarget = (typeof RESET_TARGETS)[number];
43
+
44
+ type AccountPanelResult =
45
+ | { action: "select"; email: string }
46
+ | { action: "remove"; email: string }
47
+ | undefined;
48
+
19
49
  function getErrorMessage(error: unknown): string {
20
50
  if (error instanceof Error) return error.message;
21
51
  return typeof error === "string" ? error : JSON.stringify(error);
22
52
  }
23
53
 
54
+ function toAutocompleteItems(values: readonly string[]): AutocompleteItem[] {
55
+ return values.map((value) => ({ value, label: value }));
56
+ }
57
+
58
+ function parseCommandArgs(args: string): {
59
+ subcommand: string | undefined;
60
+ rest: string;
61
+ } {
62
+ const trimmed = args.trim();
63
+ if (!trimmed) {
64
+ return { subcommand: undefined, rest: "" };
65
+ }
66
+ const firstSpaceIndex = trimmed.indexOf(" ");
67
+ if (firstSpaceIndex < 0) {
68
+ return { subcommand: trimmed.toLowerCase(), rest: "" };
69
+ }
70
+ return {
71
+ subcommand: trimmed.slice(0, firstSpaceIndex).toLowerCase(),
72
+ rest: trimmed.slice(firstSpaceIndex + 1).trim(),
73
+ };
74
+ }
75
+
76
+ function isSubcommand(value: string): value is Subcommand {
77
+ return SUBCOMMANDS.some((subcommand) => subcommand === value);
78
+ }
79
+
80
+ function parseResetTarget(value: string): ResetTarget | undefined {
81
+ if (value === "manual" || value === "quota" || value === "all") {
82
+ return value;
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ function getAccountLabel(email: string, quotaExhaustedUntil?: number): string {
88
+ if (!quotaExhaustedUntil || quotaExhaustedUntil <= Date.now()) {
89
+ return email;
90
+ }
91
+ return `${email} (Quota)`;
92
+ }
93
+
94
+ function formatAccountStatusLine(
95
+ accountManager: AccountManager,
96
+ email: string,
97
+ ): string {
98
+ const account = accountManager.getAccount(email);
99
+ if (!account) return email;
100
+ const usage = accountManager.getCachedUsage(account.email);
101
+ const active = accountManager.getActiveAccount();
102
+ const manual = accountManager.getManualAccount();
103
+ const quotaHit =
104
+ account.quotaExhaustedUntil && account.quotaExhaustedUntil > Date.now();
105
+ const untouched = isUsageUntouched(usage) ? "untouched" : null;
106
+ const imported = account.importSource ? "imported" : null;
107
+ const tags = [
108
+ active?.email === account.email ? "active" : null,
109
+ manual?.email === account.email ? "manual" : null,
110
+ quotaHit ? "quota" : null,
111
+ untouched,
112
+ imported,
113
+ ]
114
+ .filter(Boolean)
115
+ .join(", ");
116
+ const suffix = tags ? ` (${tags})` : "";
117
+ const primaryUsed = usage?.primary?.usedPercent;
118
+ const secondaryUsed = usage?.secondary?.usedPercent;
119
+ const primaryReset = usage?.primary?.resetAt;
120
+ const secondaryReset = usage?.secondary?.resetAt;
121
+ const primaryLabel =
122
+ primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
123
+ const secondaryLabel =
124
+ secondaryUsed === undefined ? "unknown" : `${Math.round(secondaryUsed)}%`;
125
+ const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
126
+ return `${account.email}${suffix} - ${usageSummary}`;
127
+ }
128
+
129
+ function getSubcommandCompletions(prefix: string): AutocompleteItem[] | null {
130
+ const matches = SUBCOMMANDS.filter((value) => value.startsWith(prefix));
131
+ return matches.length > 0 ? toAutocompleteItems(matches) : null;
132
+ }
133
+
134
+ function getUseCompletions(
135
+ prefix: string,
136
+ accountManager: AccountManager,
137
+ ): AutocompleteItem[] | null {
138
+ const matches = accountManager
139
+ .getAccounts()
140
+ .map((account) => account.email)
141
+ .filter((value) => value.startsWith(prefix));
142
+ if (matches.length === 0) return null;
143
+ return matches.map((value) => ({ value: `use ${value}`, label: value }));
144
+ }
145
+
146
+ function getResetCompletions(prefix: string): AutocompleteItem[] | null {
147
+ const matches = RESET_TARGETS.filter((value) => value.startsWith(prefix));
148
+ if (matches.length === 0) return null;
149
+ return matches.map((value) => ({ value: `reset ${value}`, label: value }));
150
+ }
151
+
152
+ function getCommandCompletions(
153
+ argumentPrefix: string,
154
+ accountManager: AccountManager,
155
+ ): AutocompleteItem[] | null {
156
+ const trimmedStart = argumentPrefix.trimStart();
157
+ if (!trimmedStart) {
158
+ return toAutocompleteItems(SUBCOMMANDS);
159
+ }
160
+
161
+ const firstSpaceIndex = trimmedStart.indexOf(" ");
162
+ if (firstSpaceIndex < 0) {
163
+ return getSubcommandCompletions(trimmedStart.toLowerCase());
164
+ }
165
+
166
+ const subcommand = trimmedStart.slice(0, firstSpaceIndex).toLowerCase();
167
+ const rest = trimmedStart.slice(firstSpaceIndex + 1).trimStart();
168
+
169
+ if (subcommand === "use") {
170
+ return getUseCompletions(rest, accountManager);
171
+ }
172
+ if (subcommand === "reset") {
173
+ return getResetCompletions(rest);
174
+ }
175
+
176
+ return null;
177
+ }
178
+
24
179
  async function loginAndActivateAccount(
25
180
  pi: ExtensionAPI,
26
181
  ctx: ExtensionCommandContext,
@@ -76,18 +231,6 @@ async function useOrLoginAccount(
76
231
  await loginAndActivateAccount(pi, ctx, accountManager, identifier);
77
232
  }
78
233
 
79
- type AccountPanelResult =
80
- | { action: "select"; email: string }
81
- | { action: "remove"; email: string }
82
- | undefined;
83
-
84
- function getAccountLabel(email: string, quotaExhaustedUntil?: number): string {
85
- if (!quotaExhaustedUntil || quotaExhaustedUntil <= Date.now()) {
86
- return email;
87
- }
88
- return `${email} (Quota)`;
89
- }
90
-
91
234
  async function openAccountSelectionPanel(
92
235
  ctx: ExtensionCommandContext,
93
236
  accountManager: AccountManager,
@@ -143,10 +286,7 @@ async function openAccountSelectionFlow(
143
286
  while (true) {
144
287
  const accounts = accountManager.getAccounts();
145
288
  if (accounts.length === 0) {
146
- ctx.ui.notify(
147
- "No managed accounts found. Use /login or /multicodex-use <identifier> first.",
148
- "warning",
149
- );
289
+ ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
150
290
  return;
151
291
  }
152
292
 
@@ -179,90 +319,341 @@ async function openAccountSelectionFlow(
179
319
  }
180
320
  }
181
321
 
322
+ async function runShowSubcommand(
323
+ ctx: ExtensionCommandContext,
324
+ accountManager: AccountManager,
325
+ ): Promise<void> {
326
+ await accountManager.syncImportedOpenAICodexAuth();
327
+ await accountManager.refreshUsageForAllAccounts();
328
+ const accounts = accountManager.getAccounts();
329
+ if (accounts.length === 0) {
330
+ ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
331
+ return;
332
+ }
333
+
334
+ if (!ctx.hasUI) {
335
+ const active = accountManager.getActiveAccount()?.email ?? "none";
336
+ const manual = accountManager.getManualAccount()?.email ?? "none";
337
+ ctx.ui.notify(
338
+ `multicodex: accounts=${accounts.length} active=${active} manual=${manual}`,
339
+ "info",
340
+ );
341
+ return;
342
+ }
343
+
344
+ const options = accounts.map((account) =>
345
+ formatAccountStatusLine(accountManager, account.email),
346
+ );
347
+ await ctx.ui.select("MultiCodex Accounts", options);
348
+ }
349
+
350
+ async function runFooterSubcommand(
351
+ ctx: ExtensionCommandContext,
352
+ statusController: ReturnType<typeof createUsageStatusController>,
353
+ ): Promise<void> {
354
+ if (!ctx.hasUI) {
355
+ await statusController.loadPreferences(ctx);
356
+ const preferences = statusController.getPreferences();
357
+ ctx.ui.notify(
358
+ `footer: usageMode=${preferences.usageMode} resetWindow=${preferences.resetWindow} showAccount=${preferences.showAccount ? "on" : "off"} showReset=${preferences.showReset ? "on" : "off"} order=${preferences.order}`,
359
+ "info",
360
+ );
361
+ return;
362
+ }
363
+
364
+ await statusController.openPreferencesPanel(ctx);
365
+ }
366
+
367
+ async function runRotationSubcommand(
368
+ ctx: ExtensionCommandContext,
369
+ ): Promise<void> {
370
+ const lines = [
371
+ "Rotation settings are not configurable yet.",
372
+ "Current policy: manual account, then untouched accounts, then earliest weekly reset, then random fallback.",
373
+ "Quota cooldown uses next known reset time, with 1 hour fallback when unknown.",
374
+ ];
375
+
376
+ if (!ctx.hasUI) {
377
+ ctx.ui.notify(
378
+ "rotation: manual->untouched->earliest-weekly-reset->random, cooldown=next-reset-or-1h",
379
+ "info",
380
+ );
381
+ return;
382
+ }
383
+
384
+ await ctx.ui.select("MultiCodex Rotation", lines);
385
+ }
386
+
387
+ async function isWritableDirectoryFor(filePath: string): Promise<boolean> {
388
+ try {
389
+ const directory = path.dirname(filePath);
390
+ await fs.mkdir(directory, { recursive: true });
391
+ await fs.access(directory, fsConstants.R_OK | fsConstants.W_OK);
392
+ return true;
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
398
+ async function runVerifySubcommand(
399
+ ctx: ExtensionCommandContext,
400
+ accountManager: AccountManager,
401
+ statusController: ReturnType<typeof createUsageStatusController>,
402
+ ): Promise<void> {
403
+ const storageWritable = await isWritableDirectoryFor(STORAGE_FILE);
404
+ const settingsWritable = await isWritableDirectoryFor(SETTINGS_FILE);
405
+ const authImported = await accountManager.syncImportedOpenAICodexAuth();
406
+ await statusController.loadPreferences(ctx);
407
+ const accounts = accountManager.getAccounts().length;
408
+ const active = accountManager.getActiveAccount()?.email ?? "none";
409
+ const ok = storageWritable && settingsWritable;
410
+
411
+ if (!ctx.hasUI) {
412
+ ctx.ui.notify(
413
+ `verify: ${ok ? "PASS" : "WARN"} storage=${storageWritable ? "ok" : "fail"} settings=${settingsWritable ? "ok" : "fail"} accounts=${accounts} active=${active} authImport=${authImported ? "updated" : "unchanged"}`,
414
+ ok ? "info" : "warning",
415
+ );
416
+ return;
417
+ }
418
+
419
+ const lines = [
420
+ `storage directory writable: ${storageWritable ? "yes" : "no"}`,
421
+ `settings directory writable: ${settingsWritable ? "yes" : "no"}`,
422
+ `managed accounts: ${accounts}`,
423
+ `active account: ${active}`,
424
+ `auth import changed state: ${authImported ? "yes" : "no"}`,
425
+ ];
426
+ await ctx.ui.select(`MultiCodex Verify (${ok ? "PASS" : "WARN"})`, lines);
427
+ }
428
+
429
+ async function runPathSubcommand(ctx: ExtensionCommandContext): Promise<void> {
430
+ if (!ctx.hasUI) {
431
+ ctx.ui.notify(
432
+ `paths: storage=${STORAGE_FILE} settings=${SETTINGS_FILE}`,
433
+ "info",
434
+ );
435
+ return;
436
+ }
437
+
438
+ await ctx.ui.select("MultiCodex Paths", [
439
+ `Managed account storage: ${STORAGE_FILE}`,
440
+ `Extension settings: ${SETTINGS_FILE}`,
441
+ ]);
442
+ }
443
+
444
+ async function chooseResetTarget(
445
+ ctx: ExtensionCommandContext,
446
+ argument: string,
447
+ ): Promise<ResetTarget | undefined> {
448
+ const explicitTarget = parseResetTarget(argument.toLowerCase());
449
+ if (explicitTarget) {
450
+ return explicitTarget;
451
+ }
452
+
453
+ if (argument) {
454
+ ctx.ui.notify(
455
+ "Unknown reset target. Use: /multicodex reset [manual|quota|all]",
456
+ "warning",
457
+ );
458
+ return undefined;
459
+ }
460
+
461
+ if (!ctx.hasUI) {
462
+ return "all";
463
+ }
464
+
465
+ const options = [
466
+ "manual - clear manual account override",
467
+ "quota - clear quota cooldown markers",
468
+ "all - clear manual override and quota cooldown markers",
469
+ ];
470
+ const selected = await ctx.ui.select("Reset MultiCodex State", options);
471
+ if (!selected) return undefined;
472
+ if (selected.startsWith("manual")) return "manual";
473
+ if (selected.startsWith("quota")) return "quota";
474
+ return "all";
475
+ }
476
+
477
+ async function runResetSubcommand(
478
+ ctx: ExtensionCommandContext,
479
+ accountManager: AccountManager,
480
+ statusController: ReturnType<typeof createUsageStatusController>,
481
+ rest: string,
482
+ ): Promise<void> {
483
+ const target = await chooseResetTarget(ctx, rest);
484
+ if (!target) return;
485
+
486
+ if (target === "all" && ctx.hasUI) {
487
+ const confirmed = await ctx.ui.confirm(
488
+ "Reset MultiCodex state",
489
+ "Clear manual account override and all quota cooldown markers?",
490
+ );
491
+ if (!confirmed) return;
492
+ }
493
+
494
+ const hadManual = accountManager.hasManualAccount();
495
+ if (target === "manual" || target === "all") {
496
+ accountManager.clearManualAccount();
497
+ }
498
+
499
+ let clearedQuota = 0;
500
+ if (target === "quota" || target === "all") {
501
+ clearedQuota = accountManager.clearAllQuotaExhaustion();
502
+ }
503
+
504
+ const manualCleared = hadManual && !accountManager.hasManualAccount();
505
+ ctx.ui.notify(
506
+ `reset: target=${target} manualCleared=${manualCleared ? "yes" : "no"} quotaCleared=${clearedQuota}`,
507
+ "info",
508
+ );
509
+ await statusController.refreshFor(ctx);
510
+ }
511
+
512
+ function runHelpSubcommand(ctx: ExtensionCommandContext): void {
513
+ ctx.ui.notify(HELP_TEXT, "info");
514
+ }
515
+
516
+ async function runUseSubcommand(
517
+ pi: ExtensionAPI,
518
+ ctx: ExtensionCommandContext,
519
+ accountManager: AccountManager,
520
+ statusController: ReturnType<typeof createUsageStatusController>,
521
+ rest: string,
522
+ ): Promise<void> {
523
+ await accountManager.syncImportedOpenAICodexAuth();
524
+
525
+ if (rest) {
526
+ await useOrLoginAccount(pi, ctx, accountManager, rest);
527
+ await statusController.refreshFor(ctx);
528
+ return;
529
+ }
530
+
531
+ if (!ctx.hasUI) {
532
+ ctx.ui.notify(
533
+ "/multicodex use requires an identifier in non-interactive mode.",
534
+ "warning",
535
+ );
536
+ return;
537
+ }
538
+
539
+ await openAccountSelectionFlow(ctx, accountManager, statusController);
540
+ }
541
+
542
+ async function runSubcommand(
543
+ subcommand: Subcommand,
544
+ rest: string,
545
+ pi: ExtensionAPI,
546
+ ctx: ExtensionCommandContext,
547
+ accountManager: AccountManager,
548
+ statusController: ReturnType<typeof createUsageStatusController>,
549
+ ): Promise<void> {
550
+ if (subcommand === "show") {
551
+ await runShowSubcommand(ctx, accountManager);
552
+ return;
553
+ }
554
+ if (subcommand === "use") {
555
+ await runUseSubcommand(pi, ctx, accountManager, statusController, rest);
556
+ return;
557
+ }
558
+ if (subcommand === "footer") {
559
+ await runFooterSubcommand(ctx, statusController);
560
+ return;
561
+ }
562
+ if (subcommand === "rotation") {
563
+ await runRotationSubcommand(ctx);
564
+ return;
565
+ }
566
+ if (subcommand === "verify") {
567
+ await runVerifySubcommand(ctx, accountManager, statusController);
568
+ return;
569
+ }
570
+ if (subcommand === "path") {
571
+ await runPathSubcommand(ctx);
572
+ return;
573
+ }
574
+ if (subcommand === "reset") {
575
+ await runResetSubcommand(ctx, accountManager, statusController, rest);
576
+ return;
577
+ }
578
+
579
+ runHelpSubcommand(ctx);
580
+ }
581
+
582
+ async function openMainPanel(
583
+ pi: ExtensionAPI,
584
+ ctx: ExtensionCommandContext,
585
+ accountManager: AccountManager,
586
+ statusController: ReturnType<typeof createUsageStatusController>,
587
+ ): Promise<void> {
588
+ const actions = [
589
+ "use: select, activate, or remove managed account",
590
+ "show: managed account and usage summary",
591
+ "footer: footer settings panel",
592
+ "rotation: current rotation behavior",
593
+ "verify: runtime health checks",
594
+ "path: storage and settings locations",
595
+ "reset: clear manual or quota state",
596
+ "help: command usage",
597
+ ];
598
+
599
+ const selected = await ctx.ui.select("MultiCodex", actions);
600
+ if (!selected) return;
601
+
602
+ const subcommandText = selected.split(":")[0]?.trim() ?? "";
603
+ if (!isSubcommand(subcommandText)) {
604
+ ctx.ui.notify(`Unknown subcommand: ${subcommandText}`, "warning");
605
+ return;
606
+ }
607
+ await runSubcommand(
608
+ subcommandText,
609
+ "",
610
+ pi,
611
+ ctx,
612
+ accountManager,
613
+ statusController,
614
+ );
615
+ }
616
+
182
617
  export function registerCommands(
183
618
  pi: ExtensionAPI,
184
619
  accountManager: AccountManager,
185
620
  statusController: ReturnType<typeof createUsageStatusController>,
186
621
  ): void {
187
- pi.registerCommand("multicodex-use", {
188
- description:
189
- "Use an existing Codex account, or log in when the identifier is missing",
622
+ pi.registerCommand("multicodex", {
623
+ description: "Manage MultiCodex accounts, rotation, and footer settings",
624
+ getArgumentCompletions: (argumentPrefix: string) =>
625
+ getCommandCompletions(argumentPrefix, accountManager),
190
626
  handler: async (
191
627
  args: string,
192
628
  ctx: ExtensionCommandContext,
193
629
  ): Promise<void> => {
194
- const identifier = args.trim();
195
- if (identifier) {
196
- await useOrLoginAccount(pi, ctx, accountManager, identifier);
197
- await statusController.refreshFor(ctx);
630
+ const parsed = parseCommandArgs(args);
631
+ if (!parsed.subcommand) {
632
+ if (!ctx.hasUI) {
633
+ ctx.ui.notify(
634
+ "/multicodex requires a subcommand in non-interactive mode. Use /multicodex help.",
635
+ "warning",
636
+ );
637
+ return;
638
+ }
639
+ await openMainPanel(pi, ctx, accountManager, statusController);
198
640
  return;
199
641
  }
200
642
 
201
- await accountManager.syncImportedOpenAICodexAuth();
202
- await openAccountSelectionFlow(ctx, accountManager, statusController);
203
- },
204
- });
205
-
206
- pi.registerCommand("multicodex-status", {
207
- description: "Show all Codex accounts and active status",
208
- handler: async (
209
- _args: string,
210
- ctx: ExtensionCommandContext,
211
- ): Promise<void> => {
212
- await accountManager.syncImportedOpenAICodexAuth();
213
- await accountManager.refreshUsageForAllAccounts();
214
- const accounts = accountManager.getAccounts();
215
- if (accounts.length === 0) {
216
- ctx.ui.notify(
217
- "No managed accounts found. Use /login or /multicodex-use <identifier> first.",
218
- "warning",
219
- );
643
+ if (!isSubcommand(parsed.subcommand)) {
644
+ ctx.ui.notify(`Unknown subcommand: ${parsed.subcommand}`, "warning");
645
+ runHelpSubcommand(ctx);
220
646
  return;
221
647
  }
222
648
 
223
- const active = accountManager.getActiveAccount();
224
- const options = accounts.map((account) => {
225
- const usage = accountManager.getCachedUsage(account.email);
226
- const isActive = active?.email === account.email;
227
- const quotaHit =
228
- account.quotaExhaustedUntil &&
229
- account.quotaExhaustedUntil > Date.now();
230
- const untouched = isUsageUntouched(usage) ? "untouched" : null;
231
- const imported = account.importSource ? "imported" : null;
232
- const tags = [
233
- isActive ? "active" : null,
234
- quotaHit ? "quota" : null,
235
- untouched,
236
- imported,
237
- ]
238
- .filter(Boolean)
239
- .join(", ");
240
- const suffix = tags ? ` (${tags})` : "";
241
- const primaryUsed = usage?.primary?.usedPercent;
242
- const secondaryUsed = usage?.secondary?.usedPercent;
243
- const primaryReset = usage?.primary?.resetAt;
244
- const secondaryReset = usage?.secondary?.resetAt;
245
- const primaryLabel =
246
- primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
247
- const secondaryLabel =
248
- secondaryUsed === undefined
249
- ? "unknown"
250
- : `${Math.round(secondaryUsed)}%`;
251
- const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
252
- return `${isActive ? "•" : " "} ${account.email}${suffix} - ${usageSummary}`;
253
- });
254
-
255
- await ctx.ui.select("MultiCodex Accounts", options);
256
- },
257
- });
258
-
259
- pi.registerCommand("multicodex-footer", {
260
- description: "Configure the MultiCodex usage footer",
261
- handler: async (
262
- _args: string,
263
- ctx: ExtensionCommandContext,
264
- ): Promise<void> => {
265
- await statusController.openPreferencesPanel(ctx);
649
+ await runSubcommand(
650
+ parsed.subcommand,
651
+ parsed.rest,
652
+ pi,
653
+ ctx,
654
+ accountManager,
655
+ statusController,
656
+ );
266
657
  },
267
658
  });
268
659
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/stream-wrapper.ts CHANGED
@@ -101,7 +101,7 @@ export function createStreamWrapper(
101
101
  }
102
102
  if (!account) {
103
103
  throw new Error(
104
- "No available Multicodex accounts. Please use /multicodex-use <identifier>.",
104
+ "No available Multicodex accounts. Please use /multicodex use <identifier>.",
105
105
  );
106
106
  }
107
107