@vellumai/cli 0.8.6 → 0.8.7-dev.202606052135.3e62c5a

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 (79) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +22 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  16. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  18. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  19. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  20. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  21. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  22. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  26. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  27. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  28. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  29. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  30. package/package.json +12 -1
  31. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  32. package/src/__tests__/clean.test.ts +179 -0
  33. package/src/__tests__/client-token.test.ts +87 -0
  34. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  35. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  36. package/src/__tests__/connect-import.test.ts +317 -0
  37. package/src/__tests__/devices.test.ts +272 -0
  38. package/src/__tests__/env-drift.test.ts +32 -44
  39. package/src/__tests__/flags.test.ts +248 -0
  40. package/src/__tests__/guardian-token.test.ts +126 -2
  41. package/src/__tests__/multi-local.test.ts +1 -1
  42. package/src/__tests__/orphan-detection.test.ts +8 -6
  43. package/src/__tests__/pair.test.ts +271 -0
  44. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  45. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  46. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  47. package/src/__tests__/unpair.test.ts +163 -0
  48. package/src/commands/client.ts +511 -11
  49. package/src/commands/connect/import.ts +217 -0
  50. package/src/commands/connect.ts +31 -0
  51. package/src/commands/devices.ts +247 -0
  52. package/src/commands/env.ts +1 -1
  53. package/src/commands/flags.ts +89 -17
  54. package/src/commands/pair.ts +222 -0
  55. package/src/commands/ps.ts +16 -0
  56. package/src/commands/retire.ts +20 -47
  57. package/src/commands/sleep.ts +7 -0
  58. package/src/commands/tunnel.ts +46 -2
  59. package/src/commands/unpair.ts +118 -0
  60. package/src/commands/wake.ts +7 -0
  61. package/src/components/DefaultMainScreen.tsx +100 -14
  62. package/src/index.ts +16 -0
  63. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  64. package/src/lib/assistant-client.ts +58 -37
  65. package/src/lib/assistant-config.ts +15 -3
  66. package/src/lib/cloudflare-tunnel.ts +276 -0
  67. package/src/lib/confirm-action.ts +57 -0
  68. package/src/lib/docker.ts +25 -1
  69. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  70. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  71. package/src/lib/environments/paths.ts +1 -1
  72. package/src/lib/environments/resolve.ts +11 -35
  73. package/src/lib/guardian-token.ts +132 -9
  74. package/src/lib/hatch-local.ts +73 -33
  75. package/src/lib/lifecycle-reporter.ts +31 -0
  76. package/src/lib/local.ts +20 -6
  77. package/src/lib/retire-local.ts +28 -14
  78. package/src/lib/segments-to-plain-text.ts +35 -0
  79. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tests for the `cloud: "paired"` lifecycle guards: `vellum wake`/`vellum sleep`
