@jameslovespancakes/pi-plus 1.0.0 → 1.0.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 (41) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +190 -190
  3. package/config/pi-plus.example.json +60 -60
  4. package/config/skills/model-routing/SKILL.md +86 -86
  5. package/images/pi-plus.svg +10 -10
  6. package/images/usage_demo.png +0 -0
  7. package/package.json +67 -67
  8. package/server/board-server.mjs +641 -641
  9. package/server/package.json +17 -17
  10. package/src/core/accounts/registry.ts +93 -93
  11. package/src/core/anthropic/client-identity.ts +241 -241
  12. package/src/core/catalog/quality.ts +314 -314
  13. package/src/core/config.ts +169 -169
  14. package/src/core/env.ts +58 -58
  15. package/src/core/exec/process.ts +146 -146
  16. package/src/core/exec/ssh-config.ts +157 -157
  17. package/src/core/policy/policy.ts +183 -183
  18. package/src/core/quota/pool.ts +64 -64
  19. package/src/core/quota/usage-source.ts +289 -289
  20. package/src/core/store.ts +43 -43
  21. package/src/domains/agents/board-setup.ts +409 -409
  22. package/src/domains/agents/index.ts +462 -462
  23. package/src/domains/models/catalog-tool.ts +361 -361
  24. package/src/domains/models/index.ts +14 -14
  25. package/src/domains/models/policy-gate.ts +169 -169
  26. package/src/domains/models/provider-picker.ts +207 -207
  27. package/src/domains/remote/config-path.ts +41 -41
  28. package/src/domains/remote/index.ts +866 -866
  29. package/src/domains/remote/setup.ts +425 -425
  30. package/src/domains/setup/index.ts +220 -220
  31. package/src/domains/subscriptions/accounts.ts +242 -242
  32. package/src/domains/subscriptions/footer.ts +182 -182
  33. package/src/domains/subscriptions/index.ts +42 -42
  34. package/src/domains/subscriptions/provider.ts +219 -219
  35. package/src/domains/subscriptions/providers/anthropic.ts +149 -149
  36. package/src/domains/subscriptions/providers/codex.ts +148 -148
  37. package/src/domains/subscriptions/routing.ts +72 -72
  38. package/src/services/usage-service.ts +186 -186
  39. package/src/ui/format.ts +73 -73
  40. package/src/ui/usage-bars.ts +154 -154
  41. package/src/vendor/anthropic.ts +109 -109
