@vellumai/cli 0.3.5 → 0.3.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,244 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ interface AllowlistConfig {
6
+ values?: string[];
7
+ prefixes?: string[];
8
+ patterns?: string[];
9
+ }
10
+
11
+ interface AllowlistValidationError {
12
+ index: number;
13
+ pattern: string;
14
+ message: string;
15
+ }
16
+
17
+ function getRootDir(): string {
18
+ return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
19
+ }
20
+
21
+ function getConfigPath(): string {
22
+ return join(getRootDir(), "workspace", "config.json");
23
+ }
24
+
25
+ function getAllowlistPath(): string {
26
+ return join(getRootDir(), "protected", "secret-allowlist.json");
27
+ }
28
+
29
+ function loadRawConfig(): Record<string, unknown> {
30
+ const configPath = getConfigPath();
31
+ if (!existsSync(configPath)) {
32
+ return {};
33
+ }
34
+ const raw = readFileSync(configPath, "utf-8");
35
+ return JSON.parse(raw) as Record<string, unknown>;
36
+ }
37
+
38
+ function saveRawConfig(config: Record<string, unknown>): void {
39
+ const configPath = getConfigPath();
40
+ const dir = dirname(configPath);
41
+ if (!existsSync(dir)) {
42
+ mkdirSync(dir, { recursive: true });
43
+ }
44
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
45
+ }
46
+
47
+ function getNestedValue(
48
+ obj: Record<string, unknown>,
49
+ path: string,
50
+ ): unknown {
51
+ const keys = path.split(".");
52
+ let current: unknown = obj;
53
+ for (const key of keys) {
54
+ if (
55
+ current === null ||
56
+ current === undefined ||
57
+ typeof current !== "object"
58
+ ) {
59
+ return undefined;
60
+ }
61
+ current = (current as Record<string, unknown>)[key];
62
+ }
63
+ return current;
64
+ }
65
+
66
+ function setNestedValue(
67
+ obj: Record<string, unknown>,
68
+ path: string,
69
+ value: unknown,
70
+ ): void {
71
+ const keys = path.split(".");
72
+ let current = obj;
73
+ for (let i = 0; i < keys.length - 1; i++) {
74
+ const key = keys[i];
75
+ if (
76
+ current[key] === undefined ||
77
+ current[key] === null ||
78
+ typeof current[key] !== "object"
79
+ ) {
80
+ current[key] = {};
81
+ }
82
+ current = current[key] as Record<string, unknown>;
83
+ }
84
+ current[keys[keys.length - 1]] = value;
85
+ }
86
+
87
+ function validateAllowlist(
88
+ allowlistConfig: AllowlistConfig,
89
+ ): AllowlistValidationError[] {
90
+ const errors: AllowlistValidationError[] = [];
91
+ if (!allowlistConfig.patterns) return errors;
92
+ if (!Array.isArray(allowlistConfig.patterns)) {
93
+ errors.push({
94
+ index: -1,
95
+ pattern: String(allowlistConfig.patterns),
96
+ message: '"patterns" must be an array',
97
+ });
98
+ return errors;
99
+ }
100
+
101
+ for (let i = 0; i < allowlistConfig.patterns.length; i++) {
102
+ const p = allowlistConfig.patterns[i];
103
+ if (typeof p !== "string") {
104
+ errors.push({
105
+ index: i,
106
+ pattern: String(p),
107
+ message: "Pattern is not a string",
108
+ });
109
+ continue;
110
+ }
111
+ try {
112
+ new RegExp(p);
113
+ } catch (err) {
114
+ errors.push({
115
+ index: i,
116
+ pattern: p,
117
+ message: (err as Error).message,
118
+ });
119
+ }
120
+ }
121
+ return errors;
122
+ }
123
+
124
+ function validateAllowlistFile(): AllowlistValidationError[] | null {
125
+ const filePath = getAllowlistPath();
126
+ if (!existsSync(filePath)) return null;
127
+
128
+ const raw = readFileSync(filePath, "utf-8");
129
+ const allowlistConfig: AllowlistConfig = JSON.parse(
130
+ raw,
131
+ ) as AllowlistConfig;
132
+ return validateAllowlist(allowlistConfig);
133
+ }
134
+
135
+ function printUsage(): void {
136
+ console.log("Usage: vellum config <subcommand> [options]");
137
+ console.log("");
138
+ console.log("Subcommands:");
139
+ console.log(
140
+ " get <key> Get a config value (supports dotted paths)",
141
+ );
142
+ console.log(
143
+ " set <key> <value> Set a config value (supports dotted paths like apiKeys.anthropic)",
144
+ );
145
+ console.log(
146
+ " list List all config values",
147
+ );
148
+ console.log(
149
+ " validate-allowlist Validate regex patterns in secret-allowlist.json",
150
+ );
151
+ }
152
+
153
+ export function config(): void {
154
+ const args = process.argv.slice(3);
155
+ const subcommand = args[0];
156
+
157
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
158
+ printUsage();
159
+ return;
160
+ }
161
+
162
+ switch (subcommand) {
163
+ case "set": {
164
+ const key = args[1];
165
+ const value = args[2];
166
+ if (!key || value === undefined) {
167
+ console.error("Usage: vellum config set <key> <value>");
168
+ process.exit(1);
169
+ }
170
+ const raw = loadRawConfig();
171
+ let parsed: unknown = value;
172
+ try {
173
+ parsed = JSON.parse(value);
174
+ } catch {
175
+ // keep as string
176
+ }
177
+ setNestedValue(raw, key, parsed);
178
+ saveRawConfig(raw);
179
+ console.log(`Set ${key} = ${JSON.stringify(parsed)}`);
180
+ break;
181
+ }
182
+
183
+ case "get": {
184
+ const key = args[1];
185
+ if (!key) {
186
+ console.error("Usage: vellum config get <key>");
187
+ process.exit(1);
188
+ }
189
+ const raw = loadRawConfig();
190
+ const val = getNestedValue(raw, key);
191
+ if (val === undefined) {
192
+ console.log("(not set)");
193
+ } else {
194
+ console.log(
195
+ typeof val === "object" ? JSON.stringify(val, null, 2) : String(val),
196
+ );
197
+ }
198
+ break;
199
+ }
200
+
201
+ case "list": {
202
+ const raw = loadRawConfig();
203
+ if (Object.keys(raw).length === 0) {
204
+ console.log("No configuration set");
205
+ } else {
206
+ console.log(JSON.stringify(raw, null, 2));
207
+ }
208
+ break;
209
+ }
210
+
211
+ case "validate-allowlist": {
212
+ try {
213
+ const errors = validateAllowlistFile();
214
+ if (errors === null) {
215
+ console.log("No secret-allowlist.json file found");
216
+ return;
217
+ }
218
+ if (errors.length === 0) {
219
+ console.log("All patterns in secret-allowlist.json are valid");
220
+ return;
221
+ }
222
+ console.error(
223
+ `Found ${errors.length} invalid pattern(s) in secret-allowlist.json:`,
224
+ );
225
+ for (const e of errors) {
226
+ console.error(` [${e.index}] "${e.pattern}": ${e.message}`);
227
+ }
228
+ process.exit(1);
229
+ } catch (err) {
230
+ console.error(
231
+ `Failed to read secret-allowlist.json: ${(err as Error).message}`,
232
+ );
233
+ process.exit(1);
234
+ }
235
+ break;
236
+ }
237
+
238
+ default: {
239
+ console.error(`Unknown config subcommand: ${subcommand}`);
240
+ printUsage();
241
+ process.exit(1);
242
+ }
243
+ }
244
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { VellumEmailClient } from "../email/vellum.js";
10
+ import { loadLatestAssistant } from "../lib/assistant-config.js";
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // Helpers
@@ -33,6 +34,7 @@ Subcommands:
33
34
  create <username> Create a new email inbox for the given username
