pi-cliproxyapi 0.1.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.
@@ -0,0 +1,289 @@
1
+ // First-run setup wizard: collects endpoint + apiKey + providerPrefix + (optional) usageKey
2
+ // and writes them to ~/.config/pi-cliproxyapi/config.json.
3
+ //
4
+ // All three fields support the same "!cmd" / "$ENV" / literal semantics as
5
+ // the rest of the config (see resolveConfigValue). For convenience the wizard
6
+ // also accepts a bare path starting with ~/ or / and wraps it into "!cat <path>".
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+
11
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ type Component,
14
+ getKeybindings,
15
+ Input,
16
+ matchesKey,
17
+ visibleWidth,
18
+ } from "@earendil-works/pi-tui";
19
+
20
+ import {
21
+ CONFIG_PATH,
22
+ loadConfig,
23
+ resolveConfigValue,
24
+ saveConfig,
25
+ } from "./config.ts";
26
+
27
+ interface Theme {
28
+ fg(name: string, s: string): string;
29
+ bold(s: string): string;
30
+ }
31
+
32
+ interface WizardStep {
33
+ label: string;
34
+ hint: string;
35
+ required: boolean;
36
+ initialValue?: string;
37
+ validate?: (raw: string) => string | null;
38
+ }
39
+
40
+ const STEPS: WizardStep[] = [
41
+ {
42
+ label: "endpoint",
43
+ hint: "OpenAI-style base URL of your CliProxyAPI deployment, must include /v1",
44
+ required: true,
45
+ validate: (raw) => {
46
+ try {
47
+ const u = new URL(raw);
48
+ if (!u.pathname.endsWith("/v1")) return "must end with /v1";
49
+ return null;
50
+ } catch {
51
+ return "not a valid URL";
52
+ }
53
+ },
54
+ },
55
+ {
56
+ label: "apiKey",
57
+ hint: "CliProxyAPI bearer key. Accepts literal, $ENV_VAR, !cmd, or ~/path",
58
+ required: true,
59
+ },
60
+ {
61
+ label: "providerPrefix",
62
+ hint: "Prefix for your custom provider names. Suggested groups become <prefix>-glm, <prefix>-gemini, etc. Use any short slug (letters/digits/dashes).",
63
+ required: true,
64
+ validate: (raw) =>
65
+ /^[a-z0-9][a-z0-9-]*$/i.test(raw) ? null : "letters/digits/dashes only",
66
+ },
67
+ {
68
+ label: "usageKey",
69
+ hint: "Optional X-Plugin-Key for /api/usage. Leave blank to skip /cliproxy-usage",
70
+ required: false,
71
+ },
72
+ ];
73
+
74
+ /**
75
+ * Run the interactive setup wizard if no usable config exists.
76
+ * Returns true when config was just saved (caller should reapply).
77
+ */
78
+ export async function runSetupIfNeeded(
79
+ ctx: ExtensionCommandContext,
80
+ ): Promise<boolean> {
81
+ const cfg = loadConfig();
82
+ const hasEndpoint = Boolean(cfg.proxy.endpoint);
83
+ const hasResolvedKey = Boolean(resolveConfigValue(cfg.proxy.apiKey));
84
+ if (hasEndpoint && hasResolvedKey) return false;
85
+ if (!ctx.hasUI) return false;
86
+ return runSetup(ctx, /*forceAll=*/ true);
87
+ }
88
+
89
+ /** Force-show the wizard from /cliproxy-setup regardless of current config. */
90
+ export async function runSetup(
91
+ ctx: ExtensionCommandContext,
92
+ forceAll = false,
93
+ ): Promise<boolean> {
94
+ const existing = loadConfig();
95
+ const values: Record<string, string> = {
96
+ endpoint: existing.proxy.endpoint ?? "",
97
+ apiKey: existing.proxy.apiKey ?? "",
98
+ providerPrefix: existing.proxy.providerPrefix ?? "",
99
+ usageKey: existing.proxy.usageKey ?? "",
100
+ };
101
+
102
+ let cancelled = false;
103
+ for (const step of STEPS) {
104
+ const prefill = forceAll
105
+ ? (values[step.label] ?? step.initialValue ?? "")
106
+ : (values[step.label] ?? "");
107
+ // Skip already-filled non-empty fields when called for first-run setup
108
+ // (we still want to ask for missing pieces, but not re-prompt for
109
+ // what's already saved).
110
+ if (!forceAll && prefill) {
111
+ continue;
112
+ }
113
+
114
+ const result = await promptStep(
115
+ ctx,
116
+ step,
117
+ prefill || step.initialValue || "",
118
+ );
119
+ if (result === undefined) {
120
+ cancelled = true;
121
+ break;
122
+ }
123
+ const trimmed = result.trim();
124
+ if (!trimmed) {
125
+ if (step.required) {
126
+ ctx.ui.notify(`${step.label} is required — aborted`, "warning");
127
+ cancelled = true;
128
+ break;
129
+ }
130
+ values[step.label] = "";
131
+ continue;
132
+ }
133
+ values[step.label] =
134
+ step.label === "providerPrefix" || step.label === "endpoint"
135
+ ? trimmed
136
+ : normalizeValue(trimmed);
137
+ }
138
+
139
+ if (cancelled) return false;
140
+
141
+ const next = { ...existing };
142
+ next.proxy = {
143
+ ...existing.proxy,
144
+ endpoint: values.endpoint ?? "",
145
+ apiKey: values.apiKey ?? "",
146
+ providerPrefix: values.providerPrefix ?? "",
147
+ };
148
+ if (values.usageKey) next.proxy.usageKey = values.usageKey;
149
+ else delete next.proxy.usageKey;
150
+
151
+ saveConfig(next);
152
+ ctx.ui.notify(`saved to ${CONFIG_PATH}`, "info");
153
+ return true;
154
+ }
155
+
156
+ /**
157
+ * If the user typed a bare ~/path or /abs/path, wrap it in `!cat <path>`
158
+ * so resolveConfigValue executes it. Otherwise return as-is.
159
+ */
160
+ function normalizeValue(raw: string): string {
161
+ if (raw.startsWith("!") || raw.startsWith("$")) return raw;
162
+ if (raw.startsWith("~/")) {
163
+ const expanded = raw.replace(/^~/, homedir());
164
+ return existsSync(expanded) ? `!cat ${raw}` : raw;
165
+ }
166
+ if (raw.startsWith("/")) {
167
+ return existsSync(raw) ? `!cat ${raw}` : raw;
168
+ }
169
+ return raw;
170
+ }
171
+
172
+ // --------------------------------------------------------------------------- step prompt
173
+
174
+ async function promptStep(
175
+ ctx: ExtensionCommandContext,
176
+ step: WizardStep,
177
+ prefill: string,
178
+ ): Promise<string | undefined> {
179
+ return ctx.ui.custom<string | undefined>(
180
+ (tui, theme, _kb, done) =>
181
+ buildStepOverlay(
182
+ tui as unknown as {
183
+ requestRender(): void;
184
+ rows?: number;
185
+ cols?: number;
186
+ },
187
+ theme as unknown as Theme,
188
+ step,
189
+ prefill,
190
+ done,
191
+ ),
192
+ {
193
+ overlay: true,
194
+ overlayOptions: { width: 100, maxHeight: "60%" },
195
+ },
196
+ );
197
+ }
198
+
199
+ function buildStepOverlay(
200
+ tui: { requestRender(): void; rows?: number; cols?: number },
201
+ theme: Theme,
202
+ step: WizardStep,
203
+ prefill: string,
204
+ done: (v: string | undefined) => void,
205
+ ): Component & { handleInput(data: string): void } {
206
+ const input = new Input();
207
+ input.setValue(prefill);
208
+ input.focused = true;
209
+ let error: string | null = null;
210
+
211
+ const submit = (raw: string): void => {
212
+ if (!raw && !step.required) {
213
+ done("");
214
+ return;
215
+ }
216
+ if (!raw && step.required) {
217
+ error = "required field";
218
+ tui.requestRender();
219
+ return;
220
+ }
221
+ if (step.validate) {
222
+ const trimmed = raw.trim();
223
+ // Only validate the literal form. Indirect ("!", "$") values are
224
+ // validated at apply time, not here.
225
+ if (trimmed && !trimmed.startsWith("!") && !trimmed.startsWith("$")) {
226
+ const err = step.validate(trimmed);
227
+ if (err) {
228
+ error = err;
229
+ tui.requestRender();
230
+ return;
231
+ }
232
+ }
233
+ }
234
+ done(raw);
235
+ };
236
+
237
+ input.onSubmit = submit;
238
+ input.onEscape = () => done(undefined);
239
+
240
+ return {
241
+ render(width: number): string[] {
242
+ const inner = Math.max(40, width - 2);
243
+ const top = theme.fg(
244
+ "borderAccent",
245
+ `\u256d\u2500 ${theme.bold(theme.fg("accent", `setup: ${step.label}`))} ${"\u2500".repeat(Math.max(0, inner - visibleWidth(`setup: ${step.label}`) - 4))}\u256e`,
246
+ );
247
+ const hintLine = pad(theme.fg("dim", step.hint), inner - 2);
248
+ const inputLines = input.render(inner - 4);
249
+ const errLine = error
250
+ ? pad(theme.fg("error", `! ${error}`), inner - 2)
251
+ : pad(theme.fg("dim", "enter = save · esc = cancel"), inner - 2);
252
+ const side = theme.fg("borderAccent", "\u2502");
253
+ const out: string[] = [top];
254
+ out.push(`${side} ${hintLine} ${side}`);
255
+ out.push(`${side} ${pad("", inner - 2)} ${side}`);
256
+ for (const ln of inputLines) {
257
+ out.push(
258
+ `${side} ${pad(theme.fg("accent", `> ${ln}`), inner - 2)} ${side}`,
259
+ );
260
+ }
261
+ out.push(`${side} ${pad("", inner - 2)} ${side}`);
262
+ out.push(`${side} ${errLine} ${side}`);
263
+ out.push(
264
+ theme.fg("borderAccent", `\u2570${"\u2500".repeat(inner)}\u256f`),
265
+ );
266
+ return out;
267
+ },
268
+ invalidate(): void {
269
+ input.invalidate();
270
+ },
271
+ handleInput(data: string): void {
272
+ const kb = getKeybindings();
273
+ // Plain Esc cancels even if Input doesn't dispatch it.
274
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
275
+ done(undefined);
276
+ return;
277
+ }
278
+ error = null;
279
+ input.handleInput(data);
280
+ tui.requestRender();
281
+ },
282
+ };
283
+ }
284
+
285
+ function pad(s: string, width: number): string {
286
+ const w = visibleWidth(s);
287
+ if (w >= width) return s;
288
+ return s + " ".repeat(width - w);
289
+ }
@@ -0,0 +1,191 @@
1
+ // Renders /api/usage as multi-line ANSI text for /cliproxy-usage overlay.
2
+ //
3
+ // Lines are wrapped pre-render so they never reflow inside the overlay box —
4
+ // long errors get truncated with an ellipsis on the same line. Progress bars
5
+ // are colored by remaining capacity: green ≥40%, yellow 15–40%, red <15%.
6
+
7
+ import type { UsageAccount, UsageDocument, UsageGroup } from "./fetch-usage.ts";
8
+
9
+ // 256-color palette codes (truecolor and 256color terminals both render).
10
+ const C = {
11
+ reset: "\x1b[0m",
12
+ bold: "\x1b[1m",
13
+ dim: "\x1b[2m",
14
+ accent: "\x1b[38;5;111m", // soft blue
15
+ muted: "\x1b[38;5;245m",
16
+ dim2: "\x1b[38;5;240m",
17
+ green: "\x1b[38;5;114m",
18
+ yellow: "\x1b[38;5;179m",
19
+ red: "\x1b[38;5;167m",
20
+ greenBg: "\x1b[48;5;22m",
21
+ yellowBg: "\x1b[48;5;94m",
22
+ redBg: "\x1b[48;5;52m",
23
+ };
24
+
25
+ const BAR_WIDTH = 18;
26
+ const MAX_LABEL_WIDTH = 28;
27
+
28
+ export interface RenderUsageOptions {
29
+ /** Include accounts with `disabled` flag in the output. */
30
+ showDisabled?: boolean;
31
+ /** Print the raw backend error string on a second line (instead of just the short [err 401] marker). */
32
+ verbose?: boolean;
33
+ }
34
+
35
+ export function renderUsage(
36
+ doc: UsageDocument,
37
+ opts: RenderUsageOptions = {},
38
+ ): string[] {
39
+ const lines: string[] = [];
40
+ const byProvider = new Map<string, UsageAccount[]>();
41
+ let hiddenDisabled = 0;
42
+ for (const a of doc.accounts) {
43
+ if (!opts.showDisabled && a.disabled) {
44
+ hiddenDisabled++;
45
+ continue;
46
+ }
47
+ const arr = byProvider.get(a.provider) ?? [];
48
+ arr.push(a);
49
+ byProvider.set(a.provider, arr);
50
+ }
51
+
52
+ lines.push(`${C.dim}generated ${doc.generatedAt}${C.reset}`);
53
+ if (doc.unsupportedProviders.length > 0) {
54
+ lines.push(
55
+ `${C.dim}providers without quota lookup:${C.reset} ${doc.unsupportedProviders.join(", ")}`,
56
+ );
57
+ }
58
+ if (hiddenDisabled > 0) {
59
+ lines.push(
60
+ `${C.dim}hidden disabled accounts:${C.reset} ${hiddenDisabled} ${C.dim2}(press d to show)${C.reset}`,
61
+ );
62
+ }
63
+
64
+ for (const [provider, accounts] of Array.from(byProvider.entries()).sort()) {
65
+ lines.push("");
66
+ lines.push(`${C.bold}${C.accent}▌ ${provider}${C.reset}`);
67
+ for (const a of accounts) {
68
+ lines.push(formatAccountHeader(a));
69
+ if (!a.supported) {
70
+ lines.push(` ${C.dim2}(provider not quota-aware)${C.reset}`);
71
+ continue;
72
+ }
73
+ if (a.error) {
74
+ if (opts.verbose) {
75
+ for (const ln of wrapText(a.error, 88)) {
76
+ lines.push(` ${C.red}${ln}${C.reset}`);
77
+ }
78
+ }
79
+ continue;
80
+ }
81
+ const groups = a.groups ?? [];
82
+ if (groups.length === 0) {
83
+ lines.push(` ${C.dim2}(no active quota windows)${C.reset}`);
84
+ continue;
85
+ }
86
+ for (const g of groups) lines.push(formatGroup(g));
87
+ }
88
+ }
89
+
90
+ // Legend at the bottom — distinguishes the per-account status icons
91
+ // (left of label) from the success/fail request counters (right of label).
92
+ lines.push("");
93
+ lines.push(
94
+ `${C.dim}legend:${C.reset} ` +
95
+ `${ICON_OK_DOT} ok ` +
96
+ `${ICON_WARN} error/unavailable ` +
97
+ `${ICON_DISABLED} disabled ` +
98
+ `${C.green}✓N${C.reset}/${C.red}✗N${C.reset} request counters ` +
99
+ `${C.dim}v = verbose, d = show disabled${C.reset}`,
100
+ );
101
+ return lines;
102
+ }
103
+
104
+ function wrapText(s: string, max: number): string[] {
105
+ if (s.length <= max) return [s];
106
+ const out: string[] = [];
107
+ let rest = s;
108
+ while (rest.length > max) {
109
+ let cut = rest.lastIndexOf(" ", max);
110
+ if (cut < max * 0.6) cut = max;
111
+ out.push(rest.slice(0, cut));
112
+ rest = rest.slice(cut).replace(/^\s+/, "");
113
+ }
114
+ if (rest) out.push(rest);
115
+ return out;
116
+ }
117
+
118
+ function formatAccountHeader(a: UsageAccount): string {
119
+ const status = accountStatus(a);
120
+ const counters = `${C.green}✓${a.success}${C.reset} ${C.red}✗${a.failed}${C.reset}`;
121
+ const label = truncate(a.label, 36);
122
+ return ` ${status} ${C.bold}${label}${C.reset} ${counters}`;
123
+ }
124
+
125
+ // Status icons — distinct from the ✓/✗ request counters so the user
126
+ // can tell account-level state (left) from per-request totals (right).
127
+ const ICON_OK_DOT = `${C.green}●${C.reset}`;
128
+ const ICON_WARN = `${C.yellow}⚠${C.reset}`;
129
+ const ICON_DISABLED = `${C.dim2}⊘${C.reset}`;
130
+
131
+ function accountStatus(a: UsageAccount): string {
132
+ if (a.disabled) return ICON_DISABLED;
133
+ if (a.unavailable || a.error || (a.status && a.status !== "active")) {
134
+ return ICON_WARN;
135
+ }
136
+ return ICON_OK_DOT;
137
+ }
138
+
139
+ function formatGroup(g: UsageGroup): string {
140
+ const f = clamp(g.remainingFraction);
141
+ const pct = Math.round(f * 100);
142
+ const bar = renderBar(f);
143
+ const reset = g.resetTime
144
+ ? ` ${C.dim}reset ${humanizeReset(g.resetTime)}${C.reset}`
145
+ : "";
146
+ const label = truncate(g.label, MAX_LABEL_WIDTH).padEnd(MAX_LABEL_WIDTH, " ");
147
+ return ` ${bar} ${formatPct(pct, f)} ${label}${reset}`;
148
+ }
149
+
150
+ function renderBar(fraction: number): string {
151
+ const f = clamp(fraction);
152
+ const filled = Math.round(f * BAR_WIDTH);
153
+ const color = colorForFraction(f);
154
+ const dimColor = C.dim2;
155
+ const full = "█".repeat(filled);
156
+ const empty = "░".repeat(BAR_WIDTH - filled);
157
+ return `${dimColor}[${color}${full}${dimColor}${empty}]${C.reset}`;
158
+ }
159
+
160
+ function formatPct(pct: number, fraction: number): string {
161
+ const color = colorForFraction(fraction);
162
+ return `${color}${String(pct).padStart(3)}%${C.reset}`;
163
+ }
164
+
165
+ function colorForFraction(f: number): string {
166
+ if (f >= 0.4) return C.green;
167
+ if (f >= 0.15) return C.yellow;
168
+ return C.red;
169
+ }
170
+
171
+ function humanizeReset(iso: string): string {
172
+ const t = Date.parse(iso);
173
+ if (!Number.isFinite(t)) return iso;
174
+ const ms = t - Date.now();
175
+ if (ms <= 0) return "now";
176
+ const min = Math.round(ms / 60_000);
177
+ if (min < 60) return `${min}m`;
178
+ const hr = Math.round(min / 60);
179
+ if (hr < 48) return `${hr}h`;
180
+ const d = Math.round(hr / 24);
181
+ return `${d}d`;
182
+ }
183
+
184
+ function clamp(f: number): number {
185
+ return Math.max(0, Math.min(1, f));
186
+ }
187
+
188
+ function truncate(s: string, max: number): string {
189
+ if (s.length <= max) return s;
190
+ return `${s.slice(0, max - 1)}…`;
191
+ }