@odla-ai/cli 0.6.0 → 0.7.1

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/dist/bin.cjs CHANGED
@@ -31,9 +31,11 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
31
31
  var import_security2 = require("@odla-ai/security");
32
32
 
33
33
  // src/admin-ai.ts
34
- var import_node_process3 = __toESM(require("process"), 1);
35
- var import_node_fs2 = require("fs");
36
- var import_db2 = require("@odla-ai/db");
34
+ var import_node_process4 = __toESM(require("process"), 1);
35
+
36
+ // src/token.ts
37
+ var import_db = require("@odla-ai/db");
38
+ var import_node_process2 = __toESM(require("process"), 1);
37
39
 
38
40
  // src/open.ts
39
41
  var import_node_child_process = require("child_process");
@@ -59,10 +61,6 @@ function openerFor(platform) {
59
61
  return { cmd: "xdg-open", args: [] };
60
62
  }
61
63
 
62
- // src/token.ts
63
- var import_db = require("@odla-ai/db");
64
- var import_node_process2 = __toESM(require("process"), 1);
65
-
66
64
  // src/local.ts
67
65
  var import_node_fs = require("fs");
68
66
  var import_node_path = require("path");
@@ -192,9 +190,18 @@ function displayPath(path, rootDir = process.cwd()) {
192
190
  // src/token.ts
193
191
  async function getDeveloperToken(cfg, options, doFetch, out) {
194
192
  if (options.token) return options.token;
195
- if (import_node_process2.default.env.ODLA_DEV_TOKEN) return import_node_process2.default.env.ODLA_DEV_TOKEN;
193
+ const audience = platformAudience(cfg.platformUrl);
194
+ if (import_node_process2.default.env.ODLA_DEV_TOKEN) {
195
+ const declared = import_node_process2.default.env.ODLA_DEV_TOKEN_AUDIENCE;
196
+ if (declared) {
197
+ if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
198
+ } else if (audience !== "https://odla.ai") {
199
+ throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
200
+ }
201
+ return import_node_process2.default.env.ODLA_DEV_TOKEN;
202
+ }
196
203
  const cached = readJsonFile(cfg.local.tokenFile);
197
- if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
204
+ if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
198
205
  out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
199
206
  return cached.token;
200
207
  }
@@ -220,7 +227,7 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
220
227
  out.log("");
221
228
  }
222
229
  });
223
- writePrivateJson(cfg.local.tokenFile, { token, expiresAt });
230
+ writePrivateJson(cfg.local.tokenFile, { platform: audience, token, expiresAt });
224
231
  out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
225
232
  return token;
226
233
  }
@@ -237,35 +244,271 @@ function handshakeUrl(platformUrl, userCode) {
237
244
  url.searchParams.set("code", userCode);
238
245
  return url.toString();
239
246
  }
247
+ function platformAudience(value) {
248
+ let url;
249
+ try {
250
+ url = new URL(value);
251
+ } catch {
252
+ throw new Error("platform must be an absolute URL");
253
+ }
254
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
255
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
256
+ throw new Error("platform credentials require HTTPS except for loopback development");
257
+ }
258
+ if (url.username || url.password || url.search || url.hash) {
259
+ throw new Error("platform URL must not contain credentials, query, or fragment");
260
+ }
261
+ return url.origin;
262
+ }
240
263
 
241
- // src/admin-ai.ts
242
- var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
264
+ // src/admin-ai-auth.ts
265
+ var import_node_fs2 = require("fs");
266
+ var import_node_process3 = __toESM(require("process"), 1);
267
+ var import_db2 = require("@odla-ai/db");
243
268
  async function getScopedPlatformToken(options) {
269
+ return resolveAdminPlatformToken(options);
270
+ }
271
+ async function resolveAdminPlatformToken(options) {
272
+ const audience = platformAudience(options.platform);
273
+ if (options.token) return options.token;
244
274
  const fromEnv = import_node_process3.default.env.ODLA_ADMIN_TOKEN;
245
- if (fromEnv) return fromEnv;
275
+ if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
246
276
  return scopedToken(
247
- options.platform.replace(/\/$/, ""),
277
+ audience,
248
278
  options.scope,
249
- { action: "show", ...options },
279
+ options,
250
280
  options.fetch ?? fetch,
251
281
  options.stdout ?? console
252
282
  );
253
283
  }
