@jameslovespancakes/pi-plus 1.0.0 → 1.0.1

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 (40) 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/package.json +67 -67
  7. package/server/board-server.mjs +641 -641
  8. package/server/package.json +17 -17
  9. package/src/core/accounts/registry.ts +93 -93
  10. package/src/core/anthropic/client-identity.ts +241 -241
  11. package/src/core/catalog/quality.ts +314 -314
  12. package/src/core/config.ts +169 -169
  13. package/src/core/env.ts +58 -58
  14. package/src/core/exec/process.ts +146 -146
  15. package/src/core/exec/ssh-config.ts +157 -157
  16. package/src/core/policy/policy.ts +183 -183
  17. package/src/core/quota/pool.ts +64 -64
  18. package/src/core/quota/usage-source.ts +289 -289
  19. package/src/core/store.ts +43 -43
  20. package/src/domains/agents/board-setup.ts +409 -409
  21. package/src/domains/agents/index.ts +462 -462
  22. package/src/domains/models/catalog-tool.ts +361 -361
  23. package/src/domains/models/index.ts +14 -14
  24. package/src/domains/models/policy-gate.ts +169 -169
  25. package/src/domains/models/provider-picker.ts +207 -207
  26. package/src/domains/remote/config-path.ts +41 -41
  27. package/src/domains/remote/index.ts +866 -866
  28. package/src/domains/remote/setup.ts +425 -425
  29. package/src/domains/setup/index.ts +220 -220
  30. package/src/domains/subscriptions/accounts.ts +242 -242
  31. package/src/domains/subscriptions/footer.ts +182 -182
  32. package/src/domains/subscriptions/index.ts +42 -42
  33. package/src/domains/subscriptions/provider.ts +219 -219
  34. package/src/domains/subscriptions/providers/anthropic.ts +149 -149
  35. package/src/domains/subscriptions/providers/codex.ts +148 -148
  36. package/src/domains/subscriptions/routing.ts +72 -72
  37. package/src/services/usage-service.ts +186 -186
  38. package/src/ui/format.ts +73 -73
  39. package/src/ui/usage-bars.ts +154 -154
  40. 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
+ }