@vellumai/cli 0.4.36 → 0.4.40

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.
@@ -21,14 +21,19 @@ import cliPkg from "../../package.json";
21
21
 
22
22
  import { buildOpenclawStartupScript } from "../adapters/openclaw";
23
23
  import {
24
+ allocateLocalResources,
25
+ defaultLocalResources,
26
+ findAssistantByName,
24
27
  loadAllAssistants,
25
28
  saveAssistantEntry,
26
29
  syncConfigToLockfile,
27
30
  } from "../lib/assistant-config";
28
- import type { AssistantEntry } from "../lib/assistant-config";
31
+ import type {
32
+ AssistantEntry,
33
+ LocalInstanceResources,
34
+ } from "../lib/assistant-config";
29
35
  import { hatchAws } from "../lib/aws";
30
36
  import {
31
- GATEWAY_PORT,
32
37
  SPECIES_CONFIG,
33
38
  VALID_REMOTE_HOSTS,
34
39
  VALID_SPECIES,
@@ -41,7 +46,6 @@ import {
41
46
  startGateway,
42
47
  stopLocalProcesses,
43
48
  } from "../lib/local";
44
- import { probePort } from "../lib/port-probe";
45
49
  import { isProcessAlive } from "../lib/process";
46
50
  import { generateRandomSuffix } from "../lib/random-name";
47
51
  import { validateAssistantName } from "../lib/retire-archive";
@@ -583,6 +587,8 @@ async function waitForDaemonReady(
583
587
  async function displayPairingQRCode(
584
588
  runtimeUrl: string,
585
589
  bearerToken: string | undefined,
590
+ /** External gateway URL for the QR payload. When omitted, runtimeUrl is used. */
591
+ externalGatewayUrl?: string,
586
592
  ): Promise<void> {
587
593
  try {
588
594
  const pairingRequestId = randomUUID();
@@ -609,7 +615,7 @@ async function displayPairingQRCode(
609
615
  body: JSON.stringify({
610
616
  pairingRequestId,
611
617
  pairingSecret,
612
- gatewayUrl: runtimeUrl,
618
+ gatewayUrl: externalGatewayUrl ?? runtimeUrl,
613
619
  }),
614
620
  });
615
621
 
@@ -628,7 +634,7 @@ async function displayPairingQRCode(
628
634
  type: "vellum-daemon",
629
635
  v: 4,
630
636
  id: hostId,
631
- g: runtimeUrl,
637
+ g: externalGatewayUrl ?? runtimeUrl,
632
638
  pairingRequestId,
633
639
  pairingSecret,
634
640
  });
@@ -703,64 +709,49 @@ async function hatchLocal(
703
709
  );
704
710
  await stopLocalProcesses();
705
711
  }
712
+ }
706
713
 
707
- // Verify required ports are available before starting any services.
708
- // Only check when no local assistants exist if there are existing local
709
- // assistants, their daemon/gateway/qdrant legitimately own these ports.
710
- const RUNTIME_HTTP_PORT = Number(process.env.RUNTIME_HTTP_PORT) || 7821;
711
- const QDRANT_PORT = 6333;
712
- const requiredPorts = [
713
- { name: "daemon", port: RUNTIME_HTTP_PORT },
714
- { name: "gateway", port: GATEWAY_PORT },
715
- { name: "qdrant", port: QDRANT_PORT },
716
- ];
717
- const conflicts: string[] = [];
718
- await Promise.all(
719
- requiredPorts.map(async ({ name, port }) => {
720
- if (await probePort(port)) {
721
- conflicts.push(` - Port ${port} (${name}) is already in use`);
722
- }
723
- }),
724
- );
725
- if (conflicts.length > 0) {
726
- throw new Error(
727
- `Cannot hatch — required ports are already in use:\n${conflicts.join("\n")}\n\n` +
728
- "Stop the conflicting processes or use environment variables to configure alternative ports " +
729
- "(RUNTIME_HTTP_PORT, GATEWAY_PORT).",
730
- );
731
- }
714
+ // Reuse existing resources if re-hatching with --name that matches a known
715
+ // local assistant, otherwise allocate fresh per-instance ports and directories.
716
+ let resources: LocalInstanceResources;
717
+ const existingEntry = findAssistantByName(instanceName);
718
+ if (existingEntry?.cloud === "local" && existingEntry.resources) {
719
+ resources = existingEntry.resources;
720
+ } else if (restart && existingEntry?.cloud === "local") {
721
+ // Legacy entry without resources — use default paths to match existing layout
722
+ resources = defaultLocalResources();
723
+ } else {
724
+ resources = await allocateLocalResources(instanceName);
732
725
  }
