@m6d/cortex-cli 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Scaffold Cortex projects and manage a Cortex deployment",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,6 +4,11 @@ import { relative, resolve } from "node:path";
4
4
  import { Option } from "@commander-js/extra-typings";
5
5
  import type { Command } from "@commander-js/extra-typings";
6
6
  import { isRecord } from "@cortex/contracts/json";
7
+ import {
8
+ callManagement,
9
+ ManagementClientError,
10
+ managementPath,
11
+ } from "@cortex/contracts/management";
7
12
  import {
8
13
  appliedManifestSchema,
9
14
  manifestApplyInputSchema,
@@ -12,7 +17,7 @@ import {
12
17
  import { loadManifestFile } from "@cortex/contracts/manifest-file";
13
18
  import { z } from "zod";
14
19
 
15
- import { callManagement, ManagementError } from "@/management/client";
20
+ import { managementTarget } from "@/management/client";
16
21
  import { callOrFail, confirmDestructive, requireProfile } from "@/commands/management";
17
22
  import { askChoice, canAsk } from "@/ui/prompts";
18
23
  import * as report from "@/ui/report";
@@ -137,7 +142,7 @@ export async function applyManifest(options: {
137
142
  const { profile, dryRun } = options;
138
143
  const answer = await sendManifest({
139
144
  ...options,
140
- path: dryRun ? "/manifests/preview" : "/manifests/sync",
145
+ path: managementPath(dryRun ? "manifests.preview" : "manifests.sync"),
141
146
  });
142
147
  // The secrets are read apart from the changes: a sync has committed by
143
148
  // now, and a summary this CLI is too old to read must not lose them.
@@ -179,14 +184,14 @@ async function sendManifest(options: {
179
184
  const resolutions: Resolution = [];
180
185
  for (;;) {
181
186
  try {
182
- return await callManagement(profile, "post", path, {
183
- ...manifest,
184
- prefer,
185
- resolutions,
187
+ return await callManagement(managementTarget(profile), {
188
+ method: "post",
189
+ path,
190
+ input: { ...manifest, prefer, resolutions },
186
191
  });
187
192
  } catch (error) {
188
193
  const refused =
189
- error instanceof ManagementError
194
+ error instanceof ManagementClientError
190
195
  ? unresolvedSchema.safeParse(error.detail)
191
196
  : undefined;
192
197
  if (!refused?.success) throw error;
@@ -3,12 +3,13 @@ import { readFileSync } from "node:fs";
3
3
  import { type Command, Option } from "@commander-js/extra-typings";
4
4
  import { canCarrySecret } from "@cortex/contracts/egress-policy";
5
5
  import { isRecord } from "@cortex/contracts/json";
6
+ import { callManagement, ManagementClientError } from "@cortex/contracts/management";
6
7
 
7
8
  import {
8
- callManagement,
9
9
  inputSchemaOf,
10
10
  loadOpenApi,
11
- ManagementError,
11
+ managementClient,
12
+ managementTarget,
12
13
  UnreachableError,
13
14
  } from "@/management/client";
14
15
  import type { OpenApiDocument, OpenApiOperation } from "@/management/client";
@@ -134,14 +135,11 @@ async function login(given: string | undefined, name: string) {
134
135
  */
135
136
  async function whoami(profile: { url: string; key: string }) {
136
137
  try {
137
- const me = (await callManagement(profile, "get", "/management-keys/mine", undefined)) as {
138
- name: string;
139
- fingerprint: string;
140
- };
138
+ const me = await managementClient(profile).managementKeys.mine();
141
139
  return `key "${me.name}" (${me.fingerprint})`;
142
140
  } catch (error) {
143
- if (error instanceof ManagementError && error.status === 401) return null;
144
- if (error instanceof ManagementError) {
141
+ if (error instanceof ManagementClientError && error.status === 401) return null;
142
+ if (error instanceof ManagementClientError) {
145
143
  report.fail({
146
144
  what: `${profile.url} answered ${error.status} ${error.code}: ${error.message}`,
147
145
  });
@@ -323,7 +321,7 @@ function registerOperation(scope: {
323
321
  const result = await callOrFail(profile, () =>
324
322
  all
325
323
  ? walkPages(profile, method, path, body)
326
- : callManagement(profile, method, path, body),
324
+ : callManagement(managementTarget(profile), { method, path, input: body }),
327
325
  );
328
326
  print(result, format);
329
327
  if (publisher) {
@@ -427,7 +425,11 @@ async function publishAfterWrite(
427
425
  }
428
426
  if (publish) {
429
427
  const published = await callOrFail(profile, () =>
430
- callManagement(profile, publisher.method, publisher.path, { id }),
428
+ callManagement(managementTarget(profile), {
429
+ method: publisher.method,
430
+ path: publisher.path,
431
+ input: { id },
432
+ }),
431
433
  );
432
434
  options.note(`Published ${id}.`);
433
435
  for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
@@ -454,7 +456,7 @@ export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promis
454
456
  * rethrown.
455
457
  */
456
458
  export function failCall(profile: { url: string }, error: unknown): never {
457
- if (error instanceof ManagementError) {
459
+ if (error instanceof ManagementClientError) {
458
460
  report.fail({
459
461
  what: `${error.status} ${error.code}: ${error.message}`,
460
462
  blocks: [
@@ -605,10 +607,10 @@ export async function walkPages(
605
607
  // Bounded: if Cortex reports more rows than it serves, the walk must not
606
608
  // spin, and a listing the bound cannot cover is an error, never a short result.
607
609
  for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
608
- const page = (await callManagement(profile, method, path, {
609
- ...given,
610
- pageIndex,
611
- pageSize,
610
+ const page = (await callManagement(managementTarget(profile), {
611
+ method,
612
+ path,
613
+ input: { ...given, pageIndex, pageSize },
612
614
  })) as { items?: unknown[]; count?: number; filteredCount?: number };
613
615
  if (!Array.isArray(page.items)) return page;
614
616
  items.push(...page.items);
@@ -12,6 +12,7 @@ import {
12
12
  normalizeHost,
13
13
  scrubCredentialStrings,
14
14
  } from "@cortex/contracts/egress-policy";
15
+ import { ManagementClientError, managementPath } from "@cortex/contracts/management";
15
16
  import { MODEL_PROVIDER_LIMITS, NAME_MAX_LENGTH, SETTING_BOUNDS } from "@cortex/contracts/manifest";
16
17
  import { z } from "zod";
17
18
 
@@ -29,7 +30,7 @@ import {
29
30
  requireProfile,
30
31
  walkPages,
31
32
  } from "@/commands/management";
32
- import { callManagement, ManagementError, UnreachableError } from "@/management/client";
33
+ import { managementClient, UnreachableError } from "@/management/client";
33
34
  import { DOCKER_HOST, EMBEDDING, withEnvValues } from "@/scaffold/files";
34
35
  import * as prompts from "@/ui/prompts";
35
36
  import * as report from "@/ui/report";
@@ -154,7 +155,7 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
154
155
  interactive,
155
156
  command: "bun cortex apply --yes",
156
157
  }).catch(function (error: unknown) {
157
- if (error instanceof ManagementError && error.message === "chat_model_required") {
158
+ if (error instanceof ManagementClientError && error.message === "chat_model_required") {
158
159
  return undefined;
159
160
  }
160
161
  return failCall(profile, error);
@@ -233,12 +234,12 @@ async function waitForCortex(profile: Profile, wait: boolean) {
233
234
  let announced = false;
234
235
  for (;;) {
235
236
  try {
236
- await callManagement(profile, "get", "/secrets/list", { pageSize: 1 });
237
+ await managementClient(profile).secrets.list({ pageSize: 1 });
237
238
  return;
238
239
  } catch (error) {
239
240
  const starting =
240
241
  error instanceof UnreachableError ||
241
- (error instanceof ManagementError && error.status >= 500);
242
+ (error instanceof ManagementClientError && error.status >= 500);
242
243
  if (!starting || Date.now() >= deadline) failCall(profile, error);
243
244
  if (!announced) {
244
245
  report.row("", `Waiting for ${profile.url}. A first start pulls the image.`);
@@ -249,24 +250,21 @@ async function waitForCortex(profile: Profile, wait: boolean) {
249
250
  }
250
251
  }
251
252
 
252
- function call(profile: Profile, method: "get" | "post", path: string, input: unknown) {
253
- return callOrFail(profile, () => callManagement(profile, method, path, input));
254
- }
255
-
256
253
  /** The entry named exactly `name`, across every page; a listing's search matches any part of it. */
257
254
  async function findByName<TItem extends typeof namedSchema>(
258
255
  profile: Profile,
259
- router: string,
256
+ router: "secrets" | "modelProviders" | "servers",
260
257
  name: string,
261
258
  item: TItem,
262
259
  ) {
263
- const { items } = z
264
- .object({ items: z.array(item) })
265
- .parse(
266
- await callOrFail(profile, () =>
267
- walkPages(profile, "get", `/${router}/list`, { q: name, pageSize: 100 }),
268
- ),
269
- );
260
+ const { items } = z.object({ items: z.array(item) }).parse(
261
+ await callOrFail(profile, () =>
262
+ walkPages(profile, "get", managementPath(`${router}.list`), {
263
+ q: name,
264
+ pageSize: 100,
265
+ }),
266
+ ),
267
+ );
270
268
  return items.find((entry) => entry.name === name);
271
269
  }
272
270
 
@@ -284,7 +282,9 @@ function secretFor(owner: string, role: "api-key" | "server-key", value: string)
284
282
 
285
283
  async function ensureSecret(profile: Profile, name: string, value: string) {
286
284
  const found = await findByName(profile, "secrets", name, namedSchema);
287
- if (!found) await call(profile, "post", "/secrets/set", { name, value });
285
+ if (!found) {
286
+ await callOrFail(profile, () => managementClient(profile).secrets.set({ name, value }));
287
+ }
288
288
  }
289
289
 
290
290
  /**
@@ -307,7 +307,7 @@ function failCallsElsewhere(what: string, calls: string | null, wanted: string,
307
307
  * write.
308
308
  */
309
309
  async function ensureProvider(profile: Profile, provider: Provider) {
310
- const found = await findByName(profile, "model-providers", provider.name, providerRowSchema);
310
+ const found = await findByName(profile, "modelProviders", provider.name, providerRowSchema);
311
311
  if (found && found.baseUrl !== provider.baseUrl) {
312
312
  failCallsElsewhere(
313
313
  `Provider ${provider.name}`,
@@ -330,13 +330,15 @@ async function ensureProvider(profile: Profile, provider: Provider) {
330
330
  await ensureSecret(profile, secretName, provider.apiKey);
331
331
 
332
332
  if (!found) {
333
- await call(profile, "post", "/model-providers/create", {
334
- name: provider.name,
335
- baseUrl: provider.baseUrl,
336
- secretName,
337
- models,
338
- efforts: [],
339
- });
333
+ await callOrFail(profile, () =>
334
+ managementClient(profile).modelProviders.create({
335
+ name: provider.name,
336
+ baseUrl: provider.baseUrl,
337
+ secretName,
338
+ models,
339
+ efforts: [],
340
+ }),
341
+ );
340
342
  report.input("Provider", `${provider.name} · created`);
341
343
  return;
342
344
  }
@@ -344,13 +346,15 @@ async function ensureProvider(profile: Profile, provider: Provider) {
344
346
  report.input("Provider", `${provider.name} · kept`);
345
347
  return;
346
348
  }
347
- await call(profile, "post", "/model-providers/update", {
348
- id: found.id,
349
- baseUrl: found.baseUrl,
350
- secretName,
351
- models,
352
- efforts: found.efforts,
353
- });
349
+ await callOrFail(profile, () =>
350
+ managementClient(profile).modelProviders.update({
351
+ id: found.id,
352
+ baseUrl: found.baseUrl,
353
+ secretName,
354
+ models,
355
+ efforts: found.efforts,
356
+ }),
357
+ );
354
358
  report.input("Provider", `${provider.name} · updated`);
355
359
  }
356
360
 
@@ -369,7 +373,11 @@ async function ensureServer(
369
373
  const url = `${project.CORTEX_SERVER_URL}/cortex`;
370
374
  const found = await findByName(profile, "servers", name, namedSchema);
371
375
  const server = found
372
- ? serverSchema.parse(await call(profile, "get", "/servers/get", { id: found.id }))
376
+ ? serverSchema.parse(
377
+ await callOrFail(profile, () =>
378
+ managementClient(profile).servers.get({ id: found.id }),
379
+ ),
380
+ )
373
381
  : undefined;
374
382
  if (server && server.url !== url) {
375
383
  failCallsElsewhere(`Server ${name}`, server.url, url, "CORTEX_SERVER_NAME");
@@ -384,15 +392,17 @@ async function ensureServer(
384
392
  };
385
393
  if (!server) {
386
394
  const created = serverSchema.parse(
387
- await call(profile, "post", "/servers/create", {
388
- name,
389
- description: "",
390
- url,
391
- keySecretName,
392
- ...model,
393
- signInChecksPerMinute: SETTING_BOUNDS.server.signInChecksPerMinute.default,
394
- triggerChecksPerMinute: SETTING_BOUNDS.server.triggerChecksPerMinute.default,
395
- }),
395
+ await callOrFail(profile, () =>
396
+ managementClient(profile).servers.create({
397
+ name,
398
+ description: "",
399
+ url,
400
+ keySecretName,
401
+ ...model,
402
+ signInChecksPerMinute: SETTING_BOUNDS.server.signInChecksPerMinute.default,
403
+ triggerChecksPerMinute: SETTING_BOUNDS.server.triggerChecksPerMinute.default,
404
+ }),
405
+ ),
396
406
  );
397
407
  report.input("Server", `${name} · created`);
398
408
  return created;
@@ -404,22 +414,24 @@ async function ensureServer(
404
414
  return server;
405
415
  }
406
416
  const updated = serverSchema.parse(
407
- await call(profile, "post", "/servers/update", {
408
- id: server.id,
409
- name,
410
- description: server.description ?? "",
411
- url,
412
- keySecretName,
413
- ...(takesModel
414
- ? model
415
- : {
416
- modelProvider: server.modelProvider,
417
- modelName: server.modelName,
418
- modelEffort: server.modelEffort,
419
- }),
420
- signInChecksPerMinute: server.signInChecksPerMinute,
421
- triggerChecksPerMinute: server.triggerChecksPerMinute,
422
- }),
417
+ await callOrFail(profile, () =>
418
+ managementClient(profile).servers.update({
419
+ id: server.id,
420
+ name,
421
+ description: server.description ?? "",
422
+ url,
423
+ keySecretName,
424
+ ...(takesModel
425
+ ? model
426
+ : {
427
+ modelProvider: server.modelProvider,
428
+ modelName: server.modelName,
429
+ modelEffort: server.modelEffort,
430
+ }),
431
+ signInChecksPerMinute: server.signInChecksPerMinute,
432
+ triggerChecksPerMinute: server.triggerChecksPerMinute,
433
+ }),
434
+ ),
423
435
  );
424
436
  report.input("Server", `${name} · updated`);
425
437
  return updated;
@@ -434,14 +446,14 @@ async function syncServer(profile: Profile, id: string, url: string) {
434
446
  const deadline = Date.now() + SYNC_WAIT_MS;
435
447
  for (;;) {
436
448
  try {
437
- await callManagement(profile, "post", "/servers/sync", { id });
449
+ await managementClient(profile).servers.sync({ id });
438
450
  report.input("Synced", `${url}/cortex`);
439
451
  return;
440
452
  } catch (error) {
441
453
  // Only a backend Cortex cannot reach yet is worth asking again; a
442
454
  // refused manifest or blocked host fails the same way next time.
443
455
  const starting =
444
- error instanceof ManagementError && error.message === "upstream_failed";
456
+ error instanceof ManagementClientError && error.message === "upstream_failed";
445
457
  if (starting && Date.now() < deadline) {
446
458
  await Bun.sleep(POLL_MS);
447
459
  continue;
@@ -0,0 +1,169 @@
1
+ import { z } from "zod";
2
+
3
+ import { isRecord } from "../json/index";
4
+ import { managementProcedureKinds } from "./procedures.generated";
5
+ import type { ManagementProcedures } from "./procedures.generated";
6
+
7
+ /*
8
+ * Cortex's management API as a caller reaches it: one REST operation per
9
+ * console procedure under `/api/management`, a query as GET with its input
10
+ * on the query string, a mutation as POST with its input as the JSON body,
11
+ * and a management key as the bearer. The console routes a procedure by
12
+ * `managementPath`; the CLI and the TypeScript SDK call through
13
+ * `callManagement`, and M6d.Cortex spells the same rules in C#.
14
+ * `management.fixtures.json` holds every caller to the same requests and
15
+ * refusals. `createManagementClient` types each call from
16
+ * `procedures.generated.ts`, which the console prints from its procedures.
17
+ */
18
+
19
+ /** Where a console serves the management API. */
20
+ export const MANAGEMENT_BASE_PATH = "/api/management";
21
+
22
+ /** A procedure's REST path: its tRPC path, kebab-cased (`managementKeys.mine` is `/management-keys/mine`). */
23
+ export function managementPath(procedure: string) {
24
+ const kebab = procedure.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
25
+ return `/${kebab.replaceAll(".", "/")}`;
26
+ }
27
+
28
+ function isScalar(value: unknown): value is string | number | boolean | null {
29
+ return value === null || ["string", "number", "boolean"].includes(typeof value);
30
+ }
31
+
32
+ /**
33
+ * A query's input on the query string, one parameter per field: a scalar as
34
+ * itself, a non-empty array of scalars as a repeated parameter, anything
35
+ * else as JSON, as the document declares each field; "" for none.
36
+ */
37
+ function managementQuery(input: Record<string, unknown>) {
38
+ const params = new URLSearchParams();
39
+ for (const [name, value] of Object.entries(input)) {
40
+ if (value === undefined) continue;
41
+ if (isScalar(value)) params.set(name, String(value));
42
+ else if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) {
43
+ for (const item of value) params.append(name, String(item));
44
+ } else params.set(name, JSON.stringify(value));
45
+ }
46
+ const encoded = params.toString();
47
+ return encoded === "" ? "" : `?${encoded}`;
48
+ }
49
+
50
+ /** The body a refusal answers with, each field kept only when it has its documented shape. */
51
+ const refusalBodySchema = z.object({
52
+ code: z.string().optional().catch(undefined),
53
+ message: z.string().optional().catch(undefined),
54
+ refusal: z.string().optional().catch(undefined),
55
+ cause: z.record(z.string(), z.unknown()).optional().catch(undefined),
56
+ });
57
+
58
+ /**
59
+ * Cortex refused a management call: `code` is the tRPC code (`FORBIDDEN`),
60
+ * the message the refusal's literal (`read_only`), `detail` what the refusal
61
+ * names when it names something (`{ error, fields }`), and `refusal` the
62
+ * sentence Cortex words it in, when it has one. A body that is no refusal
63
+ * (a proxy's error page) reads as code `error` with the status text.
64
+ */
65
+ export class ManagementClientError extends Error {
66
+ public constructor(
67
+ public readonly status: number,
68
+ public readonly code: string,
69
+ message: string,
70
+ public readonly detail?: Record<string, unknown>,
71
+ public readonly refusal?: string,
72
+ ) {
73
+ super(message);
74
+ this.name = "ManagementClientError";
75
+ }
76
+ }
77
+
78
+ /** Where management calls go: a console's origin and a management key (`cortex_mgmt_…`); a test hands in its own `fetch`. */
79
+ export type ManagementTarget = {
80
+ url: string;
81
+ key: string;
82
+ fetch?: (url: string, init: RequestInit) => Promise<Response>;
83
+ };
84
+
85
+ /**
86
+ * One management call by REST route, answering the procedure's output as
87
+ * parsed JSON. A refusal throws `ManagementClientError`; a Cortex that cannot
88
+ * be reached, or a success body that cannot be read, rejects as `fetch` and
89
+ * `Response.json` do.
90
+ */
91
+ export async function callManagement(
92
+ target: ManagementTarget,
93
+ request: { method: "get" | "post"; path: string; input?: unknown; signal?: AbortSignal },
94
+ ) {
95
+ const { method, path, input, signal } = request;
96
+ const query = method === "get" && isRecord(input) ? managementQuery(input) : "";
97
+ const url = `${target.url.replace(/\/+$/, "")}${MANAGEMENT_BASE_PATH}${path}${query}`;
98
+ const response = await (target.fetch ?? globalThis.fetch)(url, {
99
+ method: method.toUpperCase(),
100
+ headers: {
101
+ Authorization: `Bearer ${target.key}`,
102
+ ...(method === "post" ? { "Content-Type": "application/json" } : {}),
103
+ },
104
+ body: method === "post" && input !== undefined ? JSON.stringify(input) : undefined,
105
+ signal,
106
+ });
107
+ // A success that cannot be read is the caller's failure, never a null result.
108
+ if (response.ok) {
109
+ const output: unknown = await response.json();
110
+ return output;
111
+ }
112
+ const body: unknown = await response.json().catch(() => null);
113
+ const refused = refusalBodySchema.safeParse(body).data ?? {};
114
+ throw new ManagementClientError(
115
+ response.status,
116
+ refused.code ?? "error",
117
+ refused.message ?? response.statusText,
118
+ refused.cause,
119
+ refused.refusal,
120
+ );
121
+ }
122
+
123
+ type ManagementProcedure = keyof ManagementProcedures;
124
+
125
+ /** What procedure `P` takes, as it is sent. */
126
+ export type ManagementInput<P extends ManagementProcedure> = ManagementProcedures[P]["input"];
127
+
128
+ /** What procedure `P` answers, as JSON carries it: a date is its ISO string. */
129
+ export type ManagementOutput<P extends ManagementProcedure> = ManagementProcedures[P]["output"];
130
+
131
+ type CallOptions = { signal?: AbortSignal };
132
+
133
+ /** Procedure `P` as a call; its input may be left out when it takes none, or nothing it requires. */
134
+ type Call<P extends ManagementProcedure> =
135
+ Partial<ManagementInput<P>> extends ManagementInput<P>
136
+ ? (input?: ManagementInput<P>, options?: CallOptions) => Promise<ManagementOutput<P>>
137
+ : (input: ManagementInput<P>, options?: CallOptions) => Promise<ManagementOutput<P>>;
138
+
139
+ /** Every procedure as `client.<router>.<procedure>`, as the API documents it. */
140
+ export type ManagementClient = {
141
+ [R in ManagementProcedure extends `${infer Router}.${string}` ? Router : never]: {
142
+ [P in ManagementProcedure as P extends `${R}.${infer Name}` ? Name : never]: Call<P>;
143
+ };
144
+ };
145
+
146
+ /**
147
+ * A typed management client: `client.agents.saveSettings({ id, greeting })`
148
+ * sends the procedure's request, a GET or a POST as its kind is, and answers
149
+ * its output. A refusal throws `ManagementClientError`.
150
+ */
151
+ export function createManagementClient(target: ManagementTarget) {
152
+ const client: Record<
153
+ string,
154
+ Record<string, (input?: unknown, options?: CallOptions) => Promise<unknown>>
155
+ > = {};
156
+ for (const [procedure, kind] of Object.entries(managementProcedureKinds)) {
157
+ const [router = "", name = ""] = procedure.split(".");
158
+ (client[router] ??= {})[name] = (input, options) =>
159
+ callManagement(target, {
160
+ method: kind === "query" ? "get" : "post",
161
+ path: managementPath(procedure),
162
+ input,
163
+ signal: options?.signal,
164
+ });
165
+ }
166
+ // The table of kinds and the procedure map are printed from one model, so
167
+ // each call built here is the one its type names.
168
+ return client as ManagementClient;
169
+ }