@vellumai/cli 0.8.5 → 0.8.7-dev.202606052118.34cd356

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 (102) hide show
  1. package/AGENTS.md +6 -0
  2. package/bun.lock +8 -0
  3. package/knip.json +6 -1
  4. package/node_modules/@vellumai/environments/bun.lock +24 -0
  5. package/node_modules/@vellumai/environments/package.json +18 -0
  6. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  7. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  8. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  9. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  10. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  11. package/node_modules/@vellumai/local-mode/package.json +22 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  16. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  18. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  19. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  20. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  21. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  22. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  26. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  27. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  28. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  29. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  30. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  31. package/package.json +12 -1
  32. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  33. package/src/__tests__/backup.test.ts +38 -0
  34. package/src/__tests__/clean.test.ts +179 -0
  35. package/src/__tests__/client-token.test.ts +87 -0
  36. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  37. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  38. package/src/__tests__/connect-import.test.ts +317 -0
  39. package/src/__tests__/devices.test.ts +272 -0
  40. package/src/__tests__/env-drift.test.ts +32 -44
  41. package/src/__tests__/flags.test.ts +248 -0
  42. package/src/__tests__/guardian-token.test.ts +126 -2
  43. package/src/__tests__/multi-local.test.ts +1 -1
  44. package/src/__tests__/orphan-detection.test.ts +8 -6
  45. package/src/__tests__/pair.test.ts +271 -0
  46. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  47. package/src/__tests__/recover.test.ts +307 -0
  48. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  49. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  50. package/src/__tests__/unpair.test.ts +163 -0
  51. package/src/__tests__/wake.test.ts +215 -0
  52. package/src/commands/backup.ts +2 -0
  53. package/src/commands/client.ts +569 -39
  54. package/src/commands/connect/import.ts +217 -0
  55. package/src/commands/connect.ts +31 -0
  56. package/src/commands/devices.ts +247 -0
  57. package/src/commands/env.ts +1 -1
  58. package/src/commands/flags.ts +269 -0
  59. package/src/commands/gateway/token.ts +73 -0
  60. package/src/commands/gateway.ts +29 -0
  61. package/src/commands/logs.ts +6 -18
  62. package/src/commands/pair.ts +222 -0
  63. package/src/commands/ps.ts +57 -41
  64. package/src/commands/recover.ts +47 -9
  65. package/src/commands/restore.ts +8 -1
  66. package/src/commands/retire.ts +23 -70
  67. package/src/commands/rollback.ts +2 -14
  68. package/src/commands/sleep.ts +7 -0
  69. package/src/commands/ssh.ts +5 -24
  70. package/src/commands/teleport.ts +34 -26
  71. package/src/commands/tunnel.ts +46 -2
  72. package/src/commands/unpair.ts +118 -0
  73. package/src/commands/upgrade.ts +8 -16
  74. package/src/commands/wake.ts +75 -45
  75. package/src/components/DefaultMainScreen.tsx +100 -14
  76. package/src/index.ts +22 -0
  77. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  78. package/src/lib/__tests__/step-runner.test.ts +49 -1
  79. package/src/lib/assistant-client.ts +58 -37
  80. package/src/lib/assistant-config.ts +28 -3
  81. package/src/lib/cloudflare-tunnel.ts +276 -0
  82. package/src/lib/config-utils.ts +24 -3
  83. package/src/lib/confirm-action.ts +57 -0
  84. package/src/lib/docker.ts +82 -8
  85. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  86. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  87. package/src/lib/environments/paths.ts +1 -1
  88. package/src/lib/environments/resolve.ts +11 -35
  89. package/src/lib/guardian-token.ts +132 -9
  90. package/src/lib/hatch-local.ts +75 -33
  91. package/src/lib/http-client.ts +1 -3
  92. package/src/lib/lifecycle-reporter.ts +31 -0
  93. package/src/lib/local.ts +193 -298
  94. package/src/lib/orphan-detection.ts +9 -5
  95. package/src/lib/pgrep.ts +5 -1
  96. package/src/lib/platform-client.ts +97 -49
  97. package/src/lib/process.ts +109 -39
  98. package/src/lib/retire-local.ts +28 -14
  99. package/src/lib/segments-to-plain-text.ts +35 -0
  100. package/src/lib/step-runner.ts +67 -7
  101. package/src/lib/sync-cloud-assistants.ts +17 -0
  102. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -0,0 +1,269 @@