733
726
 
734
- const baseDataDir = join(
735
- process.env.BASE_DATA_DIR?.trim() ||
736
- (process.env.HOME ?? userInfo().homedir),
737
- ".vellum",
738
- );
727
+ const baseDataDir = join(resources.instanceDir, ".vellum");
739
728
 
740
729
  console.log(`🥚 Hatching local assistant: ${instanceName}`);
741
730
  console.log(` Species: ${species}`);
742
731
  console.log("");
743
732
 
744
- await startLocalDaemon(watch);
733
+ await startLocalDaemon(watch, resources);
745
734
 
746
735
  let runtimeUrl: string;
747
736
  try {
748
- runtimeUrl = await startGateway(instanceName, watch);
737
+ runtimeUrl = await startGateway(instanceName, watch, resources);
749
738
  } catch (error) {
750
739
  // Gateway failed — stop the daemon we just started so we don't leave
751
740
  // orphaned processes with no lock file entry.
752
741
  console.error(
753
742
  `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
754
743
  );
755
- await stopLocalProcesses();
744
+ await stopLocalProcesses(resources);
756
745
  throw error;
757
746
  }
758
747
 
759
748
  // Read the bearer token (JWT) written by the daemon so the CLI can
760
- // authenticate with the gateway.
749
+ // with the gateway (which requires auth by default). The daemon writes under
750
+ // getRootDir() which resolves to <instanceDir>/.vellum/.
761
751
  let bearerToken: string | undefined;
762
752
  try {
763
- const token = readFileSync(join(baseDataDir, "http-token"), "utf-8").trim();
753
+ const tokenPath = join(resources.instanceDir, ".vellum", "http-token");
754
+ const token = readFileSync(tokenPath, "utf-8").trim();
764
755
  if (token) bearerToken = token;
765
756
  } catch {
766
757
  // Token file may not exist if daemon started without HTTP server
@@ -769,11 +760,13 @@ async function hatchLocal(
769
760
  const localEntry: AssistantEntry = {
770
761
  assistantId: instanceName,
771
762
  runtimeUrl,
763
+ localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
772
764
  baseDataDir,
773
765
  bearerToken,
774
766
  cloud: "local",
775
767
  species,
776
768
  hatchedAt: new Date().toISOString(),
769
+ resources,
777
770
  };
778
771
  if (!daemonOnly && !restart) {
779
772
  saveAssistantEntry(localEntry);
@@ -791,8 +784,11 @@ async function hatchLocal(
791
784
  console.log(` Runtime: ${runtimeUrl}`);
792
785
  console.log("");
793
786
 
794
- // Generate and display pairing QR code
795
- await displayPairingQRCode(runtimeUrl, bearerToken);
787
+ // Use loopback for HTTP calls (health check + pairing register) since
788
+ // mDNS hostnames may not resolve on the local machine, but keep the
789
+ // external runtimeUrl in the QR payload so iOS devices can reach it.
790
+ const localGatewayUrl = `http://127.0.0.1:${resources.gatewayPort}`;
791
+ await displayPairingQRCode(localGatewayUrl, bearerToken, runtimeUrl);
796
792
  }
797
793
  }
798
794
 
@@ -3,20 +3,18 @@ import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
5
  import {
6
+ defaultLocalResources,
6
7
  findAssistantByName,
8
+ getActiveAssistant,
7
9
  loadAllAssistants,
8
10
  type AssistantEntry,
9
11
  } from "../lib/assistant-config";
