@vellumai/cli 0.4.41 → 0.4.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +33 -2
- package/src/__tests__/constants.test.ts +1 -0
- package/src/__tests__/multi-local.test.ts +50 -42
- package/src/__tests__/sleep.test.ts +172 -0
- package/src/commands/client.ts +72 -10
- package/src/commands/hatch.ts +65 -14
- package/src/commands/ps.ts +25 -8
- package/src/commands/recover.ts +17 -8
- package/src/commands/retire.ts +14 -23
- package/src/commands/sleep.ts +88 -16
- package/src/commands/wake.ts +9 -7
- package/src/components/DefaultMainScreen.tsx +3 -83
- package/src/index.ts +0 -3
- package/src/lib/assistant-config.ts +17 -62
- package/src/lib/aws.ts +30 -1
- package/src/lib/constants.ts +1 -1
- package/src/lib/docker.ts +319 -0
- package/src/lib/gcp.ts +53 -1
- package/src/lib/http-client.ts +114 -0
- package/src/lib/local.ts +96 -148
- package/src/lib/step-runner.ts +9 -1
- package/src/__tests__/skills-uninstall.test.ts +0 -203
- package/src/commands/skills.ts +0 -514
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
// Point lockfile operations at a temp directory
|
|
7
7
|
const testDir = mkdtempSync(join(tmpdir(), "cli-assistant-config-test-"));
|
|
8
|
-
process.env.
|
|
8
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
loadLatestAssistant,
|
|
@@ -13,12 +13,13 @@ import {
|
|
|
13
13
|
removeAssistantEntry,
|
|
14
14
|
loadAllAssistants,
|
|
15
15
|
saveAssistantEntry,
|
|
16
|
+
getActiveAssistant,
|
|
16
17
|
type AssistantEntry,
|
|
17
18
|
} from "../lib/assistant-config.js";
|
|
18
19
|
|
|
19
20
|
afterAll(() => {
|
|
20
21
|
rmSync(testDir, { recursive: true, force: true });
|
|
21
|
-
delete process.env.
|
|
22
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
function writeLockfile(data: unknown): void {
|
|
@@ -100,6 +101,36 @@ describe("assistant-config", () => {
|
|
|
100
101
|
expect(all.map((e) => e.assistantId)).toEqual(["a", "c"]);
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
test("removeAssistantEntry reassigns activeAssistant to remaining entry", () => {
|
|
105
|
+
writeLockfile({
|
|
106
|
+
assistants: [makeEntry("a"), makeEntry("b")],
|
|
107
|
+
activeAssistant: "a",
|
|
108
|
+
});
|
|
109
|
+
removeAssistantEntry("a");
|
|
110
|
+
expect(getActiveAssistant()).toBe("b");
|
|
111
|
+
expect(loadAllAssistants()).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("removeAssistantEntry clears activeAssistant when no entries remain", () => {
|
|
115
|
+
writeLockfile({
|
|
116
|
+
assistants: [makeEntry("only")],
|
|
117
|
+
activeAssistant: "only",
|
|
118
|
+
});
|
|
119
|
+
removeAssistantEntry("only");
|
|
120
|
+
expect(getActiveAssistant()).toBeNull();
|
|
121
|
+
expect(loadAllAssistants()).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("removeAssistantEntry preserves activeAssistant when removing a different entry", () => {
|
|
125
|
+
writeLockfile({
|
|
126
|
+
assistants: [makeEntry("a"), makeEntry("b"), makeEntry("c")],
|
|
127
|
+
activeAssistant: "a",
|
|
128
|
+
});
|
|
129
|
+
removeAssistantEntry("b");
|
|
130
|
+
expect(getActiveAssistant()).toBe("a");
|
|
131
|
+
expect(loadAllAssistants()).toHaveLength(2);
|
|
132
|
+
});
|
|
133
|
+
|
|
103
134
|
test("loadLatestAssistant returns null when empty", () => {
|
|
104
135
|
expect(loadLatestAssistant()).toBeNull();
|
|
105
136
|
});
|
|
@@ -23,6 +23,7 @@ describe("constants", () => {
|
|
|
23
23
|
expect(VALID_REMOTE_HOSTS).toContain("local");
|
|
24
24
|
expect(VALID_REMOTE_HOSTS).toContain("gcp");
|
|
25
25
|
expect(VALID_REMOTE_HOSTS).toContain("aws");
|
|
26
|
+
expect(VALID_REMOTE_HOSTS).toContain("docker");
|
|
26
27
|
expect(VALID_REMOTE_HOSTS).toContain("custom");
|
|
27
28
|
});
|
|
28
29
|
|
|
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// Create a temp directory that acts as a fake home, so allocateLocalResources()
|
|
7
|
-
//
|
|
7
|
+
// never touches the real ~/.vellum directory.
|
|
8
8
|
const testDir = mkdtempSync(join(tmpdir(), "cli-multi-local-test-"));
|
|
9
|
-
process.env.
|
|
9
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
10
10
|
|
|
11
11
|
// Mock homedir() to return testDir — this isolates allocateLocalResources()
|
|
12
12
|
// which uses homedir() directly for instance directory creation.
|
|
@@ -31,7 +31,6 @@ mock.module("../lib/port-probe.js", () => ({
|
|
|
31
31
|
|
|
32
32
|
import {
|
|
33
33
|
allocateLocalResources,
|
|
34
|
-
defaultLocalResources,
|
|
35
34
|
resolveTargetAssistant,
|
|
36
35
|
setActiveAssistant,
|
|
37
36
|
getActiveAssistant,
|
|
@@ -39,11 +38,15 @@ import {
|
|
|
39
38
|
saveAssistantEntry,
|
|
40
39
|
type AssistantEntry,
|
|
41
40
|
} from "../lib/assistant-config.js";
|
|
42
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
DEFAULT_DAEMON_PORT,
|
|
43
|
+
DEFAULT_GATEWAY_PORT,
|
|
44
|
+
DEFAULT_QDRANT_PORT,
|
|
45
|
+
} from "../lib/constants.js";
|
|
43
46
|
|
|
44
47
|
afterAll(() => {
|
|
45
48
|
rmSync(testDir, { recursive: true, force: true });
|
|
46
|
-
delete process.env.
|
|
49
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
47
50
|
});
|
|
48
51
|
|
|
49
52
|
function writeLockfile(data: unknown): void {
|
|
@@ -91,40 +94,54 @@ describe("multi-local", () => {
|
|
|
91
94
|
});
|
|
92
95
|
|
|
93
96
|
describe("allocateLocalResources() produces non-conflicting ports", () => {
|
|
94
|
-
test("
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
test("first instance gets XDG path and default ports", async () => {
|
|
98
|
+
// GIVEN no local assistants exist in the lockfile
|
|
99
|
+
|
|
100
|
+
// WHEN we allocate resources for the first instance
|
|
101
|
+
const res = await allocateLocalResources("instance-a");
|
|
102
|
+
|
|
103
|
+
// THEN it gets an XDG instance directory under the home dir
|
|
104
|
+
expect(res.instanceDir).toBe(
|
|
105
|
+
join(testDir, ".local", "share", "vellum", "assistants", "instance-a"),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// AND it gets the default ports since no other instances exist
|
|
109
|
+
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
110
|
+
expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
|
|
111
|
+
expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("second instance gets distinct ports and dir when first instance is saved", async () => {
|
|
115
|
+
// GIVEN a first local assistant already exists in the lockfile
|
|
116
|
+
saveAssistantEntry(makeEntry("instance-a"));
|
|
117
|
+
|
|
118
|
+
// AND the default ports are occupied
|
|
98
119
|
const occupiedPorts = new Set([
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
120
|
+
DEFAULT_DAEMON_PORT,
|
|
121
|
+
DEFAULT_GATEWAY_PORT,
|
|
122
|
+
DEFAULT_QDRANT_PORT,
|
|
102
123
|
]);
|
|
103
124
|
probePortMock.mockImplementation((port: number) =>
|
|
104
125
|
Promise.resolve(occupiedPorts.has(port)),
|
|
105
126
|
);
|
|
106
127
|
|
|
128
|
+
// WHEN we allocate resources for a second instance
|
|
107
129
|
const b = await allocateLocalResources("instance-b");
|
|
108
130
|
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
b.gatewayPort,
|
|
116
|
-
b.qdrantPort,
|
|
117
|
-
];
|
|
118
|
-
expect(new Set(allPorts).size).toBe(6);
|
|
119
|
-
|
|
120
|
-
// Instance dirs must be distinct
|
|
121
|
-
expect(a.instanceDir).not.toBe(b.instanceDir);
|
|
122
|
-
expect(a.instanceDir).toContain("instance-a");
|
|
131
|
+
// THEN the second instance gets non-default ports
|
|
132
|
+
expect(occupiedPorts.has(b.daemonPort)).toBe(false);
|
|
133
|
+
expect(occupiedPorts.has(b.gatewayPort)).toBe(false);
|
|
134
|
+
expect(occupiedPorts.has(b.qdrantPort)).toBe(false);
|
|
135
|
+
|
|
136
|
+
// AND it gets its own dedicated instance directory
|
|
123
137
|
expect(b.instanceDir).toContain("instance-b");
|
|
124
138
|
});
|
|
125
139
|
|
|
126
140
|
test("skips ports that probePort reports as in-use", async () => {
|
|
127
|
-
//
|
|
141
|
+
// GIVEN a first local assistant already exists in the lockfile
|
|
142
|
+
saveAssistantEntry(makeEntry("existing"));
|
|
143
|
+
|
|
144
|
+
// AND the default daemon ports are occupied
|
|
128
145
|
const portsInUse = new Set([
|
|
129
146
|
DEFAULT_DAEMON_PORT,
|
|
130
147
|
DEFAULT_DAEMON_PORT + 1,
|
|
@@ -133,24 +150,15 @@ describe("multi-local", () => {
|
|
|
133
150
|
Promise.resolve(portsInUse.has(port)),
|
|
134
151
|
);
|
|
135
152
|
|
|
153
|
+
// WHEN we allocate resources for a new instance
|
|
136
154
|
const res = await allocateLocalResources("probe-test");
|
|
155
|
+
|
|
156
|
+
// THEN the daemon port skips all occupied ports
|
|
137
157
|
expect(res.daemonPort).toBeGreaterThan(DEFAULT_DAEMON_PORT + 1);
|
|
138
158
|
expect(portsInUse.has(res.daemonPort)).toBe(false);
|
|
139
159
|
});
|
|
140
160
|
});
|
|
141
161
|
|
|
142
|
-
describe("defaultLocalResources() returns legacy paths", () => {
|
|
143
|
-
test("instanceDir is homedir", () => {
|
|
144
|
-
const res = defaultLocalResources();
|
|
145
|
-
expect(res.instanceDir).toBe(testDir);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("daemonPort is DEFAULT_DAEMON_PORT", () => {
|
|
149
|
-
const res = defaultLocalResources();
|
|
150
|
-
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
162
|
describe("resolveTargetAssistant() priority chain", () => {
|
|
155
163
|
test("explicit name returns that entry", () => {
|
|
156
164
|
writeLockfile({
|
|
@@ -226,14 +234,14 @@ describe("multi-local", () => {
|
|
|
226
234
|
});
|
|
227
235
|
});
|
|
228
236
|
|
|
229
|
-
describe("removeAssistantEntry()
|
|
230
|
-
test("set active to foo, remove foo, verify active is
|
|
237
|
+
describe("removeAssistantEntry() reassigns activeAssistant on removal", () => {
|
|
238
|
+
test("set active to foo, remove foo, verify active is reassigned to bar", () => {
|
|
231
239
|
writeLockfile({
|
|
232
240
|
assistants: [makeEntry("foo"), makeEntry("bar")],
|
|
233
241
|
activeAssistant: "foo",
|
|
234
242
|
});
|
|
235
243
|
removeAssistantEntry("foo");
|
|
236
|
-
expect(getActiveAssistant()).
|
|
244
|
+
expect(getActiveAssistant()).toBe("bar");
|
|
237
245
|
});
|
|
238
246
|
|
|
239
247
|
test("set active to foo, remove bar, verify active is still foo", () => {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
mock,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// Create a temp directory and set VELLUM_LOCKFILE_DIR so the real
|
|
15
|
+
// assistant-config module reads/writes the lockfile here instead of ~/.
|
|
16
|
+
const testDir = mkdtempSync(join(tmpdir(), "sleep-command-test-"));
|
|
17
|
+
const assistantRootDir = join(testDir, ".vellum");
|
|
18
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
19
|
+
|
|
20
|
+
const stopProcessByPidFileMock = mock(async () => true);
|
|
21
|
+
const isProcessAliveMock = mock((): { alive: boolean; pid: number | null } => ({
|
|
22
|
+
alive: false,
|
|
23
|
+
pid: null,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
mock.module("../lib/process.js", () => ({
|
|
27
|
+
isProcessAlive: isProcessAliveMock,
|
|
28
|
+
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { sleep } from "../commands/sleep.js";
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_DAEMON_PORT,
|
|
34
|
+
DEFAULT_GATEWAY_PORT,
|
|
35
|
+
DEFAULT_QDRANT_PORT,
|
|
36
|
+
} from "../lib/constants.js";
|
|
37
|
+
|
|
38
|
+
// Write a lockfile entry so the real resolveTargetAssistant() finds our test
|
|
39
|
+
// assistant without needing to mock the entire assistant-config module.
|
|
40
|
+
function writeLockfile(): void {
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(testDir, ".vellum.lock.json"),
|
|
43
|
+
JSON.stringify(
|
|
44
|
+
{
|
|
45
|
+
assistants: [
|
|
46
|
+
{
|
|
47
|
+
assistantId: "sleep-test",
|
|
48
|
+
runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
|
|
49
|
+
cloud: "local",
|
|
50
|
+
resources: {
|
|
51
|
+
instanceDir: testDir,
|
|
52
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
53
|
+
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
54
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
55
|
+
pidFile: join(assistantRootDir, "vellum.pid"),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
activeAssistant: "sleep-test",
|
|
60
|
+
},
|
|
61
|
+
null,
|
|
62
|
+
2,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeLeaseFile(callSessionIds: string[]): void {
|
|
68
|
+
mkdirSync(assistantRootDir, { recursive: true });
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(assistantRootDir, "active-call-leases.json"),
|
|
71
|
+
JSON.stringify(
|
|
72
|
+
{
|
|
73
|
+
version: 1,
|
|
74
|
+
leases: callSessionIds.map((callSessionId) => ({
|
|
75
|
+
callSessionId,
|
|
76
|
+
providerCallSid: null,
|
|
77
|
+
updatedAt: Date.now(),
|
|
78
|
+
})),
|
|
79
|
+
},
|
|
80
|
+
null,
|
|
81
|
+
2,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("sleep command", () => {
|
|
87
|
+
let originalArgv: string[];
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
originalArgv = [...process.argv];
|
|
91
|
+
isProcessAliveMock.mockReset();
|
|
92
|
+
isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
|
|
93
|
+
stopProcessByPidFileMock.mockReset();
|
|
94
|
+
stopProcessByPidFileMock.mockResolvedValue(true);
|
|
95
|
+
rmSync(assistantRootDir, { recursive: true, force: true });
|
|
96
|
+
writeLockfile();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterAll(() => {
|
|
100
|
+
process.argv = originalArgv;
|
|
101
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
102
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("refuses normal sleep while an active call lease exists", async () => {
|
|
106
|
+
isProcessAliveMock.mockReturnValue({ alive: true, pid: 12345 });
|
|
107
|
+
writeLeaseFile(["call-active-1", "call-active-2"]);
|
|
108
|
+
process.argv = ["bun", "vellum", "sleep", "sleep-test"];
|
|
109
|
+
|
|
110
|
+
const consoleError = spyOn(console, "error").mockImplementation(() => {});
|
|
111
|
+
const exitMock = mock((code?: number) => {
|
|
112
|
+
throw new Error(`process.exit:${code}`);
|
|
113
|
+
});
|
|
114
|
+
const originalExit = process.exit;
|
|
115
|
+
process.exit = exitMock as unknown as typeof process.exit;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await expect(sleep()).rejects.toThrow("process.exit:1");
|
|
119
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
120
|
+
expect.stringContaining("vellum sleep --force"),
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
process.exit = originalExit;
|
|
124
|
+
consoleError.mockRestore();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
expect(stopProcessByPidFileMock).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("proceeds when assistant is not running even with stale lease file", async () => {
|
|
131
|
+
isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
|
|
132
|
+
writeLeaseFile(["call-stale-1"]);
|
|
133
|
+
process.argv = ["bun", "vellum", "sleep", "sleep-test"];
|
|
134
|
+
|
|
135
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await sleep();
|
|
139
|
+
} finally {
|
|
140
|
+
consoleLog.mockRestore();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("force stops the assistant even when an active call lease exists", async () => {
|
|
147
|
+
writeLeaseFile(["call-active-1"]);
|
|
148
|
+
process.argv = ["bun", "vellum", "sleep", "sleep-test", "--force"];
|
|
149
|
+
|
|
150
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await sleep();
|
|
154
|
+
} finally {
|
|
155
|
+
consoleLog.mockRestore();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
|
|
159
|
+
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
160
|
+
1,
|
|
161
|
+
join(assistantRootDir, "vellum.pid"),
|
|
162
|
+
"assistant",
|
|
163
|
+
);
|
|
164
|
+
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
165
|
+
2,
|
|
166
|
+
join(assistantRootDir, "gateway.pid"),
|
|
167
|
+
"gateway",
|
|
168
|
+
undefined,
|
|
169
|
+
7000,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|
package/src/commands/client.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { hostname } from "os";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
findAssistantByName,
|
|
3
5
|
getActiveAssistant,
|
|
4
6
|
loadLatestAssistant,
|
|
5
7
|
} from "../lib/assistant-config";
|
|
6
8
|
import { GATEWAY_PORT, type Species } from "../lib/constants";
|
|
9
|
+
import { getLocalLanIPv4, getMacLocalHostname } from "../lib/local";
|
|
7
10
|
|
|
8
11
|
const ANSI = {
|
|
9
12
|
reset: "\x1b[0m",
|
|
@@ -46,7 +49,7 @@ function parseArgs(): ParsedArgs {
|
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
let entry: ReturnType<typeof findAssistantByName
|
|
52
|
+
let entry: ReturnType<typeof findAssistantByName> = null;
|
|
50
53
|
if (positionalName) {
|
|
51
54
|
entry = findAssistantByName(positionalName);
|
|
52
55
|
if (!entry) {
|
|
@@ -55,15 +58,30 @@ function parseArgs(): ParsedArgs {
|
|
|
55
58
|
);
|
|
56
59
|
process.exit(1);
|
|
57
60
|
}
|
|
58
|
-
} else if (process.env.RUNTIME_URL) {
|
|
59
|
-
// Explicit env var — skip assistant resolution, will use env values below
|
|
60
|
-
entry = loadLatestAssistant();
|
|
61
61
|
} else {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
const hasExplicitUrl =
|
|
63
|
+
process.env.RUNTIME_URL ||
|
|
64
|
+
flagArgs.includes("--url") ||
|
|
65
|
+
flagArgs.includes("-u");
|
|
64
66
|
const active = getActiveAssistant();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
if (active) {
|
|
68
|
+
entry = findAssistantByName(active);
|
|
69
|
+
if (!entry && !hasExplicitUrl) {
|
|
70
|
+
console.error(
|
|
71
|
+
`Active assistant '${active}' not found in lockfile. Set an active assistant with 'vellum use <name>'.`,
|
|
72
|
+
);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!entry && hasExplicitUrl) {
|
|
77
|
+
// URL provided but active assistant missing or unset — use latest for remaining defaults
|
|
78
|
+
entry = loadLatestAssistant();
|
|
79
|
+
} else if (!entry) {
|
|
80
|
+
console.error(
|
|
81
|
+
"No active assistant set. Set one with 'vellum use <name>' or specify a name: 'vellum client <name>'.",
|
|
82
|
+
);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
let runtimeUrl =
|
|
@@ -90,7 +108,7 @@ function parseArgs(): ParsedArgs {
|
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
return {
|
|
93
|
-
runtimeUrl: runtimeUrl.replace(/\/+$/, ""),
|
|
111
|
+
runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
|
|
94
112
|
assistantId,
|
|
95
113
|
species,
|
|
96
114
|
bearerToken,
|
|
@@ -99,6 +117,50 @@ function parseArgs(): ParsedArgs {
|
|
|
99
117
|
};
|
|
100
118
|
}
|
|
101
119
|
|
|
120
|
+
/**
|
|
121
|
+
* If the hostname in `url` matches this machine's local DNS name, LAN IP, or
|
|
122
|
+
* raw hostname, replace it with 127.0.0.1 so the client avoids mDNS round-trips
|
|
123
|
+
* when talking to an assistant running on the same machine.
|
|
124
|
+
*/
|
|
125
|
+
function maybeSwapToLocalhost(url: string): string {
|
|
126
|
+
let parsed: URL;
|
|
127
|
+
try {
|
|
128
|
+
parsed = new URL(url);
|
|
129
|
+
} catch {
|
|
130
|
+
return url;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const urlHost = parsed.hostname.toLowerCase();
|
|
134
|
+
|
|
135
|
+
const localNames: string[] = [];
|
|
136
|
+
|
|
137
|
+
const host = hostname();
|
|
138
|
+
if (host) {
|
|
139
|
+
localNames.push(host.toLowerCase());
|
|
140
|
+
// Also consider the bare name without .local suffix
|
|
141
|
+
if (host.toLowerCase().endsWith(".local")) {
|
|
142
|
+
localNames.push(host.toLowerCase().slice(0, -".local".length));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const macHost = getMacLocalHostname();
|
|
147
|
+
if (macHost) {
|
|
148
|
+
localNames.push(macHost.toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const lanIp = getLocalLanIPv4();
|
|
152
|
+
if (lanIp) {
|
|
153
|
+
localNames.push(lanIp);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (localNames.includes(urlHost)) {
|
|
157
|
+
parsed.hostname = "127.0.0.1";
|
|
158
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return url;
|
|
162
|
+
}
|
|
163
|
+
|
|
102
164
|
function printUsage(): void {
|
|
103
165
|
console.log(`${ANSI.bold}vellum client${ANSI.reset} - Connect to a hatched assistant
|
|
104
166
|
|
|
@@ -106,7 +168,7 @@ ${ANSI.bold}USAGE:${ANSI.reset}
|
|
|
106
168
|
vellum client [name] [options]
|
|
107
169
|
|
|
108
170
|
${ANSI.bold}ARGUMENTS:${ANSI.reset}
|
|
109
|
-
[name] Instance name (default:
|
|
171
|
+
[name] Instance name (default: active)
|
|
110
172
|
|
|
111
173
|
${ANSI.bold}OPTIONS:${ANSI.reset}
|
|
112
174
|
-u, --url <url> Runtime URL
|