1
+ import { AssistantClient } from "../lib/assistant-client.js";
2
+ import {
3
+ formatAssistantLookupError,
4
+ lookupAssistantByIdentifier,
5
+ } from "../lib/assistant-config.js";
6
+
7
+ type FeatureFlagEntry = {
8
+ key: string;
9
+ label: string;
10
+ enabled: boolean;
11
+ defaultEnabled: boolean;
12
+ description: string;
13
+ };
14
+
15
+ type FlagsResponse = {
16
+ flags: FeatureFlagEntry[];
17
+ };
18
+
19
+ function pad(s: string, w: number): string {
20
+ return s + " ".repeat(Math.max(0, w - s.length));
21
+ }
22
+
23
+ function printFlagTable(flags: FeatureFlagEntry[]): void {
24
+ const headers = {
25
+ key: "KEY",
26
+ enabled: "ENABLED",
27
+ default: "DEFAULT",
28
+ label: "LABEL",
29
+ };
30
+
31
+ const rows = flags
32
+ .slice()
33
+ .sort((a, b) => a.key.localeCompare(b.key))
34
+ .map((f) => ({
35
+ key: f.enabled !== f.defaultEnabled ? `* ${f.key}` : ` ${f.key}`,
36
+ enabled: String(f.enabled),
37
+ default: String(f.defaultEnabled),
38
+ label: f.label,
39
+ }));
40
+
41
+ const all = [headers, ...rows];
42
+ const colWidths = {
43
+ key: Math.max(...all.map((r) => r.key.length)),
44
+ enabled: Math.max(...all.map((r) => r.enabled.length)),
45
+ default: Math.max(...all.map((r) => r.default.length)),
46
+ label: Math.max(...all.map((r) => r.label.length)),
47
+ };
48
+
49
+ const formatRow = (r: typeof headers) =>
50
+ `${pad(r.key, colWidths.key)} ${pad(r.enabled, colWidths.enabled)} ${pad(r.default, colWidths.default)} ${r.label}`;
51
+
52
+ console.log(formatRow(headers));
53
+ console.log(
54
+ `${"-".repeat(colWidths.key)} ${"-".repeat(colWidths.enabled)} ${"-".repeat(colWidths.default)} ${"-".repeat(colWidths.label)}`,
55
+ );
56
+ for (const row of rows) {
57
+ console.log(formatRow(row));
58
+ }
59
+ console.log("");
60
+ console.log("* = overridden (differs from default)");
61
+ }
62
+
63
+ function printHelp(): void {
64
+ console.log("Usage: vellum flags [subcommand] [options]");
65
+ console.log("");
66
+ console.log("Show and toggle feature flags for the active assistant.");
67
+ console.log(
68
+ "Reads from the gateway's merged flag state (persisted overrides > remote > defaults).",
69
+ );
70
+ console.log("");
71
+ console.log("Subcommands:");
72
+ console.log(" (none) List all feature flags in a table");
73
+ console.log(" get <key> Show details for a single flag");
74
+ console.log(" set <key> <bool> Set a flag override to true or false");
75
+ console.log("");
76
+ console.log("Options:");
77
+ console.log(
78
+ " --assistant <name> Target a specific assistant (display name or ID)",
79
+ );
80
+ console.log(
81
+ " instead of the active one. Useful for scripted",
82
+ );
83
+ console.log(
84
+ " flows like eval harnesses that must not mutate",
85
+ );
86
+ console.log(" the user's active-assistant pointer.");
87
+ console.log(" --help, -h Show this help");
88
+ console.log("");
89
+ console.log("Examples:");
90
+ console.log(
91
+ " $ vellum flags # list flags for active assistant",
92
+ );
93
+ console.log(
94
+ " $ vellum flags get query-complexity-routing # inspect one flag",
95
+ );
96
+ console.log(
97
+ " $ vellum flags set voice-mode true # enable a flag",
98
+ );
99
+ console.log(
100
+ " $ vellum flags set external-plugins true --assistant eval-1 # target by name/id",
101
+ );
102
+ }
103
+
104
+ function createClient(assistantName?: string): AssistantClient {
105
+ // When `--assistant <name>` is provided, resolve the display name or
106
+ // explicit ID through the standard lookup helper (see cli/AGENTS.md
107
+ // "Assistant targeting convention"). Exact ID wins over display-name
108
+ // matches; ambiguous names fail loudly.
109
+ let assistantId: string | undefined;
110
+ if (assistantName) {
111
+ const result = lookupAssistantByIdentifier(assistantName);
112
+ if (result.status !== "found") {
113
+ throw new Error(formatAssistantLookupError(assistantName, result));
114
+ }
115
+ assistantId = result.entry.assistantId;
116
+ }
117
+ try {
118
+ return new AssistantClient(assistantId ? { assistantId } : undefined);
119
+ } catch {
120
+ throw new Error(
121
+ assistantName
122
+ ? `No assistant found matching '${assistantName}'.`
123
+ : "No assistant found. Hatch one with 'vellum hatch' first.",
124
+ );
125
+ }
126
+ }
127
+
128
+ function rethrowFetchError(err: unknown): never {
129
+ if (
130
+ err instanceof TypeError &&
131
+ (err.message.includes("fetch") || err.message.includes("connect"))
132
+ ) {
133
+ throw new Error(
134
+ "Could not reach the assistant gateway. Is it running? Try 'vellum wake'.",
135
+ );
136
+ }
137
+ throw err;
138
+ }
139
+
140
+ async function listFlags(assistantName?: string): Promise<void> {
141
+ const client = createClient(assistantName);
142
+ let res: Response;
143
+ try {
144
+ res = await client.get("/feature-flags");
145
+ } catch (err) {
146
+ rethrowFetchError(err);
147
+ }
148
+ if (!res.ok) {
149
+ const body = await res.text().catch(() => "");
150
+ throw new Error(`Failed to fetch flags: HTTP ${res.status} ${body}`.trim());
151
+ }
152
+ const data = (await res.json()) as FlagsResponse;
153
+ if (data.flags.length === 0) {
154
+ console.log("No feature flags found.");
155
+ return;
156
+ }
157
+ printFlagTable(data.flags);
158
+ }
159
+
160
+ async function getFlag(key: string, assistantName?: string): Promise<void> {
161
+ const client = createClient(assistantName);
162
+ let res: Response;
163
+ try {
164
+ res = await client.get("/feature-flags");
165
+ } catch (err) {
166
+ rethrowFetchError(err);
167
+ }
168
+ if (!res.ok) {
169
+ const body = await res.text().catch(() => "");
170
+ throw new Error(`Failed to fetch flags: HTTP ${res.status} ${body}`.trim());
171
+ }
172
+ const data = (await res.json()) as FlagsResponse;
173
+ const flag = data.flags.find((f) => f.key === key);
174
+ if (!flag) {
175
+ throw new Error(`Flag "${key}" not found.`);
176
+ }
177
+ console.log(`Key: ${flag.key}`);
178
+ console.log(`Enabled: ${flag.enabled}`);
179
+ console.log(`Default: ${flag.defaultEnabled}`);
180
+ console.log(`Description: ${flag.description || "(none)"}`);
181
+ }
182
+
183
+ async function setFlag(
184
+ key: string,
185
+ value: boolean,
186
+ assistantName?: string,
187
+ ): Promise<void> {
188
+ const client = createClient(assistantName);
189
+ let res: Response;
190
+ try {
191
+ res = await client.patch(`/feature-flags/${key}`, { enabled: value });
192
+ } catch (err) {
193
+ rethrowFetchError(err);
194
+ }
195
+ if (!res.ok) {
196
+ const body = await res.text().catch(() => "");
197
+ throw new Error(`Failed to set flag: HTTP ${res.status} ${body}`.trim());
198
+ }
199
+ console.log(`Flag "${key}" set to ${value}`);
200
+ }
201
+
202
+ /**
203
+ * Strip `--assistant <name>` from argv and return the captured value.
204
+ *
205
+ * Mutates the input array so positional parsing downstream sees a clean
206
+ * shape (subcommand + key + value). Returns `undefined` if the flag is
207
+ * absent. Error-reports a missing value so the user gets a clear message
208
+ * rather than the flag being silently swallowed as a positional.
209
+ */
210
+ function extractAssistantFlag(args: string[]): string | undefined {
211
+ for (let i = 0; i < args.length; i++) {
212
+ if (args[i] !== "--assistant") continue;
213
+ const value = args[i + 1];
214
+ if (!value || value.startsWith("-")) {
215
+ console.error("Missing value for --assistant <name>");
216
+ process.exit(1);
217
+ }
218
+ args.splice(i, 2);
219
+ return value;
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ export async function flags(): Promise<void> {
225
+ const args = process.argv.slice(3);
226
+
227
+ if (args.includes("--help") || args.includes("-h")) {
228
+ printHelp();
229
+ return;
230
+ }
231
+
232
+ const assistantName = extractAssistantFlag(args);
233
+
234
+ const subcommand = args[0];
235
+
236
+ if (!subcommand) {
237
+ await listFlags(assistantName);
238
+ return;
239
+ }
240
+
241
+ if (subcommand === "get") {
242
+ const key = args[1];
243
+ if (!key) {
244
+ console.error("Usage: vellum flags get <key>");
245
+ process.exit(1);
246
+ }
247
+ await getFlag(key, assistantName);
248
+ return;
249
+ }
250
+
251
+ if (subcommand === "set") {
252
+ const key = args[1];
253
+ const rawValue = args[2];
254
+ if (!key || rawValue === undefined) {
255
+ console.error("Usage: vellum flags set <key> <true|false>");
256
+ process.exit(1);
257
+ }
258
+ if (rawValue !== "true" && rawValue !== "false") {
259
+ console.error(`Invalid value "${rawValue}". Must be "true" or "false".`);
260
+ process.exit(1);
261
+ }
262
+ await setFlag(key, rawValue === "true", assistantName);
263
+ return;
264
+ }
265
+
266
+ console.error(`Unknown subcommand: ${subcommand}`);
267
+ printHelp();
268
+ process.exit(1);
269
+ }
@@ -0,0 +1,73 @@
1
+ import {
2
+ lookupAssistantByIdentifier,
3
+ formatAssistantLookupError,
4
+ } from "../../lib/assistant-config.js";
5
+ import {
6
+ loadGuardianToken,
7
+ refreshGuardianToken,
8
+ } from "../../lib/guardian-token.js";
9
+
10
+ function printUsage(): void {
11
+ console.log("Usage: vellum gateway token <subcommand> <assistantId>");
12
+ console.log("");
13
+ console.log("Manage gateway authentication tokens.");
14
+ console.log("");
15
+ console.log("Subcommands:");
16
+ console.log(" get Print the current guardian access token");
17
+ console.log(" refresh Refresh an expired access token and print it");
18
+ }
19
+
20
+ export async function gatewayToken(): Promise<void> {
21
+ const args = process.argv.slice(4);
22
+ const subcommand = args[0];
23
+
24
+ if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
25
+ printUsage();
26
+ process.exit(0);
27
+ }
28
+
29
+ if (subcommand !== "get" && subcommand !== "refresh") {
30
+ console.error(`Unknown subcommand: ${subcommand}`);
31
+ printUsage();
32
+ process.exit(1);
33
+ }
34
+
35
+ const assistantId = args[1];
36
+ if (!assistantId) {
37
+ console.error("Missing required argument: <assistantId>");
38
+ printUsage();
39
+ process.exit(1);
40
+ }
41
+
42
+ const result = lookupAssistantByIdentifier(assistantId);
43
+ if (result.status !== "found") {
44
+ console.error(formatAssistantLookupError(assistantId, result));
45
+ process.exit(1);
46
+ }
47
+ const entry = result.entry;
48
+
49
+ const tokenData = loadGuardianToken(entry.assistantId);
50
+ if (!tokenData) {
51
+ console.error("No guardian token found for this assistant.");
52
+ process.exit(1);
53
+ }
54
+
55
+ if (subcommand === "get") {
56
+ console.log(tokenData.accessToken);
57
+ return;
58
+ }
59
+
60
+ const gatewayUrl = entry.localUrl || entry.runtimeUrl;
61
+ if (!gatewayUrl) {
62
+ console.error("No gateway URL found for this assistant.");
63
+ process.exit(1);
64
+ }
65
+
66
+ const refreshed = await refreshGuardianToken(gatewayUrl, entry.assistantId);
67
+ if (!refreshed) {
68
+ console.error("Failed to refresh guardian token.");
69
+ process.exit(1);
70
+ }
71
+
72
+ console.log(refreshed.accessToken);
73
+ }
@@ -0,0 +1,29 @@
1
+ import { gatewayToken } from "./gateway/token.js";
2
+
3
+ function printUsage(): void {
4
+ console.log("Usage: vellum gateway <subcommand>");
5
+ console.log("");
6
+ console.log("Gateway management commands.");
7
+ console.log("");
8
+ console.log("Subcommands:");
9
+ console.log(" token Manage gateway authentication tokens");
10
+ }
11
+
12
+ export async function gateway(): Promise<void> {
13
+ const args = process.argv.slice(3);
14
+ const subcommand = args[0];
15
+
16
+ if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
17
+ printUsage();
18
+ process.exit(0);
19
+ }
20
+
21
+ if (subcommand === "token") {
22
+ await gatewayToken();
23
+ return;
24
+ }
25
+
26
+ console.error(`Unknown subcommand: ${subcommand}`);
27
+ printUsage();
28
+ process.exit(1);
29
+ }
@@ -4,8 +4,12 @@ import { createInterface } from "readline";
4
4
  import { watch } from "fs";
