@vellumai/cli 0.8.1 → 0.8.3
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 +24 -32
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +13 -3
- package/src/__tests__/config-utils.test.ts +31 -1
- package/src/__tests__/hatch-provider-secrets.test.ts +284 -0
- package/src/__tests__/input-history.test.ts +102 -0
- package/src/__tests__/preload.ts +5 -1
- package/src/__tests__/provider-secrets.test.ts +290 -0
- package/src/__tests__/setup.test.ts +360 -0
- package/src/__tests__/teleport.test.ts +191 -163
- package/src/commands/client.ts +57 -1
- package/src/commands/hatch.ts +53 -20
- package/src/commands/setup.ts +134 -95
- package/src/commands/teleport.ts +20 -2
- package/src/components/DefaultMainScreen.tsx +72 -119
- package/src/lib/__tests__/docker.test.ts +106 -0
- package/src/lib/assistant-config.ts +6 -2
- package/src/lib/config-utils.ts +18 -0
- package/src/lib/docker.ts +180 -19
- package/src/lib/environments/paths.ts +21 -0
- package/src/lib/hatch-local.ts +42 -3
- package/src/lib/hatch-next-steps.ts +12 -0
- package/src/lib/input-history.ts +5 -8
- package/src/lib/provider-secrets.ts +564 -0
- package/src/lib/sync-cloud-assistants.ts +23 -9
- package/src/lib/doctor-client.ts +0 -153
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { LLM_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
4
|
+
|
|
5
|
+
export type LlmProviderId = keyof typeof LLM_PROVIDER_ENV_VAR_NAMES;
|
|
6
|
+
|
|
7
|
+
export type ProviderApiKeySource = "env" | "prompt";
|
|
8
|
+
export type ProviderSecretFetch = (
|
|
9
|
+
input: Parameters<typeof fetch>[0],
|
|
10
|
+
init?: Parameters<typeof fetch>[1],
|
|
11
|
+
) => ReturnType<typeof fetch>;
|
|
12
|
+
|
|
13
|
+
export type EnsureProviderApiKeyResult =
|
|
14
|
+
| {
|
|
15
|
+
status: "already_configured";
|
|
16
|
+
provider: LlmProviderId;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
status: "configured";
|
|
20
|
+
provider: LlmProviderId;
|
|
21
|
+
source: ProviderApiKeySource;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
status: "missing";
|
|
25
|
+
provider: LlmProviderId;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
status: "failed";
|
|
30
|
+
provider: LlmProviderId;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
status: "skipped";
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface EnsureProviderApiKeyOptions {
|
|
39
|
+
gatewayUrl: string;
|
|
40
|
+
provider: string | null;
|
|
41
|
+
bearerToken?: string;
|
|
42
|
+
env?: NodeJS.ProcessEnv;
|
|
43
|
+
fetchImpl?: ProviderSecretFetch;
|
|
44
|
+
prompt?: (prompt: string) => Promise<string>;
|
|
45
|
+
stdinIsTTY?: boolean;
|
|
46
|
+
input?: NodeJS.ReadStream;
|
|
47
|
+
output?: NodeJS.WriteStream;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GatewayApiKeyReadResult {
|
|
51
|
+
found: boolean;
|
|
52
|
+
unreachable: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HatchProviderApiKeyOptions {
|
|
56
|
+
gatewayUrl: string;
|
|
57
|
+
provider: LlmProviderId | null;
|
|
58
|
+
bearerToken?: string;
|
|
59
|
+
env?: NodeJS.ProcessEnv;
|
|
60
|
+
fetchImpl?: ProviderSecretFetch;
|
|
61
|
+
log?: (message: string) => void;
|
|
62
|
+
prompt?: (prompt: string) => Promise<string>;
|
|
63
|
+
stdinIsTTY?: boolean;
|
|
64
|
+
input?: NodeJS.ReadStream;
|
|
65
|
+
output?: NodeJS.WriteStream;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PROVIDER_LABELS: Record<LlmProviderId, string> = {
|
|
69
|
+
anthropic: "Anthropic",
|
|
70
|
+
openai: "OpenAI",
|
|
71
|
+
gemini: "Gemini",
|
|
72
|
+
fireworks: "Fireworks",
|
|
73
|
+
openrouter: "OpenRouter",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function formatProviderName(provider: LlmProviderId): string {
|
|
77
|
+
return PROVIDER_LABELS[provider];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isSupportedLlmProvider(
|
|
81
|
+
provider: string,
|
|
82
|
+
): provider is LlmProviderId {
|
|
83
|
+
return Object.hasOwn(LLM_PROVIDER_ENV_VAR_NAMES, provider);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveHatchProvider(
|
|
87
|
+
configValues: Record<string, string | undefined>,
|
|
88
|
+
): LlmProviderId | null {
|
|
89
|
+
const provider = (
|
|
90
|
+
resolveConfiguredMainAgentProvider(configValues) || "anthropic"
|
|
91
|
+
).toLowerCase();
|
|
92
|
+
|
|
93
|
+
if (provider === "ollama") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!isSupportedLlmProvider(provider)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Provider '${provider}' does not have a supported API-key setup flow.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return provider;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveConfiguredMainAgentProvider(
|
|
107
|
+
configValues: Record<string, string | undefined>,
|
|
108
|
+
): string | undefined {
|
|
109
|
+
// Fresh hatches seed the active custom profile from llm.default and then
|
|
110
|
+
// that active profile wins over static mainAgent call-site defaults. Match
|
|
111
|
+
// that startup behavior so hatch prompts for the provider the assistant will
|
|
112
|
+
// actually use on first chat.
|
|
113
|
+
return (
|
|
114
|
+
resolveProfileProvider(
|
|
115
|
+
configValues,
|
|
116
|
+
readConfigValue(configValues, "llm.activeProfile"),
|
|
117
|
+
) ??
|
|
118
|
+
resolveFragmentProvider(configValues, "llm.default") ??
|
|
119
|
+
resolveFragmentProvider(configValues, "llm.callSites.mainAgent") ??
|
|
120
|
+
resolveProfileProvider(
|
|
121
|
+
configValues,
|
|
122
|
+
readConfigValue(configValues, "llm.callSites.mainAgent.profile"),
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveProfileProvider(
|
|
128
|
+
configValues: Record<string, string | undefined>,
|
|
129
|
+
profileName: string | undefined,
|
|
130
|
+
): string | undefined {
|
|
131
|
+
if (!profileName) return undefined;
|
|
132
|
+
return resolveFragmentProvider(configValues, `llm.profiles.${profileName}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveFragmentProvider(
|
|
136
|
+
configValues: Record<string, string | undefined>,
|
|
137
|
+
prefix: string,
|
|
138
|
+
): string | undefined {
|
|
139
|
+
const provider = readConfigValue(configValues, `${prefix}.provider`);
|
|
140
|
+
if (provider) return provider;
|
|
141
|
+
|
|
142
|
+
const model = readConfigValue(configValues, `${prefix}.model`);
|
|
143
|
+
return model ? inferProviderFromModel(model) : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readConfigValue(
|
|
147
|
+
configValues: Record<string, string | undefined>,
|
|
148
|
+
key: string,
|
|
149
|
+
): string | undefined {
|
|
150
|
+
const value = configValues[key]?.trim();
|
|
151
|
+
return value && value.length > 0 ? value : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function inferProviderFromModel(model: string): string | undefined {
|
|
155
|
+
if (model.startsWith("claude-")) return "anthropic";
|
|
156
|
+
if (model.startsWith("gpt-")) return "openai";
|
|
157
|
+
if (model.startsWith("gemini-")) return "gemini";
|
|
158
|
+
if (model.startsWith("accounts/fireworks/models/")) return "fireworks";
|
|
159
|
+
if (model.includes("/")) return "openrouter";
|
|
160
|
+
if (model === "llama3.2" || model === "mistral") return "ollama";
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function gatewayUrlWithPath(gatewayUrl: string, path: string): string {
|
|
165
|
+
return `${gatewayUrl.replace(/\/+$/, "")}${path}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function secretHeaders(bearerToken?: string): Record<string, string> {
|
|
169
|
+
const headers: Record<string, string> = {
|
|
170
|
+
"Content-Type": "application/json",
|
|
171
|
+
Accept: "application/json",
|
|
172
|
+
};
|
|
173
|
+
if (bearerToken) {
|
|
174
|
+
headers.Authorization = `Bearer ${bearerToken}`;
|
|
175
|
+
}
|
|
176
|
+
return headers;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function parseErrorMessage(response: Response): Promise<string> {
|
|
180
|
+
let text = "";
|
|
181
|
+
try {
|
|
182
|
+
text = await response.text();
|
|
183
|
+
} catch {
|
|
184
|
+
// Fall through to status text.
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const body = JSON.parse(text) as {
|
|
189
|
+
error?: unknown;
|
|
190
|
+
};
|
|
191
|
+
const message = extractErrorMessage(body.error);
|
|
192
|
+
if (message) return message;
|
|
193
|
+
} catch {
|
|
194
|
+
// Fall back to raw text below.
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (text.trim().length > 0) {
|
|
198
|
+
return text.trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return response.statusText || `HTTP ${response.status}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function extractErrorMessage(error: unknown): string | null {
|
|
205
|
+
if (typeof error === "string" && error.trim().length > 0) {
|
|
206
|
+
return error.trim();
|
|
207
|
+
}
|
|
208
|
+
if (!error || typeof error !== "object") {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const maybeMessage = (error as { message?: unknown }).message;
|
|
213
|
+
if (typeof maybeMessage === "string" && maybeMessage.trim().length > 0) {
|
|
214
|
+
return maybeMessage.trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function readGatewayApiKey(
|
|
221
|
+
gatewayUrl: string,
|
|
222
|
+
provider: LlmProviderId,
|
|
223
|
+
bearerToken?: string,
|
|
224
|
+
fetchImpl: ProviderSecretFetch = fetch,
|
|
225
|
+
): Promise<GatewayApiKeyReadResult> {
|
|
226
|
+
const response = await fetchImpl(
|
|
227
|
+
gatewayUrlWithPath(gatewayUrl, "/v1/secrets/read"),
|
|
228
|
+
{
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: secretHeaders(bearerToken),
|
|
231
|
+
body: JSON.stringify({
|
|
232
|
+
type: "api_key",
|
|
233
|
+
name: provider,
|
|
234
|
+
reveal: false,
|
|
235
|
+
}),
|
|
236
|
+
signal: AbortSignal.timeout(10_000),
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
const message = await parseErrorMessage(response);
|
|
242
|
+
if (response.status === 404) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Active assistant at ${gatewayUrl} does not expose /v1/secrets/read (${message}). Run \`vellum ps\` to confirm the active assistant, then select a self-hosted assistant with \`vellum use <assistant>\` or wake a current assistant before running setup.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Failed to check ${formatProviderName(provider)} API key: ${message}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const body = (await response.json()) as {
|
|
253
|
+
found?: unknown;
|
|
254
|
+
unreachable?: unknown;
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
found: body.found === true,
|
|
258
|
+
unreachable: body.unreachable === true,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function injectGatewayApiKey(
|
|
263
|
+
gatewayUrl: string,
|
|
264
|
+
provider: LlmProviderId,
|
|
265
|
+
value: string,
|
|
266
|
+
bearerToken?: string,
|
|
267
|
+
fetchImpl: ProviderSecretFetch = fetch,
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
const response = await fetchImpl(
|
|
270
|
+
gatewayUrlWithPath(gatewayUrl, "/v1/secrets"),
|
|
271
|
+
{
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: secretHeaders(bearerToken),
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
type: "api_key",
|
|
276
|
+
name: provider,
|
|
277
|
+
value,
|
|
278
|
+
}),
|
|
279
|
+
signal: AbortSignal.timeout(10_000),
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (!response.ok) {
|
|
284
|
+
const message = await parseErrorMessage(response);
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Failed to store ${formatProviderName(provider)} API key: ${message}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
291
|
+
success?: unknown;
|
|
292
|
+
error?: unknown;
|
|
293
|
+
};
|
|
294
|
+
if (body.success === false) {
|
|
295
|
+
const message =
|
|
296
|
+
typeof body.error === "string" && body.error.trim().length > 0
|
|
297
|
+
? body.error
|
|
298
|
+
: "Assistant rejected the API key.";
|
|
299
|
+
throw new Error(message);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function promptSecret(
|
|
304
|
+
prompt: string,
|
|
305
|
+
streams: {
|
|
306
|
+
input?: NodeJS.ReadStream;
|
|
307
|
+
output?: NodeJS.WriteStream;
|
|
308
|
+
} = {},
|
|
309
|
+
): Promise<string> {
|
|
310
|
+
const input = streams.input ?? process.stdin;
|
|
311
|
+
const output = streams.output ?? process.stdout;
|
|
312
|
+
|
|
313
|
+
const restoreEcho = disableTerminalEcho(input);
|
|
314
|
+
output.write(prompt);
|
|
315
|
+
|
|
316
|
+
return new Promise((resolve, reject) => {
|
|
317
|
+
const wasRaw = input.isRaw;
|
|
318
|
+
if (input.isTTY) {
|
|
319
|
+
input.setRawMode(true);
|
|
320
|
+
}
|
|
321
|
+
input.resume();
|
|
322
|
+
|
|
323
|
+
let value = "";
|
|
324
|
+
|
|
325
|
+
const cleanup = (): void => {
|
|
326
|
+
input.removeListener("data", onData);
|
|
327
|
+
if (input.isTTY) {
|
|
328
|
+
input.setRawMode(wasRaw ?? false);
|
|
329
|
+
}
|
|
330
|
+
restoreEcho();
|
|
331
|
+
input.pause();
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const finish = (): void => {
|
|
335
|
+
cleanup();
|
|
336
|
+
output.write("\n");
|
|
337
|
+
resolve(value);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const cancel = (): void => {
|
|
341
|
+
cleanup();
|
|
342
|
+
output.write("\n");
|
|
343
|
+
reject(new Error("Input cancelled."));
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const onData = (chunk: Buffer | string): void => {
|
|
347
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
348
|
+
if (bytes[0] === 27) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const byte of bytes) {
|
|
353
|
+
if (byte === 3) {
|
|
354
|
+
cancel();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (byte === 10 || byte === 13) {
|
|
358
|
+
finish();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (byte === 8 || byte === 127) {
|
|
362
|
+
value = value.slice(0, -1);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (byte >= 32 && byte <= 126) {
|
|
366
|
+
value += String.fromCharCode(byte);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
input.on("data", onData);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function disableTerminalEcho(input: NodeJS.ReadStream): () => void {
|
|
376
|
+
if (input !== process.stdin || !input.isTTY || process.platform === "win32") {
|
|
377
|
+
return () => {};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const currentState = spawnSync("stty", ["-g"], {
|
|
381
|
+
encoding: "utf8",
|
|
382
|
+
stdio: ["inherit", "pipe", "ignore"],
|
|
383
|
+
});
|
|
384
|
+
const state = currentState.stdout.trim();
|
|
385
|
+
if (currentState.status !== 0 || state.length === 0) {
|
|
386
|
+
return () => {};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const disabled = spawnSync("stty", ["-echo"], {
|
|
390
|
+
stdio: ["inherit", "ignore", "ignore"],
|
|
391
|
+
});
|
|
392
|
+
if (disabled.status !== 0) {
|
|
393
|
+
return () => {};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let restored = false;
|
|
397
|
+
return () => {
|
|
398
|
+
if (restored) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
restored = true;
|
|
402
|
+
spawnSync("stty", [state], {
|
|
403
|
+
stdio: ["inherit", "ignore", "ignore"],
|
|
404
|
+
});
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function ensureProviderApiKey(
|
|
409
|
+
options: EnsureProviderApiKeyOptions,
|
|
410
|
+
): Promise<EnsureProviderApiKeyResult> {
|
|
411
|
+
if (options.provider === null) {
|
|
412
|
+
return {
|
|
413
|
+
status: "skipped",
|
|
414
|
+
message: "Selected provider does not require an API key.",
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const normalizedProvider = options.provider.trim().toLowerCase();
|
|
419
|
+
if (!isSupportedLlmProvider(normalizedProvider)) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`Provider '${options.provider}' does not have a supported API-key setup flow.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const provider = normalizedProvider;
|
|
425
|
+
const providerName = formatProviderName(provider);
|
|
426
|
+
const envVarName = LLM_PROVIDER_ENV_VAR_NAMES[provider];
|
|
427
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
428
|
+
|
|
429
|
+
const existing = await readGatewayApiKey(
|
|
430
|
+
options.gatewayUrl,
|
|
431
|
+
provider,
|
|
432
|
+
options.bearerToken,
|
|
433
|
+
fetchImpl,
|
|
434
|
+
);
|
|
435
|
+
if (existing.unreachable) {
|
|
436
|
+
return {
|
|
437
|
+
status: "failed",
|
|
438
|
+
provider,
|
|
439
|
+
message:
|
|
440
|
+
"Assistant credential store is unavailable. Try again after the assistant finishes starting.",
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (existing.found) {
|
|
444
|
+
return {
|
|
445
|
+
status: "already_configured",
|
|
446
|
+
provider,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const envValue = options.env?.[envVarName]?.trim();
|
|
451
|
+
let apiKey = envValue;
|
|
452
|
+
let source: ProviderApiKeySource = "env";
|
|
453
|
+
|
|
454
|
+
if (!apiKey) {
|
|
455
|
+
source = "prompt";
|
|
456
|
+
if (options.prompt) {
|
|
457
|
+
apiKey = (
|
|
458
|
+
await options.prompt(
|
|
459
|
+
`Enter your ${providerName} API key (${envVarName}): `,
|
|
460
|
+
)
|
|
461
|
+
).trim();
|
|
462
|
+
} else {
|
|
463
|
+
const stdinIsTTY = options.stdinIsTTY ?? process.stdin.isTTY;
|
|
464
|
+
if (!stdinIsTTY) {
|
|
465
|
+
return {
|
|
466
|
+
status: "missing",
|
|
467
|
+
provider,
|
|
468
|
+
message: `Missing ${envVarName}. Set it in the environment or run vellum setup from an interactive terminal.`,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
apiKey = (
|
|
472
|
+
await promptSecret(
|
|
473
|
+
`Enter your ${providerName} API key (${envVarName}): `,
|
|
474
|
+
{
|
|
475
|
+
input: options.input,
|
|
476
|
+
output: options.output,
|
|
477
|
+
},
|
|
478
|
+
)
|
|
479
|
+
).trim();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!apiKey) {
|
|
484
|
+
return {
|
|
485
|
+
status: "missing",
|
|
486
|
+
provider,
|
|
487
|
+
message: "API key cannot be empty.",
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
await injectGatewayApiKey(
|
|
492
|
+
options.gatewayUrl,
|
|
493
|
+
provider,
|
|
494
|
+
apiKey,
|
|
495
|
+
options.bearerToken,
|
|
496
|
+
fetchImpl,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
status: "configured",
|
|
501
|
+
provider,
|
|
502
|
+
source,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export async function configureHatchProviderApiKey(
|
|
507
|
+
options: HatchProviderApiKeyOptions,
|
|
508
|
+
): Promise<void> {
|
|
509
|
+
const log = options.log ?? console.log;
|
|
510
|
+
const { provider } = options;
|
|
511
|
+
|
|
512
|
+
if (provider === null) {
|
|
513
|
+
log("Provider credentials not required for the selected provider.");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const result = await ensureProviderApiKey({
|
|
519
|
+
gatewayUrl: options.gatewayUrl,
|
|
520
|
+
provider,
|
|
521
|
+
bearerToken: options.bearerToken,
|
|
522
|
+
env: options.env,
|
|
523
|
+
fetchImpl: options.fetchImpl,
|
|
524
|
+
prompt: options.prompt,
|
|
525
|
+
stdinIsTTY: options.stdinIsTTY,
|
|
526
|
+
input: options.input,
|
|
527
|
+
output: options.output,
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
if (result.status === "already_configured") {
|
|
531
|
+
log(
|
|
532
|
+
`Provider credentials already configured for ${formatProviderName(result.provider)}.`,
|
|
533
|
+
);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (result.status === "configured") {
|
|
538
|
+
if (result.source === "env") {
|
|
539
|
+
log(
|
|
540
|
+
`Configured ${formatProviderName(result.provider)} credentials from ${LLM_PROVIDER_ENV_VAR_NAMES[result.provider]}.`,
|
|
541
|
+
);
|
|
542
|
+
} else {
|
|
543
|
+
log(`Configured ${formatProviderName(result.provider)} credentials.`);
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (result.status === "skipped") {
|
|
549
|
+
log(result.message);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
log(
|
|
554
|
+
`⚠️ Provider credential setup skipped: ${result.message}\n` +
|
|
555
|
+
` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` to finish setup.`,
|
|
556
|
+
);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
559
|
+
log(
|
|
560
|
+
`⚠️ Provider credential setup failed: ${message}\n` +
|
|
561
|
+
` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the issue.`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
@@ -56,9 +56,7 @@ export async function syncCloudAssistants(
|
|
|
56
56
|
const log = options?.log;
|
|
57
57
|
const platformUrl = getPlatformUrl();
|
|
58
58
|
log?.(`Platform URL: ${platformUrl}`);
|
|
59
|
-
log?.(
|
|
60
|
-
`Token found (${token.length} chars, prefix: ${token.slice(0, 6)}…)`,
|
|
61
|
-
);
|
|
59
|
+
log?.(`Token found (${token.length} chars, prefix: ${token.slice(0, 6)}…)`);
|
|
62
60
|
|
|
63
61
|
// Fetch user info for the login status line
|
|
64
62
|
let email: string | undefined;
|
|
@@ -94,27 +92,41 @@ export async function syncCloudAssistants(
|
|
|
94
92
|
const platformIds = new Set(platformAssistants.map((a) => a.id));
|
|
95
93
|
|
|
96
94
|
// Add new platform assistants not yet in the lockfile
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
.filter((a) => a.cloud === "vellum")
|
|
100
|
-
.map((a) => a.assistantId),
|
|
95
|
+
const existingCloudEntries = loadAllAssistants().filter(
|
|
96
|
+
(a) => a.cloud === "vellum",
|
|
101
97
|
);
|
|
98
|
+
const existingCloudById = new Map(
|
|
99
|
+
existingCloudEntries.map((a) => [a.assistantId, a]),
|
|
100
|
+
);
|
|
101
|
+
const existingCloudIds = new Set(existingCloudById.keys());
|
|
102
102
|
log?.(
|
|
103
103
|
`Lockfile has ${existingCloudIds.size} cloud assistant(s): ${[...existingCloudIds].join(", ") || "(none)"}`,
|
|
104
104
|
);
|
|
105
105
|
|
|
106
106
|
let added = 0;
|
|
107
|
+
let updated = 0;
|
|
107
108
|
for (const pa of platformAssistants) {
|
|
108
|
-
|
|
109
|
+
const existing = existingCloudById.get(pa.id);
|
|
110
|
+
const assistantName = pa.name.trim();
|
|
111
|
+
const nameFields = assistantName ? { name: assistantName } : {};
|
|
112
|
+
if (!existing) {
|
|
109
113
|
log?.(`Adding ${pa.name || pa.id} to lockfile`);
|
|
110
114
|
saveAssistantEntry({
|
|
111
115
|
assistantId: pa.id,
|
|
116
|
+
...nameFields,
|
|
112
117
|
runtimeUrl: getPlatformUrl(),
|
|
113
118
|
cloud: "vellum",
|
|
114
119
|
species: "vellum",
|
|
115
120
|
hatchedAt: new Date().toISOString(),
|
|
116
121
|
});
|
|
117
122
|
added++;
|
|
123
|
+
} else if (assistantName && existing.name !== assistantName) {
|
|
124
|
+
log?.(`Updating ${pa.id} name to ${assistantName}`);
|
|
125
|
+
saveAssistantEntry({
|
|
126
|
+
...existing,
|
|
127
|
+
name: assistantName,
|
|
128
|
+
});
|
|
129
|
+
updated++;
|
|
118
130
|
}
|
|
119
131
|
}
|
|
120
132
|
|
|
@@ -128,6 +140,8 @@ export async function syncCloudAssistants(
|
|
|
128
140
|
}
|
|
129
141
|
}
|
|
130
142
|
|
|
131
|
-
log?.(
|
|
143
|
+
log?.(
|
|
144
|
+
`Sync complete: ${added} added, ${updated} updated, ${removed} removed`,
|
|
145
|
+
);
|
|
132
146
|
return { added, removed, email };
|
|
133
147
|
}
|