@aliou/pi-ts-aperture 0.3.2 → 0.4.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aliou/pi-ts-aperture",
3
3
  "description": "Route Pi LLM providers through Tailscale Aperture",
4
- "version": "0.3.2",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -29,12 +29,12 @@
29
29
  "video": "https://assets.aliou.me/pi-extensions/demos/pi-ts-aperture.mp4"
30
30
  },
31
31
  "dependencies": {
32
- "@aliou/pi-utils-settings": "^0.10.0"
32
+ "@aliou/pi-utils-settings": "^0.12.0"
33
33
  },
34
34
  "peerDependencies": {
35
- "@mariozechner/pi-ai": "0.61.0",
36
- "@mariozechner/pi-coding-agent": "0.61.0",
37
- "@mariozechner/pi-tui": "0.61.0"
35
+ "@mariozechner/pi-ai": "0.64.0",
36
+ "@mariozechner/pi-coding-agent": "0.64.0",
37
+ "@mariozechner/pi-tui": "0.64.0"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@mariozechner/pi-coding-agent": {
@@ -51,7 +51,7 @@
51
51
  "@aliou/biome-plugins": "^0.3.2",
52
52
  "@biomejs/biome": "^2.3.13",
53
53
  "@changesets/cli": "^2.27.11",
54
- "@mariozechner/pi-coding-agent": "0.61.0",
54
+ "@mariozechner/pi-coding-agent": "0.64.0",
55
55
  "@sinclair/typebox": "^0.34.48",
56
56
  "@types/node": "^25.0.10",
57
57
  "@vitest/coverage-v8": "^4.0.18",
@@ -67,7 +67,8 @@
67
67
  "changeset": "changeset",
68
68
  "version": "changeset version",
69
69
  "release": "pnpm changeset publish",
70
- "test": "vitest run tests/e2e.test.ts",
71
- "test:watch": "vitest watch tests/"
70
+ "test": "vitest run",
71
+ "test:watch": "vitest",
72
+ "test:e2e": "vitest run tests/e2e.test.ts"
72
73
  }
73
74
  }