3
+ * must refuse a remote pairing with a clear "managed on its host machine"
4
+ * message instead of treating it as an on-machine process.
5
+ */
6
+ import {
7
+ afterAll,
8
+ afterEach,
9
+ beforeEach,
10
+ describe,
11
+ expect,
12
+ spyOn,
13
+ test,
14
+ } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const testDir = mkdtempSync(join(tmpdir(), "paired-lifecycle-test-"));
20
+ const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
21
+ const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
22
+ const ORIGINAL_ARGV = [...process.argv];
23
+
24
+ import { saveAssistantEntry } from "../lib/assistant-config.js";
25
+ import { retire } from "../commands/retire.js";
26
+ import { sleep } from "../commands/sleep.js";
27
+ import { wake } from "../commands/wake.js";
28
+
29
+ function seedPairedEntry(): void {
30
+ saveAssistantEntry({
31
+ assistantId: "px",
32
+ name: "Paired Box",
33
+ runtimeUrl: "http://10.0.0.9:7830",
34
+ cloud: "paired",
35
+ paired: true,
36
+ species: "vellum",
37
+ });
38
+ }
39
+
40
+ /** Run `fn` with console.error + process.exit spied; return {exited, errors}. */
41
+ async function runGuarded(
42
+ fn: () => Promise<void>,
43
+ ): Promise<{ exited: boolean; errors: string }> {
44
+ const errors: string[] = [];
45
+ const errSpy = spyOn(console, "error").mockImplementation(
46
+ (...a: unknown[]) => {
47
+ errors.push(a.join(" "));
48
+ },
49
+ );
50
+ const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
51
+ throw new Error(`exit:${c}`);
52
+ }) as never);
53
+ let exited = false;
54
+ try {
55
+ await fn();
56
+ } catch (e) {
57
+ exited = (e as Error).message === "exit:1";
58
+ } finally {
59
+ errSpy.mockRestore();
60
+ exitSpy.mockRestore();
61
+ }
62
+ return { exited, errors: errors.join("\n") };
63
+ }
64
+
65
+ describe("paired lifecycle guards", () => {
66
+ beforeEach(() => {
67
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
68
+ process.env.XDG_CONFIG_HOME = testDir;
69
+ seedPairedEntry();
70
+ });
71
+
72
+ afterEach(() => {
73
+ process.argv = [...ORIGINAL_ARGV];
74
+ if (ORIGINAL_LOCKFILE_DIR === undefined)
75
+ delete process.env.VELLUM_LOCKFILE_DIR;
76
+ else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
77
+ if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
78
+ else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
79
+ });
80
+
81
+ afterAll(() => {
82
+ rmSync(testDir, { recursive: true, force: true });
83
+ });
84
+
85
+ test("wake refuses a paired entry with a host-machine message", async () => {
86
+ process.argv = ["bun", "vellum", "wake", "px"];
87
+ const { exited, errors } = await runGuarded(wake);
88
+
89
+ expect(exited).toBe(true);
90
+ expect(errors).toContain("paired from another machine");
91
+ expect(errors).toContain("vellum client px");
92
+ // It must NOT fall through to the generic local/docker guard.
93
+ expect(errors).not.toContain("only works with local and docker");
94
+ });
95
+
96
+ test("sleep refuses a paired entry with a host-machine message", async () => {
97
+ process.argv = ["bun", "vellum", "sleep", "px"];
98
+ const { exited, errors } = await runGuarded(sleep);
99
+
100
+ expect(exited).toBe(true);
101
+ expect(errors).toContain("paired from another machine");
102
+ expect(errors).toContain("vellum client px");
103
+ expect(errors).not.toContain("only works with local and docker");
104
+ });
105
+
106
+ test("retire refuses a paired entry and points to unpair", async () => {
107
+ process.argv = ["bun", "vellum", "retire", "px", "--yes"];
108
+ const { exited, errors } = await runGuarded(retire);
109
+
110
+ expect(exited).toBe(true);
111
+ expect(errors).toContain("paired from another machine");
112
+ expect(errors).toContain("vellum unpair");
113
+ // It must NOT fall through to the generic "Unknown cloud type" path.
114
+ expect(errors).not.toContain("Unknown cloud type");
115
+ });
116
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { segmentsToPlainText } from "../lib/segments-to-plain-text.js";
3
+
4
+ describe("segmentsToPlainText", () => {
5
+ test("returns empty string for missing or empty segments", () => {
6
+ // GIVEN a message whose history payload carries no text segments
7
+ // WHEN deriving its flat body
8
+ // THEN it is the empty string (matching the daemon's old empty `content`)
9
+ expect(segmentsToPlainText(undefined)).toBe("");
10
+ expect(segmentsToPlainText([])).toBe("");
11
+ });
12
+
13
+ test("returns a single segment unchanged", () => {
14
+ // GIVEN a plain-text message with one segment
15
+ // WHEN deriving its flat body
16
+ // THEN the segment is returned verbatim
17
+ expect(segmentsToPlainText(["Real reply."])).toBe("Real reply.");
18
+ });
19
+
20
+ test("joins adjacent segments with a single inserted space", () => {
21
+ // GIVEN segments split at a tool_use boundary with no surrounding whitespace
22
+ // WHEN deriving the flat body
23
+ // THEN a single space is inserted between them
24
+ expect(segmentsToPlainText(["before", "after"])).toBe("before after");
25
+ });
26
+
27
+ test("does not double-space when either side already has whitespace", () => {
28
+ // GIVEN segments where one side already ends/starts with whitespace
29
+ // WHEN deriving the flat body
30
+ // THEN no extra space is inserted (mirrors daemon joinWithSpacing)
31
+ expect(segmentsToPlainText(["before ", "after"])).toBe("before after");
32
+ expect(segmentsToPlainText(["before", " after"])).toBe("before after");
33
+ expect(segmentsToPlainText(["line one\n", "line two"])).toBe(
34
+ "line one\nline two",
35
+ );
36
+ });
37
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Tests for maybeRefreshAuthHeaders: the TUI's mid-session 401 -> refresh of a
3
+ * PAIRED assistant's guardian token, mutating the shared auth headers in place.
4
+ * Scoped to cloud:"paired"; skips local/docker, platform session auth, ephemeral
5
+ * --token, and access-only tokens.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const ORIGINAL_XDG = process.env.XDG_CONFIG_HOME;
13
+ const ORIGINAL_ENV = process.env.VELLUM_ENVIRONMENT;
14
+ const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
15
+ const ORIGINAL_FETCH = globalThis.fetch;
16
+
17
+ import { maybeRefreshAuthHeaders } from "../components/DefaultMainScreen";
18
+ import { saveAssistantEntry } from "../lib/assistant-config";
19
+ import { saveGuardianToken } from "../lib/guardian-token";
20
+
21
+ const RUNTIME = "http://10.0.0.9:7830";
22
+ const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
23
+
24
+ function seedEntry(cloud: string): void {
25
+ saveAssistantEntry({
26
+ assistantId: "px",
27
+ name: "Paired",
28
+ runtimeUrl: RUNTIME,
29
+ cloud,
30
+ paired: cloud === "paired",
31
+ species: "vellum",
32
+ });
33
+ }
34
+
35
+ function seedToken(accessToken: string, refreshToken: string): void {
36
+ saveGuardianToken("px", {
37
+ guardianPrincipalId: "imported",
38
+ accessToken,
39
+ accessTokenExpiresAt: future(),
40
+ refreshToken,
41
+ refreshTokenExpiresAt: refreshToken ? future() : 0,
42
+ refreshAfter: "",
43
+ isNew: false,
44
+ deviceId: "dev",
45
+ leasedAt: new Date().toISOString(),
46
+ });
47
+ }
48
+
49
+ function stubRefresh(ok: boolean): { hit: () => boolean } {
50
+ let called = false;
51
+ globalThis.fetch = (async (url: unknown, _init?: RequestInit) => {
52
+ if (String(url).includes("/v1/guardian/refresh")) {
53
+ called = true;
54
+ return new Response(
55
+ ok ? JSON.stringify({ accessToken: "new-acc" }) : "x",
56
+ {
57
+ status: ok ? 200 : 401,
58
+ headers: { "content-type": "application/json" },
59
+ },
60
+ );
61
+ }
62
+ return new Response("", { status: 200 });
63
+ }) as typeof fetch;
64
+ return { hit: () => called };
65
+ }
66
+
67
+ describe("maybeRefreshAuthHeaders", () => {
68
+ let tempHome: string;
69
+
70
+ beforeEach(() => {
71
+ tempHome = mkdtempSync(join(tmpdir(), "tui-midsession-test-"));
72
+ process.env.XDG_CONFIG_HOME = tempHome;
73
+ // Isolate the lockfile too — saveAssistantEntry writes the prod lockfile
74
+ // (~/.vellum.lock.json) unless VELLUM_LOCKFILE_DIR is set, which would
75
+ // mutate the real user/CI lockfile.
76
+ process.env.VELLUM_LOCKFILE_DIR = tempHome;
77
+ delete process.env.VELLUM_ENVIRONMENT;
78
+ });
79
+
80
+ afterEach(() => {
81
+ globalThis.fetch = ORIGINAL_FETCH;
82
+ if (ORIGINAL_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
83
+ else process.env.XDG_CONFIG_HOME = ORIGINAL_XDG;
84
+ if (ORIGINAL_LOCKFILE_DIR === undefined)
85
+ delete process.env.VELLUM_LOCKFILE_DIR;
86
+ else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
87
+ if (ORIGINAL_ENV === undefined) delete process.env.VELLUM_ENVIRONMENT;
88
+ else process.env.VELLUM_ENVIRONMENT = ORIGINAL_ENV;
89
+ rmSync(tempHome, { recursive: true, force: true });
90
+ });
91
+
92
+ test("refreshes a paired assistant and mutates the auth header in place", async () => {
93
+ seedEntry("paired");
94
+ seedToken("old-acc", "ref");
95
+ const refresh = stubRefresh(true);
96
+ const auth = { Authorization: "Bearer old-acc" };
97
+
98
+ const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
99
+
100
+ expect(ok).toBe(true);
101
+ expect(auth.Authorization).toBe("Bearer new-acc");
102
+ expect(refresh.hit()).toBe(true);
103
+ });
104
+
105
+ test("does NOT refresh a local assistant (scoped to paired only)", async () => {
106
+ seedEntry("local");
107
+ seedToken("old-acc", "ref"); // even with a refreshable token
108
+ const refresh = stubRefresh(true);
109
+ const auth = { Authorization: "Bearer old-acc" };
110
+
111
+ const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
112
+
113
+ expect(ok).toBe(false);
114
+ expect(auth.Authorization).toBe("Bearer old-acc");
115
+ expect(refresh.hit()).toBe(false);
116
+ });
117
+
118
+ test("skips platform session auth (no Authorization header)", async () => {
119
+ seedEntry("paired");
120
+ seedToken("old-acc", "ref");
121
+ const refresh = stubRefresh(true);
122
+ const auth = { "X-Session-Token": "sess" };
123
+
124
+ const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
125
+
126
+ expect(ok).toBe(false);
127
+ expect(refresh.hit()).toBe(false);
128
+ });
129
+
130
+ test("skips an ephemeral token that does not match the store", async () => {
131
+ seedEntry("paired");
132
+ seedToken("stored-acc", "ref");
133
+ const refresh = stubRefresh(true);
134
+ const auth = { Authorization: "Bearer ephemeral-acc" };
135
+
136
+ const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
137
+
138
+ expect(ok).toBe(false);
139
+ expect(auth.Authorization).toBe("Bearer ephemeral-acc");
140
+ expect(refresh.hit()).toBe(false);
141
+ });
142
+
143
+ test("skips an access-only token (no refresh credential)", async () => {
144
+ seedEntry("paired");
145
+ seedToken("old-acc", ""); // no refresh token
146
+ const refresh = stubRefresh(true);
147
+ const auth = { Authorization: "Bearer old-acc" };
148
+
149
+ const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
150
+
151
+ expect(ok).toBe(false);
152
+ expect(refresh.hit()).toBe(false);
153
+ });
154
+
155
+ test("returns false and leaves auth unchanged when refresh fails", async () => {
156
+ seedEntry("paired");
157
+ seedToken("old-acc", "ref");
158
+ stubRefresh(false); // refresh endpoint returns non-ok
159
+ const auth = { Authorization: "Bearer old-acc" };
160
+
161
+ const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
162
+
163
+ expect(ok).toBe(false);
164
+ expect(auth.Authorization).toBe("Bearer old-acc");
165
+ });
166
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Tests for `vellum unpair <name>`: forget a paired (cloud:"paired") assistant
3
+ * by removing its lockfile entry + guardian token. Refuses non-paired entries.
4
+ */
5
+ import {
6
+ afterAll,
7
+ afterEach,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ spyOn,
12
+ test,
13
+ } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ const testDir = mkdtempSync(join(tmpdir(), "unpair-test-"));
19
+ const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
20
+ const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
21
+ const ORIGINAL_ARGV = [...process.argv];
22
+
23
+ import { unpair } from "../commands/unpair.js";
24
+ import {
25
+ findAssistantByName,
26
+ saveAssistantEntry,
27
+ } from "../lib/assistant-config.js";
28
+ import {
29
+ deleteGuardianToken,
30
+ loadGuardianToken,
31
+ saveGuardianToken,
32
+ } from "../lib/guardian-token.js";
33
+
34
+ function seedToken(assistantId: string): void {
35
+ saveGuardianToken(assistantId, {
36
+ guardianPrincipalId: "imported",
37
+ accessToken: "acc",
38
+ accessTokenExpiresAt: Date.now() + 3_600_000,
39
+ refreshToken: "ref",
40
+ refreshTokenExpiresAt: Date.now() + 3_600_000,
41
+ refreshAfter: "",
42
+ isNew: false,
43
+ deviceId: "dev",
44
+ leasedAt: new Date().toISOString(),
45
+ });
46
+ }
47
+
48
+ /** Run unpair with console.error + process.exit spied. */
49
+ async function runUnpair(): Promise<{ exited: boolean; errors: string }> {
50
+ const errors: string[] = [];
51
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
52
+ const errSpy = spyOn(console, "error").mockImplementation(
53
+ (...a: unknown[]) => {
54
+ errors.push(a.join(" "));
55
+ },
56
+ );
57
+ const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
58
+ throw new Error(`exit:${c}`);
59
+ }) as never);
60
+ let exited = false;
61
+ try {
62
+ await unpair();
63
+ } catch (e) {
64
+ exited = (e as Error).message === "exit:1";
65
+ } finally {
66
+ logSpy.mockRestore();
67
+ errSpy.mockRestore();
68
+ exitSpy.mockRestore();
69
+ }
70
+ return { exited, errors: errors.join("\n") };
71
+ }
72
+
73
+ describe("vellum unpair", () => {
74
+ beforeEach(() => {
75
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
76
+ process.env.XDG_CONFIG_HOME = testDir;
77
+ });
78
+
79
+ afterEach(() => {
80
+ process.argv = [...ORIGINAL_ARGV];
81
+ if (ORIGINAL_LOCKFILE_DIR === undefined)
82
+ delete process.env.VELLUM_LOCKFILE_DIR;
83
+ else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
84
+ if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
85
+ else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
86
+ });
87
+
88
+ afterAll(() => {
89
+ rmSync(testDir, { recursive: true, force: true });
90
+ });
91
+
92
+ test("removes a paired entry's lockfile entry and guardian token", async () => {
93
+ saveAssistantEntry({
94
+ assistantId: "px",
95
+ name: "Paired Box",
96
+ runtimeUrl: "http://10.0.0.9:7830",
97
+ cloud: "paired",
98
+ paired: true,
99
+ species: "vellum",
100
+ });
101
+ seedToken("px");
102
+ expect(findAssistantByName("px")).not.toBeNull();
103
+ expect(loadGuardianToken("px")).not.toBeNull();
104
+
105
+ // Non-interactive test env → use --yes to bypass the confirmation prompt.
106
+ process.argv = ["bun", "vellum", "unpair", "px", "--yes"];
107
+ const { exited } = await runUnpair();
108
+
109
+ expect(exited).toBe(false);
110
+ expect(findAssistantByName("px")).toBeNull();
111
+ expect(loadGuardianToken("px")).toBeNull();
112
+ });
113
+
114
+ test("refuses to unpair without --yes in a non-interactive terminal", async () => {
115
+ saveAssistantEntry({
116
+ assistantId: "py",
117
+ name: "Paired Two",
118
+ runtimeUrl: "http://10.0.0.9:7830",
119
+ cloud: "paired",
120
+ paired: true,
121
+ species: "vellum",
122
+ });
123
+ seedToken("py");
124
+
125
+ process.argv = ["bun", "vellum", "unpair", "py"]; // no --yes
126
+ const { exited, errors } = await runUnpair();
127
+
128
+ expect(exited).toBe(true);
129
+ expect(errors).toContain("--yes");
130
+ // Not removed — confirmation was required.
131
+ expect(findAssistantByName("py")).not.toBeNull();
132
+ expect(loadGuardianToken("py")).not.toBeNull();
133
+ });
134
+
135
+ test("refuses a non-paired (local) assistant and leaves it intact", async () => {
136
+ saveAssistantEntry({
137
+ assistantId: "desk",
138
+ name: "Desk",
139
+ runtimeUrl: "http://127.0.0.1:7830",
140
+ cloud: "local",
141
+ species: "vellum",
142
+ });
143
+
144
+ process.argv = ["bun", "vellum", "unpair", "desk"];
145
+ const { exited, errors } = await runUnpair();
146
+
147
+ expect(exited).toBe(true);
148
+ expect(errors).toContain("vellum retire");
149
+ expect(findAssistantByName("desk")).not.toBeNull(); // untouched
150
+ });
151
+
152
+ test("errors on an unknown name", async () => {
153
+ process.argv = ["bun", "vellum", "unpair", "nope"];
154
+ const { exited } = await runUnpair();
155
+ expect(exited).toBe(true);
156
+ });
157
+
158
+ test("deleteGuardianToken is a no-op when the token is absent", () => {
159
+ // No token seeded for this id; calling (twice) must not throw.
160
+ expect(() => deleteGuardianToken("ghost")).not.toThrow();
161
+ expect(() => deleteGuardianToken("ghost")).not.toThrow();
162
+ });
163
+ });