@vellumai/cli 0.3.11 → 0.3.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 +8 -8
- package/bun.lock +6 -0
- package/package.json +5 -2
- package/src/__tests__/assistant-config.test.ts +139 -0
- package/src/__tests__/constants.test.ts +48 -0
- package/src/__tests__/health-check.test.ts +64 -0
- package/src/__tests__/random-name.test.ts +19 -0
- package/src/__tests__/retire-archive.test.ts +30 -0
- package/src/__tests__/status-emoji.test.ts +44 -0
- package/src/commands/autonomy.ts +321 -0
- package/src/commands/client.ts +15 -8
- package/src/commands/contacts.ts +265 -0
- package/src/commands/hatch.ts +93 -37
- package/src/commands/login.ts +68 -0
- package/src/commands/ps.ts +7 -3
- package/src/commands/recover.ts +1 -1
- package/src/commands/retire.ts +2 -2
- package/src/commands/skills.ts +355 -0
- package/src/commands/ssh.ts +5 -2
- package/src/components/DefaultMainScreen.tsx +67 -3
- package/src/index.ts +23 -0
- package/src/lib/assistant-config.ts +1 -0
- package/src/lib/gcp.ts +9 -13
- package/src/lib/local.ts +23 -28
- package/src/lib/platform-client.ts +74 -0
- package/src/types/sh.d.ts +4 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Types & constants (ported from assistant/src/autonomy/types.ts)
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
type AutonomyTier = "auto" | "draft" | "notify";
|
|
10
|
+
|
|
11
|
+
const AUTONOMY_TIERS: readonly AutonomyTier[] = ["auto", "draft", "notify"];
|
|
12
|
+
|
|
13
|
+
interface AutonomyConfig {
|
|
14
|
+
defaultTier: AutonomyTier;
|
|
15
|
+
channelDefaults: Record<string, AutonomyTier>;
|
|
16
|
+
categoryOverrides: Record<string, AutonomyTier>;
|
|
17
|
+
contactOverrides: Record<string, AutonomyTier>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_AUTONOMY_CONFIG: AutonomyConfig = {
|
|
21
|
+
defaultTier: "notify",
|
|
22
|
+
channelDefaults: {},
|
|
23
|
+
categoryOverrides: {},
|
|
24
|
+
contactOverrides: {},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Config persistence (ported from assistant/src/autonomy/autonomy-store.ts)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function getConfigPath(): string {
|
|
32
|
+
const root = join(
|
|
33
|
+
process.env.BASE_DATA_DIR?.trim() || homedir(),
|
|
34
|
+
".vellum",
|
|
35
|
+
);
|
|
36
|
+
return join(root, "workspace", "autonomy.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isValidTier(value: unknown): value is AutonomyTier {
|
|
40
|
+
return (
|
|
41
|
+
typeof value === "string" &&
|
|
42
|
+
AUTONOMY_TIERS.includes(value as AutonomyTier)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateTierRecord(raw: unknown): Record<string, AutonomyTier> {
|
|
47
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
48
|
+
const result: Record<string, AutonomyTier> = {};
|
|
49
|
+
for (const [key, value] of Object.entries(
|
|
50
|
+
raw as Record<string, unknown>,
|
|
51
|
+
)) {
|
|
52
|
+
if (isValidTier(value)) {
|
|
53
|
+
result[key] = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateConfig(raw: unknown): AutonomyConfig {
|
|
60
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
61
|
+
return structuredClone(DEFAULT_AUTONOMY_CONFIG);
|
|
62
|
+
}
|
|
63
|
+
const obj = raw as Record<string, unknown>;
|
|
64
|
+
return {
|
|
65
|
+
defaultTier: isValidTier(obj.defaultTier)
|
|
66
|
+
? obj.defaultTier
|
|
67
|
+
: DEFAULT_AUTONOMY_CONFIG.defaultTier,
|
|
68
|
+
channelDefaults: validateTierRecord(obj.channelDefaults),
|
|
69
|
+
categoryOverrides: validateTierRecord(obj.categoryOverrides),
|
|
70
|
+
contactOverrides: validateTierRecord(obj.contactOverrides),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function loadConfig(): AutonomyConfig {
|
|
75
|
+
const configPath = getConfigPath();
|
|
76
|
+
if (!existsSync(configPath)) {
|
|
77
|
+
return structuredClone(DEFAULT_AUTONOMY_CONFIG);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
81
|
+
return validateConfig(JSON.parse(raw));
|
|
82
|
+
} catch {
|
|
83
|
+
console.error("Warning: failed to parse autonomy config; using defaults");
|
|
84
|
+
return structuredClone(DEFAULT_AUTONOMY_CONFIG);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function saveConfig(config: AutonomyConfig): void {
|
|
89
|
+
const configPath = getConfigPath();
|
|
90
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
91
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function applyUpdate(updates: Partial<AutonomyConfig>): AutonomyConfig {
|
|
95
|
+
const current = loadConfig();
|
|
96
|
+
if (updates.defaultTier !== undefined) {
|
|
97
|
+
current.defaultTier = updates.defaultTier;
|
|
98
|
+
}
|
|
99
|
+
if (updates.channelDefaults !== undefined) {
|
|
100
|
+
current.channelDefaults = {
|
|
101
|
+
...current.channelDefaults,
|
|
102
|
+
...updates.channelDefaults,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (updates.categoryOverrides !== undefined) {
|
|
106
|
+
current.categoryOverrides = {
|
|
107
|
+
...current.categoryOverrides,
|
|
108
|
+
...updates.categoryOverrides,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (updates.contactOverrides !== undefined) {
|
|
112
|
+
current.contactOverrides = {
|
|
113
|
+
...current.contactOverrides,
|
|
114
|
+
...updates.contactOverrides,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
saveConfig(current);
|
|
118
|
+
return current;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Output helpers
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function output(data: unknown, json: boolean): void {
|
|
126
|
+
process.stdout.write(
|
|
127
|
+
json ? JSON.stringify(data) + "\n" : JSON.stringify(data, null, 2) + "\n",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatConfigForHuman(config: AutonomyConfig): string {
|
|
132
|
+
const lines: string[] = [` Default tier: ${config.defaultTier}`];
|
|
133
|
+
|
|
134
|
+
const channelEntries = Object.entries(config.channelDefaults);
|
|
135
|
+
if (channelEntries.length > 0) {
|
|
136
|
+
lines.push(" Channel defaults:");
|
|
137
|
+
for (const [channel, tier] of channelEntries) {
|
|
138
|
+
lines.push(` ${channel}: ${tier}`);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
lines.push(" Channel defaults: (none)");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const categoryEntries = Object.entries(config.categoryOverrides);
|
|
145
|
+
if (categoryEntries.length > 0) {
|
|
146
|
+
lines.push(" Category overrides:");
|
|
147
|
+
for (const [category, tier] of categoryEntries) {
|
|
148
|
+
lines.push(` ${category}: ${tier}`);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
lines.push(" Category overrides: (none)");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const contactEntries = Object.entries(config.contactOverrides);
|
|
155
|
+
if (contactEntries.length > 0) {
|
|
156
|
+
lines.push(" Contact overrides:");
|
|
157
|
+
for (const [contactId, tier] of contactEntries) {
|
|
158
|
+
lines.push(` ${contactId}: ${tier}`);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
lines.push(" Contact overrides: (none)");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Arg parsing helpers
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
172
|
+
return args.includes(flag);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getFlagValue(args: string[], flag: string): string | undefined {
|
|
176
|
+
const idx = args.indexOf(flag);
|
|
177
|
+
if (idx === -1 || idx + 1 >= args.length) return undefined;
|
|
178
|
+
return args[idx + 1];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Usage
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function printUsage(): void {
|
|
186
|
+
console.log("Usage: vellum autonomy <subcommand> [options]");
|
|
187
|
+
console.log("");
|
|
188
|
+
console.log("Subcommands:");
|
|
189
|
+
console.log(" get Show current autonomy configuration");
|
|
190
|
+
console.log(" set --default <tier> Set the global default tier");
|
|
191
|
+
console.log(" set --channel <ch> --tier <t> Set tier for a channel");
|
|
192
|
+
console.log(" set --category <cat> --tier <t> Set tier for a category");
|
|
193
|
+
console.log(" set --contact <id> --tier <t> Set tier for a contact");
|
|
194
|
+
console.log("");
|
|
195
|
+
console.log("Options:");
|
|
196
|
+
console.log(" --json Machine-readable JSON output");
|
|
197
|
+
console.log("");
|
|
198
|
+
console.log("Tiers: auto, draft, notify");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Command entry point
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
export function autonomy(): void {
|
|
206
|
+
const args = process.argv.slice(3);
|
|
207
|
+
const subcommand = args[0];
|
|
208
|
+
const json = hasFlag(args, "--json");
|
|
209
|
+
|
|
210
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
211
|
+
printUsage();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
switch (subcommand) {
|
|
216
|
+
case "get": {
|
|
217
|
+
const config = loadConfig();
|
|
218
|
+
if (json) {
|
|
219
|
+
output({ ok: true, config }, true);
|
|
220
|
+
} else {
|
|
221
|
+
process.stdout.write("Autonomy configuration:\n\n");
|
|
222
|
+
process.stdout.write(formatConfigForHuman(config) + "\n");
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
case "set": {
|
|
228
|
+
const defaultTier = getFlagValue(args, "--default");
|
|
229
|
+
const channel = getFlagValue(args, "--channel");
|
|
230
|
+
const category = getFlagValue(args, "--category");
|
|
231
|
+
const contact = getFlagValue(args, "--contact");
|
|
232
|
+
const tier = getFlagValue(args, "--tier");
|
|
233
|
+
|
|
234
|
+
if (defaultTier) {
|
|
235
|
+
if (!isValidTier(defaultTier)) {
|
|
236
|
+
output(
|
|
237
|
+
{
|
|
238
|
+
ok: false,
|
|
239
|
+
error: `Invalid tier "${defaultTier}". Must be one of: ${AUTONOMY_TIERS.join(", ")}`,
|
|
240
|
+
},
|
|
241
|
+
true,
|
|
242
|
+
);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const config = applyUpdate({ defaultTier });
|
|
247
|
+
if (json) {
|
|
248
|
+
output({ ok: true, config }, true);
|
|
249
|
+
} else {
|
|
250
|
+
console.log(`Set global default tier to "${defaultTier}".`);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!tier) {
|
|
256
|
+
output(
|
|
257
|
+
{ ok: false, error: "Missing --tier. Use --tier <auto|draft|notify>." },
|
|
258
|
+
true,
|
|
259
|
+
);
|
|
260
|
+
process.exitCode = 1;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!isValidTier(tier)) {
|
|
264
|
+
output(
|
|
265
|
+
{
|
|
266
|
+
ok: false,
|
|
267
|
+
error: `Invalid tier "${tier}". Must be one of: ${AUTONOMY_TIERS.join(", ")}`,
|
|
268
|
+
},
|
|
269
|
+
true,
|
|
270
|
+
);
|
|
271
|
+
process.exitCode = 1;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (channel) {
|
|
276
|
+
const config = applyUpdate({ channelDefaults: { [channel]: tier } });
|
|
277
|
+
if (json) {
|
|
278
|
+
output({ ok: true, config }, true);
|
|
279
|
+
} else {
|
|
280
|
+
console.log(`Set channel "${channel}" default to "${tier}".`);
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (category) {
|
|
286
|
+
const config = applyUpdate({
|
|
287
|
+
categoryOverrides: { [category]: tier },
|
|
288
|
+
});
|
|
289
|
+
if (json) {
|
|
290
|
+
output({ ok: true, config }, true);
|
|
291
|
+
} else {
|
|
292
|
+
console.log(`Set category "${category}" override to "${tier}".`);
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (contact) {
|
|
298
|
+
const config = applyUpdate({ contactOverrides: { [contact]: tier } });
|
|
299
|
+
if (json) {
|
|
300
|
+
output({ ok: true, config }, true);
|
|
301
|
+
} else {
|
|
302
|
+
console.log(`Set contact "${contact}" override to "${tier}".`);
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
console.error(
|
|
308
|
+
"Specify one of: --default <tier>, --channel <channel> --tier <tier>, " +
|
|
309
|
+
"--category <category> --tier <tier>, or --contact <contactId> --tier <tier>.",
|
|
310
|
+
);
|
|
311
|
+
process.exitCode = 1;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
default: {
|
|
316
|
+
console.error(`Unknown autonomy subcommand: ${subcommand}`);
|
|
317
|
+
printUsage();
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
package/src/commands/client.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
|
-
import { ANSI, renderChatApp } from "../components/DefaultMainScreen";
|
|
5
4
|
import { findAssistantByName, loadLatestAssistant } from "../lib/assistant-config";
|
|
6
5
|
import { GATEWAY_PORT, type Species } from "../lib/constants";
|
|
7
6
|
|
|
7
|
+
const ANSI = {
|
|
8
|
+
reset: "\x1b[0m",
|
|
9
|
+
bold: "\x1b[1m",
|
|
10
|
+
dim: "\x1b[2m",
|
|
11
|
+
};
|
|
12
|
+
|
|
8
13
|
const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
|
|
9
14
|
const FALLBACK_ASSISTANT_ID = "default";
|
|
10
15
|
|
|
@@ -80,10 +85,10 @@ function parseArgs(): ParsedArgs {
|
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
function printUsage(): void {
|
|
83
|
-
console.log(`${ANSI.bold}vellum
|
|
88
|
+
console.log(`${ANSI.bold}vellum client${ANSI.reset} - Connect to a hatched assistant
|
|
84
89
|
|
|
85
90
|
${ANSI.bold}USAGE:${ANSI.reset}
|
|
86
|
-
vellum
|
|
91
|
+
vellum client [name] [options]
|
|
87
92
|
|
|
88
93
|
${ANSI.bold}ARGUMENTS:${ANSI.reset}
|
|
89
94
|
[name] Instance name (default: latest)
|
|
@@ -94,20 +99,22 @@ ${ANSI.bold}OPTIONS:${ANSI.reset}
|
|
|
94
99
|
-h, --help Show this help message
|
|
95
100
|
|
|
96
101
|
${ANSI.bold}DEFAULTS:${ANSI.reset}
|
|
97
|
-
Reads from ~/.vellum.lock.json (created by vellum
|
|
102
|
+
Reads from ~/.vellum.lock.json (created by vellum hatch).
|
|
98
103
|
Override with flags above or env vars RUNTIME_URL / ASSISTANT_ID.
|
|
99
104
|
|
|
100
105
|
${ANSI.bold}EXAMPLES:${ANSI.reset}
|
|
101
|
-
vellum
|
|
102
|
-
vellum
|
|
103
|
-
vellum
|
|
104
|
-
vellum
|
|
106
|
+
vellum client
|
|
107
|
+
vellum client vellum-assistant-foo
|
|
108
|
+
vellum client --url http://34.56.78.90:${GATEWAY_PORT}
|
|
109
|
+
vellum client vellum-assistant-foo --url http://localhost:${GATEWAY_PORT}
|
|
105
110
|
`);
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
export async function client(): Promise<void> {
|
|
109
114
|
const { runtimeUrl, assistantId, species, bearerToken, project, zone } = parseArgs();
|
|
110
115
|
|
|
116
|
+
const { renderChatApp } = await import("../components/DefaultMainScreen");
|
|
117
|
+
|
|
111
118
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
112
119
|
|
|
113
120
|
const app = renderChatApp(
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { loadLatestAssistant } from "../lib/assistant-config";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Runtime API client
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function getRuntimeUrl(): string {
|
|
12
|
+
const entry = loadLatestAssistant();
|
|
13
|
+
if (entry?.runtimeUrl) return entry.runtimeUrl;
|
|
14
|
+
return "http://localhost:7821";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getBearerToken(): string | undefined {
|
|
18
|
+
const entry = loadLatestAssistant();
|
|
19
|
+
if (entry?.bearerToken) return entry.bearerToken;
|
|
20
|
+
try {
|
|
21
|
+
const tokenPath = join(
|
|
22
|
+
process.env.BASE_DATA_DIR?.trim() || homedir(),
|
|
23
|
+
".vellum",
|
|
24
|
+
"http-token",
|
|
25
|
+
);
|
|
26
|
+
if (existsSync(tokenPath)) {
|
|
27
|
+
const token = readFileSync(tokenPath, "utf-8").trim();
|
|
28
|
+
if (token) return token;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildHeaders(): Record<string, string> {
|
|
37
|
+
const headers: Record<string, string> = {
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
};
|
|
40
|
+
const token = getBearerToken();
|
|
41
|
+
if (token) {
|
|
42
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
43
|
+
}
|
|
44
|
+
return headers;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function apiGet(path: string): Promise<unknown> {
|
|
48
|
+
const url = `${getRuntimeUrl()}/v1/${path}`;
|
|
49
|
+
const response = await fetch(url, { headers: buildHeaders() });
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const text = await response.text();
|
|
52
|
+
throw new Error(`API error ${response.status}: ${text}`);
|
|
53
|
+
}
|
|
54
|
+
return response.json();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function apiPost(
|
|
58
|
+
path: string,
|
|
59
|
+
body: unknown,
|
|
60
|
+
): Promise<unknown> {
|
|
61
|
+
const url = `${getRuntimeUrl()}/v1/${path}`;
|
|
62
|
+
const response = await fetch(url, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: buildHeaders(),
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
const text = await response.text();
|
|
69
|
+
throw new Error(`API error ${response.status}: ${text}`);
|
|
70
|
+
}
|
|
71
|
+
return response.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Types
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
interface ContactChannel {
|
|
79
|
+
type: string;
|
|
80
|
+
address: string;
|
|
81
|
+
isPrimary: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Contact {
|
|
85
|
+
id: string;
|
|
86
|
+
displayName: string;
|
|
87
|
+
relationship: string | null;
|
|
88
|
+
importance: number;
|
|
89
|
+
responseExpectation: string | null;
|
|
90
|
+
preferredTone: string | null;
|
|
91
|
+
lastInteraction: number | null;
|
|
92
|
+
interactionCount: number;
|
|
93
|
+
channels: ContactChannel[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Output helpers
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
101
|
+
return args.includes(flag);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getFlagValue(args: string[], flag: string): string | undefined {
|
|
105
|
+
const idx = args.indexOf(flag);
|
|
106
|
+
if (idx === -1 || idx + 1 >= args.length) return undefined;
|
|
107
|
+
return args[idx + 1];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatContact(c: Contact): string {
|
|
111
|
+
const lines = [
|
|
112
|
+
` ID: ${c.id}`,
|
|
113
|
+
` Name: ${c.displayName}`,
|
|
114
|
+
` Relationship: ${c.relationship ?? "(none)"}`,
|
|
115
|
+
` Importance: ${c.importance.toFixed(2)}`,
|
|
116
|
+
` Response: ${c.responseExpectation ?? "(none)"}`,
|
|
117
|
+
` Tone: ${c.preferredTone ?? "(none)"}`,
|
|
118
|
+
` Interactions: ${c.interactionCount}`,
|
|
119
|
+
];
|
|
120
|
+
if (c.lastInteraction) {
|
|
121
|
+
lines.push(` Last seen: ${new Date(c.lastInteraction).toISOString()}`);
|
|
122
|
+
}
|
|
123
|
+
if (c.channels.length > 0) {
|
|
124
|
+
lines.push(" Channels:");
|
|
125
|
+
for (const ch of c.channels) {
|
|
126
|
+
const primary = ch.isPrimary ? " (primary)" : "";
|
|
127
|
+
lines.push(` - ${ch.type}: ${ch.address}${primary}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Usage
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function printUsage(): void {
|
|
138
|
+
console.log("Usage: vellum contacts <subcommand> [options]");
|
|
139
|
+
console.log("");
|
|
140
|
+
console.log("Subcommands:");
|
|
141
|
+
console.log(" list [--limit N] List all contacts");
|
|
142
|
+
console.log(" get <id> Get a contact by ID");
|
|
143
|
+
console.log(" merge <keepId> <mergeId> Merge two contacts");
|
|
144
|
+
console.log("");
|
|
145
|
+
console.log("Options:");
|
|
146
|
+
console.log(" --json Machine-readable JSON output");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Command entry point
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
export async function contacts(): Promise<void> {
|
|
154
|
+
const args = process.argv.slice(3);
|
|
155
|
+
const subcommand = args[0];
|
|
156
|
+
const json = hasFlag(args, "--json");
|
|
157
|
+
|
|
158
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
159
|
+
printUsage();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
switch (subcommand) {
|
|
164
|
+
case "list": {
|
|
165
|
+
const limit = getFlagValue(args, "--limit") ?? "50";
|
|
166
|
+
const data = (await apiGet(`contacts?limit=${limit}`)) as {
|
|
167
|
+
ok: boolean;
|
|
168
|
+
contacts: Contact[];
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (json) {
|
|
172
|
+
console.log(JSON.stringify(data));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (data.contacts.length === 0) {
|
|
177
|
+
console.log("No contacts found.");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(`Contacts (${data.contacts.length}):\n`);
|
|
182
|
+
for (const c of data.contacts) {
|
|
183
|
+
console.log(formatContact(c) + "\n");
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case "get": {
|
|
189
|
+
const id = args[1];
|
|
190
|
+
if (!id || id.startsWith("--")) {
|
|
191
|
+
console.error("Usage: vellum contacts get <id>");
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const data = (await apiGet(`contacts/${encodeURIComponent(id)}`)) as {
|
|
197
|
+
ok: boolean;
|
|
198
|
+
contact: Contact;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (json) {
|
|
202
|
+
console.log(JSON.stringify(data));
|
|
203
|
+
} else {
|
|
204
|
+
console.log(formatContact(data.contact));
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
if (json) {
|
|
208
|
+
console.log(
|
|
209
|
+
JSON.stringify({
|
|
210
|
+
ok: false,
|
|
211
|
+
error: `Contact "${id}" not found`,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
console.error(`Contact "${id}" not found.`);
|
|
216
|
+
}
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case "merge": {
|
|
223
|
+
const keepId = args[1];
|
|
224
|
+
const mergeId = args[2];
|
|
225
|
+
if (
|
|
226
|
+
!keepId ||
|
|
227
|
+
!mergeId ||
|
|
228
|
+
keepId.startsWith("--") ||
|
|
229
|
+
mergeId.startsWith("--")
|
|
230
|
+
) {
|
|
231
|
+
console.error("Usage: vellum contacts merge <keepId> <mergeId>");
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const data = (await apiPost("contacts/merge", {
|
|
237
|
+
keepId,
|
|
238
|
+
mergeId,
|
|
239
|
+
})) as { ok: boolean; contact: Contact };
|
|
240
|
+
|
|
241
|
+
if (json) {
|
|
242
|
+
console.log(JSON.stringify(data));
|
|
243
|
+
} else {
|
|
244
|
+
console.log(`Merged contact "${mergeId}" into "${keepId}".\n`);
|
|
245
|
+
console.log(formatContact(data.contact));
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
249
|
+
if (json) {
|
|
250
|
+
console.log(JSON.stringify({ ok: false, error: msg }));
|
|
251
|
+
} else {
|
|
252
|
+
console.error(`Error: ${msg}`);
|
|
253
|
+
}
|
|
254
|
+
process.exitCode = 1;
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
default: {
|
|
260
|
+
console.error(`Unknown contacts subcommand: ${subcommand}`);
|
|
261
|
+
printUsage();
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|