284
+ function audienceBoundEnvToken(token, platform) {
285
+ const audience = platformAudience(platform);
286
+ const declared = import_node_process3.default.env.ODLA_ADMIN_TOKEN_AUDIENCE;
287
+ if (declared) {
288
+ if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
289
+ } else if (audience !== "https://odla.ai") {
290
+ throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE is required for a non-default platform");
291
+ }
292
+ return token;
293
+ }
294
+ async function scopedToken(platform, scope, options, doFetch, out) {
295
+ const audience = platformAudience(platform);
296
+ const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
297
+ const cache = readJsonFile(tokenFile);
298
+ const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
299
+ if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
300
+ out.log(`auth: using cached ${scope} grant (${tokenFile})`);
301
+ return cached.token;
302
+ }
303
+ const { token, expiresAt } = await (0, import_db2.requestToken)({
304
+ endpoint: audience,
305
+ label: `odla CLI admin AI (${scope})`,
306
+ scopes: [scope],
307
+ fetch: doFetch,
308
+ onCode: async ({ userCode, expiresIn }) => {
309
+ const approvalUrl = handshakeUrl(audience, userCode);
310
+ out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
311
+ const shouldOpen = options.open === true || options.open !== false && !import_node_process3.default.env.CI && Boolean(import_node_process3.default.stdin.isTTY && import_node_process3.default.stdout.isTTY);
312
+ if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
313
+ }
314
+ });
315
+ const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
316
+ tokens[scope] = { token, expiresAt };
317
+ if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
318
+ writePrivateJson(tokenFile, { platform: audience, tokens });
319
+ out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
320
+ return token;
321
+ }
322
+
323
+ // src/admin-ai-audit.ts
324
+ function adminAiAuditQuery(filters) {
325
+ if (filters.limit === void 0) return "";
326
+ if (!Number.isSafeInteger(filters.limit) || filters.limit < 1 || filters.limit > 200) {
327
+ throw new Error("audit limit must be an integer from 1 to 200");
328
+ }
329
+ return `?limit=${filters.limit}`;
330
+ }
331
+ async function readAdminAiAudit(request) {
332
+ const response = await request.fetch(`${request.platform}/registry/platform/ai-audit${request.query}`, {
333
+ headers: request.headers
334
+ });
335
+ const body = await responseBody(response);
336
+ if (!response.ok) throw new Error(apiError(response.status, body));
337
+ if (request.json) {
338
+ request.stdout.log(JSON.stringify(body, null, 2));
339
+ return;
340
+ }
341
+ const events = isRecord(body) && Array.isArray(body.events) ? body.events.filter(isRecord) : [];
342
+ request.stdout.log("when change target before -> after actor");
343
+ for (const event of events) {
344
+ const before = isRecord(event.oldPolicy) ? event.oldPolicy : void 0;
345
+ const after = isRecord(event.newPolicy) ? event.newPolicy : void 0;
346
+ const route = before && after ? `${String(before.provider)}/${String(before.model)}@v${String(before.version)} -> ${String(after.provider)}/${String(after.model)}@v${String(after.version)}` : "value not retained";
347
+ request.stdout.log([
348
+ timestamp(event.createdAt),
349
+ String(event.changeKind ?? ""),
350
+ String(event.purpose ?? event.provider ?? ""),
351
+ route,
352
+ `${String(event.actorType ?? "")}:${String(event.actorId ?? "")}`
353
+ ].join(" "));
354
+ }
355
+ }
356
+ async function responseBody(response) {
357
+ const text = await response.text();
358
+ if (!text) return {};
359
+ try {
360
+ return JSON.parse(text);
361
+ } catch {
362
+ return { message: text.slice(0, 300) };
363
+ }
364
+ }
365
+ function apiError(status, body) {
366
+ const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
367
+ const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
368
+ return `read System AI admin changes failed (${status}): ${message}`;
369
+ }
370
+ function timestamp(value) {
371
+ if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
372
+ const date = new Date(value);
373
+ return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
374
+ }
375
+ function isRecord(value) {
376
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
377
+ }
378
+
379
+ // src/admin-ai-policy.ts
380
+ var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
381
+ function requireSystemAiPurpose(value) {
382
+ if (!SYSTEM_AI_PURPOSES.includes(value)) {
383
+ throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
384
+ }
385
+ return value;
386
+ }
387
+
388
+ // src/admin-ai-usage.ts
389
+ function adminAiUsageQuery(filters) {
390
+ const params = new URLSearchParams();
391
+ if (filters.appId?.trim()) params.set("appId", filters.appId.trim());
392
+ if (filters.env?.trim()) params.set("env", filters.env.trim());
393
+ if (filters.runId?.trim()) params.set("runId", filters.runId.trim());
394
+ if (filters.limit !== void 0) params.set("limit", String(usageLimit(filters.limit)));
395
+ const query = params.toString();
396
+ return query ? `?${query}` : "";
397
+ }
398
+ async function readAdminAiUsage(request) {
399
+ const res = await request.fetch(`${request.platform}/registry/platform/ai-usage${request.query}`, {
400
+ headers: request.headers
401
+ });
402
+ const body = await responseBody2(res);
403
+ if (!res.ok) throw new Error(apiError2("read platform AI usage", res.status, body));
404
+ if (request.json) request.stdout.log(JSON.stringify(body, null, 2));
405
+ else printUsage(body, request.stdout);
406
+ }
407
+ function usageLimit(value) {
408
+ if (!Number.isSafeInteger(value) || value < 1 || value > 500) {
409
+ throw new Error("usage limit must be an integer from 1 to 500");
410
+ }
411
+ return value;
412
+ }
413
+ function printUsage(body, out) {
414
+ const aggregates = isRecord2(body) && isRecord2(body.aggregates) && Array.isArray(body.aggregates.byStatus) ? body.aggregates.byStatus.filter(isRecord2) : [];
415
+ out.log(`All retained hosted-security status totals (up to ${String(isRecord2(body) ? body.retentionDays ?? 90 : 90)} days; o11y triage excluded)
416
+ status calls input tokens output tokens cost unpriced calls`);
417
+ for (const row of aggregates) {
418
+ out.log([
419
+ String(row.status ?? ""),
420
+ String(row.calls ?? ""),
421
+ String(row.input_tokens ?? ""),
422
+ String(row.output_tokens ?? ""),
423
+ formatMicrousd(row.cost_microusd),
424
+ String(row.unpriced_calls ?? "")
425
+ ].join(" "));
426
+ }
427
+ const events = isRecord2(body) && Array.isArray(body.events) ? body.events.filter(isRecord2) : [];
428
+ out.log(`Latest ${String(isRecord2(body) ? body.eventLimit ?? events.length : events.length)} hosted-security events
429
+ when app/env run actor purpose/role route / policy tokens cost status`);
430
+ for (const event of events) {
431
+ const input = numeric(event.input_tokens);
432
+ const output = numeric(event.output_tokens);
433
+ const priced = event.priced === true || event.priced === 1;
434
+ out.log([
435
+ timestamp2(event.created_at),
436
+ `${String(event.app_id ?? "")}/${String(event.env ?? "")}`,
437
+ String(event.run_id ?? ""),
438
+ `${String(event.actor_type ?? "")}:${String(event.actor_id ?? "")}`,
439
+ `${String(event.purpose ?? "")}/${String(event.role ?? "")}`,
440
+ `${String(event.provider ?? "")}/${String(event.model ?? "")}@v${String(event.policy_version ?? "unknown")}`,
441
+ String(input + output),
442
+ priced ? formatMicrousd(event.cost_microusd) : "unpriced",
443
+ `${String(event.status ?? "")}${event.error_code ? `:${String(event.error_code)}` : ""}`
444
+ ].join(" "));
445
+ }
446
+ }
447
+ function numeric(value) {
448
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
449
+ }
450
+ function formatMicrousd(value) {
451
+ return typeof value === "number" && Number.isFinite(value) ? `$${(value / 1e6).toFixed(6)}` : "unpriced";
452
+ }
453
+ function timestamp2(value) {
454
+ if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
455
+ const date = new Date(value);
456
+ return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
457
+ }
458
+ async function responseBody2(res) {
459
+ const text = await res.text();
460
+ if (!text) return {};
461
+ try {
462
+ return JSON.parse(text);
463
+ } catch {
464
+ return { message: text.slice(0, 300) };
465
+ }
466
+ }
467
+ function apiError2(action, status, body) {
468
+ const error = isRecord2(body) && isRecord2(body.error) ? body.error : void 0;
469
+ const message = error && typeof error.message === "string" ? error.message : isRecord2(body) && typeof body.message === "string" ? body.message : "request failed";
470
+ return `${action} failed (${status}): ${message}`;
471
+ }
472
+ function isRecord2(value) {
473
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
474
+ }
475
+
476
+ // src/admin-ai.ts
254
477
  async function adminAi(options) {
255
- const platform = (options.platform ?? import_node_process3.default.env.ODLA_PLATFORM ?? "https://odla.ai").replace(/\/$/, "");
478
+ const platform = platformAudience(options.platform ?? import_node_process4.default.env.ODLA_PLATFORM ?? "https://odla.ai");
256
479
  const doFetch = options.fetch ?? fetch;
257
480
  const out = options.stdout ?? console;
258
- const scope = options.action === "set" || options.action === "credential-set" ? "platform:ai:write" : "platform:ai:read";
259
- const token = options.token ?? import_node_process3.default.env.ODLA_ADMIN_TOKEN ?? await scopedToken(platform, scope, options, doFetch, out);
481
+ const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
482
+ const auditQuery = options.action === "audit" ? adminAiAuditQuery(options) : void 0;
483
+ const scope = options.action === "set" ? "platform:ai:policy:write" : options.action === "credential-set" ? "platform:ai:credential:write" : options.action === "credentials" ? "platform:ai:credential:read" : options.action === "usage" ? "platform:ai:usage:read" : options.action === "audit" ? "platform:ai:audit:read" : "platform:ai:policy:read";
484
+ const token = await resolveAdminPlatformToken({
485
+ platform,
486
+ scope,
487
+ token: options.token,
488
+ open: options.open,
489
+ fetch: doFetch,
490
+ stdout: out,
491
+ openApprovalUrl: options.openApprovalUrl,
492
+ tokenFile: options.tokenFile
493
+ });
260
494
  const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
495
+ if (options.action === "usage") {
496
+ await readAdminAiUsage({ platform, query: usageQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
497
+ return;
498
+ }
499
+ if (options.action === "audit") {
500
+ await readAdminAiAudit({ platform, query: auditQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
501
+ return;
502
+ }
261
503
  if (options.action === "credentials") {
262
504
  const res2 = await doFetch(`${platform}/registry/platform/ai-credentials`, { headers });
263
- const body2 = await responseBody(res2);
264
- if (!res2.ok) throw new Error(apiError("read platform AI credential status", res2.status, body2));
265
- const credentials = isRecord(body2) && isRecord(body2.credentials) ? body2.credentials : body2;
505
+ const body2 = await responseBody3(res2);
506
+ if (!res2.ok) throw new Error(apiError3("read platform AI credential status", res2.status, body2));
507
+ const credentials = isRecord3(body2) && isRecord3(body2.credentials) ? body2.credentials : body2;
266
508
  if (options.json) out.log(JSON.stringify({ credentials }, null, 2));
267
- else for (const [provider, status] of Object.entries(isRecord(credentials) ? credentials : {})) {
268
- out.log(`${provider} ${isRecord(status) && status.set === true ? "set" : "not set"}`);
509
+ else for (const [provider, status] of Object.entries(isRecord3(credentials) ? credentials : {})) {
510
+ const label = isRecord3(status) && status.set === true ? status.readable === true ? "vault readable" : "stored; vault unreadable" : "not set";
511
+ out.log(`${provider} ${label}`);
269
512
  }
270
513
  return;
271
514
  }
@@ -277,18 +520,25 @@ async function adminAi(options) {
277
520
  headers,
278
521
  body: JSON.stringify({ value })
279
522
  });
280
- const body2 = await responseBody(res2);
281
- if (!res2.ok) throw new Error(apiError(`store ${provider} platform credential`, res2.status, body2));
523
+ const body2 = await responseBody3(res2);
524
+ if (!res2.ok) throw new Error(apiError3(`store ${provider} platform credential`, res2.status, body2));
282
525
  out.log(`stored ${provider} platform credential (write-only; value was not returned)`);
283
526
  return;
284
527
  }
285
- if (options.action === "show") {
528
+ if (options.action === "show" || options.action === "models") {
286
529
  const res2 = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
287
- const body2 = await responseBody(res2);
288
- if (!res2.ok) throw new Error(apiError("read platform AI policies", res2.status, body2));
530
+ const body2 = await responseBody3(res2);
531
+ if (!res2.ok) throw new Error(apiError3("read platform AI policies", res2.status, body2));
532
+ if (options.action === "models") {
533
+ const models = catalogModels(body2).filter((model) => !options.provider || model.provider === options.provider);
534
+ if (!models.length) throw new Error(options.provider ? `no System AI models found for provider ${options.provider}` : "platform returned no System AI models");
535
+ if (options.json) out.log(JSON.stringify({ models }, null, 2));
536
+ else for (const model of models) out.log(`${model.provider} ${model.id}`);
537
+ return;
538
+ }
289
539
  const policies = policyArray(body2);
290
540
  if (options.json) {
291
- out.log(JSON.stringify({ policies }, null, 2));
541
+ out.log(JSON.stringify({ policies, models: catalogModels(body2) }, null, 2));
292
542
  return;
293
543
  }
294
544
  out.log("purpose enabled provider model max calls max input bytes max output tokens");
@@ -305,59 +555,68 @@ async function adminAi(options) {
305
555
  }
306
556
  return;
307
557
  }
308
- const purpose = requirePurpose(options.purpose);
309
- if (!options.provider?.trim()) throw new Error("--provider is required");
310
- if (!options.model?.trim()) throw new Error("--model is required");
558
+ if (options.purpose === "security") {
559
+ const currentResponse = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
560
+ const currentBody = await responseBody3(currentResponse);
561
+ if (!currentResponse.ok) throw new Error(apiError3("read current security AI policies", currentResponse.status, currentBody));
562
+ const policies = policyArray(currentBody);
563
+ const discovery = policies.find((policy2) => policy2.purpose === "security.discovery");
564
+ const validation = policies.find((policy2) => policy2.purpose === "security.validation");
565
+ if (!discovery || !validation || !Number.isSafeInteger(discovery.version) || !Number.isSafeInteger(validation.version)) {
566
+ throw new Error("platform returned invalid security AI policy revisions");
567
+ }
568
+ const shared = {
569
+ ...options.enabled === void 0 ? {} : { enabled: options.enabled },
570
+ ...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
571
+ ...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
572
+ ...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
573
+ };
574
+ const discoveryUpdate = {
575
+ ...shared,
576
+ ...options.discoveryProvider?.trim() ? { provider: options.discoveryProvider.trim() } : {},
577
+ ...options.discoveryModel?.trim() ? { model: options.discoveryModel.trim() } : {}
578
+ };
579
+ const validationUpdate = {
580
+ ...shared,
581
+ ...options.validationProvider?.trim() ? { provider: options.validationProvider.trim() } : {},
582
+ ...options.validationModel?.trim() ? { model: options.validationModel.trim() } : {}
583
+ };
584
+ if (!Object.keys(discoveryUpdate).length && !Object.keys(validationUpdate).length) {
585
+ throw new Error("security set requires a route, enabled state, or budget change");
586
+ }
587
+ const res2 = await doFetch(`${platform}/registry/platform/ai-policies/security`, {
588
+ method: "PUT",
589
+ headers,
590
+ body: JSON.stringify({
591
+ expectedVersions: { discovery: discovery.version, validation: validation.version },
592
+ discovery: discoveryUpdate,
593
+ validation: validationUpdate
594
+ })
595
+ });
596
+ const response2 = await responseBody3(res2);
597
+ if (!res2.ok) throw new Error(apiError3("update security review routes", res2.status, response2));
598
+ out.log(options.json ? JSON.stringify(response2, null, 2) : "updated security review discovery and independent validation routes atomically");
599
+ return;
600
+ }
601
+ const purpose = requireSystemAiPurpose(options.purpose);
311
602
  const body = {
312
- provider: options.provider.trim(),
313
- model: options.model.trim(),
603
+ ...options.provider?.trim() ? { provider: options.provider.trim() } : {},
604
+ ...options.model?.trim() ? { model: options.model.trim() } : {},
314
605
  ...options.enabled === void 0 ? {} : { enabled: options.enabled },
315
606
  ...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
316
607
  ...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
317
608
  ...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
318
609
  };
610
+ if (!Object.keys(body).length) throw new Error("set requires a provider, model, enabled state, or budget change");
319
611
  const res = await doFetch(`${platform}/registry/platform/ai-policies/${encodeURIComponent(purpose)}`, {
320
612
  method: "PUT",
321
613
  headers,
322
614
  body: JSON.stringify(body)
323
615
  });
324
- const response = await responseBody(res);
325
- if (!res.ok) throw new Error(apiError(`update ${purpose}`, res.status, response));
326
- const policy = isRecord(response) && isRecord(response.policy) ? response.policy : isRecord(response) ? response : {};
327
- out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? body.provider)}/${String(policy.model ?? body.model)}`);
328
- }
329
- async function scopedToken(platform, scope, options, doFetch, out) {
330
- const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
331
- const cache = readJsonFile(tokenFile);
332
- const cached = cache?.platform === platform ? cache.tokens?.[scope] : void 0;
333
- if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
334
- out.log(`auth: using cached ${scope} grant (${tokenFile})`);
335
- return cached.token;
336
- }
337
- const { token, expiresAt } = await (0, import_db2.requestToken)({
338
- endpoint: platform,
339
- label: `odla CLI admin AI (${scope})`,
340
- scopes: [scope],
341
- fetch: doFetch,
342
- onCode: async ({ userCode, expiresIn }) => {
343
- const approvalUrl = handshakeUrl(platform, userCode);
344
- out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
345
- const shouldOpen = options.open === true || options.open !== false && !import_node_process3.default.env.CI && Boolean(import_node_process3.default.stdin.isTTY && import_node_process3.default.stdout.isTTY);
346
- if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
347
- }
348
- });
349
- const tokens = cache?.platform === platform ? { ...cache.tokens ?? {} } : {};
350
- tokens[scope] = { token, expiresAt };
351
- if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
352
- writePrivateJson(tokenFile, { platform, tokens });
353
- out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
354
- return token;
355
- }
356
- function requirePurpose(value) {
357
- if (!SYSTEM_AI_PURPOSES.includes(value)) {
358
- throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
359
- }
360
- return value;
616
+ const response = await responseBody3(res);
617
+ if (!res.ok) throw new Error(apiError3(`update ${purpose}`, res.status, response));
618
+ const policy = isRecord3(response) && isRecord3(response.policy) ? response.policy : isRecord3(response) ? response : {};
619
+ out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? "unchanged")}/${String(policy.model ?? "unchanged")}`);
361
620
  }
362
621
  function requireProvider(value) {
363
622
  if (value !== "anthropic" && value !== "openai" && value !== "google") {
@@ -368,7 +627,7 @@ function requireProvider(value) {
368
627
  async function credentialValue(options) {
369
628
  if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
370
629
  let value;
371
- if (options.fromEnv) value = import_node_process3.default.env[options.fromEnv];
630
+ if (options.fromEnv) value = import_node_process4.default.env[options.fromEnv];
372
631
  else if (options.stdin) value = await (options.readStdin ?? readStdin)();
373
632
  else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
374
633
  value = value?.replace(/[\r\n]+$/, "");
@@ -378,18 +637,24 @@ async function credentialValue(options) {
378
637
  }
379
638
  async function readStdin() {
380
639
  let value = "";
381
- for await (const chunk of import_node_process3.default.stdin) {
640
+ for await (const chunk of import_node_process4.default.stdin) {
382
641
  value += String(chunk);
383
642
  if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
384
643
  }
385
644
  return value;
386
645
  }
387
646
  function policyArray(body) {
388
- if (isRecord(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord);
389
- if (isRecord(body) && isRecord(body.policies)) return Object.values(body.policies).filter(isRecord);
647
+ if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
648
+ if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
390
649
  throw new Error("platform returned an invalid AI policy response");
391
650
  }
392
- async function responseBody(res) {
651
+ function catalogModels(body) {
652
+ if (!isRecord3(body) || !isRecord3(body.catalog) || !Array.isArray(body.catalog.models)) {
653
+ throw new Error("platform returned an invalid System AI model catalog");
654
+ }
655
+ return body.catalog.models.filter((value) => isRecord3(value) && typeof value.id === "string" && typeof value.provider === "string");
656
+ }
657
+ async function responseBody3(res) {
393
658
  const text = await res.text();
394
659
  if (!text) return {};
395
660
  try {
@@ -398,12 +663,12 @@ async function responseBody(res) {
398
663
  return { message: text.slice(0, 300) };
399
664
  }
400
665
  }
401
- function apiError(action, status, body) {
402
- const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
403
- const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
666
+ function apiError3(action, status, body) {
667
+ const error = isRecord3(body) && isRecord3(body.error) ? body.error : void 0;
668
+ const message = error && typeof error.message === "string" ? error.message : isRecord3(body) && typeof body.message === "string" ? body.message : "request failed";
404
669
  return `${action} failed (${status}): ${message}`;
405
670
  }
406
- function isRecord(value) {
671
+ function isRecord3(value) {
407
672
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
408
673
  }
409
674
 
@@ -491,7 +756,7 @@ var CAPABILITIES = {
491
756
  "push db schema/rules and configure platform AI, auth, and deployment links",
492
757
  "validate config offline and smoke-test a provisioned db environment",
493
758
  "run app-attributed hosted security discovery and independent validation without provider keys",
494
- "let admins manage system AI routes/credentials through short-lived exact-scope approval"
759
+ "let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
495
760
  ],
496
761
  agent: [
497
762
  "install and import the selected odla SDKs",
@@ -1023,7 +1288,7 @@ function relativeDisplay(path, rootDir) {
1023
1288
  // src/provision.ts
1024
1289
  var import_apps2 = require("@odla-ai/apps");
1025
1290
  var import_ai = require("@odla-ai/ai");
1026
- var import_node_process4 = __toESM(require("process"), 1);
1291
+ var import_node_process5 = __toESM(require("process"), 1);
1027
1292
 
1028
1293
  // src/provision-credentials.ts
1029
1294
  var import_apps = require("@odla-ai/apps");
@@ -1327,7 +1592,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
1327
1592
  out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
1328
1593
  }
1329
1594
  if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
1330
- const key = import_node_process4.default.env[cfg.ai.keyEnv];
1595
+ const key = import_node_process5.default.env[cfg.ai.keyEnv];
1331
1596
  if (key) {
1332
1597
  const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
1333
1598
  await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
@@ -1482,10 +1747,14 @@ function printSummary(out, appId, env, run, report, output) {
1482
1747
  out.log(`security: ${appId}/${env} run=${run.runId} profile=${run.profileVersion}`);
1483
1748
  out.log(` discovery: ${run.discovery.identity.provider}/${run.discovery.identity.model}`);
1484
1749
  out.log(` validation: ${run.validation.identity.provider}/${run.validation.identity.model}`);
1485
- out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells}`);
1750
+ out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells} budget_exhausted=${report.metrics.budgetExhaustedCells}`);
1751
+ if (report.callBudget) out.log(` calls: discovery=${formatBudget(report.callBudget.discovery)} validation=${formatBudget(report.callBudget.validation)}`);
1486
1752
  out.log(` findings: confirmed=${report.metrics.confirmed} needs_reproduction=${report.metrics.needsReproduction} candidates=${report.metrics.candidates}`);
1487
1753
  out.log(` report: ${(0, import_node_path6.resolve)(output, "REPORT.md")}`);
1488
1754
  }
1755
+ function formatBudget(usage) {
1756
+ return usage ? `${usage.usedCalls}/${usage.maxCalls} skipped=${usage.skippedCalls}` : "caller-managed";
1757
+ }
1489
1758
 
1490
1759
  // src/help.ts
1491
1760
  var import_node_fs7 = require("fs");
@@ -1502,9 +1771,14 @@ Usage:
1502
1771
  odla-ai doctor [--config odla.config.mjs]
1503
1772
  odla-ai capabilities [--json]
1504
1773
  odla-ai admin ai show [--platform https://odla.ai] [--json]
1505
- odla-ai admin ai set <purpose> --provider <id> --model <id> [--enabled|--no-enabled]
1774
+ odla-ai admin ai models [--provider <id>] [--json]
1775
+ odla-ai admin ai set <purpose> [--provider <id>] [--model <id>] [--enabled|--no-enabled]
1776
+ odla-ai admin ai set security --discovery-provider <id> --discovery-model <id>
1777
+ --validation-provider <id> --validation-model <id> [--enabled|--no-enabled]
1506
1778
  odla-ai admin ai credentials [--json]
1507
1779
  odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
1780
+ odla-ai admin ai usage [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
1781
+ odla-ai admin ai audit [--limit <1-200>] [--json]
1508
1782
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
1509
1783
  odla-ai security run [target] --self --ack-redacted-source
1510
1784
  odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
@@ -1518,7 +1792,7 @@ Commands:
1518
1792
  init Create a generic odla.config.mjs plus starter schema/rules files.
1519
1793
  doctor Validate and summarize the project config without network calls.
1520
1794
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
1521
- admin Manage platform-funded AI routing with a narrow admin-approved device grant.
1795
+ admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
1522
1796
  security Run hosted discovery + independent validation with app/run attribution.
1523
1797
  provision Register services, configure them, persist credentials, optionally push secrets.
1524
1798
  smoke Verify local credentials, public-config, live schema, and db aggregate.
@@ -1914,12 +2188,14 @@ async function runCli(argv = process.argv.slice(2)) {
1914
2188
  const action = parsed.positionals[2];
1915
2189
  const credentialSet = action === "credential" && parsed.positionals[3] === "set";
1916
2190
  const credentials = action === "credentials";
1917
- if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials) {
2191
+ const models = action === "models";
2192
+ const usage = action === "usage";
2193
+ const audit = action === "audit";
2194
+ if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials && !models && !usage && !audit) {
1918
2195
  throw new Error('unknown admin command. Try "odla-ai admin ai show".');
1919
2196
  }
1920
2197
  assertArgs(parsed, [
1921
2198
  "platform",
1922
- "token",
1923
2199
  "json",
1924
2200
  "open",
1925
2201
  "provider",
@@ -1929,10 +2205,18 @@ async function runCli(argv = process.argv.slice(2)) {
1929
2205
  "max-output-tokens",
1930
2206
  "max-calls-per-run",
1931
2207
  "from-env",
1932
- "stdin"
2208
+ "stdin",
2209
+ "discovery-provider",
2210
+ "discovery-model",
2211
+ "validation-provider",
2212
+ "validation-model",
2213
+ "app-id",
2214
+ "env",
2215
+ "run-id",
2216
+ "limit"
1933
2217
  ], credentialSet ? 5 : action === "set" ? 4 : 3);
1934
2218
  await adminAi({
1935
- action: credentialSet ? "credential-set" : credentials ? "credentials" : action,
2219
+ action: credentialSet ? "credential-set" : credentials ? "credentials" : models ? "models" : usage ? "usage" : audit ? "audit" : action,
1936
2220
  purpose: action === "set" ? parsed.positionals[3] : void 0,
1937
2221
  provider: stringOpt(parsed.options.provider),
1938
2222
  model: stringOpt(parsed.options.model),
@@ -1941,12 +2225,19 @@ async function runCli(argv = process.argv.slice(2)) {
1941
2225
  maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
1942
2226
  maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
1943
2227
  platform: stringOpt(parsed.options.platform),
1944
- token: stringOpt(parsed.options.token),
1945
2228
  json: parsed.options.json === true,
1946
2229
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
1947
2230
  credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
1948
2231
  fromEnv: stringOpt(parsed.options["from-env"]),
1949
- stdin: parsed.options.stdin === true
2232
+ stdin: parsed.options.stdin === true,
2233
+ discoveryProvider: stringOpt(parsed.options["discovery-provider"]),
2234
+ discoveryModel: stringOpt(parsed.options["discovery-model"]),
2235
+ validationProvider: stringOpt(parsed.options["validation-provider"]),
2236
+ validationModel: stringOpt(parsed.options["validation-model"]),
2237
+ appId: stringOpt(parsed.options["app-id"]),
2238
+ env: stringOpt(parsed.options.env),
2239
+ runId: stringOpt(parsed.options["run-id"]),
2240
+ limit: numberOpt(parsed.options.limit, "--limit")
1950
2241
  });
1951
2242
  return;
1952
2243
  }
@@ -1988,6 +2279,9 @@ async function runCli(argv = process.argv.slice(2)) {
1988
2279
  return getScopedPlatformToken({ platform: request.platform, scope: request.scope, open });
1989
2280
  }
1990
2281
  const cfg = await loadProjectConfig(configPath);
2282
+ if (platformAudience(cfg.platformUrl) !== platformAudience(request.platform)) {
2283
+ throw new Error("--platform cannot reuse a project developer token from another platform; update odla.config.mjs and authenticate there");
2284
+ }
1991
2285
  return getDeveloperToken(cfg, { configPath, open }, fetch, console);
1992
2286
  }
1993
2287
  });