10
- import { GATEWAY_PORT } from "../lib/constants";
11
12
  import { checkHealth } from "../lib/health-check";
12
13
  import { pgrepExact } from "../lib/pgrep";
13
14
  import { probePort } from "../lib/port-probe";
14
15
  import { withStatusEmoji } from "../lib/status-emoji";
15
16
  import { execOutput } from "../lib/step-runner";
16
17
 
17
- const RUNTIME_HTTP_PORT = Number(process.env.RUNTIME_HTTP_PORT) || 7821;
18
- const QDRANT_PORT = 6333;
19
-
20
18
  // ── Table formatting helpers ────────────────────────────────────
21
19
 
22
20
  interface TableRow {
@@ -218,25 +216,26 @@ function formatDetectionInfo(proc: DetectedProcess): string {
218
216
  }
219
217
 
220
218
  async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
221
- const vellumDir = entry.baseDataDir ?? join(homedir(), ".vellum");
219
+ const resources = entry.resources ?? defaultLocalResources();
220
+ const vellumDir = join(resources.instanceDir, ".vellum");
222
221
 
223
222
  const specs: ProcessSpec[] = [
224
223
  {
225
224
  name: "assistant",
226
225
  pgrepName: "vellum-daemon",
227
- port: RUNTIME_HTTP_PORT,
228
- pidFile: join(vellumDir, "vellum.pid"),
226
+ port: resources.daemonPort,
227
+ pidFile: resources.pidFile,
229
228
  },
230
229
  {
231
230
  name: "qdrant",
232
231
  pgrepName: "qdrant",
233
- port: QDRANT_PORT,
232
+ port: resources.qdrantPort,
234
233
  pidFile: join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"),
235
234
  },
236
235
  {
237
236
  name: "gateway",
238
237
  pgrepName: "vellum-gateway",
239
- port: GATEWAY_PORT,
238
+ port: resources.gatewayPort,
240
239
  pidFile: join(vellumDir, "gateway.pid"),
241
240
  },
242
241
  {
@@ -355,6 +354,7 @@ async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
355
354
 
356
355
  async function listAllAssistants(): Promise<void> {
357
356
  const assistants = loadAllAssistants();
357
+ const activeId = getActiveAssistant();
358
358
 
359
359
  if (assistants.length === 0) {
360
360
  console.log("No assistants found.");
@@ -381,9 +381,10 @@ async function listAllAssistants(): Promise<void> {
381
381
  const infoParts = [a.runtimeUrl];
382
382
  if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
383
383
  if (a.species) infoParts.push(`species: ${a.species}`);
384
+ const prefix = a.assistantId === activeId ? "* " : " ";
384
385
 
385
386
  return {
386
- name: a.assistantId,
387
+ name: prefix + a.assistantId,
387
388
  status: withStatusEmoji("checking..."),
388
389
  info: infoParts.join(" | "),
389
390
  };
@@ -403,15 +404,34 @@ async function listAllAssistants(): Promise<void> {
403
404
 
404
405
  await Promise.all(
405
406
  assistants.map(async (a, rowIndex) => {
406
- const health = await checkHealth(a.runtimeUrl, a.bearerToken);
407
+ // For local assistants, check if the daemon process is alive before
408
+ // hitting the health endpoint. If the PID file is missing or the
409
+ // process isn't running, the assistant is sleeping — skip the
410
+ // network health check to avoid a misleading "unreachable" status.
411
+ let health: { status: string; detail: string | null };
412
+ const resources =
413
+ a.resources ??
414
+ (a.cloud === "local" ? defaultLocalResources() : undefined);
415
+ if (a.cloud === "local" && resources) {
416
+ const pid = readPidFile(resources.pidFile);
417
+ const alive = pid !== null && isProcessAlive(pid);
418
+ if (!alive) {
419
+ health = { status: "sleeping", detail: null };
420
+ } else {
421
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
422
+ }
423
+ } else {
424
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
425
+ }
407
426
 
408
427
  const infoParts = [a.runtimeUrl];
409
428
  if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
410
429
  if (a.species) infoParts.push(`species: ${a.species}`);
411
430
  if (health.detail) infoParts.push(health.detail);
412
431
 
432
+ const prefix = a.assistantId === activeId ? "* " : " ";
413
433
  const updatedRow: TableRow = {
414
- name: a.assistantId,
434
+ name: prefix + a.assistantId,
415
435
  status: withStatusEmoji(health.status),
416
436
  info: infoParts.join(" | "),
417
437
  };
@@ -4,7 +4,9 @@ import { homedir } from "os";
4
4
  import { basename, dirname, join } from "path";
5
5
 
6
6
  import {
7
+ defaultLocalResources,
7
8
  findAssistantByName,
9
+ loadAllAssistants,
8
10
  removeAssistantEntry,
9
11
  } from "../lib/assistant-config";
10
12
  import type { AssistantEntry } from "../lib/assistant-config";
@@ -40,18 +42,44 @@ function extractHostFromUrl(url: string): string {
40
42
  }
41
43
  }
42
44
 
43
- function getBaseDir(): string {
44
- return process.env.BASE_DATA_DIR?.trim() || homedir();
45
- }
46
-
47
45
  async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
48
46
  console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n");
49
47
 
50
- const vellumDir = join(getBaseDir(), ".vellum");
48
+ // Use entry resources when available; for legacy entries, derive paths
49
+ // from baseDataDir (which may differ from homedir if BASE_DATA_DIR was set).
50
+ const resources = entry.resources ?? defaultLocalResources();
51
+ const legacyDir = entry.baseDataDir;
52
+ const vellumDir = legacyDir ?? join(resources.instanceDir, ".vellum");
53
+
54
+ // Check whether another local assistant shares the same data directory.
55
+ // Legacy entries without `resources` all resolve to ~/.vellum/ — if we
56
+ // blindly kill processes and archive the directory, we'd destroy the
57
+ // other assistant's running daemon and data.
58
+ const otherSharesDir = loadAllAssistants().some((other) => {
59
+ if (other.cloud !== "local") return false;
60
+ if (other.assistantId === name) return false;
61
+ const otherVellumDir =
62
+ other.baseDataDir ??
63
+ join((other.resources ?? defaultLocalResources()).instanceDir, ".vellum");
64
+ return otherVellumDir === vellumDir;
65
+ });
51
66
 
52
- // Stop daemon via PID file
53
- const daemonPidFile = join(vellumDir, "vellum.pid");
54
- const socketFile = join(vellumDir, "vellum.sock");
67
+ if (otherSharesDir) {
68
+ console.log(
69
+ ` Skipping process stop and archive — another local assistant shares ${vellumDir}.`,
70
+ );
71
+ console.log("\u2705 Local instance retired (config entry removed only).");
72
+ return;
73
+ }
74
+
75
+ // Stop daemon via PID file — prefer resources paths, but for legacy entries
76
+ // with a custom baseDataDir, derive from that directory instead.
77
+ const daemonPidFile = legacyDir
78
+ ? join(legacyDir, "vellum.pid")
79
+ : resources.pidFile;
80
+ const socketFile = legacyDir
81
+ ? join(legacyDir, "vellum.sock")
82
+ : resources.socketPath;
55
83
  const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [
56
84
  socketFile,
57
85
  ]);
@@ -67,14 +95,22 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
67
95
  await stopOrphanedDaemonProcesses();
68
96
  }
69
97
 
98
+ // For named instances (instanceDir differs from homedir), archive and
99
+ // remove the entire instance directory. For the default instance
100
+ // (instanceDir is homedir), archive only the .vellum subdirectory.
101
+ const isNamedInstance = resources.instanceDir !== homedir();
102
+ const dirToArchive = isNamedInstance ? resources.instanceDir : vellumDir;
103
+
70
104
  // Move the data directory out of the way so the path is immediately available
71
105
  // for the next hatch, then kick off the tar archive in the background.
72
106
  const archivePath = getArchivePath(name);
73
107
  const metadataPath = getMetadataPath(name);
74
108
  const stagingDir = `${archivePath}.staging`;
75
109
 
76
- if (!existsSync(vellumDir)) {
77
- console.log(` No data directory at ${vellumDir} — nothing to archive.`);
110
+ if (!existsSync(dirToArchive)) {
111
+ console.log(
112
+ ` No data directory at ${dirToArchive} — nothing to archive.`,
113
+ );
78
114
  console.log("\u2705 Local instance retired.");
79
115
  return;
80
116
  }
@@ -83,10 +119,10 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
83
119
  mkdirSync(dirname(stagingDir), { recursive: true });
84
120
 
85
121
  try {
86
- renameSync(vellumDir, stagingDir);
122
+ renameSync(dirToArchive, stagingDir);
87
123
  } catch (err) {
88
124
  console.warn(
89
- `⚠️ Failed to move ${vellumDir}: ${err instanceof Error ? err.message : err}`,
125
+ `⚠️ Failed to move ${dirToArchive}: ${err instanceof Error ? err.message : err}`,
90
126
  );
91
127
  console.warn("Skipping archive.");
92
128
  console.log("\u2705 Local instance retired.");
@@ -1,10 +1,12 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import {
4
+ cpSync,
4
5
  existsSync,
5
6
  mkdirSync,
6
7
  readFileSync,
7
8
  renameSync,
9
+ rmSync,
8
10
  writeFileSync,
9
11
  } from "node:fs";
10
12
  import { homedir } from "node:os";
@@ -27,6 +29,38 @@ function getSkillsIndexPath(): string {
27
29
  return join(getSkillsDir(), "SKILLS.md");
28
30
  }
29
31
 
32
+ /**
33
+ * Resolve the repo-level skills/ directory when running in dev mode.
34
+ * Returns the path if VELLUM_DEV is set and the directory exists, or undefined.
35
+ */
36
+ function getRepoSkillsDir(): string | undefined {
37
+ if (!process.env.VELLUM_DEV) return undefined;
38
+
39
+ // cli/src/commands/skills.ts -> ../../../skills/
40
+ const candidate = join(import.meta.dir, "..", "..", "..", "skills");
41
+ if (existsSync(join(candidate, "catalog.json"))) {
42
+ return candidate;
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ /**
48
+ * Read skills from the repo-local catalog.json.
49
+ */
50
+ function readLocalCatalog(repoSkillsDir: string): CatalogSkill[] {
51
+ try {
52
+ const raw = readFileSync(
53
+ join(repoSkillsDir, "catalog.json"),
54
+ "utf-8",
55
+ );
56
+ const manifest = JSON.parse(raw) as CatalogManifest;
57
+ if (!Array.isArray(manifest.skills)) return [];
58
+ return manifest.skills;
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
30
64
  // ---------------------------------------------------------------------------
31
65
  // Platform API client
32
66
  // ---------------------------------------------------------------------------
@@ -221,6 +255,33 @@ function upsertSkillsIndex(id: string): void {
221
255
  atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
222
256
  }
223
257
 
258
+ function removeSkillsIndexEntry(id: string): void {
259
+ const indexPath = getSkillsIndexPath();
260
+ if (!existsSync(indexPath)) return;
261
+
262
+ const lines = readFileSync(indexPath, "utf-8").split("\n");
263
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
264
+ const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
265
+ const filtered = lines.filter((line) => !pattern.test(line));
266
+
267
+ // If nothing changed, skip the write
268
+ if (filtered.length === lines.length) return;
269
+
270
+ const content = filtered.join("\n");
271
+ atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
272
+ }
273
+
274
+ function uninstallSkillLocally(skillId: string): void {
275
+ const skillDir = join(getSkillsDir(), skillId);
276
+
277
+ if (!existsSync(skillDir)) {
278
+ throw new Error(`Skill "${skillId}" is not installed.`);
279
+ }
280
+
281
+ rmSync(skillDir, { recursive: true, force: true });
282
+ removeSkillsIndexEntry(skillId);
283
+ }
284
+
224
285
  async function installSkillLocally(
225
286
  skillId: string,
226
287
  catalogEntry: CatalogSkill,
@@ -237,8 +298,17 @@ async function installSkillLocally(
237
298
 
238
299
  mkdirSync(skillDir, { recursive: true });
239
300
 
240
- // Extract all files from the archive into the skill directory
241
- await fetchAndExtractSkill(skillId, skillDir);
301
+ // In dev mode, install from the local repo skills directory if available
302
+ const repoSkillsDir = getRepoSkillsDir();
303
+ const repoSkillSource = repoSkillsDir
304
+ ? join(repoSkillsDir, skillId)
305
+ : undefined;
306
+
307
+ if (repoSkillSource && existsSync(join(repoSkillSource, "SKILL.md"))) {
308
+ cpSync(repoSkillSource, skillDir, { recursive: true });
309
+ } else {
310
+ await fetchAndExtractSkill(skillId, skillDir);
311
+ }
242
312
 
243
313
  // Write version metadata
244
314
  if (catalogEntry.version) {
@@ -288,6 +358,9 @@ function printUsage(): void {
288
358
  console.log(
289
359
  " install <skill-id> [--overwrite] Install a skill from the catalog",
290
360
  );
361
+ console.log(
362
+ " uninstall <skill-id> Uninstall a previously installed skill",
363
+ );
291
364
  console.log("");
292
365
  console.log("Options:");
293
366
  console.log(" --json Machine-readable JSON output");
@@ -312,6 +385,18 @@ export async function skills(): Promise<void> {
312
385
  try {
313
386
  const catalog = await fetchCatalog();
314
387
 
388
+ // In dev mode, merge in skills from the repo-local skills/ directory
389
+ const repoSkillsDir = getRepoSkillsDir();
390
+ if (repoSkillsDir) {
391
+ const localSkills = readLocalCatalog(repoSkillsDir);
392
+ const remoteIds = new Set(catalog.map((s) => s.id));
393
+ for (const local of localSkills) {
394
+ if (!remoteIds.has(local.id)) {
395
+ catalog.push(local);
396
+ }
397
+ }
398
+ }
399
+
315
400
  if (json) {
316
401
  console.log(JSON.stringify({ ok: true, skills: catalog }));
317
402
  return;
@@ -353,9 +438,20 @@ export async function skills(): Promise<void> {
353
438
  const overwrite = hasFlag(args, "--overwrite");
354
439
 
355
440
  try {
356
- // Verify skill exists in catalog
357
- const catalog = await fetchCatalog();
358
- const entry = catalog.find((s) => s.id === skillId);
441
+ // In dev mode, also check the repo-local skills/ directory
442
+ const repoSkillsDir = getRepoSkillsDir();
443
+ let localSkills: CatalogSkill[] = [];
444
+ if (repoSkillsDir) {
445
+ localSkills = readLocalCatalog(repoSkillsDir);
446
+ }
447
+
448
+ // Check local catalog first, then fall back to remote
449
+ let entry = localSkills.find((s) => s.id === skillId);
450
+ if (!entry) {
451
+ const catalog = await fetchCatalog();
452
+ entry = catalog.find((s) => s.id === skillId);
453
+ }
454
+
359
455
  if (!entry) {
360
456
  throw new Error(`Skill "${skillId}" not found in the Vellum catalog`);
361
457
  }
@@ -380,6 +476,35 @@ export async function skills(): Promise<void> {
380
476
  break;
381
477
  }
382
478
 
479
+ case "uninstall": {
480
+ const skillId = args.find(
481
+ (a) => !a.startsWith("--") && a !== "uninstall",
482
+ );
483
+ if (!skillId) {
484
+ console.error("Usage: vellum skills uninstall <skill-id>");
485
+ process.exit(1);
486
+ }
487
+
488
+ try {
489
+ uninstallSkillLocally(skillId);
490
+
491
+ if (json) {
492
+ console.log(JSON.stringify({ ok: true, skillId }));
493
+ } else {
494
+ console.log(`Uninstalled skill "${skillId}".`);
495
+ }
496
+ } catch (err) {
497
+ const msg = err instanceof Error ? err.message : String(err);
498
+ if (json) {
499
+ console.log(JSON.stringify({ ok: false, error: msg }));
500
+ } else {
501
+ console.error(`Error: ${msg}`);
502
+ }
503
+ process.exitCode = 1;
504
+ }
505
+ break;
506
+ }
507
+
383
508
  default: {
384
509
  console.error(`Unknown skills subcommand: ${subcommand}`);
385
510
  printUsage();
@@ -1,20 +1,40 @@
1
- import { homedir } from "os";
2
1
  import { join } from "path";
3
2
 
3
+ import {
4
+ defaultLocalResources,
5
+ resolveTargetAssistant,
6
+ } from "../lib/assistant-config.js";
4
7
  import { stopProcessByPidFile } from "../lib/process";
5
8
 
6
9
  export async function sleep(): Promise<void> {
7
10
  const args = process.argv.slice(3);
8
11
  if (args.includes("--help") || args.includes("-h")) {
9
- console.log("Usage: vellum sleep");
12
+ console.log("Usage: vellum sleep [<name>]");
10
13
  console.log("");
11
14
  console.log("Stop the assistant and gateway processes.");
15
+ console.log("");
16
+ console.log("Arguments:");
17
+ console.log(
18
+ " <name> Name of the assistant to stop (default: active or only local)",
19
+ );
12
20
  process.exit(0);
13
21
  }
14
22
 
15
- const vellumDir = join(homedir(), ".vellum");
16
- const daemonPidFile = join(vellumDir, "vellum.pid");
17
- const socketFile = join(vellumDir, "vellum.sock");
23
+ const nameArg = args.find((a) => !a.startsWith("-"));
24
+ const entry = resolveTargetAssistant(nameArg);
25
+
26
+ if (entry.cloud && entry.cloud !== "local") {
27
+ console.error(
28
+ `Error: 'vellum sleep' only works with local assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
29
+ );
30
+ process.exit(1);
31
+ }
32
+
33
+ const resources = entry.resources ?? defaultLocalResources();
34
+
35
+ const daemonPidFile = resources.pidFile;
36
+ const socketFile = resources.socketPath;
37
+ const vellumDir = join(resources.instanceDir, ".vellum");
18
38
  const gatewayPidFile = join(vellumDir, "gateway.pid");
19
39
 
20
40
  // Stop daemon
@@ -40,5 +60,4 @@ export async function sleep(): Promise<void> {
40
60
  } else {
41
61
  console.log("Gateway stopped.");
42
62
  }
43
-
44
63
  }
@@ -0,0 +1,44 @@
1
+ import {
2
+ findAssistantByName,
3
+ getActiveAssistant,
4
+ setActiveAssistant,
5
+ } from "../lib/assistant-config.js";
6
+
7
+ export async function use(): Promise<void> {
8
+ const args = process.argv.slice(3);
9
+
10
+ if (args.includes("--help") || args.includes("-h")) {
11
+ console.log("Usage: vellum use [<name>]");
12
+ console.log("");
13
+ console.log("Set the active assistant for commands.");
14
+ console.log("");
15
+ console.log("Arguments:");
16
+ console.log(" <name> Name of the assistant to make active");
17
+ console.log("");
18
+ console.log(
19
+ "When called without a name, prints the current active assistant.",
20
+ );
21
+ process.exit(0);
22
+ }
23
+
24
+ const name = args.find((a) => !a.startsWith("-"));
25
+
26
+ if (!name) {
27
+ const active = getActiveAssistant();
28
+ if (active) {
29
+ console.log(`Active assistant: ${active}`);
30
+ } else {
31
+ console.log("No active assistant set.");
32
+ }
33
+ return;
34
+ }
35
+
36
+ const entry = findAssistantByName(name);
37
+ if (!entry) {
38
+ console.error(`No assistant found with name '${name}'.`);
39
+ process.exit(1);
40
+ }
41
+
42
+ setActiveAssistant(name);
43
+ console.log(`Active assistant set to '${name}'.`);
44
+ }