34
35
 
35
36
  Options:
37
+ --assistant <id> Assistant ID (defaults to the most recently hatched)
36
38
  --help, -h Show this help message
37
39
  `);
38
40
  }
@@ -49,18 +51,36 @@ export async function email(): Promise<void> {
49
51
  return;
50
52
  }
51
53
 
54
+ // Resolve assistant ID from --assistant flag or latest assistant
55
+ const assistantFlagIdx = args.indexOf("--assistant");
56
+ let assistantId: string | undefined;
57
+ if (assistantFlagIdx !== -1) {
58
+ const value = args[assistantFlagIdx + 1];
59
+ if (!value || value.startsWith("-")) {
60
+ exitError("--assistant requires a value.");
61
+ return;
62
+ }
63
+ assistantId = value;
64
+ args.splice(assistantFlagIdx, 2);
65
+ }
66
+ if (!assistantId) {
67
+ assistantId = loadLatestAssistant()?.assistantId;
68
+ }
69
+ if (!assistantId) {
70
+ exitError(
71
+ "No assistant ID available. Pass --assistant <id> or hatch an assistant first.",
72
+ );
73
+ return;
74
+ }
75
+
52
76
  const subcommand = args[0];
53
77
 
54
78
  switch (subcommand) {
55
79
  case "status": {
56
80
  try {
57
- const client = new VellumEmailClient();
58
- const status = await client.status();
59
- output({
60
- ok: true,
61
- provider: status.provider,
62
- inboxes: status.inboxes,
63
- });
81
+ const client = new VellumEmailClient(assistantId);
82
+ const addresses = await client.status();
83
+ output({ ok: true, addresses });
64
84
  } catch (err) {
65
85
  exitError(err instanceof Error ? err.message : String(err));
66
86
  }
@@ -73,7 +93,7 @@ export async function email(): Promise<void> {
73
93
  return;
74
94
  }
75
95
  try {
76
- const client = new VellumEmailClient();
96
+ const client = new VellumEmailClient(assistantId);
77
97
  const inbox = await client.createInbox(username);
78
98
  output({ ok: true, inbox });
79
99
  } catch (err) {
@@ -22,6 +22,7 @@ import type { PollResult, WatchHatchingResult } from "../lib/gcp";
22
22
  import { startLocalDaemon, startGateway, stopLocalProcesses } from "../lib/local";
23
23
  import { isProcessAlive } from "../lib/process";
24
24
  import { generateRandomSuffix } from "../lib/random-name";
25
+ import { validateAssistantName } from "../lib/retire-archive";
25
26
  import { exec } from "../lib/step-runner";
26
27
 
27
28
  export type { PollResult, WatchHatchingResult } from "../lib/gcp";
@@ -169,6 +170,12 @@ function parseArgs(): HatchArgs {
169
170
  console.error("Error: --name requires a value");
170
171
  process.exit(1);
171
172
  }
173
+ try {
174
+ validateAssistantName(next);
175
+ } catch {
176
+ console.error(`Error: --name contains invalid characters (path separators or traversal segments are not allowed)`);
177
+ process.exit(1);
178
+ }
172
179
  name = next;
173
180
  i++;
174
181
  } else if (arg === "--remote") {
@@ -245,6 +245,57 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
245
245
  printTable(rows);
246
246
  }
247
247
 
248
+ // ── Orphaned process detection ──────────────────────────────────
249
+
250
+ interface OrphanedProcess {
251
+ name: string;
252
+ pid: string;
253
+ source: string;
254
+ }
255
+
256
+ async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
257
+ const results: OrphanedProcess[] = [];
258
+ const seenPids = new Set<string>();
259
+ const vellumDir = join(homedir(), ".vellum");
260
+
261
+ // Strategy 1: PID file scan
262
+ const pidFiles: Array<{ file: string; name: string }> = [
263
+ { file: join(vellumDir, "vellum.pid"), name: "daemon" },
264
+ { file: join(vellumDir, "gateway.pid"), name: "gateway" },
265
+ { file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
266
+ ];
267
+
268
+ for (const { file, name } of pidFiles) {
269
+ const result = checkPidFile(file);
270
+ if (result.status === "running" && result.pid) {
271
+ results.push({ name, pid: result.pid, source: "pid file" });
272
+ seenPids.add(result.pid);
273
+ }
274
+ }
275
+
276
+ // Strategy 2: Process table scan
277
+ try {
278
+ const output = await execOutput("sh", [
279
+ "-c",
280
+ "ps ax -o pid=,ppid=,args= | grep -E 'vellum|gateway|qdrant|openclaw' | grep -v grep",
281
+ ]);
282
+ const procs = parseRemotePs(output);
283
+ const ownPid = String(process.pid);
284
+
285
+ for (const p of procs) {
286
+ if (p.pid === ownPid || seenPids.has(p.pid)) continue;
287
+ const type = classifyProcess(p.command);
288
+ if (type === "unknown") continue;
289
+ results.push({ name: type, pid: p.pid, source: "process table" });
290
+ seenPids.add(p.pid);
291
+ }
292
+ } catch {
293
+ // grep exits 1 when no matches found — ignore
294
+ }
295
+
296
+ return results;
297
+ }
298
+
248
299
  // ── List all assistants (no arg) ────────────────────────────────
249
300
 
250
301
  async function listAllAssistants(): Promise<void> {
@@ -252,6 +303,20 @@ async function listAllAssistants(): Promise<void> {
252
303
 
253
304
  if (assistants.length === 0) {
254
305
  console.log("No assistants found.");
306
+
307
+ const orphans = await detectOrphanedProcesses();
308
+ if (orphans.length > 0) {
309
+ console.log("\nOrphaned processes detected:\n");
310
+ const rows: TableRow[] = orphans.map((o) => ({
311
+ name: o.name,
312
+ status: withStatusEmoji("running"),
313
+ info: `PID ${o.pid} (from ${o.source})`,
314
+ }));
315
+ printTable(rows);
316
+ const pids = orphans.map((o) => o.pid).join(" ");
317
+ console.log(`\nHint: Run \`kill ${pids}\` to clean up orphaned processes.`);
318
+ }
319
+
255
320
  return;