@@ -37,6 +37,9 @@ export function registerApertureSettings(
37
37
 
38
38
  const providers = tabConfig?.providers ?? resolved.providers;
39
39
 
40
+ const checkGatewayModels: string[] =
41
+ tabConfig?.checkGatewayModels ?? resolved.checkGatewayModels;
42
+
40
43
  return [
41
44
  {
42
45
  label: "Connection",
@@ -51,6 +54,39 @@ export function registerApertureSettings(
51
54
  values: undefined,
52
55
  submenu: undefined,
53
56
  },
57
+ {
58
+ id: "checkGatewayModels",
59
+ label: "Gateway model checking",
60
+ description:
61
+ "Providers for which gateway model availability is checked",
62
+ currentValue:
63
+ checkGatewayModels.length > 0
64
+ ? `${checkGatewayModels.length} provider(s)`
65
+ : "disabled",
66
+ values: undefined,
67
+ submenu: (_val, submenuDone) => {
68
+ let latest = [...checkGatewayModels];
69
+ return new ArrayEditor({
70
+ label: "Gateway-checked providers",
71
+ items: [...checkGatewayModels],
72
+ theme: settingsTheme,
73
+ onSave: (items) => {
74
+ latest = items;
75
+ const updated = structuredClone(
76
+ tabConfig ?? {},
77
+ ) as ApertureConfig;
78
+ setNestedValue(updated, "checkGatewayModels", items);
79
+ setDraft(updated);
80
+ },
81
+ onDone: () =>
82
+ submenuDone(
83
+ latest.length > 0
84
+ ? `${latest.length} provider(s)`
85
+ : "disabled",
86
+ ),
87
+ });
88
+ },
89
+ },
54
90
  ],
55
91
  },
56
92
  {
@@ -2,253 +2,135 @@
2
2
  * aperture:setup -- interactive wizard for configuring Aperture.
3
3
  *
4
4
  * Steps:
5
- * 1. Ask for Aperture base URL (Input)
6
- * 2. Select providers to route through Aperture (FuzzySelector, multi-select loop)
7
- * 3. Save config and register providers
5
+ * 1. URL input (health check runs inline on Enter, auto-advances on success)
6
+ * 2. Provider selection with per-provider "verify models" sub-option
8
7
  */
9
8
 
10
- import { FuzzySelector } from "@aliou/pi-utils-settings";
9
+ import {
10
+ FuzzyMultiSelector,
11
+ type FuzzyMultiSelectorItem,
12
+ getSettingsTheme,
13
+ type SettingsTheme,
14
+ Wizard,
15
+ type WizardStepContext,
16
+ } from "@aliou/pi-utils-settings";
11
17
  import type {
12
18
  ExtensionAPI,
13
19
  ExtensionContext,
14
20
  } from "@mariozechner/pi-coding-agent";
15
- import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
16
21
  import type { Component, TUI } from "@mariozechner/pi-tui";
17
- import { Input, Key, matchesKey } from "@mariozechner/pi-tui";
22
+ import { Input } from "@mariozechner/pi-tui";
18
23
  import { configLoader } from "../config";
24
+ import { normalizeInputUrl } from "../core";
19
25
  import { checkApertureHealth } from "../lib/health";
20
26
 
21
- function normalizeUrl(url: string): string {
22
- let result = url.trim();
23
- if (!result) return result;
24
- if (!result.startsWith("http://") && !result.startsWith("https://")) {
25
- result = `http://${result}`;
26
- }
27
- return result.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
28
- }
29
-
30
27
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
31
28
 
32
- /**
33
- * Shows a spinner while verifying the Aperture URL is reachable.
34
- * Kicks off the health check on construction and resolves done(boolean)
35
- * when the check completes.
36
- */
37
- class HealthCheckSpinner implements Component {
38
- private theme: ReturnType<typeof getSettingsListTheme>;
39
- private url: string;
29
+ // ---------------------------------------------------------------------------
30
+ // Step 1: URL input with inline health check
31
+ // ---------------------------------------------------------------------------
32
+
33
+ class UrlStep implements Component {
34
+ private input: Input;
35
+ private theme: SettingsTheme;
40
36
  private tui: TUI;
41
- // true = healthy, false = failed (retry), undefined = cancelled
42
- private done: (result: boolean | undefined) => void;
37
+ private wizCtx: WizardStepContext;
38
+ private onUrl: (url: string) => void;
39
+ private readonly placeholder = "ai.pango-lin.ts.net";
40
+
41
+ private state: "idle" | "checking" | "ok" | "error" = "idle";
42
+ private errorMessage = "";
43
43
  private frame = 0;
44
- private timer: ReturnType<typeof setInterval>;
45
- private result: { ok: boolean; error?: string } | null = null;
44
+ private timer: ReturnType<typeof setInterval> | null = null;
46
45
 
47
46
  constructor(
48
- theme: ReturnType<typeof getSettingsListTheme>,
49
- url: string,
47
+ theme: SettingsTheme,
50
48
  tui: TUI,
51
- done: (result: boolean | undefined) => void,
49
+ currentValue: string,
50
+ wizCtx: WizardStepContext,
51
+ onUrl: (url: string) => void,
52
52
  ) {
53
53
  this.theme = theme;
54
- this.url = url;
55
54
  this.tui = tui;
56
- this.done = done;
55
+ this.wizCtx = wizCtx;
56
+ this.onUrl = onUrl;
57
+ this.input = new Input();
58
+ if (currentValue) {
59
+ this.input.setValue(currentValue);
60
+ }
61
+ this.input.onSubmit = () => this.submit();
62
+ }
63
+
64
+ private submit(): void {
65
+ const value = this.input.getValue().trim();
66
+ if (!value || this.state === "checking") return;
67
+
68
+ const url = normalizeInputUrl(value);
69
+ this.state = "checking";
70
+ this.frame = 0;
57
71
 
58
- // Animate spinner at ~80ms per frame.
59
72
  this.timer = setInterval(() => {
60
73
  this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
61
74
  this.tui.requestRender();
62
75
  }, 80);
63
76
 
64
- // Fire the check.
65
77
  checkApertureHealth(url).then((res) => {
66
- clearInterval(this.timer);
67
- this.result = res;
68
- this.tui.requestRender();
78
+ if (this.timer) clearInterval(this.timer);
79
+ this.timer = null;
69
80
 
70
- // On success, auto-advance after a brief pause.
71
- // On failure, wait for user input (see handleInput).
72
81
  if (res.ok) {
73
- setTimeout(() => this.done(true), 600);
82
+ this.state = "ok";
83
+ this.onUrl(url);
84
+ this.wizCtx.markComplete();
85
+ this.tui.requestRender();
86
+ setTimeout(() => this.wizCtx.goNext(), 400);
87
+ } else {
88
+ this.state = "error";
89
+ this.errorMessage = res.error ?? "unknown error";
90
+ this.tui.requestRender();
74
91
  }
75
92
  });
76
93
  }
77
94
 
78
- render(_width: number): string[] {
79
- const lines: string[] = [];
80
- lines.push(this.theme.label(" Aperture Setup", true));
81
- lines.push("");
82
-
83
- if (!this.result) {
84
- const spinner = SPINNER_FRAMES[this.frame];
85
- lines.push(
86
- this.theme.hint(` ${spinner} Checking connection to ${this.url}...`),
87
- );
88
- } else if (this.result.ok) {
89
- lines.push(this.theme.hint(` Connected to ${this.url}`));
90
- } else {
91
- lines.push(
92
- this.theme.hint(` Could not reach ${this.url}: ${this.result.error}`),
93
- );
94
- lines.push("");
95
- lines.push(
96
- this.theme.hint(
97
- " Make sure the URL is correct and you are connected to the tailnet.",
98
- ),
99
- );
100
- lines.push("");
101
- lines.push(this.theme.hint(" Enter: try another URL · Esc: cancel"));
102
- }
103
-
104
- lines.push("");
105
- return lines;
106
- }
107
-
108
- invalidate() {}
109
-
110
- handleInput(data: string) {
111
- // Only handle input after a failed check.
112
- if (!this.result || this.result.ok) return;
113
-
114
- if (matchesKey(data, Key.enter)) {
115
- this.done(false); // retry
116
- } else if (matchesKey(data, Key.escape)) {
117
- this.done(undefined); // cancel wizard
118
- }
119
- }
120
-
121
- dispose() {
122
- clearInterval(this.timer);
123
- }
124
- }
125
-
126
- /**
127
- * Simple input prompt component for the base URL step.
128
- */
129
- class UrlPrompt implements Component {
130
- private input: Input;
131
- private done: (value: string | undefined) => void;
132
- private theme: ReturnType<typeof getSettingsListTheme>;
133
- private placeholder = "ai.pango-lin.ts.net";
134
-
135
- constructor(
136
- theme: ReturnType<typeof getSettingsListTheme>,
137
- currentValue: string,
138
- done: (value: string | undefined) => void,
139
- ) {
140
- this.theme = theme;
141
- this.done = done;
142
- this.input = new Input();
143
- if (currentValue) {
144
- this.input.setValue(currentValue);
145
- }
146
-
147
- this.input.onSubmit = () => {
148
- const value = this.input.getValue().trim();
149
- if (!value) return;
150
- this.done(normalizeUrl(value));
151
- };
152
- this.input.onEscape = () => {
153
- this.done(undefined);
154
- };
155
- }
156
-
157
95
  render(width: number): string[] {
158
96
  const lines: string[] = [];
159
- lines.push(this.theme.label(" Aperture Setup", true));
160
- lines.push("");
97
+
161
98
  lines.push(
162
99
  this.theme.hint(` Aperture base URL (e.g. ${this.placeholder}):`),
163
100
  );
164
101
  lines.push(` ${this.input.render(width - 4).join("")}`);
165
102
  lines.push("");
166
- lines.push(this.theme.hint(" Enter: confirm · Esc: cancel"));
167
- return lines;
168
- }
169
-
170
- invalidate() {}
171
-
172
- handleInput(data: string) {
173
- this.input.handleInput(data);
174
- }
175
- }
176
-
177
- /**
178
- * Provider multi-select component. Uses FuzzySelector for search,
179
- * tracks selected providers, and lets the user confirm with Ctrl+S.
180
- */
181
- class ProviderMultiSelect implements Component {
182
- private allProviders: string[];
183
- private selected: Set<string>;
184
- private theme: ReturnType<typeof getSettingsListTheme>;
185
- private done: (value: string[] | undefined) => void;
186
- private fuzzy: FuzzySelector;
187
103
 
188
- constructor(
189
- theme: ReturnType<typeof getSettingsListTheme>,
190
- providers: string[],
191
- preselected: string[],
192
- done: (value: string[] | undefined) => void,
193
- ) {
194
- this.allProviders = providers;
195
- this.selected = new Set(preselected);
196
- this.theme = theme;
197
- this.done = done;
198
-
199
- this.fuzzy = new FuzzySelector({
200
- label: "Select providers (Enter to toggle, Ctrl+S to confirm)",
201
- items: this.allProviders,
202
- theme,
203
- onSelect: (value) => {
204
- if (this.selected.has(value)) {
205
- this.selected.delete(value);
206
- } else {
207
- this.selected.add(value);
208
- }
209
- },
210
- onDone: () => {
211
- this.done(undefined);
212
- },
213
- });
214
- }
215
-
216
- render(width: number): string[] {
217
- const lines = this.fuzzy.render(width);
218
-
219
- // Append selected providers summary
220
- if (this.selected.size > 0) {
221
- lines.push("");
222
- lines.push(this.theme.hint(` Selected (${this.selected.size}):`));
223
- for (const p of this.selected) {
224
- lines.push(` ${this.theme.value(p, false)}`);
225
- }
226
- }
227
-
228
- // Replace the hint line from FuzzySelector
229
- const hintIndex = lines.findIndex((l) => l.includes("Type to search"));
230
- if (hintIndex !== -1) {
231
- lines[hintIndex] = this.theme.hint(
232
- " Type to search · Enter: toggle · Ctrl+S: confirm · Esc: cancel",
233
- );
104
+ if (this.state === "checking") {
105
+ const spinner = SPINNER_FRAMES[this.frame];
106
+ lines.push(this.theme.hint(` ${spinner} Checking connection...`));
107
+ } else if (this.state === "ok") {
108
+ lines.push(this.theme.hint(" Connected."));
109
+ } else if (this.state === "error") {
110
+ lines.push(this.theme.hint(` Could not connect: ${this.errorMessage}`));
111
+ lines.push(this.theme.hint(" Fix the URL and press Enter to retry."));
234
112
  }
235
113
 
236
114
  return lines;
237
115
  }
238
116
 
239
- invalidate() {
240
- this.fuzzy.invalidate();
117
+ invalidate(): void {}
118
+
119
+ handleInput(data: string): void {
120
+ if (this.state === "checking") return;
121
+ this.state = "idle";
122
+ this.input.handleInput(data);
241
123
  }
242
124
 
243
- handleInput(data: string) {
244
- if (matchesKey(data, Key.ctrl("s"))) {
245
- this.done([...this.selected]);
246
- return;
247
- }
248
- this.fuzzy.handleInput(data);
125
+ dispose(): void {
126
+ if (this.timer) clearInterval(this.timer);
249
127
  }
250
128
  }
251
129
 
130
+ // ---------------------------------------------------------------------------
131
+ // Command registration
132
+ // ---------------------------------------------------------------------------
133
+
252
134
  export function registerSetupCommand(
253
135
  pi: ExtensionAPI,
254
136
  onConfigChange: (ctx: ExtensionContext) => void,
@@ -265,56 +147,81 @@ export function registerSetupCommand(
265
147
  }
266
148
 
267
149
  const config = configLoader.getConfig();
268
- const settingsTheme = getSettingsListTheme();
150
+ const checkGatewayProviders = config.checkGatewayModels ?? [];
269
151
 
270
- // Step 1: base URL + health check loop.
271
- // On failure, loop back to the URL prompt so the user can retry.
272
- let baseUrl: string | undefined;
273
- while (true) {
274
- baseUrl = await ctx.ui.custom<string | undefined>(
275
- (_tui, _theme, _kb, done) => {
276
- return new UrlPrompt(
277
- settingsTheme,
278
- baseUrl ?? config.baseUrl,
279
- done,
280
- );
281
- },
282
- );
283
-
284
- if (!baseUrl) return;
285
- const urlToCheck = baseUrl;
286
-
287
- const result = await ctx.ui.custom<boolean | undefined>(
288
- (tui, _theme, _kb, done) => {
289
- return new HealthCheckSpinner(settingsTheme, urlToCheck, tui, done);
290
- },
291
- );
292
-
293
- if (result === true) break; // healthy, proceed
294
- if (result === undefined) return; // cancelled
295
- }
296
-
297
- // Step 2: select providers
298
- // Use model registry so custom/extension providers are included.
299
152
  const knownProviders = Array.from(
300
153
  new Set(ctx.modelRegistry.getAll().map((model) => model.provider)),
301
154
  ).sort((a, b) => a.localeCompare(b));
302
155
 
303
- const providers = await ctx.ui.custom<string[] | undefined>(
304
- (_tui, _theme, _kb, done) => {
305
- return new ProviderMultiSelect(
306
- settingsTheme,
307
- knownProviders,
308
- config.providers,
309
- done,
310
- );
156
+ let baseUrl = config.baseUrl;
157
+
158
+ const providerItems: FuzzyMultiSelectorItem[] = knownProviders.map(
159
+ (p) => ({
160
+ label: p,
161
+ checked: config.providers.includes(p),
162
+ subOptions: [
163
+ {
164
+ label: "verify models on gateway",
165
+ description:
166
+ "Warn at startup if this provider's models are missing from the Aperture gateway",
167
+ checked: checkGatewayProviders.includes(p),
168
+ },
169
+ ],
170
+ }),
171
+ );
172
+
173
+ const confirmed = await ctx.ui.custom<boolean | undefined>(
174
+ (tui, theme, _kb, done) => {
175
+ const settingsTheme = getSettingsTheme(theme);
176
+
177
+ return new Wizard({
178
+ title: "Aperture Setup",
179
+ theme: settingsTheme,
180
+ minContentHeight: 16,
181
+ steps: [
182
+ {
183
+ label: "URL",
184
+ build: (wCtx: WizardStepContext) =>
185
+ new UrlStep(settingsTheme, tui, baseUrl, wCtx, (url) => {
186
+ baseUrl = url;
187
+ }),
188
+ },
189
+ {
190
+ label: "Providers",
191
+ build: (wCtx: WizardStepContext) => {
192
+ wCtx.markComplete();
193
+ return new FuzzyMultiSelector({
194
+ label: "Providers to route through Aperture",
195
+ items: providerItems,
196
+ theme: settingsTheme,
197
+ showHints: false,
198
+ showCount: false,
199
+ maxVisible: 7,
200
+ });
201
+ },
202
+ },
203
+ ],
204
+ onComplete: () => done(true),
205
+ onCancel: () => done(undefined),
206
+ });
311
207
  },
312
208
  );
313
209
 
314
- if (!providers) return;
210
+ if (!confirmed) return;
211
+
212
+ const providers = providerItems
213
+ .filter((i) => i.checked)
214
+ .map((i) => i.label);
215
+
216
+ const checkGatewayModels = providerItems
217
+ .filter((i) => i.checked && i.subOptions?.[0]?.checked)
218
+ .map((i) => i.label);
315
219
 
316
- // Step 3: save and register
317
- await configLoader.save("global", { baseUrl, providers });
220
+ await configLoader.save("global", {
221
+ baseUrl,
222
+ providers,
223
+ checkGatewayModels,
224
+ });
318
225
  onConfigChange(ctx);
319
226
  ctx.ui.notify(
320
227
  `Aperture configured: ${providers.length} provider(s) via ${baseUrl}`,
package/src/config.ts CHANGED
@@ -10,16 +10,19 @@ import { ConfigLoader } from "@aliou/pi-utils-settings";
10
10
  export interface ApertureConfig {
11
11
  baseUrl?: string;
12
12
  providers?: string[];
13
+ checkGatewayModels?: string[];
13
14
  }
14
15
 
15
16
  export interface ResolvedConfig {
16
17
  baseUrl: string;
17
18
  providers: string[];
19
+ checkGatewayModels: string[];
18
20
  }
19
21
 
20
22
  const DEFAULT_CONFIG: ResolvedConfig = {
21
23
  baseUrl: "",
22
24
  providers: [],
25
+ checkGatewayModels: [],
23
26
  };
24
27
 
25
28
  export const configLoader = new ConfigLoader<ApertureConfig, ResolvedConfig>(
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Core module exports -- pure functions and data types.
3
+ */
4
+
5
+ export * from "./plan";
6
+ export * from "./types";
7
+ export * from "./url";