@vellumai/cli 0.4.10 → 0.4.12
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/README.md +23 -0
- package/package.json +1 -1
- package/src/__tests__/coverage.test.ts +41 -0
- package/src/commands/config.ts +8 -73
- package/src/commands/retire.ts +27 -10
- package/src/commands/sleep.ts +3 -9
- package/src/commands/tunnel.ts +88 -0
- package/src/index.ts +10 -11
- package/src/lib/config.ts +73 -0
- package/src/lib/local.ts +69 -30
- package/src/lib/ngrok.ts +263 -0
- package/src/commands/skills.ts +0 -355
package/README.md
CHANGED
|
@@ -14,6 +14,29 @@ bun run ./src/index.ts <command> [options]
|
|
|
14
14
|
|
|
15
15
|
## Commands
|
|
16
16
|
|
|
17
|
+
### Lifecycle: `ps`, `sleep`, `wake`
|
|
18
|
+
|
|
19
|
+
Day-to-day process management for the daemon and gateway.
|
|
20
|
+
|
|
21
|
+
| Command | Description |
|
|
22
|
+
|---------|-------------|
|
|
23
|
+
| `vellum ps` | List assistants and per-assistant process status (daemon, gateway PIDs and health). |
|
|
24
|
+
| `vellum sleep` | Stop daemon and gateway processes. Directory-agnostic — works from anywhere. |
|
|
25
|
+
| `vellum wake` | Start the daemon and gateway from the current checkout. |
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Start everything
|
|
29
|
+
vellum wake
|
|
30
|
+
|
|
31
|
+
# Check what's running
|
|
32
|
+
vellum ps
|
|
33
|
+
|
|
34
|
+
# Stop everything
|
|
35
|
+
vellum sleep
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> **Note:** `vellum wake` requires a hatched assistant. Run `vellum hatch` first, or launch the macOS app which handles hatching automatically.
|
|
39
|
+
|
|
17
40
|
### `hatch`
|
|
18
41
|
|
|
19
42
|
Provision a new assistant instance and bootstrap the Vellum runtime on it.
|
package/package.json
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Bun's coverage reporter only tracks files that are actually loaded during
|
|
2
|
+
// test execution. There is no config option to include all source files.
|
|
3
|
+
// See: https://github.com/oven-sh/bun/issues/5928
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { expect, test } from "bun:test";
|
|
6
|
+
|
|
7
|
+
const EXCLUDE_PATTERNS = [".test.ts", ".d.ts"];
|
|
8
|
+
const EXCLUDE_FILES = [
|
|
9
|
+
// index.ts calls main() at module level, causing side effects on import
|
|
10
|
+
"index.ts",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
async function importAllModules(dir: string): Promise<string[]> {
|
|
14
|
+
const glob = new Bun.Glob("**/*.{ts,tsx}");
|
|
15
|
+
const files = [...glob.scanSync(dir)].filter(
|
|
16
|
+
(f) =>
|
|
17
|
+
!EXCLUDE_PATTERNS.some((pattern) => f.endsWith(pattern)) &&
|
|
18
|
+
!EXCLUDE_FILES.some((excluded) => f === excluded) &&
|
|
19
|
+
!f.includes("__tests__"),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
await Promise.all(files.map((relPath) => import(resolve(dir, relPath))));
|
|
23
|
+
|
|
24
|
+
return files;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("imports all source modules for coverage tracking", async () => {
|
|
28
|
+
/**
|
|
29
|
+
* Ensures all source files are loaded so Bun's coverage reporter
|
|
30
|
+
* includes them in the report, not just files touched by other tests.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// GIVEN the src directory containing all source modules
|
|
34
|
+
const srcDir = resolve(import.meta.dir, "..");
|
|
35
|
+
|
|
36
|
+
// WHEN we dynamically import every source module
|
|
37
|
+
const files = await importAllModules(srcDir);
|
|
38
|
+
|
|
39
|
+
// THEN at least one file should have been imported
|
|
40
|
+
expect(files.length).toBeGreaterThan(0);
|
|
41
|
+
});
|
package/src/commands/config.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
2
|
|
|
5
3
|
import { syncConfigToLockfile } from "../lib/assistant-config";
|
|
4
|
+
import {
|
|
5
|
+
getAllowlistPath,
|
|
6
|
+
getNestedValue,
|
|
7
|
+
loadRawConfig,
|
|
8
|
+
saveRawConfig,
|
|
9
|
+
setNestedValue,
|
|
10
|
+
} from "../lib/config";
|
|
6
11
|
|
|
7
12
|
interface AllowlistConfig {
|
|
8
13
|
values?: string[];
|
|
@@ -16,76 +21,6 @@ interface AllowlistValidationError {
|
|
|
16
21
|
message: string;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
function getRootDir(): string {
|
|
20
|
-
return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function getConfigPath(): string {
|
|
24
|
-
return join(getRootDir(), "workspace", "config.json");
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function getAllowlistPath(): string {
|
|
28
|
-
return join(getRootDir(), "protected", "secret-allowlist.json");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function loadRawConfig(): Record<string, unknown> {
|
|
32
|
-
const configPath = getConfigPath();
|
|
33
|
-
if (!existsSync(configPath)) {
|
|
34
|
-
return {};
|
|
35
|
-
}
|
|
36
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
37
|
-
return JSON.parse(raw) as Record<string, unknown>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function saveRawConfig(config: Record<string, unknown>): void {
|
|
41
|
-
const configPath = getConfigPath();
|
|
42
|
-
const dir = dirname(configPath);
|
|
43
|
-
if (!existsSync(dir)) {
|
|
44
|
-
mkdirSync(dir, { recursive: true });
|
|
45
|
-
}
|
|
46
|
-
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function getNestedValue(
|
|
50
|
-
obj: Record<string, unknown>,
|
|
51
|
-
path: string,
|
|
52
|
-
): unknown {
|
|
53
|
-
const keys = path.split(".");
|
|
54
|
-
let current: unknown = obj;
|
|
55
|
-
for (const key of keys) {
|
|
56
|
-
if (
|
|
57
|
-
current === null ||
|
|
58
|
-
current === undefined ||
|
|
59
|
-
typeof current !== "object"
|
|
60
|
-
) {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
current = (current as Record<string, unknown>)[key];
|
|
64
|
-
}
|
|
65
|
-
return current;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function setNestedValue(
|
|
69
|
-
obj: Record<string, unknown>,
|
|
70
|
-
path: string,
|
|
71
|
-
value: unknown,
|
|
72
|
-
): void {
|
|
73
|
-
const keys = path.split(".");
|
|
74
|
-
let current = obj;
|
|
75
|
-
for (let i = 0; i < keys.length - 1; i++) {
|
|
76
|
-
const key = keys[i];
|
|
77
|
-
if (
|
|
78
|
-
current[key] === undefined ||
|
|
79
|
-
current[key] === null ||
|
|
80
|
-
typeof current[key] !== "object"
|
|
81
|
-
) {
|
|
82
|
-
current[key] = {};
|
|
83
|
-
}
|
|
84
|
-
current = current[key] as Record<string, unknown>;
|
|
85
|
-
}
|
|
86
|
-
current[keys[keys.length - 1]] = value;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
24
|
function validateAllowlist(
|
|
90
25
|
allowlistConfig: AllowlistConfig,
|
|
91
26
|
): AllowlistValidationError[] {
|
package/src/commands/retire.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { renameSync, writeFileSync } from "fs";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { basename, dirname, join } from "path";
|
|
5
5
|
|
|
@@ -63,20 +63,37 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
|
|
|
63
63
|
} catch {}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
//
|
|
66
|
+
// Move ~/.vellum out of the way so the path is immediately available for the
|
|
67
|
+
// next hatch, then kick off the tar archive in the background.
|
|
68
|
+
const archivePath = getArchivePath(name);
|
|
69
|
+
const metadataPath = getMetadataPath(name);
|
|
70
|
+
const stagingDir = `${archivePath}.staging`;
|
|
71
|
+
|
|
67
72
|
try {
|
|
68
|
-
|
|
69
|
-
const metadataPath = getMetadataPath(name);
|
|
70
|
-
await exec("tar", ["czf", archivePath, "-C", dirname(vellumDir), basename(vellumDir)]);
|
|
71
|
-
writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
|
|
72
|
-
console.log(`📦 Archived to ${archivePath}`);
|
|
73
|
+
renameSync(vellumDir, stagingDir);
|
|
73
74
|
} catch (err) {
|
|
74
|
-
console.warn(`⚠️ Failed to
|
|
75
|
-
console.warn("
|
|
75
|
+
console.warn(`⚠️ Failed to move ${vellumDir}: ${err instanceof Error ? err.message : err}`);
|
|
76
|
+
console.warn("Skipping archive.");
|
|
77
|
+
console.log("\u2705 Local instance retired.");
|
|
78
|
+
return;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
|
|
81
|
+
writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
|
|
82
|
+
|
|
83
|
+
// Spawn tar + cleanup in the background and detach so the CLI can exit
|
|
84
|
+
// immediately. The staging directory is removed once the archive is written.
|
|
85
|
+
const tarCmd = [
|
|
86
|
+
`tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(dirname(stagingDir))} ${JSON.stringify(basename(stagingDir))}`,
|
|
87
|
+
`rm -rf ${JSON.stringify(stagingDir)}`,
|
|
88
|
+
].join(" && ");
|
|
89
|
+
|
|
90
|
+
const child = spawn("sh", ["-c", tarCmd], {
|
|
91
|
+
stdio: "ignore",
|
|
92
|
+
detached: true,
|
|
93
|
+
});
|
|
94
|
+
child.unref();
|
|
79
95
|
|
|
96
|
+
console.log(`📦 Archiving to ${archivePath} in the background.`);
|
|
80
97
|
console.log("\u2705 Local instance retired.");
|
|
81
98
|
}
|
|
82
99
|
|
package/src/commands/sleep.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { homedir } from "os";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
|
-
import { loadAllAssistants } from "../lib/assistant-config";
|
|
5
4
|
import { stopProcessByPidFile } from "../lib/process";
|
|
6
5
|
|
|
7
6
|
export async function sleep(): Promise<void> {
|
|
@@ -13,20 +12,15 @@ export async function sleep(): Promise<void> {
|
|
|
13
12
|
process.exit(0);
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
const assistants = loadAllAssistants();
|
|
17
|
-
const hasLocal = assistants.some((a) => a.cloud === "local");
|
|
18
|
-
if (!hasLocal) {
|
|
19
|
-
console.error("Error: No local assistant found in lock file. Run 'vellum hatch local' first.");
|
|
20
|
-
process.exit(1);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
15
|
const vellumDir = join(homedir(), ".vellum");
|
|
24
16
|
const daemonPidFile = join(vellumDir, "vellum.pid");
|
|
25
17
|
const socketFile = join(vellumDir, "vellum.sock");
|
|
26
18
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
27
19
|
|
|
28
20
|
// Stop daemon
|
|
29
|
-
const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [
|
|
21
|
+
const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [
|
|
22
|
+
socketFile,
|
|
23
|
+
]);
|
|
30
24
|
if (!daemonStopped) {
|
|
31
25
|
console.log("Daemon is not running.");
|
|
32
26
|
} else {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { findAssistantByName, loadLatestAssistant } from "../lib/assistant-config";
|
|
2
|
+
import { runNgrokTunnel } from "../lib/ngrok";
|
|
3
|
+
|
|
4
|
+
const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
|
|
5
|
+
type TunnelProvider = (typeof VALID_PROVIDERS)[number];
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PROVIDER: TunnelProvider = "vellum";
|
|
8
|
+
|
|
9
|
+
interface TunnelArgs {
|
|
10
|
+
assistantName: string | null;
|
|
11
|
+
provider: TunnelProvider;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseArgs(): TunnelArgs {
|
|
15
|
+
const args = process.argv.slice(3);
|
|
16
|
+
let assistantName: string | null = null;
|
|
17
|
+
let provider: TunnelProvider = DEFAULT_PROVIDER;
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
const arg = args[i];
|
|
21
|
+
if (arg === "--help" || arg === "-h") {
|
|
22
|
+
console.log("Usage: vellum tunnel [<name>] [options]");
|
|
23
|
+
console.log("");
|
|
24
|
+
console.log("Create a tunnel for a locally hosted assistant.");
|
|
25
|
+
console.log("");
|
|
26
|
+
console.log("Arguments:");
|
|
27
|
+
console.log(
|
|
28
|
+
" <name> Name of the assistant (defaults to latest)",
|
|
29
|
+
);
|
|
30
|
+
console.log("");
|
|
31
|
+
console.log("Options:");
|
|
32
|
+
console.log(
|
|
33
|
+
` --provider <provider> Tunnel provider: ${VALID_PROVIDERS.join(", ")} (default: ${DEFAULT_PROVIDER})`,
|
|
34
|
+
);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
} else if (arg === "--provider") {
|
|
37
|
+
const next = args[i + 1];
|
|
38
|
+
if (!next || !VALID_PROVIDERS.includes(next as TunnelProvider)) {
|
|
39
|
+
console.error(
|
|
40
|
+
`Error: --provider requires one of: ${VALID_PROVIDERS.join(", ")}`,
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
provider = next as TunnelProvider;
|
|
45
|
+
i++;
|
|
46
|
+
} else if (arg.startsWith("-")) {
|
|
47
|
+
console.error(`Error: Unknown option '${arg}'.`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
} else if (!assistantName) {
|
|
50
|
+
assistantName = arg;
|
|
51
|
+
} else {
|
|
52
|
+
console.error(`Error: Unexpected argument '${arg}'.`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { assistantName, provider };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function tunnel(): Promise<void> {
|
|
61
|
+
const { assistantName, provider } = parseArgs();
|
|
62
|
+
|
|
63
|
+
const entry = assistantName
|
|
64
|
+
? findAssistantByName(assistantName)
|
|
65
|
+
: loadLatestAssistant();
|
|
66
|
+
|
|
67
|
+
if (!entry) {
|
|
68
|
+
if (assistantName) {
|
|
69
|
+
console.error(
|
|
70
|
+
`No assistant instance found with name '${assistantName}'.`,
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
console.error(
|
|
74
|
+
"No assistant instance found. Run `vellum hatch` first.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (provider === "ngrok") {
|
|
81
|
+
await runNgrokTunnel();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Tunnel provider '${provider}' is not yet implemented.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,9 +18,9 @@ import { pair } from "./commands/pair";
|
|
|
18
18
|
import { ps } from "./commands/ps";
|
|
19
19
|
import { recover } from "./commands/recover";
|
|
20
20
|
import { retire } from "./commands/retire";
|
|
21
|
-
import { skills } from "./commands/skills";
|
|
22
21
|
import { sleep } from "./commands/sleep";
|
|
23
22
|
import { ssh } from "./commands/ssh";
|
|
23
|
+
import { tunnel } from "./commands/tunnel";
|
|
24
24
|
import { wake } from "./commands/wake";
|
|
25
25
|
|
|
26
26
|
const commands = {
|
|
@@ -36,9 +36,9 @@ const commands = {
|
|
|
36
36
|
ps,
|
|
37
37
|
recover,
|
|
38
38
|
retire,
|
|
39
|
-
skills,
|
|
40
39
|
sleep,
|
|
41
40
|
ssh,
|
|
41
|
+
tunnel,
|
|
42
42
|
wake,
|
|
43
43
|
whoami,
|
|
44
44
|
} as const;
|
|
@@ -49,9 +49,8 @@ function resolveAssistantEntry(): string | undefined {
|
|
|
49
49
|
// When installed globally, resolve from node_modules
|
|
50
50
|
try {
|
|
51
51
|
const require = createRequire(import.meta.url);
|
|
52
|
-
const assistantPkgPath =
|
|
53
|
-
"@vellumai/assistant/package.json"
|
|
54
|
-
);
|
|
52
|
+
const assistantPkgPath =
|
|
53
|
+
require.resolve("@vellumai/assistant/package.json");
|
|
55
54
|
return join(dirname(assistantPkgPath), "src", "index.ts");
|
|
56
55
|
} catch {
|
|
57
56
|
// For local development, resolve from sibling directory
|
|
@@ -62,7 +61,7 @@ function resolveAssistantEntry(): string | undefined {
|
|
|
62
61
|
"..",
|
|
63
62
|
"assistant",
|
|
64
63
|
"src",
|
|
65
|
-
"index.ts"
|
|
64
|
+
"index.ts",
|
|
66
65
|
);
|
|
67
66
|
if (existsSync(localPath)) {
|
|
68
67
|
return localPath;
|
|
@@ -93,12 +92,14 @@ async function main() {
|
|
|
93
92
|
console.log(" login Log in to the Vellum platform");
|
|
94
93
|
console.log(" logout Log out of the Vellum platform");
|
|
95
94
|
console.log(" pair Pair with a remote assistant via QR code");
|
|
96
|
-
console.log(
|
|
95
|
+
console.log(
|
|
96
|
+
" ps List assistants (or processes for a specific assistant)",
|
|
97
|
+
);
|
|
97
98
|
console.log(" recover Restore a previously retired local assistant");
|
|
98
99
|
console.log(" retire Delete an assistant instance");
|
|
99
|
-
console.log(" skills Browse and install skills from the Vellum catalog");
|
|
100
100
|
console.log(" sleep Stop the daemon process");
|
|
101
101
|
console.log(" ssh SSH into a remote assistant instance");
|
|
102
|
+
console.log(" tunnel Create a tunnel for a locally hosted assistant");
|
|
102
103
|
console.log(" wake Start the daemon and gateway");
|
|
103
104
|
console.log(" whoami Show current logged-in user");
|
|
104
105
|
process.exit(0);
|
|
@@ -117,9 +118,7 @@ async function main() {
|
|
|
117
118
|
});
|
|
118
119
|
} else {
|
|
119
120
|
console.error(`Unknown command: ${commandName}`);
|
|
120
|
-
console.error(
|
|
121
|
-
"Install the full stack with: bun install -g vellum"
|
|
122
|
-
);
|
|
121
|
+
console.error("Install the full stack with: bun install -g vellum");
|
|
123
122
|
process.exit(1);
|
|
124
123
|
}
|
|
125
124
|
return;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
function getRootDir(): string {
|
|
6
|
+
return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getConfigPath(): string {
|
|
10
|
+
return join(getRootDir(), "workspace", "config.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getAllowlistPath(): string {
|
|
14
|
+
return join(getRootDir(), "protected", "secret-allowlist.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadRawConfig(): Record<string, unknown> {
|
|
18
|
+
const configPath = getConfigPath();
|
|
19
|
+
if (!existsSync(configPath)) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
23
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveRawConfig(config: Record<string, unknown>): void {
|
|
27
|
+
const configPath = getConfigPath();
|
|
28
|
+
const dir = dirname(configPath);
|
|
29
|
+
if (!existsSync(dir)) {
|
|
30
|
+
mkdirSync(dir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getNestedValue(
|
|
36
|
+
obj: Record<string, unknown>,
|
|
37
|
+
path: string,
|
|
38
|
+
): unknown {
|
|
39
|
+
const keys = path.split(".");
|
|
40
|
+
let current: unknown = obj;
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
if (
|
|
43
|
+
current === null ||
|
|
44
|
+
current === undefined ||
|
|
45
|
+
typeof current !== "object"
|
|
46
|
+
) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
current = (current as Record<string, unknown>)[key];
|
|
50
|
+
}
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setNestedValue(
|
|
55
|
+
obj: Record<string, unknown>,
|
|
56
|
+
path: string,
|
|
57
|
+
value: unknown,
|
|
58
|
+
): void {
|
|
59
|
+
const keys = path.split(".");
|
|
60
|
+
let current = obj;
|
|
61
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
62
|
+
const key = keys[i];
|
|
63
|
+
if (
|
|
64
|
+
current[key] === undefined ||
|
|
65
|
+
current[key] === null ||
|
|
66
|
+
typeof current[key] !== "object"
|
|
67
|
+
) {
|
|
68
|
+
current[key] = {};
|
|
69
|
+
}
|
|
70
|
+
current = current[key] as Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
current[keys[keys.length - 1]] = value;
|
|
73
|
+
}
|
package/src/lib/local.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync, spawn } from "child_process";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3
3
|
import { createRequire } from "module";
|
|
4
4
|
import { createConnection } from "net";
|
|
5
|
-
import { homedir } from "os";
|
|
5
|
+
import { homedir, hostname, networkInterfaces, platform } from "os";
|
|
6
6
|
import { dirname, join } from "path";
|
|
7
7
|
|
|
8
8
|
import { loadLatestAssistant } from "./assistant-config.js";
|
|
@@ -254,8 +254,8 @@ async function discoverPublicUrl(): Promise<string | undefined> {
|
|
|
254
254
|
|
|
255
255
|
let externalIp: string | undefined;
|
|
256
256
|
|
|
257
|
-
// Try cloud-specific metadata services
|
|
258
|
-
if (cloud
|
|
257
|
+
// Try cloud-specific metadata services for GCP and AWS.
|
|
258
|
+
if (cloud === "gcp" || cloud === "aws") {
|
|
259
259
|
try {
|
|
260
260
|
if (cloud === "gcp") {
|
|
261
261
|
const resp = await fetch(
|
|
@@ -281,46 +281,85 @@ async function discoverPublicUrl(): Promise<string | undefined> {
|
|
|
281
281
|
} catch {
|
|
282
282
|
// metadata service not reachable
|
|
283
283
|
}
|
|
284
|
+
|
|
285
|
+
if (externalIp) {
|
|
286
|
+
console.log(` Discovered external IP: ${externalIp}`);
|
|
287
|
+
return `http://${externalIp}:${GATEWAY_PORT}`;
|
|
288
|
+
}
|
|
284
289
|
}
|
|
285
290
|
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
291
|
+
// For local and custom environments, use the local LAN address.
|
|
292
|
+
// On macOS, prefer the .local hostname (Bonjour/mDNS) so other devices on
|
|
293
|
+
// the same network can reach the gateway by name.
|
|
294
|
+
if (platform() === "darwin") {
|
|
295
|
+
const localHostname = getMacLocalHostname();
|
|
296
|
+
if (localHostname) {
|
|
297
|
+
console.log(` Discovered macOS local hostname: ${localHostname}`);
|
|
298
|
+
return `http://${localHostname}:${GATEWAY_PORT}`;
|
|
299
|
+
}
|
|
290
300
|
}
|
|
291
301
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
302
|
+
const lanIp = getLocalLanIPv4();
|
|
303
|
+
if (lanIp) {
|
|
304
|
+
console.log(` Discovered LAN IP: ${lanIp}`);
|
|
305
|
+
return `http://${lanIp}:${GATEWAY_PORT}`;
|
|
295
306
|
}
|
|
296
307
|
|
|
297
|
-
// Final fallback to localhost when no
|
|
308
|
+
// Final fallback to localhost when no LAN address could be discovered.
|
|
298
309
|
return `http://localhost:${GATEWAY_PORT}`;
|
|
299
310
|
}
|
|
300
311
|
|
|
301
|
-
/**
|
|
302
|
-
*
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
312
|
+
/**
|
|
313
|
+
* Returns the macOS Bonjour/mDNS `.local` hostname (e.g. "Vargass-Mac-Mini.local"),
|
|
314
|
+
* or undefined if not running on macOS or the hostname cannot be determined.
|
|
315
|
+
*/
|
|
316
|
+
function getMacLocalHostname(): string | undefined {
|
|
317
|
+
const host = hostname();
|
|
318
|
+
if (!host) return undefined;
|
|
319
|
+
// macOS hostnames already end with .local when Bonjour is active
|
|
320
|
+
if (host.endsWith(".local")) return host;
|
|
321
|
+
// Otherwise, append .local — macOS resolves <ComputerName>.local via mDNS
|
|
322
|
+
return `${host}.local`;
|
|
323
|
+
}
|
|
309
324
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
325
|
+
/**
|
|
326
|
+
* Returns the local IPv4 address most likely to be reachable from other
|
|
327
|
+
* devices on the same LAN.
|
|
328
|
+
*
|
|
329
|
+
* Priority order:
|
|
330
|
+
* 1. en0 (Wi-Fi on macOS)
|
|
331
|
+
* 2. en1 (secondary network on macOS)
|
|
332
|
+
* 3. First non-loopback IPv4 on any interface
|
|
333
|
+
*
|
|
334
|
+
* Skips link-local addresses (169.254.x.x) and IPv6.
|
|
335
|
+
* Returns undefined if no suitable address is found.
|
|
336
|
+
*/
|
|
337
|
+
function getLocalLanIPv4(): string | undefined {
|
|
338
|
+
const ifaces = networkInterfaces();
|
|
339
|
+
|
|
340
|
+
// Priority interfaces in order
|
|
341
|
+
const priorityInterfaces = ["en0", "en1"];
|
|
342
|
+
|
|
343
|
+
for (const ifName of priorityInterfaces) {
|
|
344
|
+
const addrs = ifaces[ifName];
|
|
345
|
+
if (!addrs) continue;
|
|
346
|
+
for (const addr of addrs) {
|
|
347
|
+
if (addr.family === "IPv4" && !addr.internal && !addr.address.startsWith("169.254.")) {
|
|
348
|
+
return addr.address;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Fallback: first non-loopback, non-link-local IPv4 on any interface
|
|
354
|
+
for (const [, addrs] of Object.entries(ifaces)) {
|
|
355
|
+
if (!addrs) continue;
|
|
356
|
+
for (const addr of addrs) {
|
|
357
|
+
if (addr.family === "IPv4" && !addr.internal && !addr.address.startsWith("169.254.")) {
|
|
358
|
+
return addr.address;
|
|
319
359
|
}
|
|
320
|
-
} catch {
|
|
321
|
-
// Service unreachable, try the next one
|
|
322
360
|
}
|
|
323
361
|
}
|
|
362
|
+
|
|
324
363
|
return undefined;
|
|
325
364
|
}
|
|
326
365
|
|
package/src/lib/ngrok.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { loadRawConfig, saveRawConfig } from "./config";
|
|
4
|
+
import { GATEWAY_PORT } from "./constants";
|
|
5
|
+
|
|
6
|
+
const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
|
|
7
|
+
const NGROK_POLL_INTERVAL_MS = 500;
|
|
8
|
+
const NGROK_POLL_TIMEOUT_MS = 15_000;
|
|
9
|
+
|
|
10
|
+
interface NgrokTunnel {
|
|
11
|
+
public_url: string;
|
|
12
|
+
config?: { addr?: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface NgrokTunnelsResponse {
|
|
16
|
+
tunnels: NgrokTunnel[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check whether ngrok is installed and accessible on the PATH.
|
|
21
|
+
* Returns the version string if installed, null otherwise.
|
|
22
|
+
*/
|
|
23
|
+
export function getNgrokVersion(): string | null {
|
|
24
|
+
try {
|
|
25
|
+
const output = execFileSync("ngrok", ["version"], {
|
|
26
|
+
encoding: "utf-8",
|
|
27
|
+
timeout: 5_000,
|
|
28
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
29
|
+
});
|
|
30
|
+
return output.trim();
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Query the ngrok local API for running tunnels.
|
|
38
|
+
* Returns the list of tunnels, or null if the API is unreachable.
|
|
39
|
+
*/
|
|
40
|
+
async function queryNgrokTunnels(): Promise<NgrokTunnel[] | null> {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(NGROK_API_URL, {
|
|
43
|
+
signal: AbortSignal.timeout(2_000),
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok) return null;
|
|
46
|
+
const data = (await res.json()) as NgrokTunnelsResponse;
|
|
47
|
+
return data.tunnels ?? [];
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Find an existing ngrok tunnel that targets the given local address.
|
|
55
|
+
* Returns the HTTPS public URL if found, null otherwise.
|
|
56
|
+
*/
|
|
57
|
+
export async function findExistingTunnel(
|
|
58
|
+
targetPort: number,
|
|
59
|
+
): Promise<string | null> {
|
|
60
|
+
const tunnels = await queryNgrokTunnels();
|
|
61
|
+
if (!tunnels || tunnels.length === 0) return null;
|
|
62
|
+
|
|
63
|
+
const targetAddrs = [
|
|
64
|
+
`localhost:${targetPort}`,
|
|
65
|
+
`127.0.0.1:${targetPort}`,
|
|
66
|
+
`http://localhost:${targetPort}`,
|
|
67
|
+
`http://127.0.0.1:${targetPort}`,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// Prefer HTTPS tunnel
|
|
71
|
+
for (const t of tunnels) {
|
|
72
|
+
const addr = t.config?.addr ?? "";
|
|
73
|
+
if (targetAddrs.includes(addr) && t.public_url.startsWith("https://")) {
|
|
74
|
+
return t.public_url;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fall back to any tunnel pointing at the target
|
|
79
|
+
for (const t of tunnels) {
|
|
80
|
+
const addr = t.config?.addr ?? "";
|
|
81
|
+
if (targetAddrs.includes(addr) && t.public_url) {
|
|
82
|
+
return t.public_url;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start an ngrok process tunneling HTTP traffic to the given local port.
|
|
91
|
+
* Returns the spawned child process.
|
|
92
|
+
*/
|
|
93
|
+
export function startNgrokProcess(targetPort: number): ChildProcess {
|
|
94
|
+
const child = spawn("ngrok", ["http", String(targetPort), "--log=stdout"], {
|
|
95
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
96
|
+
});
|
|
97
|
+
return child;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Poll the ngrok local API until an HTTPS tunnel URL appears.
|
|
102
|
+
* Returns the public URL, or throws if the timeout is exceeded.
|
|
103
|
+
*/
|
|
104
|
+
export async function waitForNgrokUrl(
|
|
105
|
+
timeoutMs: number = NGROK_POLL_TIMEOUT_MS,
|
|
106
|
+
): Promise<string> {
|
|
107
|
+
const start = Date.now();
|
|
108
|
+
while (Date.now() - start < timeoutMs) {
|
|
109
|
+
const tunnels = await queryNgrokTunnels();
|
|
110
|
+
if (tunnels && tunnels.length > 0) {
|
|
111
|
+
// Prefer HTTPS
|
|
112
|
+
const httpsTunnel = tunnels.find((t) =>
|
|
113
|
+
t.public_url.startsWith("https://"),
|
|
114
|
+
);
|
|
115
|
+
if (httpsTunnel) return httpsTunnel.public_url;
|
|
116
|
+
if (tunnels[0]?.public_url) return tunnels[0].public_url;
|
|
117
|
+
}
|
|
118
|
+
await new Promise((r) => setTimeout(r, NGROK_POLL_INTERVAL_MS));
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
`ngrok tunnel did not become available within ${timeoutMs / 1000}s. Check ngrok logs for errors.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Persist a public ingress URL to the workspace config and enable ingress.
|
|
127
|
+
*/
|
|
128
|
+
function saveIngressUrl(publicUrl: string): void {
|
|
129
|
+
const config = loadRawConfig();
|
|
130
|
+
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
131
|
+
ingress.publicBaseUrl = publicUrl;
|
|
132
|
+
ingress.enabled = true;
|
|
133
|
+
config.ingress = ingress;
|
|
134
|
+
saveRawConfig(config);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Clear the ingress public base URL from the workspace config.
|
|
139
|
+
*/
|
|
140
|
+
function clearIngressUrl(): void {
|
|
141
|
+
const config = loadRawConfig();
|
|
142
|
+
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
143
|
+
delete ingress.publicBaseUrl;
|
|
144
|
+
config.ingress = ingress;
|
|
145
|
+
saveRawConfig(config);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run the ngrok tunnel workflow: check installation, find or start a tunnel,
|
|
150
|
+
* save the public URL to config, and block until exit or signal.
|
|
151
|
+
*/
|
|
152
|
+
export async function runNgrokTunnel(): Promise<void> {
|
|
153
|
+
const version = getNgrokVersion();
|
|
154
|
+
if (!version) {
|
|
155
|
+
console.error("Error: ngrok is not installed.");
|
|
156
|
+
console.error("");
|
|
157
|
+
console.error("Install ngrok:");
|
|
158
|
+
console.error(" macOS: brew install ngrok/ngrok/ngrok");
|
|
159
|
+
console.error(" Linux: sudo snap install ngrok");
|
|
160
|
+
console.error("");
|
|
161
|
+
console.error(
|
|
162
|
+
"Then authenticate: ngrok config add-authtoken <your-token>",
|
|
163
|
+
);
|
|
164
|
+
console.error(
|
|
165
|
+
" Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
|
|
166
|
+
);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(`Using ${version}`);
|
|
171
|
+
|
|
172
|
+
const port = GATEWAY_PORT;
|
|
173
|
+
|
|
174
|
+
// Check for an existing ngrok tunnel pointing at the gateway
|
|
175
|
+
const existingUrl = await findExistingTunnel(port);
|
|
176
|
+
if (existingUrl) {
|
|
177
|
+
console.log(`Found existing ngrok tunnel: ${existingUrl}`);
|
|
178
|
+
saveIngressUrl(existingUrl);
|
|
179
|
+
console.log("Ingress URL saved to config.");
|
|
180
|
+
console.log("");
|
|
181
|
+
console.log(
|
|
182
|
+
"Tunnel is already running. Press Ctrl+C to detach (tunnel stays active).",
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Block until SIGINT/SIGTERM
|
|
186
|
+
await new Promise<void>((resolve) => {
|
|
187
|
+
process.on("SIGINT", () => resolve());
|
|
188
|
+
process.on("SIGTERM", () => resolve());
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(`Starting ngrok tunnel to localhost:${port}...`);
|
|
194
|
+
|
|
195
|
+
let publicUrl: string | undefined;
|
|
196
|
+
|
|
197
|
+
const ngrokProcess = startNgrokProcess(port);
|
|
198
|
+
|
|
199
|
+
const cleanup = () => {
|
|
200
|
+
if (!ngrokProcess.killed) {
|
|
201
|
+
ngrokProcess.kill("SIGTERM");
|
|
202
|
+
}
|
|
203
|
+
if (publicUrl) {
|
|
204
|
+
console.log("\nClearing ingress URL from config...");
|
|
205
|
+
clearIngressUrl();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
process.on("SIGINT", () => {
|
|
210
|
+
cleanup();
|
|
211
|
+
process.exit(0);
|
|
212
|
+
});
|
|
213
|
+
process.on("SIGTERM", () => {
|
|
214
|
+
cleanup();
|
|
215
|
+
process.exit(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
ngrokProcess.on("error", (err: Error) => {
|
|
219
|
+
console.error(`ngrok process error: ${err.message}`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
ngrokProcess.on("exit", (code: number | null) => {
|
|
224
|
+
if (code !== null && code !== 0) {
|
|
225
|
+
console.error(`ngrok exited with code ${code}.`);
|
|
226
|
+
console.error(
|
|
227
|
+
"Check that ngrok is authenticated: ngrok config add-authtoken <token>",
|
|
228
|
+
);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Pipe ngrok stdout/stderr to console for visibility
|
|
234
|
+
ngrokProcess.stdout?.on("data", (data: Buffer) => {
|
|
235
|
+
const line = data.toString().trim();
|
|
236
|
+
if (line) console.log(`[ngrok] ${line}`);
|
|
237
|
+
});
|
|
238
|
+
ngrokProcess.stderr?.on("data", (data: Buffer) => {
|
|
239
|
+
const line = data.toString().trim();
|
|
240
|
+
if (line) console.error(`[ngrok] ${line}`);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
publicUrl = await waitForNgrokUrl();
|
|
245
|
+
} catch (err) {
|
|
246
|
+
cleanup();
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log("");
|
|
251
|
+
console.log(`Tunnel established: ${publicUrl}`);
|
|
252
|
+
console.log(`Forwarding to: localhost:${port}`);
|
|
253
|
+
|
|
254
|
+
saveIngressUrl(publicUrl);
|
|
255
|
+
console.log("Ingress URL saved to config.");
|
|
256
|
+
console.log("");
|
|
257
|
+
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
258
|
+
|
|
259
|
+
// Keep running until the ngrok process exits or we receive a signal
|
|
260
|
+
await new Promise<void>((resolve) => {
|
|
261
|
+
ngrokProcess.on("exit", () => resolve());
|
|
262
|
+
});
|
|
263
|
+
}
|
package/src/commands/skills.ts
DELETED
|
@@ -1,355 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
existsSync,
|
|
3
|
-
mkdirSync,
|
|
4
|
-
readFileSync,
|
|
5
|
-
renameSync,
|
|
6
|
-
writeFileSync,
|
|
7
|
-
} from "node:fs";
|
|
8
|
-
import { homedir } from "node:os";
|
|
9
|
-
import { join, dirname } from "node:path";
|
|
10
|
-
import { gunzipSync } from "node:zlib";
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Path helpers
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
function getRootDir(): string {
|
|
18
|
-
return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function getSkillsDir(): string {
|
|
22
|
-
return join(getRootDir(), "workspace", "skills");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getSkillsIndexPath(): string {
|
|
26
|
-
return join(getSkillsDir(), "SKILLS.md");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Platform API client
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
function getConfigPlatformUrl(): string | undefined {
|
|
34
|
-
try {
|
|
35
|
-
const configPath = join(getRootDir(), "workspace", "config.json");
|
|
36
|
-
if (!existsSync(configPath)) return undefined;
|
|
37
|
-
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
38
|
-
string,
|
|
39
|
-
unknown
|
|
40
|
-
>;
|
|
41
|
-
const platform = raw.platform as Record<string, unknown> | undefined;
|
|
42
|
-
const baseUrl = platform?.baseUrl;
|
|
43
|
-
if (typeof baseUrl === "string" && baseUrl.trim()) return baseUrl.trim();
|
|
44
|
-
} catch {
|
|
45
|
-
// ignore
|
|
46
|
-
}
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function getPlatformUrl(): string {
|
|
51
|
-
return (
|
|
52
|
-
process.env.VELLUM_ASSISTANT_PLATFORM_URL ??
|
|
53
|
-
getConfigPlatformUrl() ??
|
|
54
|
-
"https://platform.vellum.ai"
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function getPlatformToken(): string | null {
|
|
59
|
-
try {
|
|
60
|
-
return readFileSync(join(getRootDir(), "platform-token"), "utf-8").trim();
|
|
61
|
-
} catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function buildHeaders(): Record<string, string> {
|
|
67
|
-
const headers: Record<string, string> = {};
|
|
68
|
-
const token = getPlatformToken();
|
|
69
|
-
if (token) {
|
|
70
|
-
headers["X-Session-Token"] = token;
|
|
71
|
-
}
|
|
72
|
-
return headers;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
// Types
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
interface CatalogSkill {
|
|
80
|
-
id: string;
|
|
81
|
-
name: string;
|
|
82
|
-
description: string;
|
|
83
|
-
emoji?: string;
|
|
84
|
-
includes?: string[];
|
|
85
|
-
version?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
interface CatalogManifest {
|
|
89
|
-
version: number;
|
|
90
|
-
skills: CatalogSkill[];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// Catalog operations
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
|
|
97
|
-
async function fetchCatalog(): Promise<CatalogSkill[]> {
|
|
98
|
-
const url = `${getPlatformUrl()}/v1/skills/`;
|
|
99
|
-
const response = await fetch(url, {
|
|
100
|
-
headers: buildHeaders(),
|
|
101
|
-
signal: AbortSignal.timeout(10000),
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
if (!response.ok) {
|
|
105
|
-
throw new Error(`Platform API error ${response.status}: ${response.statusText}`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const manifest = (await response.json()) as CatalogManifest;
|
|
109
|
-
if (!Array.isArray(manifest.skills)) {
|
|
110
|
-
throw new Error("Platform catalog has invalid skills array");
|
|
111
|
-
}
|
|
112
|
-
return manifest.skills;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Extract SKILL.md content from a tar archive (uncompressed).
|
|
117
|
-
*/
|
|
118
|
-
function extractSkillMdFromTar(tarBuffer: Buffer): string | null {
|
|
119
|
-
let offset = 0;
|
|
120
|
-
while (offset + 512 <= tarBuffer.length) {
|
|
121
|
-
const header = tarBuffer.subarray(offset, offset + 512);
|
|
122
|
-
|
|
123
|
-
// End-of-archive (two consecutive zero blocks)
|
|
124
|
-
if (header.every((b) => b === 0)) break;
|
|
125
|
-
|
|
126
|
-
// Filename (bytes 0-99, null-terminated)
|
|
127
|
-
const nameEnd = header.indexOf(0, 0);
|
|
128
|
-
const name = header
|
|
129
|
-
.subarray(0, Math.min(nameEnd >= 0 ? nameEnd : 100, 100))
|
|
130
|
-
.toString("utf-8");
|
|
131
|
-
|
|
132
|
-
// File size (bytes 124-135, octal)
|
|
133
|
-
const sizeStr = header.subarray(124, 136).toString("utf-8").trim();
|
|
134
|
-
const size = parseInt(sizeStr, 8) || 0;
|
|
135
|
-
|
|
136
|
-
offset += 512; // past header
|
|
137
|
-
|
|
138
|
-
if (name.endsWith("SKILL.md") || name === "SKILL.md") {
|
|
139
|
-
return tarBuffer.subarray(offset, offset + size).toString("utf-8");
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Skip to next header (data padded to 512 bytes)
|
|
143
|
-
offset += Math.ceil(size / 512) * 512;
|
|
144
|
-
}
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async function fetchSkillContent(skillId: string): Promise<string> {
|
|
149
|
-
const url = `${getPlatformUrl()}/v1/skills/${encodeURIComponent(skillId)}/`;
|
|
150
|
-
const response = await fetch(url, {
|
|
151
|
-
headers: buildHeaders(),
|
|
152
|
-
signal: AbortSignal.timeout(15000),
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
if (!response.ok) {
|
|
156
|
-
throw new Error(
|
|
157
|
-
`Failed to fetch skill "${skillId}": HTTP ${response.status}`,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const gzipBuffer = Buffer.from(await response.arrayBuffer());
|
|
162
|
-
const tarBuffer = gunzipSync(gzipBuffer);
|
|
163
|
-
const skillMd = extractSkillMdFromTar(tarBuffer);
|
|
164
|
-
|
|
165
|
-
if (!skillMd) {
|
|
166
|
-
throw new Error(`SKILL.md not found in archive for "${skillId}"`);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return skillMd;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
// Managed skill installation
|
|
174
|
-
// ---------------------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
function atomicWriteFile(filePath: string, content: string): void {
|
|
177
|
-
const dir = dirname(filePath);
|
|
178
|
-
mkdirSync(dir, { recursive: true });
|
|
179
|
-
const tmpPath = join(dir, `.tmp-${randomUUID()}`);
|
|
180
|
-
writeFileSync(tmpPath, content, "utf-8");
|
|
181
|
-
renameSync(tmpPath, filePath);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function upsertSkillsIndex(id: string): void {
|
|
185
|
-
const indexPath = getSkillsIndexPath();
|
|
186
|
-
let lines: string[] = [];
|
|
187
|
-
if (existsSync(indexPath)) {
|
|
188
|
-
lines = readFileSync(indexPath, "utf-8").split("\n");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
192
|
-
const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
|
|
193
|
-
if (lines.some((line) => pattern.test(line))) return;
|
|
194
|
-
|
|
195
|
-
const nonEmpty = lines.filter((l) => l.trim());
|
|
196
|
-
nonEmpty.push(`- ${id}`);
|
|
197
|
-
const content = nonEmpty.join("\n");
|
|
198
|
-
atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function installSkillLocally(
|
|
202
|
-
skillId: string,
|
|
203
|
-
skillMdContent: string,
|
|
204
|
-
catalogEntry: CatalogSkill,
|
|
205
|
-
overwrite: boolean,
|
|
206
|
-
): void {
|
|
207
|
-
const skillDir = join(getSkillsDir(), skillId);
|
|
208
|
-
const skillFilePath = join(skillDir, "SKILL.md");
|
|
209
|
-
|
|
210
|
-
if (existsSync(skillFilePath) && !overwrite) {
|
|
211
|
-
throw new Error(
|
|
212
|
-
`Skill "${skillId}" is already installed. Use --overwrite to replace it.`,
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
mkdirSync(skillDir, { recursive: true });
|
|
217
|
-
atomicWriteFile(skillFilePath, skillMdContent);
|
|
218
|
-
|
|
219
|
-
// Write version metadata
|
|
220
|
-
if (catalogEntry.version) {
|
|
221
|
-
const meta = {
|
|
222
|
-
version: catalogEntry.version,
|
|
223
|
-
installedAt: new Date().toISOString(),
|
|
224
|
-
};
|
|
225
|
-
atomicWriteFile(
|
|
226
|
-
join(skillDir, "version.json"),
|
|
227
|
-
JSON.stringify(meta, null, 2) + "\n",
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
upsertSkillsIndex(skillId);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// ---------------------------------------------------------------------------
|
|
235
|
-
// Helpers
|
|
236
|
-
// ---------------------------------------------------------------------------
|
|
237
|
-
|
|
238
|
-
function hasFlag(args: string[], flag: string): boolean {
|
|
239
|
-
return args.includes(flag);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// ---------------------------------------------------------------------------
|
|
243
|
-
// Usage
|
|
244
|
-
// ---------------------------------------------------------------------------
|
|
245
|
-
|
|
246
|
-
function printUsage(): void {
|
|
247
|
-
console.log("Usage: vellum skills <subcommand> [options]");
|
|
248
|
-
console.log("");
|
|
249
|
-
console.log("Subcommands:");
|
|
250
|
-
console.log(" list List available catalog skills");
|
|
251
|
-
console.log(
|
|
252
|
-
" install <skill-id> [--overwrite] Install a skill from the catalog",
|
|
253
|
-
);
|
|
254
|
-
console.log("");
|
|
255
|
-
console.log("Options:");
|
|
256
|
-
console.log(" --json Machine-readable JSON output");
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ---------------------------------------------------------------------------
|
|
260
|
-
// Command entry point
|
|
261
|
-
// ---------------------------------------------------------------------------
|
|
262
|
-
|
|
263
|
-
export async function skills(): Promise<void> {
|
|
264
|
-
const args = process.argv.slice(3);
|
|
265
|
-
const subcommand = args[0];
|
|
266
|
-
const json = hasFlag(args, "--json");
|
|
267
|
-
|
|
268
|
-
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
269
|
-
printUsage();
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
switch (subcommand) {
|
|
274
|
-
case "list": {
|
|
275
|
-
try {
|
|
276
|
-
const catalog = await fetchCatalog();
|
|
277
|
-
|
|
278
|
-
if (json) {
|
|
279
|
-
console.log(JSON.stringify({ ok: true, skills: catalog }));
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (catalog.length === 0) {
|
|
284
|
-
console.log("No skills available in the catalog.");
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
console.log(`Available skills (${catalog.length}):\n`);
|
|
289
|
-
for (const s of catalog) {
|
|
290
|
-
const emoji = s.emoji ? `${s.emoji} ` : "";
|
|
291
|
-
const deps = s.includes?.length
|
|
292
|
-
? ` (requires: ${s.includes.join(", ")})`
|
|
293
|
-
: "";
|
|
294
|
-
console.log(` ${emoji}${s.id}`);
|
|
295
|
-
console.log(` ${s.name} — ${s.description}${deps}`);
|
|
296
|
-
}
|
|
297
|
-
} catch (err) {
|
|
298
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
299
|
-
if (json) {
|
|
300
|
-
console.log(JSON.stringify({ ok: false, error: msg }));
|
|
301
|
-
} else {
|
|
302
|
-
console.error(`Error: ${msg}`);
|
|
303
|
-
}
|
|
304
|
-
process.exitCode = 1;
|
|
305
|
-
}
|
|
306
|
-
break;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
case "install": {
|
|
310
|
-
const skillId = args.find((a) => !a.startsWith("--") && a !== "install");
|
|
311
|
-
if (!skillId) {
|
|
312
|
-
console.error("Usage: vellum skills install <skill-id>");
|
|
313
|
-
process.exit(1);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const overwrite = hasFlag(args, "--overwrite");
|
|
317
|
-
|
|
318
|
-
try {
|
|
319
|
-
// Verify skill exists in catalog
|
|
320
|
-
const catalog = await fetchCatalog();
|
|
321
|
-
const entry = catalog.find((s) => s.id === skillId);
|
|
322
|
-
if (!entry) {
|
|
323
|
-
throw new Error(`Skill "${skillId}" not found in the Vellum catalog`);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Fetch SKILL.md from platform
|
|
327
|
-
const content = await fetchSkillContent(skillId);
|
|
328
|
-
|
|
329
|
-
// Install locally
|
|
330
|
-
installSkillLocally(skillId, content, entry, overwrite);
|
|
331
|
-
|
|
332
|
-
if (json) {
|
|
333
|
-
console.log(JSON.stringify({ ok: true, skillId }));
|
|
334
|
-
} else {
|
|
335
|
-
console.log(`Installed skill "${skillId}".`);
|
|
336
|
-
}
|
|
337
|
-
} catch (err) {
|
|
338
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
339
|
-
if (json) {
|
|
340
|
-
console.log(JSON.stringify({ ok: false, error: msg }));
|
|
341
|
-
} else {
|
|
342
|
-
console.error(`Error: ${msg}`);
|
|
343
|
-
}
|
|
344
|
-
process.exitCode = 1;
|
|
345
|
-
}
|
|
346
|
-
break;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
default: {
|
|
350
|
-
console.error(`Unknown skills subcommand: ${subcommand}`);
|
|
351
|
-
printUsage();
|
|
352
|
-
process.exit(1);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
}
|