@@ -1,154 +1,154 @@
1
- import { truncateToWidth } from "@earendil-works/pi-tui";
2
- import { combinedWindow, isClaudeAccount, poolAvailability, scopedLabels, type UsageRow } from "../core/quota/pool.ts";
3
- import type { UsageState } from "../services/usage-service.ts";
4
- import { formatReset, formatShortReset, hasTruecolor, levelColor, themeLevel } from "./format.ts";
5
-
6
- /**
7
- * Renders the Claude/Codex quota bars. Takes state as an argument rather than
8
- * importing the service, so the renderer stays a pure function of its input.
9
- */
10
-
11
- type Cell = { label: string; remaining?: number; resetAt?: number; partial?: boolean };
12
-
13
- function pooled(state: UsageState, label: string): Cell | undefined {
14
- return combinedWindow(state.rows, label, state.accounts, Date.now(), true);
15
- }
16
-
17
- function codexCell(state: UsageState, display: string, match: (label: string) => boolean): Cell | undefined {
18
- const row = state.rows.find((candidate) => candidate.group === "Codex" && match(candidate.label));
19
- return row ? { label: display, remaining: row.remaining, resetAt: row.resetAt } : { label: display };
20
- }
21
-
22
- /**
23
- * Turns a scoped limit id into something that fits the label column.
24
- *
25
- * Anthropic names these with an internal id, e.g.
26
- * `7d claude-weekly-scoped-fable`. Rendered raw it was truncated to the 6
27
- * column label width and came out as "claude", which named neither the window
28
- * nor the model. The trailing segment is the model family, so that is what is
29
- * shown.
30
- */
31
- function scopedDisplayName(label: string): string {
32
- const family = label.replace(/^7d\s+/, "").split("-").pop() ?? label;
33
- return family.charAt(0).toUpperCase() + family.slice(1);
34
- }
35
-
36
- function buildColumns(state: UsageState, modelId?: string): { claude: Cell[]; codex: Cell[] } {
37
- // Three fixed tiers, so the block keeps its shape whether or not a scoped
38
- // limit is currently reported.
39
- const scoped = scopedLabels(state.rows, modelId)[0];
40
- const scopedCell = scoped
41
- ? { ...(pooled(state, scoped) ?? { label: scoped }), label: scopedDisplayName(scoped) }
42
- : { label: "Fable" };
43
-
44
- const claude: Cell[] = [
45
- { ...(pooled(state, "5h") ?? { label: "5h" }), label: "5h" },
46
- { ...(pooled(state, "7d") ?? { label: "7d" }), label: "weekly" },
47
- scopedCell,
48
- ];
49
-
50
- // Codex reports only the two windows; it has no scoped equivalent.
51
- const codex: Cell[] = [
52
- codexCell(state, "5h", (label) => label === "5h") ?? { label: "5h" },
53
- codexCell(state, "weekly", (label) => label === "weekly") ?? { label: "weekly" },
54
- ];
55
- return { claude, codex };
56
- }
57
-
58
- /** `Work 61% · Personal 88%`. Empty when there is nothing extra to say. */
59
- function renderAccountSummary(state: UsageState, cellWidth: number): string | undefined {
60
- const stamps = state.lastUsedAt ?? {};
61
- const groups = [...new Set(state.rows.filter(isClaudeAccount).map((row) => row.group))];
62
- if (groups.length < 2) return undefined;
63
-
64
- const ordered = groups.sort((a, b) => (stamps[b] ?? 0) - (stamps[a] ?? 0) || a.localeCompare(b));
65
- const shown = ordered.slice(0, 2);
66
-
67
- const parts = shown.map((group) => {
68
- const row = state.rows.find((candidate) => candidate.group === group && candidate.label === "5h");
69
- const name = group.replace(/^Claude /, "");
70
- if (!row) return `${name} -`;
71
- return `${name} ${Math.round(row.remaining)}%${row.stale ? "*" : ""}`;
72
- });
73
-
74
- const hidden = ordered.length - shown.length;
75
- const text = parts.join(" · ") + (hidden > 0 ? ` +${hidden}` : "");
76
- return text.length > cellWidth * 2 ? text.slice(0, cellWidth * 2) : text;
77
- }
78
-
79
- function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: number): string {
80
- const label = cell.label.slice(0, labelWidth).padEnd(labelWidth);
81
- const reset = formatShortReset(cell.resetAt);
82
- const resetWidth = 4;
83
- const barWidth = Math.max(4, cellWidth - labelWidth - 6 - resetWidth - 3);
84
-
85
- if (cell.remaining === undefined) {
86
- return `${theme.fg("muted", label)} ${theme.fg("dim", "·".repeat(barWidth))} ${theme.fg("dim", " n/a")}${" ".repeat(resetWidth + 1)}`;
87
- }
88
-
89
- const filled = Math.round((cell.remaining / 100) * barWidth);
90
- const percentText = `${cell.partial ? "~" : ""}${Math.round(cell.remaining)}%`.padStart(5);
91
- let bar: string;
92
- let percent: string;
93
-
94
- if (hasTruecolor()) {
95
- // One smooth hue per bar: green when full, amber mid-way, red as it empties.
96
- const paint = levelColor(cell.remaining);
97
- bar = paint("█".repeat(filled)) + theme.fg("dim", "░".repeat(barWidth - filled));
98
- percent = paint(percentText);
99
- } else {
100
- const color = themeLevel(cell.remaining);
101
- bar = theme.fg(color, "█".repeat(filled)) + theme.fg("dim", "░".repeat(barWidth - filled));
102
- percent = theme.fg(color, percentText);
103
- }
104
-
105
- return `${theme.fg("muted", label)} ${bar} ${percent} ${theme.fg("dim", reset.padEnd(resetWidth))}`;
106
- }
107
-
108
- export function renderUsageLines(state: UsageState, theme: any, width: number, modelId?: string): string[] {
109
- if (state.loading) return [theme.fg("dim", " usage: loading…")];
110
- if (state.rows.length === 0) {
111
- if (state.errors.length > 0) return state.errors.map((error) => theme.fg("warning", ` ${error}`));
112
- return [theme.fg("dim", " usage: unavailable")];
113
- }
114
-
115
- const gap = 3;
116
- const cellWidth = Math.max(22, Math.floor((width - 2 - gap) / 2));
117
- const labelWidth = 6;
118
- const { claude, codex } = buildColumns(state, modelId);
119
- const availability = poolAvailability(state.rows, state.accounts, modelId);
120
- const status = availability.ready
121
- ? `${availability.ready}/${availability.total} ready`
122
- : availability.unknown ? "unknown/stale" : "exhausted";
123
- const partial = claude.some((cell) => cell.partial);
124
- const claudeTitle = `Claude Σ${state.accounts} · ${status}${partial ? " · partial" : ""}`;
125
- const codexTitle = state.codexPlan ? `Codex · ${state.codexPlan}` : "Codex";
126
- const lines = [` ${theme.fg("accent", claudeTitle.padEnd(cellWidth))}${" ".repeat(gap)}${theme.fg("accent", codexTitle)}`];
127
-
128
- for (let index = 0; index < Math.max(claude.length, codex.length); index += 1) {
129
- const left = claude[index] ? renderCell(theme, claude[index], labelWidth, cellWidth) : " ".repeat(cellWidth);
130
- const right = codex[index] ? renderCell(theme, codex[index], labelWidth, cellWidth) : "";
131
- lines.push(` ${left}${" ".repeat(gap)}${right}`);
132
- }
133
-
134
- // Per-account detail. With more than two accounts only the two most recently
135
- // used are shown, so the footer stays two lines regardless of pool size.
136
- const accountLine = renderAccountSummary(state, cellWidth);
137
- if (accountLine) lines.push(` ${theme.fg("dim", accountLine)}`);
138
-
139
- for (const error of state.errors) lines.push(theme.fg("warning", ` ${error}`));
140
- return lines.map((line) => truncateToWidth(line, width, ""));
141
- }
142
-
143
- export function usageSummaryText(state: UsageState): string {
144
- const combined = ["5h", "7d", ...scopedLabels(state.rows)].map((label) => {
145
- const pool = combinedWindow(state.rows, label, state.accounts, Date.now(), true);
146
- return `Claude combined ${label}: ${pool
147
- ? `${pool.partial ? "~" : ""}${Math.round(pool.remaining)}% left${pool.partial ? " (partial: reporting accounts only)" : ""} ${formatReset(pool.resetAt)}`
148
- : "unknown/stale"}`;
149
- });
150
- const summary = [...combined, ...state.rows.map(
151
- (row: UsageRow) => `${row.group} ${row.label}: ${Math.round(row.remaining)}% left ${formatReset(row.resetAt)}${row.stale ? " (stale)" : ""}`.trim(),
152
- )];
153
- return [...summary, ...state.errors].join("\n") || "No usage data";
154
- }
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { combinedWindow, isClaudeAccount, poolAvailability, scopedLabels, type UsageRow } from "../core/quota/pool.ts";
3
+ import type { UsageState } from "../services/usage-service.ts";
4
+ import { formatReset, formatShortReset, hasTruecolor, levelColor, themeLevel } from "./format.ts";
5
+
6
+ /**
7
+ * Renders the Claude/Codex quota bars. Takes state as an argument rather than
8
+ * importing the service, so the renderer stays a pure function of its input.
9
+ */
10
+
11
+ type Cell = { label: string; remaining?: number; resetAt?: number; partial?: boolean };
12
+
13
+ function pooled(state: UsageState, label: string): Cell | undefined {
14
+ return combinedWindow(state.rows, label, state.accounts, Date.now(), true);
15
+ }
16
+
17
+ function codexCell(state: UsageState, display: string, match: (label: string) => boolean): Cell | undefined {
18
+ const row = state.rows.find((candidate) => candidate.group === "Codex" && match(candidate.label));
19
+ return row ? { label: display, remaining: row.remaining, resetAt: row.resetAt } : { label: display };
20
+ }
21
+
22
+ /**
23
+ * Turns a scoped limit id into something that fits the label column.
24
+ *
25
+ * Anthropic names these with an internal id, e.g.
26
+ * `7d claude-weekly-scoped-fable`. Rendered raw it was truncated to the 6
27
+ * column label width and came out as "claude", which named neither the window
28
+ * nor the model. The trailing segment is the model family, so that is what is
29
+ * shown.
30
+ */
31
+ function scopedDisplayName(label: string): string {
32
+ const family = label.replace(/^7d\s+/, "").split("-").pop() ?? label;
33
+ return family.charAt(0).toUpperCase() + family.slice(1);
34
+ }
35
+
36
+ function buildColumns(state: UsageState, modelId?: string): { claude: Cell[]; codex: Cell[] } {
37
+ // Three fixed tiers, so the block keeps its shape whether or not a scoped
38
+ // limit is currently reported.
39
+ const scoped = scopedLabels(state.rows, modelId)[0];
40
+ const scopedCell = scoped
41
+ ? { ...(pooled(state, scoped) ?? { label: scoped }), label: scopedDisplayName(scoped) }
42
+ : { label: "Fable" };
43
+
44
+ const claude: Cell[] = [
45
+ { ...(pooled(state, "5h") ?? { label: "5h" }), label: "5h" },
46
+ { ...(pooled(state, "7d") ?? { label: "7d" }), label: "weekly" },
47
+ scopedCell,
48
+ ];
49
+
50
+ // Codex reports only the two windows; it has no scoped equivalent.
51
+ const codex: Cell[] = [
52
+ codexCell(state, "5h", (label) => label === "5h") ?? { label: "5h" },
53
+ codexCell(state, "weekly", (label) => label === "weekly") ?? { label: "weekly" },
54
+ ];
55
+ return { claude, codex };
56
+ }
57
+
58
+ /** `Work 61% · Personal 88%`. Empty when there is nothing extra to say. */
59
+ function renderAccountSummary(state: UsageState, cellWidth: number): string | undefined {
60
+ const stamps = state.lastUsedAt ?? {};
61
+ const groups = [...new Set(state.rows.filter(isClaudeAccount).map((row) => row.group))];
62
+ if (groups.length < 2) return undefined;
63
+
64
+ const ordered = groups.sort((a, b) => (stamps[b] ?? 0) - (stamps[a] ?? 0) || a.localeCompare(b));
65
+ const shown = ordered.slice(0, 2);
66
+
67
+ const parts = shown.map((group) => {
68
+ const row = state.rows.find((candidate) => candidate.group === group && candidate.label === "5h");
69
+ const name = group.replace(/^Claude /, "");
70
+ if (!row) return `${name} -`;
71
+ return `${name} ${Math.round(row.remaining)}%${row.stale ? "*" : ""}`;
72
+ });
73
+
74
+ const hidden = ordered.length - shown.length;
75
+ const text = parts.join(" · ") + (hidden > 0 ? ` +${hidden}` : "");
76
+ return text.length > cellWidth * 2 ? text.slice(0, cellWidth * 2) : text;
77
+ }
78
+
79
+ function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: number): string {
80
+ const label = cell.label.slice(0, labelWidth).padEnd(labelWidth);
81
+ const reset = formatShortReset(cell.resetAt);
82
+ const resetWidth = 4;
83
+ const barWidth = Math.max(4, cellWidth - labelWidth - 6 - resetWidth - 3);
84
+
85
+ if (cell.remaining === undefined) {
86
+ return `${theme.fg("muted", label)} ${theme.fg("dim", "·".repeat(barWidth))} ${theme.fg("dim", " n/a")}${" ".repeat(resetWidth + 1)}`;
87
+ }
88
+
89
+ const filled = Math.round((cell.remaining / 100) * barWidth);
90
+ const percentText = `${cell.partial ? "~" : ""}${Math.round(cell.remaining)}%`.padStart(5);
91
+ let bar: string;
92
+ let percent: string;
93
+
94
+ if (hasTruecolor()) {
95
+ // One smooth hue per bar: green when full, amber mid-way, red as it empties.
96
+ const paint = levelColor(cell.remaining);
97
+ bar = paint("█".repeat(filled)) + theme.fg("dim", "░".repeat(barWidth - filled));
98
+ percent = paint(percentText);
99
+ } else {
100
+ const color = themeLevel(cell.remaining);
101
+ bar = theme.fg(color, "█".repeat(filled)) + theme.fg("dim", "░".repeat(barWidth - filled));
102
+ percent = theme.fg(color, percentText);
103
+ }
104
+
105
+ return `${theme.fg("muted", label)} ${bar} ${percent} ${theme.fg("dim", reset.padEnd(resetWidth))}`;
106
+ }
107
+
108
+ export function renderUsageLines(state: UsageState, theme: any, width: number, modelId?: string): string[] {
109
+ if (state.loading) return [theme.fg("dim", " usage: loading…")];
110
+ if (state.rows.length === 0) {
111
+ if (state.errors.length > 0) return state.errors.map((error) => theme.fg("warning", ` ${error}`));
112
+ return [theme.fg("dim", " usage: unavailable")];
113
+ }
114
+
115
+ const gap = 3;
116
+ const cellWidth = Math.max(22, Math.floor((width - 2 - gap) / 2));
117
+ const labelWidth = 6;
118
+ const { claude, codex } = buildColumns(state, modelId);
119
+ const availability = poolAvailability(state.rows, state.accounts, modelId);
120
+ const status = availability.ready
121
+ ? `${availability.ready}/${availability.total} ready`
122
+ : availability.unknown ? "unknown/stale" : "exhausted";
123
+ const partial = claude.some((cell) => cell.partial);
124
+ const claudeTitle = `Claude Σ${state.accounts} · ${status}${partial ? " · partial" : ""}`;
125
+ const codexTitle = state.codexPlan ? `Codex · ${state.codexPlan}` : "Codex";
126
+ const lines = [` ${theme.fg("accent", claudeTitle.padEnd(cellWidth))}${" ".repeat(gap)}${theme.fg("accent", codexTitle)}`];
127
+
128
+ for (let index = 0; index < Math.max(claude.length, codex.length); index += 1) {
129
+ const left = claude[index] ? renderCell(theme, claude[index], labelWidth, cellWidth) : " ".repeat(cellWidth);
130
+ const right = codex[index] ? renderCell(theme, codex[index], labelWidth, cellWidth) : "";
131
+ lines.push(` ${left}${" ".repeat(gap)}${right}`);
132
+ }
133
+
134
+ // Per-account detail. With more than two accounts only the two most recently
135
+ // used are shown, so the footer stays two lines regardless of pool size.
136
+ const accountLine = renderAccountSummary(state, cellWidth);
137
+ if (accountLine) lines.push(` ${theme.fg("dim", accountLine)}`);
138
+
139
+ for (const error of state.errors) lines.push(theme.fg("warning", ` ${error}`));
140
+ return lines.map((line) => truncateToWidth(line, width, ""));
141
+ }
142
+
143
+ export function usageSummaryText(state: UsageState): string {
144
+ const combined = ["5h", "7d", ...scopedLabels(state.rows)].map((label) => {
145
+ const pool = combinedWindow(state.rows, label, state.accounts, Date.now(), true);
146
+ return `Claude combined ${label}: ${pool
147
+ ? `${pool.partial ? "~" : ""}${Math.round(pool.remaining)}% left${pool.partial ? " (partial: reporting accounts only)" : ""} ${formatReset(pool.resetAt)}`
148
+ : "unknown/stale"}`;
149
+ });
150
+ const summary = [...combined, ...state.rows.map(
151
+ (row: UsageRow) => `${row.group} ${row.label}: ${Math.round(row.remaining)}% left ${formatReset(row.resetAt)}${row.stale ? " (stale)" : ""}`.trim(),
152
+ )];
153
+ return [...summary, ...state.errors].join("\n") || "No usage data";
154
+ }
@@ -1,109 +1,109 @@
1
- import { homedir } from "node:os";
2
- import { join } from "node:path";
3
- import { pathToFileURL } from "node:url";
4
-
5
- /**
6
- * The single import point for `@cortexkit/anthropic-auth-core`.
7
- *
8
- * Nothing else in this repo may reach into the pi-managed npm tree. The old
9
- * code hardcoded `../npm/node_modules/@cortexkit/...` relative to the
10
- * extension file, which broke whenever an extension moved directory depth.
11
- * Resolving from the agent dir at runtime makes placement irrelevant.
12
- */
13
-
14
- function agentDir(): string {
15
- return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
16
- }
17
-
18
- function coreEntry(): string {
19
- return pathToFileURL(
20
- join(agentDir(), "npm", "node_modules", "@cortexkit", "anthropic-auth-core", "dist", "index.js"),
21
- ).href;
22
- }
23
-
24
- export interface AnthropicAccount {
25
- id: string;
26
- label?: string;
27
- type: string;
28
- enabled?: boolean;
29
- access?: string;
30
- refresh?: string;
31
- expires?: number;
32
- addedAt?: number;
33
- lastRefreshedAt?: number;
34
- authLineageId?: string;
35
- }
36
-
37
- export interface AccountStorage {
38
- accounts: AnthropicAccount[];
39
- }
40
-
41
- interface AnthropicCore {
42
- addAccountPersistent(account: AnthropicAccount, path: string): Promise<void>;
43
- loadAccounts(path: string): Promise<AccountStorage | undefined>;
44
- authorize(mode: string): Promise<{ url: string; verifier: string; redirectUri: string; state: string }>;
45
- exchange(
46
- callback: string,
47
- verifier: string,
48
- redirectUri: string,
49
- state: string,
50
- ): Promise<{ type: string; access: string; refresh: string; expires: number }>;
51
- refreshClaudeOAuthToken(options: {
52
- refreshToken: string;
53
- maxRetries?: number;
54
- fetchImpl?: typeof fetch;
55
- }): Promise<{ access: string; refresh: string; expires: number }>;
56
- getRoutingMode(storage: unknown): string;
57
- setRoutingMode(mode: string, path: string): Promise<unknown>;
58
- }
59
-
60
- let cached: Promise<AnthropicCore> | undefined;
61
-
62
- /** Lazily loads and memoizes the vendor core module. */
63
- export function core(): Promise<AnthropicCore> {
64
- cached ??= import(coreEntry()).then((module) => module as unknown as AnthropicCore).catch((error) => {
65
- cached = undefined;
66
- throw new Error(
67
- `@cortexkit/anthropic-auth-core is unavailable (${error instanceof Error ? error.message : String(error)}). `
68
- + "Reinstall with: pi package add @cortexkit/pi-anthropic-auth",
69
- );
70
- });
71
- return cached;
72
- }
73
-
74
- /** Where the multi-account OAuth store lives. */
75
- export function accountStoragePath(): string {
76
- return process.env.PI_ANTHROPIC_AUTH_FILE ?? join(agentDir(), "anthropic-auth.json");
77
- }
78
-
79
- export async function loadAccounts(): Promise<AccountStorage | undefined> {
80
- return (await core()).loadAccounts(accountStoragePath());
81
- }
82
-
83
- export async function saveAccount(account: AnthropicAccount): Promise<void> {
84
- return (await core()).addAccountPersistent(account, accountStoragePath());
85
- }
86
-
87
- export async function refreshToken(
88
- refreshTokenValue: string,
89
- options: { maxRetries?: number; timeoutMs?: number } = {},
90
- ): Promise<{ access: string; refresh: string; expires: number }> {
91
- const { refreshClaudeOAuthToken } = await core();
92
- return refreshClaudeOAuthToken({
93
- refreshToken: refreshTokenValue,
94
- maxRetries: options.maxRetries,
95
- fetchImpl: options.timeoutMs
96
- ? (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(options.timeoutMs!) })
97
- : undefined,
98
- });
99
- }
100
-
101
- export async function routingMode(): Promise<string> {
102
- const { getRoutingMode } = await core();
103
- return getRoutingMode(await loadAccounts());
104
- }
105
-
106
- export async function setRoutingMode(mode: "sticky-balanced" | "main-first"): Promise<string> {
107
- const { setRoutingMode: apply, getRoutingMode } = await core();
108
- return getRoutingMode(await apply(mode, accountStoragePath()));
109
- }
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ /**
6
+ * The single import point for `@cortexkit/anthropic-auth-core`.
7
+ *
8
+ * Nothing else in this repo may reach into the pi-managed npm tree. The old
9
+ * code hardcoded `../npm/node_modules/@cortexkit/...` relative to the
10
+ * extension file, which broke whenever an extension moved directory depth.
11
+ * Resolving from the agent dir at runtime makes placement irrelevant.
12
+ */
13
+
14
+ function agentDir(): string {
15
+ return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
16
+ }
17
+
18
+ function coreEntry(): string {
19
+ return pathToFileURL(
20
+ join(agentDir(), "npm", "node_modules", "@cortexkit", "anthropic-auth-core", "dist", "index.js"),
21
+ ).href;
22
+ }
23
+
24
+ export interface AnthropicAccount {
25
+ id: string;
26
+ label?: string;
27
+ type: string;
28
+ enabled?: boolean;
29
+ access?: string;
30
+ refresh?: string;
31
+ expires?: number;
32
+ addedAt?: number;
33
+ lastRefreshedAt?: number;
34
+ authLineageId?: string;
35
+ }
36
+
37
+ export interface AccountStorage {
38
+ accounts: AnthropicAccount[];
39
+ }
40
+
41
+ interface AnthropicCore {
42
+ addAccountPersistent(account: AnthropicAccount, path: string): Promise<void>;
43
+ loadAccounts(path: string): Promise<AccountStorage | undefined>;
44
+ authorize(mode: string): Promise<{ url: string; verifier: string; redirectUri: string; state: string }>;
45
+ exchange(
46
+ callback: string,
47
+ verifier: string,
48
+ redirectUri: string,
49
+ state: string,
50
+ ): Promise<{ type: string; access: string; refresh: string; expires: number }>;
51
+ refreshClaudeOAuthToken(options: {
52
+ refreshToken: string;
53
+ maxRetries?: number;
54
+ fetchImpl?: typeof fetch;
55
+ }): Promise<{ access: string; refresh: string; expires: number }>;
56
+ getRoutingMode(storage: unknown): string;
57
+ setRoutingMode(mode: string, path: string): Promise<unknown>;
58
+ }
59
+
60
+ let cached: Promise<AnthropicCore> | undefined;
61
+
62
+ /** Lazily loads and memoizes the vendor core module. */
63
+ export function core(): Promise<AnthropicCore> {
64
+ cached ??= import(coreEntry()).then((module) => module as unknown as AnthropicCore).catch((error) => {
65
+ cached = undefined;
66
+ throw new Error(
67
+ `@cortexkit/anthropic-auth-core is unavailable (${error instanceof Error ? error.message : String(error)}). `
68
+ + "Reinstall with: pi package add @cortexkit/pi-anthropic-auth",
69
+ );
70
+ });
71
+ return cached;
72
+ }
73
+
74
+ /** Where the multi-account OAuth store lives. */
75
+ export function accountStoragePath(): string {
76
+ return process.env.PI_ANTHROPIC_AUTH_FILE ?? join(agentDir(), "anthropic-auth.json");
77
+ }
78
+
79
+ export async function loadAccounts(): Promise<AccountStorage | undefined> {
80
+ return (await core()).loadAccounts(accountStoragePath());
81
+ }
82
+
83
+ export async function saveAccount(account: AnthropicAccount): Promise<void> {
84
+ return (await core()).addAccountPersistent(account, accountStoragePath());
85
+ }
86
+
87
+ export async function refreshToken(
88
+ refreshTokenValue: string,
89
+ options: { maxRetries?: number; timeoutMs?: number } = {},
90
+ ): Promise<{ access: string; refresh: string; expires: number }> {
91
+ const { refreshClaudeOAuthToken } = await core();
92
+ return refreshClaudeOAuthToken({
93
+ refreshToken: refreshTokenValue,
94
+ maxRetries: options.maxRetries,
95
+ fetchImpl: options.timeoutMs
96
+ ? (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(options.timeoutMs!) })
97
+ : undefined,
98
+ });
99
+ }
100
+
101
+ export async function routingMode(): Promise<string> {
102
+ const { getRoutingMode } = await core();
103
+ return getRoutingMode(await loadAccounts());
104
+ }
105
+
106
+ export async function setRoutingMode(mode: "sticky-balanced" | "main-first"): Promise<string> {
107
+ const { setRoutingMode: apply, getRoutingMode } = await core();
108
+ return getRoutingMode(await apply(mode, accountStoragePath()));
109
+ }