@vtriv/cli 0.1.0 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +237 -30
  2. package/index.ts +601 -403
  3. package/package.json +1 -1
package/index.ts CHANGED
@@ -2,13 +2,17 @@
2
2
  /**
3
3
  * vtriv-cli - Command-line interface for vtriv services
4
4
  *
5
- * Gateway-first: single baseUrl with service routing by path prefix
6
- * Auto-mints JWTs for non-auth services using root key
5
+ * Configuration hierarchy:
6
+ * 1. Account key (vtriv_ak_*) - set via `vtriv config`
7
+ * 2. Project profiles - created via `vtriv init <name>`
8
+ *
9
+ * The CLI auto-creates projects prefixed with account slug,
10
+ * and translates short names to full names transparently.
7
11
  */
8
12
 
9
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
10
14
  import { homedir } from "node:os";
11
- import { join } from "node:path";
15
+ import { join, dirname } from "node:path";
12
16
  import { Command } from "commander";
13
17
  import chalk from "chalk";
14
18
 
@@ -17,14 +21,15 @@ import chalk from "chalk";
17
21
  // =============================================================================
18
22
 
19
23
  interface Profile {
20
- baseUrl: string;
21
- rootKey: string;
24
+ apiKey: string; // vtriv_sk_*
22
25
  }
23
26
 
24
27
  interface Config {
25
- default: string;
28
+ baseUrl: string;
29
+ accountKey?: string; // vtriv_ak_*
30
+ accountSlug?: string;
31
+ default?: string;
26
32
  profiles: Record<string, Profile>;
27
- apiKeys?: Record<string, string>;
28
33
  }
29
34
 
