gambi 0.2.3

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,937 @@
1
+ import {
2
+ confirm,
3
+ intro,
4
+ outro,
5
+ password as passwordPrompt,
6
+ select,
7
+ spinner,
8
+ text,
9
+ } from "@clack/prompts";
10
+ import type { EndpointProbeResult } from "@gambi/core/endpoint";
11
+ import { probeEndpoint } from "@gambi/core/endpoint";
12
+ import type {
13
+ ParticipantAuthHeaders,
14
+ ParticipantCapabilities,
15
+ RuntimeConfig,
16
+ } from "@gambi/core/types";
17
+ import { HEALTH_CHECK_INTERVAL } from "@gambi/core/types";
18
+ import { nanoid } from "nanoid";
19
+ import { Command, Option } from "../utils/option.ts";
20
+ import {
21
+ isLoopbackLikeHost,
22
+ isRemoteHubUrl,
23
+ listNetworkCandidates,
24
+ rankNetworkCandidatesForHub,
25
+ replaceEndpointHost,
26
+ } from "../utils/network-endpoint.ts";
27
+ import { handleCancel, isInteractive, LLM_PROVIDERS } from "../utils/prompt.ts";
28
+ import {
29
+ hasRuntimeConfig,
30
+ loadRuntimeConfigFile,
31
+ promptRuntimeConfig,
32
+ } from "../utils/runtime-config.ts";
33
+ import { detectSpecs, formatSpecs } from "../utils/specs.ts";
34
+
35
+ interface ErrorResponse {
36
+ error: string;
37
+ }
38
+
39
+ interface JoinResponse {
40
+ participant: {
41
+ id: string;
42
+ nickname: string;
43
+ model: string;
44
+ endpoint: string;
45
+ };
46
+ roomId: string;
47
+ }
48
+
49
+ interface JoinDraftInputs {
50
+ authHeaders: ParticipantAuthHeaders;
51
+ code: string;
52
+ config: RuntimeConfig;
53
+ localEndpoint: string;
54
+ model: string;
55
+ networkEndpoint: string | undefined;
56
+ nickname: string | undefined;
57
+ noNetworkRewrite: boolean;
58
+ noSpecs: boolean;
59
+ password: string | undefined;
60
+ }
61
+
62
+ interface JoinInputs {
63
+ authHeaders: ParticipantAuthHeaders;
64
+ capabilities: ParticipantCapabilities;
65
+ code: string;
66
+ config: RuntimeConfig;
67
+ localEndpoint: string;
68
+ model: string;
69
+ nickname: string | undefined;
70
+ noSpecs: boolean;
71
+ password: string | undefined;
72
+ publishedEndpoint: string;
73
+ }
74
+
75
+ interface ResolvePublishedEndpointParams {
76
+ hubUrl: string;
77
+ interactive: boolean;
78
+ localEndpoint: string;
79
+ networkEndpoint: string | undefined;
80
+ noNetworkRewrite: boolean;
81
+ stderr: NodeJS.WritableStream;
82
+ stdout: NodeJS.WritableStream;
83
+ }
84
+
85
+ function parseHeaderAssignment(input: string): { name: string; value: string } {
86
+ const separatorIndex = input.indexOf("=");
87
+ if (separatorIndex <= 0 || separatorIndex === input.length - 1) {
88
+ throw new Error(
89
+ `Invalid header assignment '${input}'. Use the format Header=Value.`
90
+ );
91
+ }
92
+
93
+ const name = input.slice(0, separatorIndex).trim();
94
+ const value = input.slice(separatorIndex + 1).trim();
95
+ if (!(name && value)) {
96
+ throw new Error(
97
+ `Invalid header assignment '${input}'. Header name and value are required.`
98
+ );
99
+ }
100
+
101
+ return { name, value };
102
+ }
103
+
104
+ function resolveAuthHeaders(
105
+ rawHeaders: string[],
106
+ envHeaders: string[]
107
+ ): ParticipantAuthHeaders {
108
+ const authHeaders: ParticipantAuthHeaders = {};
109
+
110
+ for (const header of rawHeaders) {
111
+ const { name, value } = parseHeaderAssignment(header);
112
+ authHeaders[name] = value;
113
+ }
114
+
115
+ for (const header of envHeaders) {
116
+ const { name, value: envVar } = parseHeaderAssignment(header);
117
+ const envValue = process.env[envVar];
118
+ if (!envValue) {
119
+ throw new Error(
120
+ `Environment variable '${envVar}' is required for header '${name}'.`
121
+ );
122
+ }
123
+ authHeaders[name] = envValue;
124
+ }
125
+
126
+ return authHeaders;
127
+ }
128
+
129
+ function getEndpointHost(endpoint: string): string {
130
+ return new URL(endpoint).hostname;
131
+ }
132
+
133
+ function getRankedPublishedEndpoints(
134
+ hubUrl: string,
135
+ localEndpoint: string
136
+ ): string[] {
137
+ const rankedCandidates = rankNetworkCandidatesForHub(
138
+ hubUrl,
139
+ listNetworkCandidates()
140
+ );
141
+ const publishedEndpoints = rankedCandidates.map((candidate) =>
142
+ replaceEndpointHost(localEndpoint, candidate.address)
143
+ );
144
+
145
+ return [...new Set(publishedEndpoints)];
146
+ }
147
+
148
+ function writeRemoteLoopbackExplanation(
149
+ stdout: NodeJS.WritableStream,
150
+ hubUrl: string,
151
+ localEndpoint: string
152
+ ): void {
153
+ stdout.write("\n");
154
+ stdout.write("Remote hub detected with a loopback endpoint.\n");
155
+ stdout.write(` Hub URL: ${hubUrl}\n`);
156
+ stdout.write(` Local endpoint: ${localEndpoint}\n`);
157
+ stdout.write(
158
+ " localhost only works on your own machine, so the hub needs a network-reachable URL.\n\n"
159
+ );
160
+ }
161
+
162
+ async function promptAuthHeaders(): Promise<ParticipantAuthHeaders> {
163
+ const shouldAddHeaders = await confirm({
164
+ message: "Does this endpoint require auth headers?",
165
+ initialValue: false,
166
+ });
167
+ handleCancel(shouldAddHeaders);
168
+
169
+ if (!shouldAddHeaders) {
170
+ return {};
171
+ }
172
+
173
+ const authHeaders: ParticipantAuthHeaders = {};
174
+
175
+ while (true) {
176
+ const headerNameResult = await text({
177
+ message: "Header name (leave empty to finish):",
178
+ placeholder: "Authorization",
179
+ });
180
+ handleCancel(headerNameResult);
181
+
182
+ const headerName = (headerNameResult as string).trim();
183
+ if (!headerName) {
184
+ return authHeaders;
185
+ }
186
+
187
+ const headerValueResult = await passwordPrompt({
188
+ message: `Value for ${headerName}:`,
189
+ validate: (value) =>
190
+ value?.trim() ? undefined : "Header value is required",
191
+ });
192
+ handleCancel(headerValueResult);
193
+
194
+ authHeaders[headerName] = (headerValueResult as string).trim();
195
+ }
196
+ }
197
+
198
+ async function promptEndpoint(currentEndpoint: string): Promise<string> {
199
+ if (currentEndpoint !== "http://localhost:11434") {
200
+ return currentEndpoint;
201
+ }
202
+
203
+ const providerOptions = [
204
+ ...LLM_PROVIDERS.map((provider) => ({
205
+ value: `http://localhost:${provider.port}`,
206
+ label: `${provider.name} (localhost:${provider.port})`,
207
+ })),
208
+ { value: "custom", label: "Custom URL" },
209
+ ];
210
+
211
+ const providerResult = await select({
212
+ message: "LLM Provider:",
213
+ options: providerOptions,
214
+ });
215
+ handleCancel(providerResult);
216
+
217
+ if (providerResult === "custom") {
218
+ const urlResult = await text({
219
+ message: "Endpoint URL:",
220
+ placeholder: "http://localhost:11434",
221
+ validate: (value) => (value ? undefined : "Endpoint URL is required"),
222
+ });
223
+ handleCancel(urlResult);
224
+ return urlResult as string;
225
+ }
226
+
227
+ return providerResult as string;
228
+ }
229
+
230
+ async function promptModels(
231
+ endpoint: string,
232
+ authHeaders: ParticipantAuthHeaders,
233
+ stderr: NodeJS.WritableStream
234
+ ): Promise<{ model: string; probe: EndpointProbeResult } | null> {
235
+ const s = spinner();
236
+ s.start("Detecting available models...");
237
+
238
+ const probe = await probeEndpoint(endpoint, { authHeaders });
239
+
240
+ if (probe.models.length === 0) {
241
+ s.stop("No models found");
242
+ stderr.write(`No models found at ${endpoint}\n`);
243
+ stderr.write(
244
+ "Make sure your LLM server is running and has models available.\n"
245
+ );
246
+ return null;
247
+ }
248
+
249
+ s.stop(`Found ${probe.models.length} model(s)`);
250
+
251
+ const modelResult = await select({
252
+ message: "Select model:",
253
+ options: probe.models.map((model) => ({ value: model, label: model })),
254
+ });
255
+ handleCancel(modelResult);
256
+
257
+ return { model: modelResult as string, probe };
258
+ }
259
+
260
+ async function promptManualPublishedEndpoint(
261
+ localEndpoint: string
262
+ ): Promise<string> {
263
+ const manualEndpointResult = await text({
264
+ message: "Published network endpoint:",
265
+ placeholder: replaceEndpointHost(localEndpoint, "192.168.1.50"),
266
+ validate: (value) => {
267
+ if (!value?.trim()) {
268
+ return "Published endpoint is required";
269
+ }
270
+
271
+ try {
272
+ new URL(value);
273
+ return undefined;
274
+ } catch {
275
+ return "Enter a valid URL";
276
+ }
277
+ },
278
+ });
279
+ handleCancel(manualEndpointResult);
280
+ return (manualEndpointResult as string).trim();
281
+ }
282
+
283
+ async function resolvePublishedEndpoint(
284
+ params: ResolvePublishedEndpointParams
285
+ ): Promise<string | null> {
286
+ const {
287
+ hubUrl,
288
+ interactive,
289
+ localEndpoint,
290
+ networkEndpoint,
291
+ noNetworkRewrite,
292
+ stderr,
293
+ stdout,
294
+ } = params;
295
+
296
+ try {
297
+ new URL(localEndpoint);
298
+ } catch {
299
+ stderr.write(`Invalid endpoint URL: ${localEndpoint}\n`);
300
+ return null;
301
+ }
302
+
303
+ if (networkEndpoint) {
304
+ try {
305
+ const normalizedNetworkEndpoint = new URL(networkEndpoint).toString();
306
+ stdout.write(
307
+ `Using manually published network endpoint: ${normalizedNetworkEndpoint}\n`
308
+ );
309
+ return normalizedNetworkEndpoint;
310
+ } catch {
311
+ stderr.write(`Invalid --network-endpoint URL: ${networkEndpoint}\n`);
312
+ return null;
313
+ }
314
+ }
315
+
316
+ if (noNetworkRewrite || !isRemoteHubUrl(hubUrl)) {
317
+ return localEndpoint;
318
+ }
319
+
320
+ if (!isLoopbackLikeHost(getEndpointHost(localEndpoint))) {
321
+ return localEndpoint;
322
+ }
323
+
324
+ const rankedPublishedEndpoints = getRankedPublishedEndpoints(
325
+ hubUrl,
326
+ localEndpoint
327
+ );
328
+
329
+ if (!interactive) {
330
+ if (rankedPublishedEndpoints.length === 1) {
331
+ const publishedEndpoint = rankedPublishedEndpoints[0];
332
+ if (!publishedEndpoint) {
333
+ return null;
334
+ }
335
+ stdout.write(
336
+ `Remote hub detected. Publishing ${publishedEndpoint} instead of ${localEndpoint}.\n`
337
+ );
338
+ return publishedEndpoint;
339
+ }
340
+
341
+ if (rankedPublishedEndpoints.length === 0) {
342
+ stderr.write(
343
+ "Remote hub detected, but no LAN IP could be inferred for your local endpoint.\n"
344
+ );
345
+ stderr.write(
346
+ "Pass --network-endpoint with the URL that the hub can reach, or use --no-network-rewrite to opt out.\n"
347
+ );
348
+ return null;
349
+ }
350
+
351
+ stderr.write(
352
+ "Remote hub detected and multiple LAN endpoints are possible for your machine:\n"
353
+ );
354
+ for (const candidate of rankedPublishedEndpoints) {
355
+ stderr.write(` - ${candidate}\n`);
356
+ }
357
+ stderr.write(
358
+ "Pass --network-endpoint to choose one explicitly, or use interactive mode to select it.\n"
359
+ );
360
+ return null;
361
+ }
362
+
363
+ writeRemoteLoopbackExplanation(stdout, hubUrl, localEndpoint);
364
+
365
+ const options = rankedPublishedEndpoints.map((publishedEndpoint, index) => ({
366
+ value: publishedEndpoint,
367
+ label:
368
+ index === 0
369
+ ? `Use ${publishedEndpoint} (Recommended)`
370
+ : `Use ${publishedEndpoint}`,
371
+ }));
372
+
373
+ options.push(
374
+ { value: "__manual__", label: "Enter network endpoint manually" },
375
+ { value: "__keep__", label: `Keep ${localEndpoint}` }
376
+ );
377
+
378
+ const selectedEndpoint = await select({
379
+ message: "How should this endpoint be published to the hub?",
380
+ options,
381
+ });
382
+ handleCancel(selectedEndpoint);
383
+
384
+ if (selectedEndpoint === "__manual__") {
385
+ return await promptManualPublishedEndpoint(localEndpoint);
386
+ }
387
+
388
+ if (selectedEndpoint === "__keep__") {
389
+ stdout.write(
390
+ "Keeping the current endpoint. A remote hub may reject this loopback URL.\n"
391
+ );
392
+ return localEndpoint;
393
+ }
394
+
395
+ return selectedEndpoint as string;
396
+ }
397
+
398
+ async function collectInteractiveInputs(
399
+ defaults: {
400
+ authHeaders: ParticipantAuthHeaders;
401
+ code: string | undefined;
402
+ config: RuntimeConfig;
403
+ hubUrl: string;
404
+ localEndpoint: string;
405
+ model: string | undefined;
406
+ networkEndpoint: string | undefined;
407
+ nickname: string | undefined;
408
+ noNetworkRewrite: boolean;
409
+ noSpecs: boolean;
410
+ password: string | undefined;
411
+ },
412
+ stdout: NodeJS.WritableStream,
413
+ stderr: NodeJS.WritableStream
414
+ ): Promise<JoinInputs | null> {
415
+ intro("gambi join");
416
+
417
+ let { authHeaders, code, localEndpoint, model, nickname, password, noSpecs } =
418
+ defaults;
419
+
420
+ if (!code) {
421
+ const codeResult = await text({
422
+ message: "Room code:",
423
+ validate: (value) => (value ? undefined : "Room code is required"),
424
+ });
425
+ handleCancel(codeResult);
426
+ code = codeResult as string;
427
+ }
428
+
429
+ localEndpoint = await promptEndpoint(localEndpoint);
430
+ authHeaders = await promptAuthHeaders();
431
+
432
+ let capabilities: ParticipantCapabilities | undefined;
433
+
434
+ if (!model) {
435
+ const result = await promptModels(localEndpoint, authHeaders, stderr);
436
+ if (!result) {
437
+ return null;
438
+ }
439
+ model = result.model;
440
+ capabilities = result.probe.capabilities;
441
+ }
442
+
443
+ if (nickname === undefined) {
444
+ const nicknameResult = await text({
445
+ message: "Nickname (leave empty for auto-generated):",
446
+ placeholder: `${model}@${nanoid().slice(0, 6)}`,
447
+ });
448
+ handleCancel(nicknameResult);
449
+ const resolvedNickname = (nicknameResult as string).trim();
450
+ if (resolvedNickname) {
451
+ nickname = resolvedNickname;
452
+ }
453
+ }
454
+
455
+ if (password === undefined) {
456
+ const passwordResult = await passwordPrompt({
457
+ message: "Room password (leave empty if none):",
458
+ });
459
+ handleCancel(passwordResult);
460
+ const resolvedPassword = (passwordResult as string).trim();
461
+ if (resolvedPassword) {
462
+ password = resolvedPassword;
463
+ }
464
+ }
465
+
466
+ if (!noSpecs) {
467
+ const specsResult = await confirm({
468
+ message: "Share machine specs (CPU, RAM, GPU)?",
469
+ initialValue: true,
470
+ });
471
+ handleCancel(specsResult);
472
+ noSpecs = !(specsResult as boolean);
473
+ }
474
+
475
+ if (!capabilities) {
476
+ const probe = await probeEndpoint(localEndpoint, { authHeaders });
477
+ capabilities = probe.capabilities;
478
+ }
479
+
480
+ const publishedEndpoint = await resolvePublishedEndpoint({
481
+ hubUrl: defaults.hubUrl,
482
+ interactive: true,
483
+ localEndpoint,
484
+ networkEndpoint: defaults.networkEndpoint,
485
+ noNetworkRewrite: defaults.noNetworkRewrite,
486
+ stderr,
487
+ stdout,
488
+ });
489
+ if (!publishedEndpoint) {
490
+ return null;
491
+ }
492
+
493
+ let config: RuntimeConfig;
494
+ try {
495
+ config = await promptRuntimeConfig("participant", defaults.config);
496
+ } catch (error) {
497
+ stderr.write(`${error}\n`);
498
+ return null;
499
+ }
500
+
501
+ return {
502
+ authHeaders,
503
+ capabilities,
504
+ code,
505
+ config,
506
+ localEndpoint,
507
+ model,
508
+ nickname,
509
+ noSpecs,
510
+ password,
511
+ publishedEndpoint,
512
+ };
513
+ }
514
+
515
+ export class JoinCommand extends Command {
516
+ static override paths = [["join"]];
517
+
518
+ static override usage = Command.Usage({
519
+ description: "Join a room and expose your LLM endpoint",
520
+ examples: [
521
+ ["Join a room (interactive)", "gambi join"],
522
+ [
523
+ "Join with Ollama",
524
+ "gambi join --code ABC123 --model llama3 --endpoint http://localhost:11434",
525
+ ],
526
+ [
527
+ "Join with LM Studio",
528
+ "gambi join --code ABC123 --model gpt-4 --endpoint http://localhost:1234",
529
+ ],
530
+ [
531
+ "Join with custom nickname",
532
+ "gambi join --code ABC123 --model llama3 --endpoint http://localhost:11434 --nickname 'My GPU'",
533
+ ],
534
+ [
535
+ "Join a remote hub with an explicit published endpoint",
536
+ "gambi join --code ABC123 --hub http://192.168.1.10:3000 --model llama3 --network-endpoint http://192.168.1.25:11434",
537
+ ],
538
+ [
539
+ "Join password-protected room",
540
+ "gambi join --code ABC123 --model llama3 --password secret123",
541
+ ],
542
+ ],
543
+ });
544
+
545
+ code = Option.String("--code,-c", {
546
+ description: "Room code to join",
547
+ required: false,
548
+ });
549
+
550
+ model = Option.String("--model,-m", {
551
+ description: "Model to expose",
552
+ required: false,
553
+ });
554
+
555
+ endpoint = Option.String("--endpoint,-e", "http://localhost:11434", {
556
+ description: "Local LLM endpoint URL used for probing and inference",
557
+ });
558
+
559
+ networkEndpoint = Option.String("--network-endpoint", {
560
+ description: "Network-reachable URL to publish to the hub",
561
+ required: false,
562
+ });
563
+
564
+ nickname = Option.String("--nickname,-n", {
565
+ description: "Display name for your endpoint",
566
+ });
567
+
568
+ headers = Option.Array("--header", [], {
569
+ description: "Auth header in the format Header=Value",
570
+ });
571
+
572
+ headerEnv = Option.Array("--header-env", [], {
573
+ description: "Auth header in the format Header=ENV_VAR",
574
+ });
575
+
576
+ password = Option.String("--password,-p", {
577
+ description: "Room password (if the room is password-protected)",
578
+ required: false,
579
+ });
580
+
581
+ hub = Option.String("--hub,-H", "http://localhost:3000", {
582
+ description: "Hub URL",
583
+ });
584
+
585
+ configPath = Option.String("--config", {
586
+ description: "Path to a JSON file with participant defaults",
587
+ required: false,
588
+ });
589
+
590
+ noSpecs = Option.Boolean("--no-specs", false, {
591
+ description: "Don't share machine specs (CPU, RAM, GPU)",
592
+ });
593
+
594
+ noNetworkRewrite = Option.Boolean("--no-network-rewrite", false, {
595
+ description:
596
+ "Disable automatic localhost-to-LAN endpoint rewrite for remote hubs",
597
+ });
598
+
599
+ private async loadConfig(): Promise<RuntimeConfig | null> {
600
+ if (!this.configPath) {
601
+ return {};
602
+ }
603
+
604
+ try {
605
+ return await loadRuntimeConfigFile(this.configPath);
606
+ } catch (error) {
607
+ this.context.stderr.write(`${error}\n`);
608
+ return null;
609
+ }
610
+ }
611
+
612
+ private async resolveInputs(): Promise<JoinDraftInputs | null> {
613
+ if (this.code && this.model) {
614
+ let authHeaders: ParticipantAuthHeaders;
615
+ try {
616
+ authHeaders = resolveAuthHeaders(this.headers, this.headerEnv);
617
+ } catch (error) {
618
+ this.context.stderr.write(`${error}\n`);
619
+ return null;
620
+ }
621
+
622
+ const config = await this.loadConfig();
623
+ if (config === null) {
624
+ return null;
625
+ }
626
+
627
+ return {
628
+ authHeaders,
629
+ code: this.code,
630
+ config,
631
+ localEndpoint: this.endpoint,
632
+ model: this.model,
633
+ networkEndpoint: this.networkEndpoint,
634
+ nickname: this.nickname,
635
+ noNetworkRewrite: this.noNetworkRewrite,
636
+ noSpecs: this.noSpecs,
637
+ password: this.password,
638
+ };
639
+ }
640
+
641
+ if (!this.code) {
642
+ this.context.stderr.write(
643
+ "Error: --code is required (or run in a terminal for interactive mode)\n"
644
+ );
645
+ }
646
+ if (!this.model) {
647
+ this.context.stderr.write(
648
+ "Error: --model is required (or run in a terminal for interactive mode)\n"
649
+ );
650
+ }
651
+ return null;
652
+ }
653
+
654
+ private async collectInputsForExecution(
655
+ interactive: boolean
656
+ ): Promise<JoinInputs | null> {
657
+ if (!interactive) {
658
+ const resolved = await this.resolveInputs();
659
+ if (!resolved) {
660
+ return null;
661
+ }
662
+
663
+ const publishedEndpoint = await resolvePublishedEndpoint({
664
+ hubUrl: this.hub,
665
+ interactive: false,
666
+ localEndpoint: resolved.localEndpoint,
667
+ networkEndpoint: resolved.networkEndpoint,
668
+ noNetworkRewrite: resolved.noNetworkRewrite,
669
+ stderr: this.context.stderr,
670
+ stdout: this.context.stdout,
671
+ });
672
+ if (!publishedEndpoint) {
673
+ return null;
674
+ }
675
+
676
+ return {
677
+ ...resolved,
678
+ capabilities: {
679
+ openResponses: "unknown",
680
+ chatCompletions: "unknown",
681
+ },
682
+ publishedEndpoint,
683
+ };
684
+ }
685
+
686
+ const config = await this.loadConfig();
687
+ if (config === null) {
688
+ return null;
689
+ }
690
+
691
+ return collectInteractiveInputs(
692
+ {
693
+ authHeaders: {},
694
+ code: this.code,
695
+ config,
696
+ hubUrl: this.hub,
697
+ localEndpoint: this.endpoint,
698
+ model: this.model,
699
+ networkEndpoint: this.networkEndpoint,
700
+ nickname: this.nickname,
701
+ noNetworkRewrite: this.noNetworkRewrite,
702
+ noSpecs: this.noSpecs,
703
+ password: this.password,
704
+ },
705
+ this.context.stdout,
706
+ this.context.stderr
707
+ );
708
+ }
709
+
710
+ private async executeInteractiveJoin(inputs: JoinInputs): Promise<number> {
711
+ const specs = inputs.noSpecs
712
+ ? { cpu: "Hidden", ram: 0 }
713
+ : await detectSpecs();
714
+ const participantId = nanoid();
715
+ const finalNickname =
716
+ inputs.nickname ?? `${inputs.model}@${participantId.slice(0, 6)}`;
717
+
718
+ return this.registerAndListen({
719
+ authHeaders: inputs.authHeaders,
720
+ capabilities: inputs.capabilities,
721
+ code: inputs.code,
722
+ config: inputs.config,
723
+ finalNickname,
724
+ interactive: true,
725
+ localEndpoint: inputs.localEndpoint,
726
+ model: inputs.model,
727
+ participantId,
728
+ password: inputs.password,
729
+ publishedEndpoint: inputs.publishedEndpoint,
730
+ specs,
731
+ });
732
+ }
733
+
734
+ private async executeNonInteractiveJoin(inputs: JoinInputs): Promise<number> {
735
+ const [probe, specs] = await Promise.all([
736
+ probeEndpoint(inputs.localEndpoint, { authHeaders: inputs.authHeaders }),
737
+ inputs.noSpecs
738
+ ? Promise.resolve({ cpu: "Hidden" as const, ram: 0 })
739
+ : detectSpecs(),
740
+ ]);
741
+
742
+ if (!this.validateProbe(probe, inputs.localEndpoint, inputs.model)) {
743
+ return 1;
744
+ }
745
+
746
+ if (!inputs.noSpecs) {
747
+ this.context.stdout.write(`Detected specs: ${formatSpecs(specs)}\n\n`);
748
+ }
749
+
750
+ const participantId = nanoid();
751
+ const finalNickname =
752
+ inputs.nickname ?? `${inputs.model}@${participantId.slice(0, 6)}`;
753
+
754
+ return this.registerAndListen({
755
+ authHeaders: inputs.authHeaders,
756
+ capabilities: probe.capabilities,
757
+ code: inputs.code,
758
+ config: inputs.config,
759
+ finalNickname,
760
+ interactive: false,
761
+ localEndpoint: inputs.localEndpoint,
762
+ model: inputs.model,
763
+ participantId,
764
+ password: inputs.password,
765
+ publishedEndpoint: inputs.publishedEndpoint,
766
+ specs,
767
+ });
768
+ }
769
+
770
+ private validateProbe(
771
+ probe: EndpointProbeResult,
772
+ endpoint: string,
773
+ model: string
774
+ ): boolean {
775
+ if (probe.models.length === 0) {
776
+ this.context.stderr.write(`No models found at ${endpoint}\n`);
777
+ this.context.stderr.write(
778
+ "Make sure your LLM server is running and has models available.\n"
779
+ );
780
+ return false;
781
+ }
782
+
783
+ if (!probe.models.includes(model)) {
784
+ this.context.stderr.write(`Model '${model}' not found.\n`);
785
+ this.context.stderr.write(
786
+ `Available models: ${probe.models.join(", ")}\n`
787
+ );
788
+ return false;
789
+ }
790
+
791
+ return true;
792
+ }
793
+
794
+ async execute(): Promise<number> {
795
+ const interactive = isInteractive() && !(this.code && this.model);
796
+ const inputs = await this.collectInputsForExecution(interactive);
797
+ if (!inputs) {
798
+ return 1;
799
+ }
800
+
801
+ return interactive
802
+ ? this.executeInteractiveJoin(inputs)
803
+ : this.executeNonInteractiveJoin(inputs);
804
+ }
805
+
806
+ private async registerAndListen(opts: {
807
+ authHeaders: ParticipantAuthHeaders;
808
+ capabilities: ParticipantCapabilities;
809
+ code: string;
810
+ config: RuntimeConfig;
811
+ finalNickname: string;
812
+ interactive: boolean;
813
+ localEndpoint: string;
814
+ model: string;
815
+ participantId: string;
816
+ password: string | undefined;
817
+ publishedEndpoint: string;
818
+ specs: { cpu: string; ram: number; gpu?: string };
819
+ }): Promise<number> {
820
+ const {
821
+ authHeaders,
822
+ capabilities,
823
+ code,
824
+ config,
825
+ finalNickname,
826
+ interactive,
827
+ localEndpoint,
828
+ model,
829
+ participantId,
830
+ password,
831
+ publishedEndpoint,
832
+ specs,
833
+ } = opts;
834
+
835
+ try {
836
+ const body: Record<string, unknown> = {
837
+ id: participantId,
838
+ nickname: finalNickname,
839
+ model,
840
+ endpoint: publishedEndpoint,
841
+ specs,
842
+ config,
843
+ capabilities,
844
+ };
845
+
846
+ if (!hasRuntimeConfig(config)) {
847
+ body.config = undefined;
848
+ }
849
+
850
+ if (Object.keys(authHeaders).length > 0) {
851
+ body.authHeaders = authHeaders;
852
+ }
853
+
854
+ if (password) {
855
+ body.password = password;
856
+ }
857
+
858
+ const response = await fetch(`${this.hub}/rooms/${code}/join`, {
859
+ method: "POST",
860
+ headers: { "Content-Type": "application/json" },
861
+ body: JSON.stringify(body),
862
+ });
863
+
864
+ if (!response.ok) {
865
+ const data = (await response.json()) as ErrorResponse;
866
+ this.context.stderr.write(`Error: ${data.error}\n`);
867
+ return 1;
868
+ }
869
+
870
+ const data = (await response.json()) as JoinResponse;
871
+
872
+ const successMsg = [
873
+ `Joined room ${code}!`,
874
+ ` Participant ID: ${data.participant.id}`,
875
+ ` Nickname: ${data.participant.nickname}`,
876
+ ` Model: ${data.participant.model}`,
877
+ ` Local endpoint: ${localEndpoint}`,
878
+ ` Published endpoint: ${data.participant.endpoint}`,
879
+ "",
880
+ "Your endpoint is now available through the hub.",
881
+ "Press Ctrl+C to leave the room.",
882
+ ].join("\n");
883
+
884
+ if (interactive) {
885
+ outro(successMsg);
886
+ } else {
887
+ this.context.stdout.write(`${successMsg}\n\n`);
888
+ }
889
+ } catch (error) {
890
+ this.context.stderr.write(`Failed to connect to hub at ${this.hub}\n`);
891
+ this.context.stderr.write(`${error}\n`);
892
+ return 1;
893
+ }
894
+
895
+ const healthInterval = setInterval(async () => {
896
+ try {
897
+ const response = await fetch(`${this.hub}/rooms/${code}/health`, {
898
+ method: "POST",
899
+ headers: { "Content-Type": "application/json" },
900
+ body: JSON.stringify({ id: participantId }),
901
+ });
902
+
903
+ if (!response.ok) {
904
+ this.context.stderr.write("Health check failed, leaving room...\n");
905
+ clearInterval(healthInterval);
906
+ process.exit(1);
907
+ }
908
+ } catch {
909
+ this.context.stderr.write("Lost connection to hub, leaving room...\n");
910
+ clearInterval(healthInterval);
911
+ process.exit(1);
912
+ }
913
+ }, HEALTH_CHECK_INTERVAL);
914
+
915
+ const cleanup = async () => {
916
+ this.context.stdout.write("\nLeaving room...\n");
917
+ clearInterval(healthInterval);
918
+
919
+ try {
920
+ await fetch(`${this.hub}/rooms/${code}/leave/${participantId}`, {
921
+ method: "DELETE",
922
+ });
923
+ this.context.stdout.write("Left room successfully.\n");
924
+ } catch {
925
+ this.context.stderr.write("Failed to notify hub of departure.\n");
926
+ }
927
+
928
+ process.exit(0);
929
+ };
930
+
931
+ process.on("SIGINT", cleanup);
932
+ process.on("SIGTERM", cleanup);
933
+
934
+ await new Promise(() => undefined);
935
+ return 0;
936
+ }
937
+ }