5
5
  import { join } from "path";
6
6
 
7
- import { resolveAssistant } from "../lib/assistant-config";
8
- import type { AssistantEntry } from "../lib/assistant-config";
7
+ import {
8
+ extractHostFromUrl,
9
+ resolveAssistant,
10
+ resolveCloud,
11
+ type AssistantEntry,
12
+ } from "../lib/assistant-config";
9
13
  import { dockerResourceNames } from "../lib/docker";
10
14
  import { getLogDir } from "../lib/xdg-log";
11
15
  import { execOutput } from "../lib/step-runner";
@@ -112,13 +116,6 @@ function parseArgs(): LogsArgs {
112
116
 
113
117
  // ── Helpers ─────────────────────────────────────────────────────
114
118
 
115
- function resolveCloud(entry: AssistantEntry): string {
116
- if (entry.cloud) return entry.cloud;
117
- if (entry.project) return "gcp";
118
- if (entry.sshUser) return "custom";
119
- return "local";
120
- }
121
-
122
119
  /**
123
120
  * Parse a relative time string like "10m", "2h", "30s" into a Date.
124
121
  * Returns null if the string doesn't look like a relative time.
@@ -494,15 +491,6 @@ async function showGcpLogs(
494
491
  }
495
492
  }
496
493
 
497
- function extractHostFromUrl(url: string): string {
498
- try {
499
- const parsed = new URL(url);
500
- return parsed.hostname;
501
- } catch {
502
- return url.replace(/^https?:\/\//, "").split(":")[0];
503
- }
504
- }
505
-
506
494
  async function showCustomLogs(
507
495
  entry: AssistantEntry,
508
496
  opts: LogsArgs,
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `vellum pair [assistant] [--label <name>]`
3
+ *
4
+ * Mint a device-scoped token for another machine and print a pairing bundle.
5
+ * Runs on the machine hosting the assistant: it calls the local gateway's
6
+ * loopback-only `POST /v1/pair` (cli interface) with a freshly generated
7
+ * deviceId, then prints the credentials to hand to a second device.
8
+ *
9
+ * Each invocation generates a NEW random deviceId, so each pairing is an
10
+ * independent, separately-revocable device (see `vellum unpair`, forthcoming).
11
+ */
12
+
13
+ import { nanoid } from "nanoid";
14
+
15
+ import { extractFlag } from "../lib/arg-utils.js";
16
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
17
+ import {
18
+ formatAssistantLookupError,
19
+ lookupAssistantByIdentifier,
20
+ resolveAssistant,
21
+ type AssistantEntry,
22
+ } from "../lib/assistant-config.js";
23
+ import {
24
+ CLI_INTERFACE_ID,
25
+ getClientRegistrationHeaders,
26
+ } from "../lib/client-identity.js";
27
+ import { GATEWAY_PORT } from "../lib/constants.js";
28
+ import { getLocalLanIPv4 } from "../lib/local.js";
29
+
30
+ function isLoopbackHost(url: string): boolean {
31
+ try {
32
+ const host = new URL(url).hostname.toLowerCase();
33
+ return host === "localhost" || host === "::1" || host.startsWith("127.");
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function printUsage(): void {
40
+ console.log(`vellum pair - Mint a device-scoped token for another machine
41
+
42
+ USAGE:
43
+ vellum pair [assistant] [options]
44
+
45
+ ARGUMENTS:
46
+ [assistant] Instance name (default: active assistant)
47
+
48
+ OPTIONS:
49
+ --url <url> Reachable gateway URL to advertise in the bundle
50
+ (default: the assistant's runtime URL, not loopback)
51
+ --label <name> Human label for this pairing (echoed in the output)
52
+ --json Output the raw bundle as JSON
53
+
54
+ EXAMPLES:
55
+ vellum pair
56
+ vellum pair "My Assistant" --label "phone"
57
+ vellum pair --url https://abc123.ngrok.app
58
+ vellum pair --json
59
+ `);
60
+ }
61
+
62
+ interface PairResponse {
63
+ token: string;
64
+ expiresAt: string;
65
+ guardianId: string;
66
+ assistantId: string;
67
+ // Present on the device-bound path: a long-lived refresh credential the
68
+ // imported client uses to renew its access token (ISO-8601 strings).
69
+ refreshToken?: string;
70
+ refreshTokenExpiresAt?: string;
71
+ refreshAfter?: string;
72
+ }
73
+
74
+ export async function pair(): Promise<void> {
75
+ const rawArgs = process.argv.slice(3);
76
+
77
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
78
+ printUsage();
79
+ return;
80
+ }
81
+
82
+ const jsonOutput = rawArgs.includes("--json");
83
+ let args = rawArgs.filter((a) => a !== "--json");
84
+
85
+ const [label, afterLabel] = extractFlag(args, "--label");
86
+ const [urlOverride, afterUrl] = extractFlag(afterLabel, "--url");
87
+ args = afterUrl;
88
+
89
+ // Resolve the target. An explicit argument is matched by display name OR id
90
+ // (with the standard ambiguity error); no argument falls back to the active
91
+ // assistant. Join positional tokens so multi-word display names work even
92
+ // unquoted (e.g. `vellum pair My Assistant`).
93
+ const assistantName = parseAssistantTargetArg(args);
94
+ let entry: AssistantEntry | null;
95
+ if (assistantName) {
96
+ const result = lookupAssistantByIdentifier(assistantName);
97
+ if (result.status !== "found") {
98
+ console.error(formatAssistantLookupError(assistantName, result));
99
+ process.exit(1);
100
+ }
101
+ entry = result.entry;
102
+ } else {
103
+ entry = resolveAssistant();
104
+ if (!entry) {
105
+ console.error("No assistant instance found. Run `vellum hatch` first.");
106
+ process.exit(1);
107
+ }
108
+ }
109
+
110
+ // Mint over loopback (localUrl avoids mDNS for same-machine calls), but
111
+ // advertise a REACHABLE url in the bundle — the loopback url would point the
112
+ // other machine at its own localhost. Prefer an explicit --url, then the
113
+ // runtime (LAN/tunnel) url.
114
+ const mintUrl = (
115
+ entry.localUrl ||
116
+ entry.runtimeUrl ||
117
+ `http://127.0.0.1:${GATEWAY_PORT}`
118
+ ).replace(/\/+$/, "");
119
+ const advertisedUrl = (urlOverride || entry.runtimeUrl || mintUrl).replace(
120
+ /\/+$/,
121
+ "",
122
+ );
123
+
124
+ // A local hatch's runtimeUrl is itself loopback (http://localhost:<port>),
125
+ // so without an explicit --url the bundle would point the other machine at
126
+ // its own localhost. Refuse to advertise a loopback URL unless the user
127
+ // explicitly passed one. (An explicit --url is trusted as-is.)
128
+ if (!urlOverride && isLoopbackHost(advertisedUrl)) {
129
+ const lan = getLocalLanIPv4();
130
+ // Use THIS assistant's gateway port (not the global default) — second
131
+ // local instances listen on a different port.
132
+ let port = String(GATEWAY_PORT);
133
+ try {
134
+ port = new URL(mintUrl).port || port;
135
+ } catch {
136
+ /* keep default */
137
+ }
138
+ const suggestion = lan
139
+ ? `http://${lan}:${port}`
140
+ : `http://<this-machine-ip>:${port}`;
141
+ console.error(
142
+ "Error: this assistant has no reachable gateway URL — its address is " +
143
+ `loopback (${advertisedUrl}), which the other machine can't connect to.`,
144
+ );
145
+ console.error(
146
+ `Re-run with a reachable URL, e.g.:\n vellum pair --url ${suggestion}`,
147
+ );
148
+ process.exit(1);
149
+ }
150
+
151
+ // Fresh per-pairing device identity — each `vellum pair` is independently
152
+ // revocable.
153
+ const deviceId = nanoid();
154
+
155
+ let response: Response;
156
+ try {
157
+ response = await fetch(`${mintUrl}/v1/pair`, {
158
+ method: "POST",
159
+ headers: {
160
+ "Content-Type": "application/json",
161
+ ...getClientRegistrationHeaders(CLI_INTERFACE_ID),
162
+ },
163
+ body: JSON.stringify({ deviceId, platform: "cli" }),
164
+ });
165
+ } catch (err) {
166
+ console.error(
167
+ `Error: could not reach the gateway at ${mintUrl} ` +
168
+ `(${err instanceof Error ? err.message : String(err)}).`,
169
+ );
170
+ console.error("Is the assistant running? Try `vellum wake`.");
171
+ process.exit(1);
172
+ }
173
+
174
+ if (!response.ok) {
175
+ const body = await response.text().catch(() => "");
176
+ console.error(
177
+ `Error: HTTP ${response.status}: ${body || response.statusText}`,
178
+ );
179
+ process.exit(1);
180
+ }
181
+
182
+ const result = (await response.json()) as PairResponse;
183
+
184
+ // Single-line, copy-pasteable blob for the consume side (`vellum connect
185
+ // import <blob>`, forthcoming).
186
+ const bundle = {
187
+ gatewayUrl: advertisedUrl,
188
+ assistantId: result.assistantId,
189
+ token: result.token,
190
+ deviceId,
191
+ // Carry the refresh credential through when the gateway issued one, so the
192
+ // imported client can renew without re-pairing. Omitted entirely for an
193
+ // access-only (older gateway) response so the bundle stays clean.
194
+ ...(result.refreshToken
195
+ ? {
196
+ refreshToken: result.refreshToken,
197
+ refreshTokenExpiresAt: result.refreshTokenExpiresAt,
198
+ refreshAfter: result.refreshAfter,
199
+ }
200
+ : {}),
201
+ };
202
+ const blob = Buffer.from(JSON.stringify(bundle)).toString("base64");
203
+
204
+ if (jsonOutput) {
205
+ console.log(
206
+ JSON.stringify({ ...bundle, expiresAt: result.expiresAt }, null, 2),
207
+ );
208
+ return;
209
+ }
210
+
211
+ const displayName = entry.name || entry.assistantName || entry.assistantId;
212
+ console.log(`Paired ${label ? `"${label}" ` : ""}with ${displayName}.`);
213
+ console.log("");
214
+ console.log(` Gateway: ${advertisedUrl}`);
215
+ console.log(` Assistant: ${result.assistantId}`);
216
+ console.log(` Expires: ${result.expiresAt}`);
217
+ console.log("");
218
+ console.log("Hand this to the other machine (keep it secret):");
219
+ console.log("");
220
+ console.log(` ${blob}`);
221
+ console.log("");
222
+ }