@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.
- package/package.json +2 -2
- package/src/__tests__/multi-local.test.ts +275 -0
- package/src/__tests__/skills-uninstall.test.ts +203 -0
- package/src/commands/client.ts +23 -7
- package/src/commands/hatch.ts +38 -42
- package/src/commands/ps.ts +32 -12
- package/src/commands/retire.ts +48 -12
- package/src/commands/skills.ts +130 -5
- package/src/commands/sleep.ts +25 -6
- package/src/commands/use.ts +44 -0
- package/src/commands/wake.ts +25 -16
- package/src/index.ts +5 -49
- package/src/lib/assistant-config.ts +226 -3
- package/src/lib/constants.ts +6 -0
- package/src/lib/local.ts +189 -49
- package/src/lib/status-emoji.ts +3 -0
package/src/commands/hatch.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
795
|
-
|
|
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
|
|
package/src/commands/ps.ts
CHANGED
|
@@ -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
|
|
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:
|
|
228
|
-
pidFile:
|
|
226
|
+
port: resources.daemonPort,
|
|
227
|
+
pidFile: resources.pidFile,
|
|
229
228
|
},
|
|
230
229
|
{
|
|
231
230
|
name: "qdrant",
|
|
232
231
|
pgrepName: "qdrant",
|
|
233
|
-
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:
|
|
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
|
-
|
|
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
|
};
|
package/src/commands/retire.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
77
|
-
console.log(
|
|
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(
|
|
122
|
+
renameSync(dirToArchive, stagingDir);
|
|
87
123
|
} catch (err) {
|
|
88
124
|
console.warn(
|
|
89
|
-
`⚠️ Failed to move ${
|
|
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.");
|
package/src/commands/skills.ts
CHANGED
|
@@ -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
|
-
//
|
|
241
|
-
|
|
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
|
-
//
|
|
357
|
-
const
|
|
358
|
-
|
|
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();
|
package/src/commands/sleep.ts
CHANGED
|
@@ -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
|
|
16
|
-
const
|
|
17
|
-
|
|
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
|
+
}
|