256
321
  }
257
322
 
@@ -0,0 +1,54 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ import { saveAssistantEntry } from "../lib/assistant-config";
6
+ import type { AssistantEntry } from "../lib/assistant-config";
7
+ import { startLocalDaemon, startGateway } from "../lib/local";
8
+ import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
9
+ import { exec } from "../lib/step-runner";
10
+
11
+ export async function recover(): Promise<void> {
12
+ const name = process.argv[3];
13
+ if (!name) {
14
+ console.error("Usage: vellum-cli recover <name>");
15
+ process.exit(1);
16
+ }
17
+
18
+ const archivePath = getArchivePath(name);
19
+ const metadataPath = getMetadataPath(name);
20
+
21
+ // 1. Verify archive exists
22
+ if (!existsSync(archivePath) || !existsSync(metadataPath)) {
23
+ console.error(`No retired archive found for '${name}'.`);
24
+ process.exit(1);
25
+ }
26
+
27
+ // 2. Check ~/.vellum doesn't already exist
28
+ const vellumDir = join(homedir(), ".vellum");
29
+ if (existsSync(vellumDir)) {
30
+ console.error(
31
+ "Error: ~/.vellum already exists. Retire the current assistant first."
32
+ );
33
+ process.exit(1);
34
+ }
35
+
36
+ // 3. Extract archive
37
+ await exec("tar", ["xzf", archivePath, "-C", homedir()]);
38
+
39
+ // 4. Restore lockfile entry
40
+ const entry: AssistantEntry = JSON.parse(readFileSync(metadataPath, "utf-8"));
41
+ saveAssistantEntry(entry);
42
+
43
+ // 5. Clean up archive
44
+ unlinkSync(archivePath);
45
+ unlinkSync(metadataPath);
46
+
47
+ // 6. Start daemon + gateway (same as wake)
48
+ await startLocalDaemon();
49
+ if (!process.env.VELLUM_DESKTOP_APP) {
50
+ await startGateway();
51
+ }
52
+
53
+ console.log(`✅ Recovered assistant '${name}'.`);
54
+ }
@@ -1,13 +1,14 @@
1
1
  import { spawn } from "child_process";
