@hypabolic/crossbar 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,439 @@
1
+ /**
2
+ * Crossbar onboarding overlay — the `/crossbar` in-TUI setup flow.
3
+ *
4
+ * Exports:
5
+ * - Pure, unit-testable helpers:
6
+ * buildDiscoveredItems — SelectItem[] from discovered servers + existing registry
7
+ * buildModelItems — SelectItem[] from ModelDescriptor[]
8
+ * capabilityActions — capability-filtered action list
9
+ * normalizeManualUrl — coerce bare host:port / missing-scheme inputs to a valid origin
10
+ *
11
+ * - The flow driver:
12
+ * openOnboarding — ctx.ui.custom overlay: discover → pick → (manual add) → test → model → save
13
+ *
14
+ * HARD RULES (mirrored from ARCHITECTURE.md):
15
+ * - Never log or serialize the API key the user enters.
16
+ * - No raw ANSI — all styling through theme.fg(token, ...).
17
+ * - No new dependencies; only the injected Probe (via createProbe) for connection tests.
18
+ * - Do NOT modify src/core/, src/adapters/, src/registry/, or any other frozen modules.
19
+ */
20
+
21
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
22
+ import { DynamicBorder, getSelectListTheme } from "@earendil-works/pi-coding-agent";
23
+ import { Container, type SelectItem, SelectList, Text, matchesKey } from "@earendil-works/pi-tui";
24
+
25
+ import type { BackendAdapter } from "../core/backend-adapter.ts";
26
+ import { canIntrospect, canLoadUnload, canSwitch } from "../core/backend-adapter.ts";
27
+ import type { DiscoveredServer, ModelDescriptor, ServerRecord } from "../core/types.ts";
28
+ import type { ServerRegistry } from "../registry/registry.ts";
29
+ import { serverId } from "../registry/ids.ts";
30
+ import { adapterFor } from "../adapters/index.ts";
31
+ import { createProbe } from "../discovery/probe.ts";
32
+
33
+ // ─── Pure helpers ────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Build a `SelectItem[]` representing the discovered servers for the top-level
37
+ * onboarding list. Already-registered servers are marked with a "(added)" suffix
38
+ * so the user can see what is new vs. what Crossbar already knows about.
39
+ *
40
+ * Items are ordered: discovered servers first (in discovery order), then a
41
+ * sentinel "Add manually" entry at the end.
42
+ */
43
+ export function buildDiscoveredItems(
44
+ discovered: DiscoveredServer[],
45
+ existing: ServerRecord[],
46
+ ): SelectItem[] {
47
+ const existingIds = new Set(existing.map((r) => r.id));
48
+
49
+ const items: SelectItem[] = discovered.map((server): SelectItem => {
50
+ const id = serverId(server.kind, server.baseUrl);
51
+ const isAdded = existingIds.has(id);
52
+
53
+ // Extract host:port from baseUrl for the label suffix
54
+ let hostPort: string;
55
+ try {
56
+ const u = new URL(server.baseUrl);
57
+ hostPort = `${u.hostname}:${u.port || (u.protocol === "https:" ? "443" : "80")}`;
58
+ } catch {
59
+ hostPort = server.baseUrl.replace(/^https?:\/\//, "");
60
+ }
61
+
62
+ // Compose a label: "[kind] host:port ✓ healthy" or "(added)"
63
+ const kindLabel = server.kind.charAt(0).toUpperCase() + server.kind.slice(1);
64
+ const healthMark = isAdded ? "(added)" : "✓ healthy";
65
+ const label = `${kindLabel} (${hostPort})`;
66
+
67
+ return {
68
+ value: server.baseUrl,
69
+ label: isAdded ? `${label} (added)` : label,
70
+ description: isAdded
71
+ ? `Already registered · ${healthMark}`
72
+ : `${healthMark} · auth: ${server.auth}${server.version ? ` · v${server.version}` : ""}`,
73
+ };
74
+ });
75
+
76
+ // Always append the manual-add sentinel
77
+ items.push({
78
+ value: "__manual__",
79
+ label: "+ Add server…",
80
+ description: "Enter a URL manually",
81
+ });
82
+
83
+ return items;
84
+ }
85
+
86
+ /**
87
+ * Build a `SelectItem[]` from a list of ModelDescriptors for the model-picker
88
+ * step of the onboarding flow.
89
+ *
90
+ * The description line surfaces the context window (when known) and any
91
+ * capability badges (vision, tools, reasoning, embeddings).
92
+ */
93
+ export function buildModelItems(models: ModelDescriptor[]): SelectItem[] {
94
+ return models.map((m): SelectItem => {
95
+ const parts: string[] = [];
96
+
97
+ if (m.contextWindow !== undefined) {
98
+ const ctx =
99
+ m.contextWindow >= 1000
100
+ ? `${Math.round(m.contextWindow / 1000)}k ctx`
101
+ : `${m.contextWindow} ctx`;
102
+ parts.push(ctx);
103
+ }
104
+
105
+ const caps: string[] = [];
106
+ if (m.reasoning) caps.push("reasoning");
107
+ if (m.tools) caps.push("tools");
108
+ if (m.input.includes("image")) caps.push("vision");
109
+ if (m.embeddings) caps.push("embeddings");
110
+ if (caps.length > 0) parts.push(caps.join(" · "));
111
+
112
+ const item: SelectItem = {
113
+ value: m.id,
114
+ label: m.name || m.id,
115
+ };
116
+ if (parts.length > 0) {
117
+ item.description = parts.join(" ");
118
+ }
119
+ return item;
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Return only the actions that `adapter` actually supports — capability-driven
125
+ * hiding in its simplest form.
126
+ *
127
+ * - "switch" requires SwitchModel (present on Ollama, LM Studio, llama-swap)
128
+ * - "load" requires LoadUnload (present on Ollama, LM Studio)
129
+ * - "unload" requires LoadUnload
130
+ * - "introspect" requires IntrospectLoaded (present on Ollama, LM Studio)
131
+ *
132
+ * vLLM, OpenAI, Anthropic, and the generic adapter all lack these, so the
133
+ * returned list will be empty (or reduced) for them.
134
+ */
135
+ export function capabilityActions(
136
+ adapter: BackendAdapter,
137
+ ): { label: string; value: string }[] {
138
+ const actions: { label: string; value: string }[] = [];
139
+
140
+ if (canSwitch(adapter)) {
141
+ actions.push({ label: "Switch model", value: "switch" });
142
+ }
143
+ if (canLoadUnload(adapter)) {
144
+ actions.push({ label: "Load model", value: "load" });
145
+ actions.push({ label: "Unload model", value: "unload" });
146
+ }
147
+ if (canIntrospect(adapter)) {
148
+ actions.push({ label: "Inspect loaded models", value: "introspect" });
149
+ }
150
+
151
+ return actions;
152
+ }
153
+
154
+ /**
155
+ * Coerce a user-supplied string (which may be bare "host:port", missing a scheme,
156
+ * or already a valid URL) into a well-formed origin with no trailing slash.
157
+ *
158
+ * Rules applied in order:
159
+ * 1. Trim whitespace.
160
+ * 2. If input already starts with "http://" or "https://", parse as-is.
161
+ * 3. If it looks like "host:port" (no "://"), prefix "http://".
162
+ * 4. Strip any path/query/fragment — we want only the origin.
163
+ * 5. Strip trailing slashes.
164
+ *
165
+ * Returns the coerced origin string. Throws if the result is not a valid URL.
166
+ */
167
+ export function normalizeManualUrl(input: string): string {
168
+ let raw = input.trim();
169
+
170
+ // Already has a scheme → use as-is for parsing
171
+ if (!raw.startsWith("http://") && !raw.startsWith("https://")) {
172
+ raw = `http://${raw}`;
173
+ }
174
+
175
+ const u = new URL(raw); // throws DOMException / TypeError on invalid input
176
+ // Return only the origin (scheme + host + port), no path
177
+ return u.origin.replace(/\/+$/, "");
178
+ }
179
+
180
+ // ─── Overlay flow driver ────────────────────────────────────────────────────
181
+
182
+ export interface OnboardingDeps {
183
+ registry: ServerRegistry;
184
+ discover: () => Promise<DiscoveredServer[]>;
185
+ }
186
+
187
+ /**
188
+ * Open the Crossbar onboarding overlay.
189
+ *
190
+ * Flow:
191
+ * 1. Run discovery and show discovered servers + "Add manually" entry.
192
+ * 2a. If user picks a discovered server → test connection (health + listModels).
193
+ * 2b. If user picks "Add manually" → prompt for URL, optional API key, test.
194
+ * 3. Pick a default model from the server's model list.
195
+ * 4. Save to registry (+ auth.json for api keys).
196
+ *
197
+ * The driver is intentionally thin: item building is delegated to the pure helpers
198
+ * above, and connection testing goes through the same `createProbe` + adapter
199
+ * `health`/`listModels` pair that production and the conformance tests use.
200
+ *
201
+ * @param pi - ExtensionAPI (needed to honour the ExtensionCommandContext signature for commands)
202
+ * @param ctx - ExtensionCommandContext from the `/crossbar` command handler
203
+ * @param deps - injected registry + discover function (for testability)
204
+ */
205
+ export async function openOnboarding(
206
+ _pi: ExtensionAPI,
207
+ ctx: ExtensionCommandContext,
208
+ deps: OnboardingDeps,
209
+ ): Promise<void> {
210
+ const { registry, discover } = deps;
211
+
212
+ // ── Step 0: run discovery (non-blocking for the overlay; we show a spinner) ──
213
+ ctx.ui.notify("Crossbar: scanning localhost for backends…", "info");
214
+
215
+ let discovered: DiscoveredServer[] = [];
216
+ try {
217
+ discovered = await discover();
218
+ } catch {
219
+ // Discovery failures are non-fatal — the user can still add manually
220
+ }
221
+
222
+ const existing = registry.list();
223
+
224
+ // ── Step 1: show top-level discovery list ──────────────────────────────────
225
+ const topItems = buildDiscoveredItems(discovered, existing);
226
+
227
+ const chosenBaseUrl = await ctx.ui.custom<string | null>(
228
+ (_tui, theme, _kb, done) => {
229
+ const container = new Container();
230
+
231
+ container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
232
+ container.addChild(
233
+ new Text(theme.fg("accent", theme.bold("Crossbar — Local Model Servers"))),
234
+ );
235
+ container.addChild(
236
+ new Text(theme.fg("muted", "Select a discovered server or add one manually.")),
237
+ );
238
+
239
+ const list = new SelectList(
240
+ topItems,
241
+ Math.min(topItems.length, 12),
242
+ getSelectListTheme(),
243
+ );
244
+
245
+ list.onSelect = (item) => done(item.value);
246
+ list.onCancel = () => done(null);
247
+
248
+ container.addChild(list);
249
+ container.addChild(
250
+ new Text(theme.fg("dim", "↑↓ navigate · Enter select · Esc cancel")),
251
+ );
252
+ container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
253
+
254
+ return {
255
+ render: (width: number) => container.render(width),
256
+ invalidate: () => container.invalidate(),
257
+ handleInput: (data: string) => {
258
+ list.handleInput(data);
259
+ _tui.requestRender();
260
+ },
261
+ };
262
+ },
263
+ { overlay: true, overlayOptions: { width: "60%" } },
264
+ );
265
+
266
+ if (!chosenBaseUrl) return; // user cancelled
267
+
268
+ // ── Step 2: manual add branch ──────────────────────────────────────────────
269
+ let targetBaseUrl: string;
270
+ let manualApiKey: string | undefined;
271
+ let discoveredServer: DiscoveredServer | undefined;
272
+
273
+ if (chosenBaseUrl === "__manual__") {
274
+ // 2a. Ask for URL
275
+ const rawUrl = await ctx.ui.input("Server URL", "e.g. localhost:11434 or http://192.168.1.5:8080");
276
+ if (!rawUrl) return;
277
+
278
+ let normalizedUrl: string;
279
+ try {
280
+ normalizedUrl = normalizeManualUrl(rawUrl);
281
+ } catch {
282
+ ctx.ui.notify("Crossbar: invalid URL — could not parse.", "error");
283
+ return;
284
+ }
285
+
286
+ // 2b. Ask for API key (with no-auth option)
287
+ const authChoice = await ctx.ui.select(
288
+ "Authentication",
289
+ ["No authentication (open server)", "Enter API key"],
290
+ );
291
+ if (authChoice === undefined) return;
292
+
293
+ if (authChoice === "Enter API key") {
294
+ const key = await ctx.ui.input("API key", "Paste your key (hidden after this dialog)");
295
+ if (key === undefined) return;
296
+ // key may be empty string if user submitted blank — treat as no-auth
297
+ manualApiKey = key.length > 0 ? key : undefined;
298
+ }
299
+
300
+ targetBaseUrl = normalizedUrl;
301
+ } else {
302
+ // Discovered server path
303
+ discoveredServer = discovered.find((s) => s.baseUrl === chosenBaseUrl);
304
+ targetBaseUrl = chosenBaseUrl;
305
+ }
306
+
307
+ // ── Step 3: fingerprint (if we don't already have a DiscoveredServer) ─────
308
+ if (!discoveredServer) {
309
+ ctx.ui.notify("Crossbar: testing connection…", "info");
310
+
311
+ const cred =
312
+ manualApiKey !== undefined
313
+ ? { mode: "apiKey" as const, apiKey: manualApiKey }
314
+ : { mode: "none" as const };
315
+
316
+ const probe = createProbe(targetBaseUrl, { auth: cred, defaultTimeoutMs: 3000 });
317
+
318
+ // Try each non-cloud adapter in probe order; first match wins
319
+ const { DISCOVERY_ADAPTERS } = await import("../adapters/index.ts");
320
+ for (const adapter of DISCOVERY_ADAPTERS) {
321
+ try {
322
+ const result = await adapter.fingerprint(targetBaseUrl, probe);
323
+ if (result) {
324
+ discoveredServer = result;
325
+ // Propagate auth mode from the user's input
326
+ if (manualApiKey !== undefined) {
327
+ discoveredServer = { ...result, auth: "apiKey" };
328
+ }
329
+ break;
330
+ }
331
+ } catch {
332
+ // fingerprint failures are non-fatal
333
+ }
334
+ }
335
+
336
+ if (!discoveredServer) {
337
+ ctx.ui.notify(
338
+ "Crossbar: could not identify the server — check the URL and try again.",
339
+ "error",
340
+ );
341
+ return;
342
+ }
343
+ }
344
+
345
+ // ── Step 4: list models ────────────────────────────────────────────────────
346
+ ctx.ui.notify(`Crossbar: connected to ${discoveredServer.label} — fetching models…`, "info");
347
+
348
+ const adapter = adapterFor(discoveredServer.kind);
349
+ const cred =
350
+ manualApiKey !== undefined
351
+ ? { mode: "apiKey" as const, apiKey: manualApiKey }
352
+ : { mode: "none" as const };
353
+
354
+ const probe = createProbe(targetBaseUrl, { auth: cred, defaultTimeoutMs: 5000 });
355
+
356
+ let models: ModelDescriptor[] = [];
357
+ try {
358
+ models = await adapter.listModels(discoveredServer, cred, probe);
359
+ } catch (err) {
360
+ const msg = err instanceof Error ? err.message : String(err);
361
+ ctx.ui.notify(`Crossbar: could not list models — ${msg}`, "error");
362
+ return;
363
+ }
364
+
365
+ if (models.length === 0) {
366
+ ctx.ui.notify("Crossbar: server is reachable but returned no models.", "warning");
367
+ return;
368
+ }
369
+
370
+ // ── Step 5: pick default model ─────────────────────────────────────────────
371
+ const modelItems = buildModelItems(models);
372
+
373
+ const chosenModelId = await ctx.ui.custom<string | null>(
374
+ (_tui, theme, _kb, done) => {
375
+ const container = new Container();
376
+
377
+ container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
378
+ container.addChild(
379
+ new Text(theme.fg("accent", theme.bold(`Pick default model — ${discoveredServer!.label}`))),
380
+ );
381
+
382
+ const list = new SelectList(
383
+ modelItems,
384
+ Math.min(modelItems.length, 12),
385
+ getSelectListTheme(),
386
+ );
387
+
388
+ list.onSelect = (item) => done(item.value);
389
+ list.onCancel = () => done(null);
390
+
391
+ container.addChild(list);
392
+ container.addChild(
393
+ new Text(theme.fg("dim", "↑↓ navigate · Enter select · Esc skip")),
394
+ );
395
+ container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
396
+
397
+ return {
398
+ render: (width: number) => container.render(width),
399
+ invalidate: () => container.invalidate(),
400
+ handleInput: (data: string) => {
401
+ // Allow Esc to skip model selection
402
+ if (matchesKey(data, "escape")) {
403
+ done(null);
404
+ return;
405
+ }
406
+ list.handleInput(data);
407
+ _tui.requestRender();
408
+ },
409
+ };
410
+ },
411
+ { overlay: true, overlayOptions: { width: "60%" } },
412
+ );
413
+
414
+ // chosenModelId === null means the user skipped — still register the server
415
+
416
+ // ── Step 6: save to registry ───────────────────────────────────────────────
417
+ const id = serverId(discoveredServer.kind, targetBaseUrl);
418
+ const record: ServerRecord = {
419
+ id,
420
+ kind: discoveredServer.kind,
421
+ baseUrl: targetBaseUrl,
422
+ label: discoveredServer.label,
423
+ auth: discoveredServer.auth,
424
+ enabled: true,
425
+ addedAt: Date.now(),
426
+ ...(chosenModelId !== null ? { lastKnownModels: models } : {}),
427
+ };
428
+
429
+ // Pass the api key separately so it goes through the registry → authStorage path
430
+ // (never written into crossbar.json)
431
+ await registry.add(record, manualApiKey);
432
+
433
+ const modelNote =
434
+ chosenModelId !== null ? ` Default model: ${chosenModelId}.` : "";
435
+ ctx.ui.notify(
436
+ `Crossbar: ${discoveredServer.label} added!${modelNote} It will appear in /model.`,
437
+ "info",
438
+ );
439
+ }