30
35
  interface GlobalOptions {
@@ -37,34 +42,55 @@ interface GlobalOptions {
37
42
  // Configuration
38
43
  // =============================================================================
39
44
 
40
- const CONFIG_PATH = join(homedir(), ".vtrivrc");
45
+ function getConfigPath(): string {
46
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
47
+ return join(xdgConfig, "vtriv", "config.json");
48
+ }
41
49
 
42
50
  function loadConfig(): Config | null {
43
- if (!existsSync(CONFIG_PATH)) return null;
51
+ const configPath = getConfigPath();
52
+ if (!existsSync(configPath)) return null;
44
53
  try {
45
- return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
54
+ return JSON.parse(readFileSync(configPath, "utf-8"));
46
55
  } catch {
47
56
  return null;
48
57
  }
49
58
  }
50
59
 
51
60
  function saveConfig(config: Config): void {
52
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
61
+ const configPath = getConfigPath();
62
+ const configDir = dirname(configPath);
63
+ if (!existsSync(configDir)) {
64
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
65
+ }
66
+ writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
67
+ chmodSync(configPath, 0o600);
53
68
  }
54
69
 
55
- function getProfile(profileName?: string): Profile {
70
+ function getConfig(): Config {
56
71
  const config = loadConfig();
57
72
  if (!config) {
58
- console.error(chalk.red("No config found. Run 'vtriv init' first."));
73
+ console.error(chalk.red("No config found. Run 'vtriv config' first."));
74
+ process.exit(1);
75
+ }
76
+ return config;
77
+ }
78
+
79
+ function getProfile(profileName?: string): { config: Config; profile: Profile; fullProjectName: string; shortName: string } {
80
+ const config = getConfig();
81
+ const shortName = profileName || config.default;
82
+ if (!shortName) {
83
+ console.error(chalk.red("No default profile. Run 'vtriv init <name>' to create one."));
59
84
  process.exit(1);
60
85
  }
61
- const name = profileName || config.default;
62
- const profile = config.profiles[name];
86
+ const profile = config.profiles[shortName];
63
87
  if (!profile) {
64
- console.error(chalk.red(`Profile '${name}' not found.`));
88
+ console.error(chalk.red(`Profile '${shortName}' not found. Run 'vtriv init ${shortName}' to create it.`));
65
89
  process.exit(1);
66
90
  }
67
- return profile;
91
+ // Expand short name to full project name
92
+ const fullProjectName = config.accountSlug ? `${config.accountSlug}-${shortName}` : shortName;
93
+ return { config, profile, fullProjectName, shortName };
68
94
  }
69
95
 
70
96
  // =============================================================================
@@ -107,7 +133,7 @@ function printTable(rows: Record<string, unknown>[]): void {
107
133
  }
108
134
  }
109
135
 
110
- function error(msg: string, opts: GlobalOptions): void {
136
+ function error(msg: string, opts: GlobalOptions): never {
111
137
  if (opts.json) {
112
138
  console.error(JSON.stringify({ error: msg }));
113
139
  } else {
@@ -117,45 +143,86 @@ function error(msg: string, opts: GlobalOptions): void {
117
143
  }
118
144
 
119
145
  // =============================================================================
120
- // HTTP Request Helper
146
+ // Token Management
121
147
  // =============================================================================
122
148
 
123
- // Cache for minted tokens per project
149
+ // In-memory cache for minted tokens (per profile)
124
150
  const tokenCache = new Map<string, { token: string; expires: number }>();
125
151
 
152
+ async function getToken(opts: GlobalOptions, template?: string): Promise<string> {
153
+ const { config, profile, fullProjectName } = getProfile(opts.profile);
154
+
155
+ // Cache key includes template
156
+ const cacheKey = template ? `${fullProjectName}:${template}` : fullProjectName;
157
+ const cached = tokenCache.get(cacheKey);
158
+ if (cached && cached.expires > Date.now()) {
159
+ return cached.token;
160
+ }
161
+
162
+ // Mint a new token using the API key
163
+ const body: Record<string, unknown> = { expires_in: 3600 };
164
+ if (template) {
165
+ body.template = template;
166
+ }
167
+
168
+ const mintRes = await fetch(`${config.baseUrl}/auth/${fullProjectName}/token`, {
169
+ method: "POST",
170
+ headers: {
171
+ Authorization: `Bearer ${profile.apiKey}`,
172
+ "Content-Type": "application/json",
173
+ },
174
+ body: JSON.stringify(body),
175
+ });
176
+
177
+ if (!mintRes.ok) {
178
+ const err = await mintRes.text();
179
+ let msg: string;
180
+ try {
181
+ msg = JSON.parse(err).error || err;
182
+ } catch {
183
+ msg = err;
184
+ }
185
+ error(`Failed to mint token: ${msg}`, opts);
186
+ }
187
+
188
+ const tokenData = (await mintRes.json()) as { token: string };
189
+ const token = tokenData.token;
190
+
191
+ // Cache for 55 minutes (token valid for 60)
192
+ tokenCache.set(cacheKey, { token, expires: Date.now() + 55 * 60 * 1000 });
193
+
194
+ return token;
195
+ }
196
+
197
+ // =============================================================================
198
+ // HTTP Request Helper
199
+ // =============================================================================
200
+
126
201
  async function request(
127
202
  service: string,
128
203
  path: string,
129
204
  options: {
130
205
  method?: string;
131
206
  body?: unknown;
132
- project?: string;
207
+ template?: string;
133
208
  },
134
209
  opts: GlobalOptions
135
210
  ): Promise<unknown> {
136
- const profile = getProfile(opts.profile);
137
- const url = `${profile.baseUrl}/${service}${path}`;
211
+ const { config, fullProjectName } = getProfile(opts.profile);
212
+ const url = `${config.baseUrl}/${service}/${fullProjectName}${path}`;
138
213
  const method = options.method || "GET";
139
214
 
140
215
  const headers: Record<string, string> = {
141
216
  "Content-Type": "application/json",
142
217
  };
143
218
 
144
- // Auth service uses root key via Bearer header
145
- if (service === "auth") {
146
- headers["Authorization"] = `Bearer ${profile.rootKey}`;
147
- } else if (options.project) {
148
- // Other services need a JWT minted via auth
149
- const token = await getProjectToken(options.project, opts);
150
- headers["Authorization"] = `Bearer ${token}`;
151
- }
219
+ // Get a JWT token for the request
220
+ const token = await getToken(opts, options.template);
221
+ headers.Authorization = `Bearer ${token}`;
152
222
 
153
223
  if (opts.debug) {
154
224
  const bodyArg = options.body ? `-d '${JSON.stringify(options.body)}'` : "";
155
- const authArg = headers["Authorization"]
156
- ? `-H "Authorization: ${headers["Authorization"]}"`
157
- : "";
158
- console.error(chalk.dim(`curl -X ${method} "${url}" ${authArg} ${bodyArg}`));
225
+ console.error(chalk.dim(`curl -X ${method} "${url}" -H "Authorization: Bearer <token>" ${bodyArg}`));
159
226
  }
160
227
 
161
228
  const res = await fetch(url, {
@@ -173,95 +240,16 @@ async function request(
173
240
  }
174
241
 
175
242
  if (!res.ok) {
176
- const errMsg = typeof data === "object" && data !== null && "error" in data
177
- ? (data as { error: string }).error
178
- : text;
243
+ const errMsg =
244
+ typeof data === "object" && data !== null && "error" in data
245
+ ? (data as { error: string }).error
246
+ : text;
179
247
  error(errMsg, opts);
180
248
  }
181
249
 
182
250
  return data;
183
251
  }
184
252
 
185
- async function getProjectToken(project: string, opts: GlobalOptions): Promise<string> {
186
- const cached = tokenCache.get(project);
187
- if (cached && cached.expires > Date.now()) {
188
- return cached.token;
189
- }
190
-
191
- const profile = getProfile(opts.profile);
192
-
193
- // Get or create a CLI API key
194
- let cliApiKey = getCachedApiKey(project);
195
-
196
- if (!cliApiKey) {
197
- // Create a new API key for CLI use
198
- const createKeyRes = await fetch(`${profile.baseUrl}/auth/${project}/api-keys`, {
199
- method: "POST",
200
- headers: {
201
- "Authorization": `Bearer ${profile.rootKey}`,
202
- "Content-Type": "application/json",
203
- },
204
- body: JSON.stringify({ name: "vtriv-cli" }),
205
- });
206
-
207
- if (!createKeyRes.ok) {
208
- const err = await createKeyRes.text();
209
- error(`Failed to create CLI API key: ${err}`, opts);
210
- }
211
-
212
- const keyData = (await createKeyRes.json()) as { api_key: string };
213
- cliApiKey = keyData.api_key;
214
- cacheApiKey(project, cliApiKey);
215
- }
216
-
217
- // Now mint a token using the API key
218
- const mintRes = await fetch(`${profile.baseUrl}/auth/${project}/token`, {
219
- method: "POST",
220
- headers: {
221
- "Authorization": `Bearer ${cliApiKey}`,
222
- "Content-Type": "application/json",
223
- },
224
- body: JSON.stringify({ expires_in: 3600 }),
225
- });
226
-
227
- if (!mintRes.ok) {
228
- // API key might be invalid/deleted, clear cache and retry
229
- clearCachedApiKey(project);
230
- error(`Failed to mint token. Try again.`, opts);
231
- }
232
-
233
- const tokenData = (await mintRes.json()) as { token: string };
234
- const token = tokenData.token;
235
-
236
- // Cache for 55 minutes (token valid for 60)
237
- tokenCache.set(project, { token, expires: Date.now() + 55 * 60 * 1000 });
238
-
239
- return token;
240
- }
241
-
242
- // API key cache (stored in config)
243
- function getCachedApiKey(project: string): string | null {
244
- const config = loadConfig();
245
- return config?.apiKeys?.[project] || null;
246
- }
247
-
248
- function cacheApiKey(project: string, apiKey: string): void {
249
- const config = loadConfig();
250
- if (!config) return;
251
- config.apiKeys = config.apiKeys || {};
252
- config.apiKeys[project] = apiKey;
253
- saveConfig(config);
254
- }
255
-
256
- function clearCachedApiKey(project: string): void {
257
- const config = loadConfig();
258
- if (!config) return;
259
- if (config.apiKeys) {
260
- delete config.apiKeys[project];
261
- saveConfig(config);
262
- }
263
- }
264
-
265
253
  // =============================================================================
266
254
  // Program
267
255
  // =============================================================================
@@ -277,189 +265,304 @@ program
277
265
  .option("--debug", "Show HTTP requests");
278
266
 
279
267
  // =============================================================================
280
- // Init Command
268
+ // Config Command
281
269
  // =============================================================================
282
270
 
283
271
  program
284
- .command("init")
285
- .description("Initialize CLI configuration")
272
+ .command("config")
273
+ .description("Configure account key and base URL")
286
274
  .action(async () => {
287
275
  const existing = loadConfig();
288
-
289
- console.log(chalk.bold("vtriv CLI Setup\n"));
290
-
276
+
277
+ console.log(chalk.bold("vtriv Account Configuration\n"));
278
+
291
279
  const readline = await import("node:readline");
292
280
  const rl = readline.createInterface({
293
281
  input: process.stdin,
294
282
  output: process.stdout,
295
283
  });
296
-
284
+
297
285
  const question = (q: string): Promise<string> =>
298
286
  new Promise((resolve) => rl.question(q, resolve));
299
-
300
- const baseUrl = await question(
301
- `Base URL [${existing?.profiles?.prod?.baseUrl || "https://api.vtriv.com"}]: `
302
- ) || existing?.profiles?.prod?.baseUrl || "https://api.vtriv.com";
303
-
304
- const rootKey = await question("Root Key (vtriv_rk_...): ");
305
-
306
- if (!rootKey) {
307
- console.error(chalk.red("Root key is required."));
287
+
288
+ const baseUrl =
289
+ (await question(`Base URL [${existing?.baseUrl || "http://localhost:3000"}]: `)) ||
290
+ existing?.baseUrl ||
291
+ "http://localhost:3000";
292
+
293
+ const accountKey = await question("Account Key (vtriv_ak_...): ");
294
+ if (!accountKey) {
295
+ console.error(chalk.red("Account key is required."));
308
296
  rl.close();
309
297
  process.exit(1);
310
298
  }
311
-
312
- if (!rootKey.startsWith("vtriv_rk_")) {
313
- console.error(chalk.red("Root key must start with 'vtriv_rk_'."));
299
+
300
+ if (!accountKey.startsWith("vtriv_ak_")) {
301
+ console.error(chalk.red("Account key must start with 'vtriv_ak_'."));
314
302
  rl.close();
315
303
  process.exit(1);
316
304
  }
317
-
305
+
306
+ rl.close();
307
+
308
+ // Fetch account info to get slug
309
+ console.log(chalk.dim("\nVerifying account key..."));
310
+ const res = await fetch(`${baseUrl}/auth/accounts/me`, {
311
+ headers: { Authorization: `Bearer ${accountKey}` },
312
+ });
313
+
314
+ if (!res.ok) {
315
+ const err = await res.text();
316
+ let msg: string;
317
+ try {
318
+ msg = JSON.parse(err).error || err;
319
+ } catch {
320
+ msg = err;
321
+ }
322
+ console.error(chalk.red(`Failed to verify account: ${msg}`));
323
+ process.exit(1);
324
+ }
325
+
326
+ const accountInfo = (await res.json()) as { id: string; slug: string; name: string | null };
327
+
318
328
  const config: Config = {
319
- default: "prod",
320
- profiles: {
321
- prod: { baseUrl, rootKey },
322
- },
329
+ baseUrl,
330
+ accountKey,
331
+ accountSlug: accountInfo.slug,
332
+ default: existing?.default,
333
+ profiles: existing?.profiles || {},
323
334
  };
324
-
335
+
325
336
  saveConfig(config);
326
- console.log(chalk.green(`\nConfig saved to ${CONFIG_PATH}`));
327
- rl.close();
337
+ console.log(chalk.green(`\nAccount configured!`));
338
+ console.log(chalk.dim(` Slug: ${accountInfo.slug}`));
339
+ console.log(chalk.dim(` Name: ${accountInfo.name || "(none)"}`));
340
+ console.log(chalk.dim(` Config: ${getConfigPath()}`));
328
341
  });
329
342
 
330
343
  // =============================================================================
331
- // Auth Commands
344
+ // Init Command
332
345
  // =============================================================================
333
346
 
334
- const auth = program.command("auth").description("Auth service commands");
347
+ program
348
+ .command("init")
349
+ .description("Create a project and configure profile")
350
+ .argument("<name>", "Project name (short name, will be prefixed with account slug)")
351
+ .option("--default", "Set as default profile")
352
+ .action(async (name: string, cmdOpts: { default?: boolean }) => {
353
+ const config = loadConfig();
354
+
355
+ if (!config?.accountKey || !config?.accountSlug) {
356
+ console.error(chalk.red("No account configured. Run 'vtriv config' first."));
357
+ process.exit(1);
358
+ }
335
359
 
336
- // Account commands (root key only)
337
- auth
338
- .command("account:list")
339
- .description("List all accounts")
340
- .action(async () => {
341
- const opts = program.opts<GlobalOptions>();
342
- const data = (await request("auth", "/accounts", {}, opts)) as { accounts: unknown[] };
343
- output(data.accounts, opts);
344
- });
360
+ // Validate project name
361
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
362
+ console.error(chalk.red("Invalid project name. Use alphanumeric, dash, or underscore only."));
363
+ process.exit(1);
364
+ }
345
365
 
346
- auth
347
- .command("account:create")
348
- .description("Create a new account")
349
- .option("--name <name>", "Account name")
350
- .action(async (cmdOpts: { name?: string }) => {
351
- const opts = program.opts<GlobalOptions>();
352
- const body: Record<string, unknown> = {};
353
- if (cmdOpts.name) body.name = cmdOpts.name;
354
- const data = await request("auth", "/accounts", { method: "POST", body }, opts);
355
- output(data, opts);
356
- if (!opts.json) {
357
- console.log(chalk.yellow("\nIMPORTANT: Save the account_key above - it cannot be retrieved later!"));
366
+ const fullProjectName = `${config.accountSlug}-${name}`;
367
+
368
+ // Try to create project
369
+ console.log(chalk.dim(`Creating project ${fullProjectName}...`));
370
+ const createRes = await fetch(`${config.baseUrl}/auth/projects`, {
371
+ method: "POST",
372
+ headers: {
373
+ Authorization: `Bearer ${config.accountKey}`,
374
+ "Content-Type": "application/json",
375
+ },
376
+ body: JSON.stringify({ name }),
377
+ });
378
+
379
+ let projectExists = false;
380
+ if (!createRes.ok) {
381
+ const err = (await createRes.json()) as { error?: string };
382
+ if (err.error === "Project already exists") {
383
+ projectExists = true;
384
+ console.log(chalk.yellow(`Project ${fullProjectName} already exists.`));
385
+ } else {
386
+ console.error(chalk.red(`Failed to create project: ${err.error}`));
387
+ process.exit(1);
388
+ }
389
+ } else {
390
+ console.log(chalk.green(`Created project ${fullProjectName}`));
358
391
  }
359
- });
360
392
 
361
- auth
362
- .command("account:get")
363
- .description("Get account details")
364
- .argument("<id>", "Account ID")
365
- .action(async (id: string) => {
366
- const opts = program.opts<GlobalOptions>();
367
- const data = await request("auth", `/accounts/${id}`, {}, opts);
368
- output(data, opts);
393
+ // Create or fetch API key
394
+ console.log(chalk.dim("Creating API key..."));
395
+ const keyRes = await fetch(`${config.baseUrl}/auth/${fullProjectName}/api-keys`, {
396
+ method: "POST",
397
+ headers: {
398
+ Authorization: `Bearer ${config.accountKey}`,
399
+ "Content-Type": "application/json",
400
+ },
401
+ body: JSON.stringify({ name: "CLI" }),
402
+ });
403
+
404
+ if (!keyRes.ok) {
405
+ const err = (await keyRes.json()) as { error?: string };
406
+ console.error(chalk.red(`Failed to create API key: ${err.error}`));
407
+ process.exit(1);
408
+ }
409
+
410
+ const keyData = (await keyRes.json()) as { api_key: string };
411
+
412
+ // Save profile
413
+ config.profiles[name] = { apiKey: keyData.api_key };
414
+ if (cmdOpts.default || !config.default) {
415
+ config.default = name;
416
+ }
417
+
418
+ saveConfig(config);
419
+ console.log(chalk.green(`\nProfile '${name}' saved.`));
420
+ if (config.default === name) {
421
+ console.log(chalk.dim("Set as default profile."));
422
+ }
423
+ if (projectExists) {
424
+ console.log(chalk.dim("Note: A new API key was created for the existing project."));
425
+ }
369
426
  });
370
427
 
371
- auth
372
- .command("account:delete")
373
- .description("Delete an account")
374
- .argument("<id>", "Account ID")
375
- .action(async (id: string) => {
428
+ // =============================================================================
429
+ // Status Command
430
+ // =============================================================================
431
+
432
+ program
433
+ .command("status")
434
+ .description("Show configuration and profiles")
435
+ .action(() => {
376
436
  const opts = program.opts<GlobalOptions>();
377
- const data = await request("auth", `/accounts/${id}`, { method: "DELETE" }, opts);
378
- output(data, opts);
437
+ const config = loadConfig();
438
+ if (!config) {
439
+ console.log(chalk.dim("No config found. Run 'vtriv config' first."));
440
+ return;
441
+ }
442
+
443
+ if (opts.json) {
444
+ // Don't expose the actual keys
445
+ const safeConfig = {
446
+ baseUrl: config.baseUrl,
447
+ accountSlug: config.accountSlug,
448
+ hasAccountKey: !!config.accountKey,
449
+ default: config.default,
450
+ profiles: Object.keys(config.profiles),
451
+ };
452
+ console.log(JSON.stringify(safeConfig, null, 2));
453
+ return;
454
+ }
455
+
456
+ console.log(chalk.bold("Configuration"));
457
+ console.log(` Base URL: ${config.baseUrl}`);
458
+ console.log(` Account: ${config.accountSlug || chalk.dim("(not configured)")}`);
459
+ console.log(` Default: ${config.default || chalk.dim("(none)")}`);
460
+ console.log();
461
+
462
+ if (Object.keys(config.profiles).length === 0) {
463
+ console.log(chalk.dim("No profiles. Run 'vtriv init <name>' to create one."));
464
+ return;
465
+ }
466
+
467
+ console.log(chalk.bold("Profiles"));
468
+ for (const [name] of Object.entries(config.profiles)) {
469
+ const fullName = config.accountSlug ? `${config.accountSlug}-${name}` : name;
470
+ const isDefault = name === config.default ? chalk.green(" (default)") : "";
471
+ console.log(` ${name}${isDefault}`);
472
+ console.log(chalk.dim(` → ${fullName}`));
473
+ }
379
474
  });
380
475
 
381
- // Project commands
382
- auth
383
- .command("project:list")
384
- .description("List all projects")
476
+ // =============================================================================
477
+ // Project Commands
478
+ // =============================================================================
479
+
480
+ const project = program.command("project").description("Project management");
481
+
482
+ project
483
+ .command("ls")
484
+ .description("List projects")
385
485
  .action(async () => {
386
486
  const opts = program.opts<GlobalOptions>();
387
- const data = (await request("auth", "/projects", {}, opts)) as { projects: unknown[] };
388
- output(data.projects, opts);
389
- });
487
+ const config = getConfig();
390
488
 
391
- auth
392
- .command("project:create")
393
- .description("Create a new project")
394
- .argument("<name>", "Project name")
395
- .action(async (name: string) => {
396
- const opts = program.opts<GlobalOptions>();
397
- const data = await request("auth", "/projects", { method: "POST", body: { name } }, opts);
398
- output(data, opts);
399
- });
489
+ if (!config.accountKey) {
490
+ console.error(chalk.red("No account configured. Run 'vtriv config' first."));
491
+ process.exit(1);
492
+ }
400
493
 
401
- auth
402
- .command("project:get")
403
- .description("Get project details")
404
- .argument("<name>", "Project name")
405
- .action(async (name: string) => {
406
- const opts = program.opts<GlobalOptions>();
407
- const data = await request("auth", `/projects/${name}`, {}, opts);
408
- output(data, opts);
494
+ const res = await fetch(`${config.baseUrl}/auth/projects`, {
495
+ headers: { Authorization: `Bearer ${config.accountKey}` },
496
+ });
497
+
498
+ if (!res.ok) {
499
+ const err = (await res.json()) as { error?: string };
500
+ error(err.error || "Failed to list projects", opts);
501
+ }
502
+
503
+ const data = (await res.json()) as { projects: Array<{ name: string; short_name: string; created_at: string }> };
504
+
505
+ if (opts.json) {
506
+ console.log(JSON.stringify(data.projects, null, 2));
507
+ } else {
508
+ const rows = data.projects.map((p) => ({
509
+ name: p.short_name,
510
+ full_name: p.name,
511
+ created: p.created_at.split("T")[0],
512
+ profile: config.profiles[p.short_name] ? "✓" : "",
513
+ }));
514
+ printTable(rows);
515
+ }
409
516
  });
410
517
 
411
- auth
412
- .command("project:delete")
518
+ project
519
+ .command("rm")
413
520
  .description("Delete a project")
414
- .argument("<name>", "Project name")
521
+ .argument("<name>", "Project name (short name)")
415
522
  .action(async (name: string) => {
416
523
  const opts = program.opts<GlobalOptions>();
417
- const data = await request("auth", `/projects/${name}`, { method: "DELETE" }, opts);
418
- output(data, opts);
419
- });
524
+ const config = getConfig();
420
525
 
421
- auth
422
- .command("user:list")
423
- .description("List users in a project")
424
- .argument("<project>", "Project name")
425
- .action(async (project: string) => {
426
- const opts = program.opts<GlobalOptions>();
427
- const data = (await request("auth", `/${project}/users`, {}, opts)) as { users: unknown[] };
428
- output(data.users, opts);
429
- });
526
+ if (!config.accountKey || !config.accountSlug) {
527
+ console.error(chalk.red("No account configured. Run 'vtriv config' first."));
528
+ process.exit(1);
529
+ }
430
530
 
431
- auth
432
- .command("user:create")
433
- .description("Create a user")
434
- .argument("<project>", "Project name")
435
- .argument("<email>", "User email")
436
- .argument("[password]", "User password")
437
- .action(async (project: string, email: string, password?: string) => {
438
- const opts = program.opts<GlobalOptions>();
439
- const body: Record<string, unknown> = { email };
440
- if (password) body.password = password;
441
- const data = await request("auth", `/${project}/users`, { method: "POST", body }, opts);
442
- output(data, opts);
443
- });
531
+ const fullName = `${config.accountSlug}-${name}`;
444
532
 
445
- auth
446
- .command("user:delete")
447
- .description("Delete a user")
448
- .argument("<project>", "Project name")
449
- .argument("<id>", "User ID")
450
- .action(async (project: string, id: string) => {
451
- const opts = program.opts<GlobalOptions>();
452
- const data = await request("auth", `/${project}/users/${id}`, { method: "DELETE" }, opts);
453
- output(data, opts);
533
+ const res = await fetch(`${config.baseUrl}/auth/projects/${fullName}`, {
534
+ method: "DELETE",
535
+ headers: { Authorization: `Bearer ${config.accountKey}` },
536
+ });
537
+
538
+ if (!res.ok) {
539
+ const err = (await res.json()) as { error?: string };
540
+ error(err.error || "Failed to delete project", opts);
541
+ }
542
+
543
+ // Remove from local profiles
544
+ if (config.profiles[name]) {
545
+ delete config.profiles[name];
546
+ if (config.default === name) {
547
+ config.default = Object.keys(config.profiles)[0];
548
+ }
549
+ saveConfig(config);
550
+ }
551
+
552
+ console.log(chalk.green(`Deleted project ${fullName}`));
454
553
  });
455
554
 
456
- auth
457
- .command("token:mint")
458
- .description("Mint a JWT token for a project")
459
- .argument("<project>", "Project name")
460
- .action(async (project: string) => {
555
+ // =============================================================================
556
+ // Token Command
557
+ // =============================================================================
558
+
559
+ program
560
+ .command("token")
561
+ .description("Mint and display a JWT token")
562
+ .option("-t, --template <name>", "Use a specific template")
563
+ .action(async (cmdOpts: { template?: string }) => {
461
564
  const opts = program.opts<GlobalOptions>();
462
- const token = await getProjectToken(project, opts);
565
+ const token = await getToken(opts, cmdOpts.template);
463
566
  if (opts.json) {
464
567
  console.log(JSON.stringify({ token }));
465
568
  } else {
@@ -467,38 +570,128 @@ auth
467
570
  }
468
571
  });
469
572
 
470
- auth
471
- .command("apikey:list")
472
- .description("List API keys for a project")
473
- .argument("<project>", "Project name")
474
- .action(async (project: string) => {
573
+ // =============================================================================
574
+ // Template Commands
575
+ // =============================================================================
576
+
577
+ const template = program.command("template").description("Token template management");
578
+
579
+ template
580
+ .command("ls")
581
+ .description("List all templates")
582
+ .action(async () => {
475
583
  const opts = program.opts<GlobalOptions>();
476
- const data = (await request("auth", `/${project}/api-keys`, {}, opts)) as { api_keys: unknown[] };
477
- output(data.api_keys, opts);
584
+ const { config, profile, fullProjectName } = getProfile(opts.profile);
585
+
586
+ const res = await fetch(`${config.baseUrl}/auth/${fullProjectName}/templates`, {
587
+ headers: { Authorization: `Bearer ${profile.apiKey}` },
588
+ });
589
+
590
+ if (!res.ok) {
591
+ const data = await res.json();
592
+ error((data as { error?: string }).error || "Failed to list templates", opts);
593
+ }
594
+
595
+ const data = (await res.json()) as { templates: Record<string, { claims: Record<string, unknown> }> };
596
+
597
+ if (opts.json) {
598
+ console.log(JSON.stringify(data.templates, null, 2));
599
+ } else {
600
+ const rows = Object.entries(data.templates).map(([name, tmpl]) => ({
601
+ name,
602
+ claims: JSON.stringify(tmpl.claims),
603
+ }));
604
+ printTable(rows);
605
+ }
478
606
  });
479
607
 
480
- auth
481
- .command("apikey:create")
482
- .description("Create an API key")
483
- .argument("<project>", "Project name")
484
- .option("--name <name>", "Key name")
485
- .action(async (project: string, cmdOpts: { name?: string }) => {
608
+ template
609
+ .command("get")
610
+ .description("Get a specific template")
611
+ .argument("<name>", "Template name")
612
+ .action(async (name: string) => {
486
613
  const opts = program.opts<GlobalOptions>();
487
- const body: Record<string, unknown> = {};
488
- if (cmdOpts.name) body.name = cmdOpts.name;
489
- const data = await request("auth", `/${project}/api-keys`, { method: "POST", body }, opts);
614
+ const { config, profile, fullProjectName } = getProfile(opts.profile);
615
+
616
+ const res = await fetch(`${config.baseUrl}/auth/${fullProjectName}/templates/${name}`, {
617
+ headers: { Authorization: `Bearer ${profile.apiKey}` },
618
+ });
619
+
620
+ if (!res.ok) {
621
+ const data = await res.json();
622
+ error((data as { error?: string }).error || "Failed to get template", opts);
623
+ }
624
+
625
+ const data = await res.json();
490
626
  output(data, opts);
491
627
  });
492
628
 
493
- auth
494
- .command("apikey:delete")
495
- .description("Delete an API key")
496
- .argument("<project>", "Project name")
497
- .argument("<id>", "API key ID")
498
- .action(async (project: string, id: string) => {
629
+ template
630
+ .command("set")
631
+ .description("Create or update a template (reads JSON from stdin)")
632
+ .argument("<name>", "Template name")
633
+ .action(async (name: string) => {
499
634
  const opts = program.opts<GlobalOptions>();
500
- const data = await request("auth", `/${project}/api-keys/${id}`, { method: "DELETE" }, opts);
501
- output(data, opts);
635
+ const { config, profile, fullProjectName } = getProfile(opts.profile);
636
+
637
+ // Read claims JSON from stdin
638
+ const chunks: Buffer[] = [];
639
+ for await (const chunk of Bun.stdin.stream()) {
640
+ chunks.push(Buffer.from(chunk));
641
+ }
642
+ const input = Buffer.concat(chunks).toString("utf-8").trim();
643
+
644
+ if (!input) {
645
+ error("No JSON provided on stdin. Provide claims object, e.g. {\"x-ai\": true}", opts);
646
+ }
647
+
648
+ let claims: Record<string, unknown>;
649
+ try {
650
+ claims = JSON.parse(input);
651
+ } catch {
652
+ error("Invalid JSON on stdin", opts);
653
+ }
654
+
655
+ const res = await fetch(`${config.baseUrl}/auth/${fullProjectName}/templates/${name}`, {
656
+ method: "PUT",
657
+ headers: {
658
+ Authorization: `Bearer ${profile.apiKey}`,
659
+ "Content-Type": "application/json",
660
+ },
661
+ body: JSON.stringify({ claims }),
662
+ });
663
+
664
+ if (!res.ok) {
665
+ const data = await res.json();
666
+ error((data as { error?: string }).error || "Failed to set template", opts);
667
+ }
668
+
669
+ console.log(chalk.green(`Template '${name}' saved.`));
670
+ });
671
+
672
+ template
673
+ .command("rm")
674
+ .description("Delete a template")
675
+ .argument("<name>", "Template name")
676
+ .action(async (name: string) => {
677
+ const opts = program.opts<GlobalOptions>();
678
+ const { config, profile, fullProjectName } = getProfile(opts.profile);
679
+
680
+ if (name === "default") {
681
+ error("Cannot delete the default template", opts);
682
+ }
683
+
684
+ const res = await fetch(`${config.baseUrl}/auth/${fullProjectName}/templates/${name}`, {
685
+ method: "DELETE",
686
+ headers: { Authorization: `Bearer ${profile.apiKey}` },
687
+ });
688
+
689
+ if (!res.ok) {
690
+ const data = await res.json();
691
+ error((data as { error?: string }).error || "Failed to delete template", opts);
692
+ }
693
+
694
+ console.log(chalk.green(`Template '${name}' deleted.`));
502
695
  });
503
696
 
504
697
  // =============================================================================
@@ -507,15 +700,13 @@ auth
507
700
 
508
701
  const db = program.command("db").description("Database service commands");
509
702
 
510
- db
511
- .command("ls")
703
+ db.command("ls")
512
704
  .description("List documents")
513
- .argument("<project>", "Project name")
514
705
  .argument("<collection>", "Collection name")
515
706
  .option("-f, --filter <json>", "Filter criteria (JSON)")
516
707
  .option("-l, --limit <n>", "Limit results")
517
708
  .option("-s, --sort <field>", "Sort field (prefix with - for desc)")
518
- .action(async (project: string, collection: string, cmdOpts: { filter?: string; limit?: string; sort?: string }) => {
709
+ .action(async (collection: string, cmdOpts: { filter?: string; limit?: string; sort?: string }) => {
519
710
  const opts = program.opts<GlobalOptions>();
520
711
  const params = new URLSearchParams();
521
712
  if (cmdOpts.filter) {
@@ -527,81 +718,71 @@ db
527
718
  if (cmdOpts.limit) params.set("_limit", cmdOpts.limit);
528
719
  if (cmdOpts.sort) params.set("_sort", cmdOpts.sort);
529
720
  const query = params.toString() ? `?${params.toString()}` : "";
530
- const data = (await request("db", `/${project}/${collection}${query}`, { project }, opts)) as { data: unknown[] };
721
+ const data = (await request("db", `/${collection}${query}`, {}, opts)) as { data: unknown[] };
531
722
  output(data.data, opts);
532
723
  });
533
724
 
534
- db
535
- .command("get")
725
+ db.command("get")
536
726
  .description("Get a document")
537
- .argument("<project>", "Project name")
538
727
  .argument("<collection>", "Collection name")
539
728
  .argument("<id>", "Document ID")
540
- .action(async (project: string, collection: string, id: string) => {
729
+ .action(async (collection: string, id: string) => {
541
730
  const opts = program.opts<GlobalOptions>();
542
- const data = await request("db", `/${project}/${collection}/${id}`, { project }, opts);
731
+ const data = await request("db", `/${collection}/${id}`, {}, opts);
543
732
  output(data, opts);
544
733
  });
545
734
 
546
- db
547
- .command("put")
735
+ db.command("put")
548
736
  .description("Create or update a document (reads JSON from stdin)")
549
- .argument("<project>", "Project name")
550
737
  .argument("<collection>", "Collection name")
551
738
  .argument("[id]", "Document ID (for update)")
552
- .action(async (project: string, collection: string, id?: string) => {
739
+ .action(async (collection: string, id?: string) => {
553
740
  const opts = program.opts<GlobalOptions>();
554
-
741
+
555
742
  // Read from stdin
556
743
  const chunks: Buffer[] = [];
557
744
  for await (const chunk of Bun.stdin.stream()) {
558
745
  chunks.push(Buffer.from(chunk));
559
746
  }
560
747
  const input = Buffer.concat(chunks).toString("utf-8").trim();
561
-
748
+
562
749
  if (!input) {
563
750
  error("No JSON provided on stdin", opts);
564
751
  }
565
-
752
+
566
753
  let body: unknown;
567
754
  try {
568
755
  body = JSON.parse(input);
569
756
  } catch {
570
757
  error("Invalid JSON on stdin", opts);
571
758
  }
572
-
759
+
573
760
  if (id) {
574
- // Update (PUT)
575
- const data = await request("db", `/${project}/${collection}/${id}`, { method: "PUT", body, project }, opts);
761
+ const data = await request("db", `/${collection}/${id}`, { method: "PUT", body }, opts);
576
762
  output(data, opts);
577
763
  } else {
578
- // Create (POST)
579
- const data = await request("db", `/${project}/${collection}`, { method: "POST", body, project }, opts);
764
+ const data = await request("db", `/${collection}`, { method: "POST", body }, opts);
580
765
  output(data, opts);
581
766
  }
582
767
  });
583
768
 
584
- db
585
- .command("rm")
769
+ db.command("rm")
586
770
  .description("Delete a document")
587
- .argument("<project>", "Project name")
588
771
  .argument("<collection>", "Collection name")
589
772
  .argument("<id>", "Document ID")
590
- .action(async (project: string, collection: string, id: string) => {
773
+ .action(async (collection: string, id: string) => {
591
774
  const opts = program.opts<GlobalOptions>();
592
- const data = await request("db", `/${project}/${collection}/${id}`, { method: "DELETE", project }, opts);
775
+ const data = await request("db", `/${collection}/${id}`, { method: "DELETE" }, opts);
593
776
  output(data, opts);
594
777
  });
595
778
 
596
- db
597
- .command("schema")
779
+ db.command("schema")
598
780
  .description("Get or set collection schema")
599
- .argument("<project>", "Project name")
600
781
  .argument("<collection>", "Collection name")
601
782
  .option("--set", "Set schema (reads JSON from stdin)")
602
- .action(async (project: string, collection: string, cmdOpts: { set?: boolean }) => {
783
+ .action(async (collection: string, cmdOpts: { set?: boolean }) => {
603
784
  const opts = program.opts<GlobalOptions>();
604
-
785
+
605
786
  if (cmdOpts.set) {
606
787
  const chunks: Buffer[] = [];
607
788
  for await (const chunk of Bun.stdin.stream()) {
@@ -609,10 +790,10 @@ db
609
790
  }
610
791
  const input = Buffer.concat(chunks).toString("utf-8").trim();
611
792
  const body = JSON.parse(input);
612
- const data = await request("db", `/${project}/${collection}/_schema`, { method: "PUT", body, project }, opts);
793
+ const data = await request("db", `/${collection}/_schema`, { method: "PUT", body }, opts);
613
794
  output(data, opts);
614
795
  } else {
615
- const data = await request("db", `/${project}/${collection}/_schema`, { project }, opts);
796
+ const data = await request("db", `/${collection}/_schema`, {}, opts);
616
797
  output(data, opts);
617
798
  }
618
799
  });
@@ -626,34 +807,33 @@ const blob = program.command("blob").description("Blob storage commands");
626
807
  blob
627
808
  .command("put")
628
809
  .description("Upload a file")
629
- .argument("<project>", "Project name")
630
810
  .argument("<path>", "Remote path")
631
811
  .argument("<file>", "Local file path")
632
- .action(async (project: string, remotePath: string, localFile: string) => {
812
+ .action(async (remotePath: string, localFile: string) => {
633
813
  const opts = program.opts<GlobalOptions>();
634
- const profile = getProfile(opts.profile);
635
- const token = await getProjectToken(project, opts);
636
-
814
+ const { config, fullProjectName } = getProfile(opts.profile);
815
+ const token = await getToken(opts);
816
+
637
817
  const file = Bun.file(localFile);
638
818
  if (!(await file.exists())) {
639
819
  error(`File not found: ${localFile}`, opts);
640
820
  }
641
-
642
- const url = `${profile.baseUrl}/blob/${project}/${remotePath}`;
643
-
821
+
822
+ const url = `${config.baseUrl}/blob/${fullProjectName}/${remotePath}`;
823
+
644
824
  if (opts.debug) {
645
825
  console.error(chalk.dim(`curl -X PUT "${url}" -T "${localFile}"`));
646
826
  }
647
-
827
+
648
828
  const res = await fetch(url, {
649
829
  method: "PUT",
650
830
  headers: {
651
- "Authorization": `Bearer ${token}`,
831
+ Authorization: `Bearer ${token}`,
652
832
  "Content-Type": file.type || "application/octet-stream",
653
833
  },
654
834
  body: file,
655
835
  });
656
-
836
+
657
837
  const data = await res.json();
658
838
  if (!res.ok) {
659
839
  error((data as { error?: string }).error || "Upload failed", opts);
@@ -664,28 +844,27 @@ blob
664
844
  blob
665
845
  .command("get")
666
846
  .description("Download a file (outputs to stdout)")
667
- .argument("<project>", "Project name")
668
847
  .argument("<path>", "Remote path")
669
- .action(async (project: string, remotePath: string) => {
848
+ .action(async (remotePath: string) => {
670
849
  const opts = program.opts<GlobalOptions>();
671
- const profile = getProfile(opts.profile);
672
- const token = await getProjectToken(project, opts);
673
-
674
- const url = `${profile.baseUrl}/blob/${project}/${remotePath}`;
675
-
850
+ const { config, fullProjectName } = getProfile(opts.profile);
851
+ const token = await getToken(opts);
852
+
853
+ const url = `${config.baseUrl}/blob/${fullProjectName}/${remotePath}`;
854
+
676
855
  if (opts.debug) {
677
856
  console.error(chalk.dim(`curl "${url}"`));
678
857
  }
679
-
858
+
680
859
  const res = await fetch(url, {
681
- headers: { "Authorization": `Bearer ${token}` },
860
+ headers: { Authorization: `Bearer ${token}` },
682
861
  });
683
-
862
+
684
863
  if (!res.ok) {
685
864
  const data = await res.json();
686
865
  error((data as { error?: string }).error || "Download failed", opts);
687
866
  }
688
-
867
+
689
868
  // Stream to stdout
690
869
  const writer = Bun.stdout.writer();
691
870
  const reader = res.body?.getReader();
@@ -702,20 +881,19 @@ blob
702
881
  blob
703
882
  .command("rm")
704
883
  .description("Delete a file")
705
- .argument("<project>", "Project name")
706
884
  .argument("<path>", "Remote path")
707
- .action(async (project: string, remotePath: string) => {
885
+ .action(async (remotePath: string) => {
708
886
  const opts = program.opts<GlobalOptions>();
709
- const profile = getProfile(opts.profile);
710
- const token = await getProjectToken(project, opts);
711
-
712
- const url = `${profile.baseUrl}/blob/${project}/${remotePath}`;
713
-
887
+ const { config, fullProjectName } = getProfile(opts.profile);
888
+ const token = await getToken(opts);
889
+
890
+ const url = `${config.baseUrl}/blob/${fullProjectName}/${remotePath}`;
891
+
714
892
  const res = await fetch(url, {
715
893
  method: "DELETE",
716
- headers: { "Authorization": `Bearer ${token}` },
894
+ headers: { Authorization: `Bearer ${token}` },
717
895
  });
718
-
896
+
719
897
  const data = await res.json();
720
898
  if (!res.ok) {
721
899
  error((data as { error?: string }).error || "Delete failed", opts);
@@ -732,58 +910,54 @@ const cron = program.command("cron").description("Cron service commands");
732
910
  cron
733
911
  .command("ls")
734
912
  .description("List jobs")
735
- .argument("<project>", "Project name")
736
- .action(async (project: string) => {
913
+ .action(async () => {
737
914
  const opts = program.opts<GlobalOptions>();
738
- const data = (await request("cron", `/${project}/jobs`, { project }, opts)) as { jobs: unknown[] };
915
+ const data = (await request("cron", "/jobs", {}, opts)) as { jobs: unknown[] };
739
916
  output(data.jobs, opts);
740
917
  });
741
918
 
742
919
  cron
743
920
  .command("runs")
744
921
  .description("List run history")
745
- .argument("<project>", "Project name")
746
922
  .option("-l, --limit <n>", "Limit results", "100")
747
923
  .option("--job <id>", "Filter by job ID")
748
- .action(async (project: string, cmdOpts: { limit: string; job?: string }) => {
924
+ .action(async (cmdOpts: { limit: string; job?: string }) => {
749
925
  const opts = program.opts<GlobalOptions>();
750
926
  const params = new URLSearchParams({ limit: cmdOpts.limit });
751
927
  if (cmdOpts.job) params.set("job_id", cmdOpts.job);
752
- const data = (await request("cron", `/${project}/runs?${params}`, { project }, opts)) as { runs: unknown[] };
928
+ const data = (await request("cron", `/runs?${params}`, {}, opts)) as { runs: unknown[] };
753
929
  output(data.runs, opts);
754
930
  });
755
931
 
756
932
  cron
757
933
  .command("trigger")
758
934
  .description("Manually trigger a job")
759
- .argument("<project>", "Project name")
760
935
  .argument("<job_id>", "Job ID")
761
- .action(async (project: string, jobId: string) => {
936
+ .action(async (jobId: string) => {
762
937
  const opts = program.opts<GlobalOptions>();
763
- const data = await request("cron", `/${project}/jobs/${jobId}/trigger`, { method: "POST", project }, opts);
938
+ const data = await request("cron", `/jobs/${jobId}/trigger`, { method: "POST" }, opts);
764
939
  output(data, opts);
765
940
  });
766
941
 
767
942
  cron
768
943
  .command("script:cat")
769
944
  .description("Output script content")
770
- .argument("<project>", "Project name")
771
945
  .argument("<script>", "Script name")
772
- .action(async (project: string, script: string) => {
946
+ .action(async (script: string) => {
773
947
  const opts = program.opts<GlobalOptions>();
774
- const profile = getProfile(opts.profile);
775
- const token = await getProjectToken(project, opts);
776
-
777
- const url = `${profile.baseUrl}/cron/${project}/scripts/${script}`;
948
+ const { config, fullProjectName } = getProfile(opts.profile);
949
+ const token = await getToken(opts);
950
+
951
+ const url = `${config.baseUrl}/cron/${fullProjectName}/scripts/${script}`;
778
952
  const res = await fetch(url, {
779
- headers: { "Authorization": `Bearer ${token}` },
953
+ headers: { Authorization: `Bearer ${token}` },
780
954
  });
781
-
955
+
782
956
  if (!res.ok) {
783
957
  const data = await res.json();
784
958
  error((data as { error?: string }).error || "Failed to get script", opts);
785
959
  }
786
-
960
+
787
961
  console.log(await res.text());
788
962
  });
789
963
 
@@ -793,52 +967,60 @@ cron
793
967
 
794
968
  const ai = program.command("ai").description("AI service commands");
795
969
 
796
- ai
797
- .command("stats")
970
+ ai.command("stats")
798
971
  .description("View usage statistics")
799
- .argument("<project>", "Project name")
800
972
  .option("-p, --period <days>", "Period in days", "30")
801
- .action(async (project: string, cmdOpts: { period: string }) => {
973
+ .action(async (cmdOpts: { period: string }) => {
802
974
  const opts = program.opts<GlobalOptions>();
803
- const data = await request("ai", `/usage/stats?period=${cmdOpts.period}d`, { project }, opts);
804
- output(data, opts);
975
+ const { config } = getProfile(opts.profile);
976
+ const token = await getToken(opts);
977
+
978
+ const url = `${config.baseUrl}/ai/usage/stats?period=${cmdOpts.period}d`;
979
+ const res = await fetch(url, {
980
+ headers: { Authorization: `Bearer ${token}` },
981
+ });
982
+
983
+ if (!res.ok) {
984
+ const data = await res.json();
985
+ error((data as { error?: string }).error || "Failed to get stats", opts);
986
+ }
987
+
988
+ output(await res.json(), opts);
805
989
  });
806
990
 
807
- ai
808
- .command("chat")
991
+ ai.command("chat")
809
992
  .description("Send a chat completion request")
810
- .argument("<project>", "Project name")
811
993
  .argument("<prompt>", "Chat prompt")
812
994
  .option("-m, --model <model>", "Model to use (defaults to project config)")
813
- .action(async (project: string, prompt: string, cmdOpts: { model?: string }) => {
995
+ .action(async (prompt: string, cmdOpts: { model?: string }) => {
814
996
  const opts = program.opts<GlobalOptions>();
815
- const profile = getProfile(opts.profile);
816
- const token = await getProjectToken(project, opts);
817
-
997
+ const { config } = getProfile(opts.profile);
998
+ const token = await getToken(opts);
999
+
818
1000
  // Get default model from project config if not specified
819
1001
  let model = cmdOpts.model;
820
1002
  if (!model) {
821
- const configRes = await fetch(`${profile.baseUrl}/ai/config`, {
822
- headers: { "Authorization": `Bearer ${token}` },
1003
+ const configRes = await fetch(`${config.baseUrl}/ai/config`, {
1004
+ headers: { Authorization: `Bearer ${token}` },
823
1005
  });
824
1006
  if (configRes.ok) {
825
- const config = await configRes.json() as { default_model?: string };
826
- model = config.default_model || "openai/gpt-4o-mini";
1007
+ const aiConfig = (await configRes.json()) as { default_model?: string };
1008
+ model = aiConfig.default_model || "openai/gpt-4o-mini";
827
1009
  } else {
828
1010
  model = "openai/gpt-4o-mini";
829
1011
  }
830
1012
  }
831
-
832
- const url = `${profile.baseUrl}/ai/v1/chat/completions`;
833
-
1013
+
1014
+ const url = `${config.baseUrl}/ai/v1/chat/completions`;
1015
+
834
1016
  if (opts.debug) {
835
1017
  console.error(chalk.dim(`curl -X POST "${url}" ...`));
836
1018
  }
837
-
1019
+
838
1020
  const res = await fetch(url, {
839
1021
  method: "POST",
840
1022
  headers: {
841
- "Authorization": `Bearer ${token}`,
1023
+ Authorization: `Bearer ${token}`,
842
1024
  "Content-Type": "application/json",
843
1025
  },
844
1026
  body: JSON.stringify({
@@ -846,44 +1028,60 @@ ai
846
1028
  messages: [{ role: "user", content: prompt }],
847
1029
  }),
848
1030
  });
849
-
1031
+
850
1032
  const data = await res.json();
851
1033
  if (!res.ok) {
852
1034
  error((data as { error?: string }).error || "Chat failed", opts);
853
1035
  }
854
-
1036
+
855
1037
  if (opts.json) {
856
1038
  console.log(JSON.stringify(data, null, 2));
857
1039
  } else {
858
- const content = (data as { choices: Array<{ message: { content: string } }> }).choices?.[0]?.message?.content;
1040
+ const content = (data as { choices: Array<{ message: { content: string } }> }).choices?.[0]
1041
+ ?.message?.content;
859
1042
  console.log(content || "(no response)");
860
1043
  }
861
1044
  });
862
1045
 
863
- ai
864
- .command("config")
865
- .description("Get or set AI config for a project")
866
- .argument("<project>", "Project name")
1046
+ ai.command("config")
1047
+ .description("Get or set AI config")
867
1048
  .option("--model <model>", "Set default model")
868
1049
  .option("--rate-limit <usd>", "Set rate limit in USD")
869
1050
  .option("--key <key>", "Set custom OpenRouter API key")
870
- .action(async (project: string, cmdOpts: { model?: string; rateLimit?: string; key?: string }) => {
1051
+ .action(async (cmdOpts: { model?: string; rateLimit?: string; key?: string }) => {
871
1052
  const opts = program.opts<GlobalOptions>();
872
-
1053
+ const { config } = getProfile(opts.profile);
1054
+ const token = await getToken(opts);
1055
+
873
1056
  // If any options provided, update config
874
1057
  if (cmdOpts.model || cmdOpts.rateLimit || cmdOpts.key) {
875
1058
  const body: Record<string, unknown> = {};
876
1059
  if (cmdOpts.model) body.default_model = cmdOpts.model;
877
1060
  if (cmdOpts.rateLimit) body.rate_limit_usd = Number.parseFloat(cmdOpts.rateLimit);
878
1061
  if (cmdOpts.key) body.key = cmdOpts.key;
879
-
880
- await request("ai", "/config", { method: "PUT", body, project }, opts);
1062
+
1063
+ const updateRes = await fetch(`${config.baseUrl}/ai/config`, {
1064
+ method: "PUT",
1065
+ headers: {
1066
+ Authorization: `Bearer ${token}`,
1067
+ "Content-Type": "application/json",
1068
+ },
1069
+ body: JSON.stringify(body),
1070
+ });
1071
+
1072
+ if (!updateRes.ok) {
1073
+ const data = await updateRes.json();
1074
+ error((data as { error?: string }).error || "Failed to update config", opts);
1075
+ }
1076
+
881
1077
  console.log("Config updated");
882
1078
  }
883
-
1079
+
884
1080
  // Always show current config
885
- const data = await request("ai", "/config", { project }, opts);
886
- output(data, opts);
1081
+ const res = await fetch(`${config.baseUrl}/ai/config`, {
1082
+ headers: { Authorization: `Bearer ${token}` },
1083
+ });
1084
+ output(await res.json(), opts);
887
1085
  });
888
1086
 
889
1087
  // =============================================================================
@@ -895,12 +1093,11 @@ const search = program.command("search").description("Search service commands");
895
1093
  search
896
1094
  .command("config")
897
1095
  .description("Get or set index configuration")
898
- .argument("<project>", "Project name")
899
1096
  .argument("<index>", "Index name")
900
1097
  .option("--set", "Set config (reads JSON from stdin)")
901
- .action(async (project: string, index: string, cmdOpts: { set?: boolean }) => {
1098
+ .action(async (index: string, cmdOpts: { set?: boolean }) => {
902
1099
  const opts = program.opts<GlobalOptions>();
903
-
1100
+
904
1101
  if (cmdOpts.set) {
905
1102
  const chunks: Buffer[] = [];
906
1103
  for await (const chunk of Bun.stdin.stream()) {
@@ -908,10 +1105,10 @@ search
908
1105
  }
909
1106
  const input = Buffer.concat(chunks).toString("utf-8").trim();
910
1107
  const body = JSON.parse(input);
911
- const data = await request("search", `/${project}/${index}/_config`, { method: "PUT", body, project }, opts);
1108
+ const data = await request("search", `/${index}/_config`, { method: "PUT", body }, opts);
912
1109
  output(data, opts);
913
1110
  } else {
914
- const data = await request("search", `/${project}/${index}/_config`, { project }, opts);
1111
+ const data = await request("search", `/${index}/_config`, {}, opts);
915
1112
  output(data, opts);
916
1113
  }
917
1114
  });
@@ -919,14 +1116,15 @@ search
919
1116
  search
920
1117
  .command("query")
921
1118
  .description("Search an index")
922
- .argument("<project>", "Project name")
923
1119
  .argument("<index>", "Index name")
924
1120
  .argument("<q>", "Search query")
925
1121
  .option("-l, --limit <n>", "Limit results", "10")
926
- .action(async (project: string, index: string, q: string, cmdOpts: { limit: string }) => {
1122
+ .action(async (index: string, q: string, cmdOpts: { limit: string }) => {
927
1123
  const opts = program.opts<GlobalOptions>();
928
1124
  const params = new URLSearchParams({ q, limit: cmdOpts.limit });
929
- const data = (await request("search", `/${project}/${index}/search?${params}`, { project }, opts)) as { results: unknown[] };
1125
+ const data = (await request("search", `/${index}/search?${params}`, {}, opts)) as {
1126
+ results: unknown[];
1127
+ };
930
1128
  output(data.results, opts);
931
1129
  });
932
1130