2
- import { rmSync } from "fs";
2
+ import { rmSync, writeFileSync } from "fs";
3
3
  import { homedir } from "os";
4
- import { join } from "path";
4
+ import { basename, dirname, join } from "path";
5
5
 
6
6
  import { findAssistantByName, removeAssistantEntry } from "../lib/assistant-config";
7
7
  import type { AssistantEntry } from "../lib/assistant-config";
8
8
  import { retireInstance as retireAwsInstance } from "../lib/aws";
9
9
  import { retireInstance as retireGcpInstance } from "../lib/gcp";
10
10
  import { stopProcessByPidFile } from "../lib/process";
11
+ import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
11
12
  import { exec } from "../lib/step-runner";
12
13
 
13
14
  function resolveCloud(entry: AssistantEntry): string {
@@ -32,7 +33,7 @@ function extractHostFromUrl(url: string): string {
32
33
  }
33
34
  }
34
35
 
35
- async function retireLocal(): Promise<void> {
36
+ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
36
37
  console.log("\u{1F5D1}\ufe0f Stopping local daemon...\n");
37
38
 
38
39
  const vellumDir = join(homedir(), ".vellum");
@@ -61,6 +62,18 @@ async function retireLocal(): Promise<void> {
61
62
  } catch {}
62
63
  }
63
64
 
65
+ // Archive ~/.vellum before deleting
66
+ try {
67
+ const archivePath = getArchivePath(name);
68
+ const metadataPath = getMetadataPath(name);
69
+ await exec("tar", ["czf", archivePath, "-C", dirname(vellumDir), basename(vellumDir)]);
70
+ writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
71
+ console.log(`📦 Archived to ${archivePath}`);
72
+ } catch (err) {
73
+ console.warn(`⚠️ Failed to archive: ${err instanceof Error ? err.message : err}`);
74
+ console.warn("Proceeding with permanent deletion.");
75
+ }
76
+
64
77
  rmSync(vellumDir, { recursive: true, force: true });
65
78
 
66
79
  console.log("\u2705 Local instance retired.");
@@ -142,7 +155,7 @@ export async function retire(): Promise<void> {
142
155
  }
143
156
  await retireAwsInstance(name, region, source);
144
157
  } else if (cloud === "local") {
145
- await retireLocal();
158
+ await retireLocal(name, entry);
146
159
  } else if (cloud === "custom") {
147
160
  await retireCustom(entry);
148
161
  } else {
@@ -9,17 +9,10 @@ const DEFAULT_VELLUM_API_URL = "https://api.vellum.ai";
9
9
  // Types
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
- export interface EmailInbox {
12
+ export interface AssistantEmailAddress {
13
13
  id: string;
14
14
  address: string;
15
- displayName?: string;
16
- createdAt: string;
17
- }
18
-
19
- export interface EmailStatus {
20
- provider: string;
21
- ok: boolean;
22
- inboxes: EmailInbox[];
15
+ created_at: string;
23
16
  }
24
17
 
25
18
  // ---------------------------------------------------------------------------
@@ -63,8 +56,10 @@ async function vellumFetch(
63
56
  export class VellumEmailClient {
64
57
  private apiKey: string;
65
58
  private baseUrl: string;
59
+ private assistantId: string;
66
60
 
67
- constructor(apiKey?: string, baseUrl?: string) {
61
+ constructor(assistantId: string, apiKey?: string, baseUrl?: string) {
62
+ this.assistantId = assistantId;
68
63
  const resolvedKey = apiKey ?? process.env.VELLUM_API_KEY;
69
64
  if (!resolvedKey) {
70
65
  throw new Error(
@@ -77,27 +72,26 @@ export class VellumEmailClient {
77
72
  }
78
73
 
79
74
  /** List existing email addresses and check connectivity. */
80
- async status(): Promise<EmailStatus> {
75
+ async status(): Promise<AssistantEmailAddress[]> {
81
76
  const result = await vellumFetch(
82
77
  this.apiKey,
83
78
  this.baseUrl,
84
- "/v1/email-addresses",
79
+ `/v1/assistants/${this.assistantId}/email-addresses/`,
85
80
  );
86
- const inboxes = (result as { inboxes: EmailInbox[] }).inboxes;
87
- return { provider: "vellum", ok: true, inboxes };
81
+ return result as AssistantEmailAddress[];
88
82
  }
89
83
 
90
84
  /** Provision a new email address for the given username. */
91
- async createInbox(username: string): Promise<EmailInbox> {
85
+ async createInbox(username: string): Promise<AssistantEmailAddress> {
92
86
  const result = await vellumFetch(
93
87
  this.apiKey,
94
88
  this.baseUrl,
95
- "/v1/email-addresses",
89
+ `/v1/assistants/${this.assistantId}/email-addresses/`,
96
90
  {
97
91
  method: "POST",
98
92
  body: { username },
99
93
  },
100
94
  );
101
- return result as EmailInbox;
95
+ return result as AssistantEmailAddress;
102
96
  }
103
97
  }
package/src/index.ts CHANGED
@@ -6,9 +6,11 @@ import { dirname, join } from "node:path";
6
6
  import { spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { client } from "./commands/client";
9
+ import { config } from "./commands/config";
9
10
  import { email } from "./commands/email";
10
11
  import { hatch } from "./commands/hatch";
11
12
  import { ps } from "./commands/ps";
13
+ import { recover } from "./commands/recover";
12
14
  import { retire } from "./commands/retire";
13
15
  import { sleep } from "./commands/sleep";
14
16
  import { ssh } from "./commands/ssh";
@@ -16,9 +18,11 @@ import { wake } from "./commands/wake";
16
18
 
17
19
  const commands = {
18
20
  client,
21
+ config,
19
22
  email,
20
23
  hatch,
21
24
  ps,
25
+ recover,
22
26
  retire,
23
27
  sleep,
24
28
  ssh,
@@ -62,9 +66,11 @@ async function main() {
62
66
  console.log("");
63
67
  console.log("Commands:");
64
68
  console.log(" client Connect to a hatched assistant");
69
+ console.log(" config Manage configuration");
65
70
  console.log(" email Email operations (status, create inbox)");
66
71
  console.log(" hatch Create a new assistant instance");
67
72
  console.log(" ps List assistants (or processes for a specific assistant)");
73
+ console.log(" recover Restore a previously retired local assistant");
68
74
  console.log(" retire Delete an assistant instance");
69
75
  console.log(" sleep Stop the daemon process");
70
76
  console.log(" ssh SSH into a remote assistant instance");
package/src/lib/local.ts CHANGED
@@ -193,6 +193,7 @@ export async function startLocalDaemon(): Promise<void> {
193
193
  for (const key of [
194
194
  "ANTHROPIC_API_KEY",
195
195
  "BASE_DATA_DIR",
196
+ "RUNTIME_HTTP_PORT",
196
197
  "VELLUM_DAEMON_TCP_PORT",
197
198
  "VELLUM_DAEMON_TCP_HOST",
198
199
  "VELLUM_DAEMON_SOCKET",
@@ -0,0 +1,43 @@
1
+ import { mkdirSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { basename, join, resolve } from "path";
4
+
5
+ export function getRetiredDir(): string {
6
+ const xdgData =
7
+ process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
8
+ const dir = join(xdgData, "vellum", "retired");
9
+ mkdirSync(dir, { recursive: true });
10
+ return dir;
11
+ }
12
+
13
+ /** Throws if the name contains path separators or traversal segments. */
14
+ export function validateAssistantName(name: string): void {
15
+ if (
16
+ !name ||
17
+ name.includes("/") ||
18
+ name.includes("\\") ||
19
+ name === ".." ||
20
+ name === "."
21
+ ) {
22
+ throw new Error(`Invalid assistant name: '${name}'`);
23
+ }
24
+ }
25
+
26
+ function safeName(assistantId: string): string {
27
+ validateAssistantName(assistantId);
28
+ // Canonicalize and verify the result stays inside the retired directory
29
+ const retiredDir = getRetiredDir();
30
+ const candidate = resolve(retiredDir, basename(assistantId));
31
+ if (!candidate.startsWith(retiredDir + "/")) {
32
+ throw new Error(`Invalid assistant name: '${assistantId}'`);
33
+ }
34
+ return basename(assistantId);
35
+ }
36
+
37
+ export function getArchivePath(assistantId: string): string {
38
+ return join(getRetiredDir(), `${safeName(assistantId)}.tar.gz`);
39
+ }
40
+
41
+ export function getMetadataPath(assistantId: string): string {
42
+ return join(getRetiredDir(), `${safeName(assistantId)}.json`);
43
+ }