@m6d/cortex-cli 0.13.0 → 0.15.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/README.md CHANGED
@@ -10,8 +10,8 @@ bunx cortex apply # make it match ./cortex.manifest.ts
10
10
  ```
11
11
 
12
12
  Commands are noun-first, `cortex <resource> <procedure>`, except `new`, which acts on no existing
13
- resource, `apply`, which acts on a whole deployment, and `login`, `logout` and `status`, which act
14
- on your profiles. Run `cortex --help`, then `cortex <resource> --help`.
13
+ resource, `setup` and `apply`, which act on a whole deployment, and `login`, `logout` and `status`,
14
+ which act on your profiles. Run `cortex --help`, then `cortex <resource> --help`.
15
15
 
16
16
  ```sh
17
17
  bun add -d @m6d/cortex-cli
@@ -28,15 +28,16 @@ Scaffolds `./<name>`: a backend for Cortex agents, and Cortex itself to run them
28
28
  (`userName`) and one native tool (`rollDice`), mounted at `/cortex` by `index.ts`.
29
29
  - `cortex.manifest.ts` declares a `helper` agent on that server, for `cortex apply`.
30
30
  - `docker-compose.yml` runs Cortex's app, worker and engine on the one `syscodeia/cortex` image,
31
- with Postgres, Redis and MinIO. Only the app and the engine publish host ports, picked at scaffold
32
- time from what was free.
33
- - `.env` holds the backend's own key and the checkout's Compose project name, and `cortex.env`
34
- Cortex's secrets, which only Compose reads. Neither is committed: `setup.ts` writes both from
35
- their `.example` copies on every `bun install`, generating each value left blank, so a fresh
36
- clone gets its own secrets and a stack that never shares the original's volumes. `.dockerignore`
37
- keeps all four out of images.
38
- - `README.md` lists the steps: start the stack and the backend, register the server in the
39
- console, then `cortex apply`.
31
+ with Postgres, Redis and MinIO. Only the app and the engine publish host ports. They and the
32
+ backend's port are picked at scaffold time from what was free.
33
+ - `.env` holds every setting: Cortex's secrets, the backend's key and name, the model provider, and
34
+ the `CORTEX_URL` and `CORTEX_KEY` the CLI reaches this checkout's Cortex with. It is not
35
+ committed: `setup.ts` writes it from `.env.example` on every `bun install`, generating each
36
+ secret left blank, so a fresh clone gets its own secrets and a stack that never shares the
37
+ original's volumes. `.dockerignore` keeps both out of images.
38
+ - `bun run dev` starts Cortex (`docker compose up`), the backend and `bun cortex setup --wait --yes` in
39
+ an mprocs dashboard. `bun run dev:agent` runs the same through `concurrently`, one labelled log for
40
+ coding agents and CI.
40
41
  - eslint, prettier, cspell, fallow and tsconfig are inlined, so `bun run verify` works standalone.
41
42
 
42
43
  It then runs `bun install`, and `git init` unless the target is already inside a work tree. An
@@ -47,20 +48,48 @@ manifest takes its id from the name and Cortex keeps the id `cortex` for its own
47
48
  bunx @m6d/cortex-cli new shop --datasets
48
49
  ```
49
50
 
50
- On a TTY it asks only what the flags left open: the name, and whether to add the dataset host. With
51
- no TTY it never prompts and behaves as `--yes`, so a missing name is a hard error rather than a
52
- hang.
51
+ On a TTY it asks only what the flags left open: the name, whether to set up the `helper` agent on
52
+ the first `bun run dev`, and if so the model provider (name, base URL, API key, model). It checks
53
+ the provider with one chat message to the model, and reuses it for embeddings when it serves the
54
+ stack's embedding model. With no TTY it never prompts and behaves as `--yes`, so a missing name is a
55
+ hard error rather than a hang.
53
56
 
54
- | Flag | Default | Writes |
55
- | ------------------------------ | ------- | ------------------------------------------------------------------------------------------- |
56
- | `--datasets` / `--no-datasets` | off | `datasets.ts`, an in-memory `orders` table served as a dataset host, and its manifest entry |
57
- | `--yes` | | Accept the defaults instead of asking |
57
+ | Flag | Default | Writes |
58
+ | ------------------------ | ------- | ------------------------------------------------------------------------------------------- |
59
+ | `--datasets` | off | `datasets.ts`, an in-memory `orders` table served as a dataset host, and its manifest entry |
60
+ | `--setup` / `--no-setup` | on | `cortex setup` among the processes `bun run dev` starts |
61
+ | `--yes` | | Accept the defaults instead of asking; setup then runs with no model provider |
58
62
 
59
63
  The image runs at the Cortex release that was current when this CLI was published
60
64
  (`CORTEX_VERSION` in the shell picks another); the backend depends on the `@m6d/cortex` release of
61
65
  that time. A server name stops at Cortex's 100 characters, so a longer project name is
62
66
  registered cut to that length.
63
67
 
68
+ ## `cortex setup`
69
+
70
+ Registers a `cortex new` project's backend in its Cortex, then applies its manifest. It reads the
71
+ backend's name, URL and key (`CORTEX_SERVER_*`) and the model provider (`MODEL_PROVIDER_*`) from
72
+ `.env`, and goes in the one order Cortex accepts: the server key's secret, the provider's key and
73
+ the provider, the server on the provider's model, a sync of the server, then the apply. Each step
74
+ creates what is missing and keeps what exists, so a second run changes nothing. A key's secret is
75
+ named for its value (`openai-api-key-1a2b3c4d`), so a changed key gets a new secret the provider or
76
+ server moves to, and whatever else presents the old one keeps it. A provider or server under
77
+ `.env`'s name that calls another URL belongs to something else: setup leaves it alone and stops. So
78
+ does a `MODEL_PROVIDER_*` set only in part, or a provider URL on this machine or a private network
79
+ that `EGRESS_ALLOWED_HOSTS` in `.env` does not admit. A server with no model yet gets the provider's.
80
+
81
+ ```sh
82
+ bun cortex setup # asks for a model provider when .env has none
83
+ bun cortex setup --wait --yes # what `bun run dev` runs
84
+ ```
85
+
86
+ `--wait` waits up to 10 minutes for Cortex to answer, long enough for a first start to pull the
87
+ image. Without `-p`, setup sets up the Cortex `.env` names: a different `CORTEX_URL` exported in the
88
+ shell, which Bun lets win over `.env`, ends the run. The retried sync, a minute while Cortex cannot
89
+ reach the backend, gives up at once on any other refusal. With no model provider and `--yes`, it
90
+ registers the server and applies nothing: Cortex publishes no agent without a model. The run that
91
+ first creates something says how to sign in, with the password only on a terminal.
92
+
64
93
  ## Management commands
65
94
 
66
95
  Manage a Cortex deployment from the terminal. The commands come from
@@ -80,9 +109,9 @@ cortex logout
80
109
 
81
110
  Profiles live in `~/.cortex/profiles.json` (owner-only). `-p, --profile <name>`,
82
111
  a global option, picks one for any command, and names the one `login` saves
83
- and `logout` forgets (`default` otherwise); `CORTEX_URL` and
84
- `CORTEX_KEY`, both set, win over the file for CI. `CORTEX_HOME` relocates
85
- the directory.
112
+ and `logout` forgets (`default` otherwise). `CORTEX_URL` and `CORTEX_KEY`, both
113
+ set, win over the current profile, for CI and for a project's `.env`; a
114
+ profile `-p` names wins over them. `CORTEX_HOME` relocates the directory.
86
115
 
87
116
  The grammar is `cortex <router> <procedure> [id] [flags]`, with routers as
88
117
  the API names them (`datasetHosts`, `modelProviders`). Each top-level input
@@ -114,8 +143,16 @@ touched, and a name one of them holds fails the apply.
114
143
  cortex apply # ./cortex.manifest.ts
115
144
  cortex apply deploy/manifest.json --dry-run
116
145
  cortex apply -p prod
146
+ cortex apply --prefer live # keep every live edit the manifest also changed
117
147
  ```
118
148
 
149
+ An entry's `overridable` lists the fields people may change in Cortex, such
150
+ as an agent's `prompt`. An apply keeps those edits. When the manifest changes
151
+ a field that was also edited live, `cortex apply` asks which to keep for
152
+ each one. `--prefer live` or `--prefer manifest` answers them all. Without a
153
+ terminal and without `--prefer`, the apply changes nothing and lists the
154
+ conflicts; `--dry-run` marks each with `!`.
155
+
119
156
  The path defaults to exactly `./cortex.manifest.ts` in the directory you run
120
157
  in. TypeScript and JSON load the same way: the file's default export is the
121
158
  manifest. `defineManifest` from `@m6d/cortex` types one as you write it:
@@ -144,6 +181,36 @@ export default defineManifest({
144
181
  });
145
182
  ```
146
183
 
184
+ An `openapi` source makes every operation of an OpenAPI document a tool the
185
+ manifest owns, so a backend with many endpoints needs no entry per tool:
186
+
187
+ ```ts
188
+ openapi: [
189
+ {
190
+ name: "shop",
191
+ url: "https://shop.example.com/openapi.json",
192
+ credentials: [{ header: "Authorization", prefix: "Bearer ", credential: null }],
193
+ pinned: ["findOrder"],
194
+ // A GET runs at once; any other method waits for the end user's approval.
195
+ approval: { never: ["addOrderNote"], always: ["exportOrders"] },
196
+ audience: { agents: ["support"] },
197
+ },
198
+ ],
199
+ ```
200
+
201
+ Every apply reads the document first, without the source's credentials. An
202
+ operation it adds is created, a changed one updated, and a dropped one
203
+ deleted. A source with credentials is read only over https, or from a host
204
+ the deployment allows, since the document says where they go. A source needs the `tools` write permission even when nothing
205
+ changes. Each tool is named after its `operationId`, made an identifier of at
206
+ most 64 characters; an operation Cortex cannot import is left
207
+ out. Two operations that come out with one name are told apart by a number
208
+ (`getOrders`, `getOrders2`) in the order of their paths. The apply fails
209
+ when `pinned` or `approval` names a tool no operation has (`unknown_tool`)
210
+ or any name such a collision decided (`ambiguous_tool`), since a path the
211
+ document adds or drops could move it to another endpoint. It also fails when an
212
+ operation's name is already a listed tool's or another source's.
213
+
147
214
  The manifest is checked against the contract before anything is sent, then
148
215
  sent as written, so Cortex fills its own defaults. It goes to the
149
216
  management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
@@ -173,8 +240,9 @@ it; drop it first, then apply again.
173
240
 
174
241
  ## Exit codes
175
242
 
176
- | Command | 0 | 1 |
177
- | ------- | -------------------------------------------- | ----------------------------------- |
178
- | `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled |
179
- | `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, or refused |
180
- | others | The procedure ran | It failed, or couldn't run |
243
+ | Command | 0 | 1 |
244
+ | ------- | ---------------------------------------------------------- | --------------------------------------------------- |
245
+ | `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled |
246
+ | `setup` | Set up, or the server registered with no model to apply on | Not a project, or Cortex refused or was unreachable |
247
+ | `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, or refused |
248
+ | others | The procedure ran | It failed, or couldn't run |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Scaffold Cortex projects and manage a Cortex deployment",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import pkg from "../package.json";
4
4
  import { registerApply } from "@/commands/apply";
5
5
  import { registerManagement } from "@/commands/management";
6
6
  import { registerNew } from "@/commands/new";
7
+ import { registerSetup } from "@/commands/setup";
7
8
  import * as report from "@/ui/report";
8
9
 
9
10
  const program = new Command()
@@ -34,6 +35,7 @@ const program = new Command()
34
35
  // Each command owns its help group, so the two headings survive these files being
35
36
  // rewritten. Registration order fixes the order the groups print in.
36
37
  registerNew(program);
38
+ registerSetup(program);
37
39
  registerApply(program);
38
40
  // The management commands come from Cortex's API, so this one may fetch
39
41
  // before parsing; it looks at argv to decide whether it needs to, which it
@@ -1,18 +1,37 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { relative, resolve } from "node:path";
3
3
 
4
+ import { Option } from "@commander-js/extra-typings";
4
5
  import type { Command } from "@commander-js/extra-typings";
5
- import { appliedManifestSchema, manifestChangesSchema } from "@cortex/contracts/manifest";
6
+ import { isRecord } from "@cortex/contracts/json";
7
+ import {
8
+ appliedManifestSchema,
9
+ manifestApplyInputSchema,
10
+ manifestPreviewSchema,
11
+ } from "@cortex/contracts/manifest";
6
12
  import { loadManifestFile } from "@cortex/contracts/manifest-file";
7
13
  import { z } from "zod";
8
14
 
9
- import { callManagement } from "@/management/client";
10
- import { callOrFail, requireProfile } from "@/commands/management";
15
+ import { callManagement, ManagementError } from "@/management/client";
16
+ import { failCall, requireProfile } from "@/commands/management";
17
+ import { askChoice } from "@/ui/prompts";
11
18
  import * as report from "@/ui/report";
12
19
 
13
20
  // Discovery is this exact name in the cwd, with no upward walk, so the
14
21
  // argument's default is the discovery rule.
15
- const DEFAULT_MANIFEST = "./cortex.manifest.ts";
22
+ export const DEFAULT_MANIFEST = "./cortex.manifest.ts";
23
+
24
+ /** Which value a field both the manifest and a live write changed keeps. */
25
+ const SIDES = manifestApplyInputSchema.shape.prefer.unwrap().options;
26
+ type Side = (typeof SIDES)[number];
27
+ type Resolution = z.input<typeof manifestApplyInputSchema>["resolutions"];
28
+ type Conflict = z.infer<typeof manifestPreviewSchema>["conflicts"][number];
29
+
30
+ /** A sync refused for fields both sides changed that nothing answered. */
31
+ const unresolvedSchema = z.object({
32
+ error: z.literal("unresolved_conflicts"),
33
+ conflicts: manifestPreviewSchema.shape.conflicts,
34
+ });
16
35
 
17
36
  const CHANGES = ["create", "update", "delete"] as const;
18
37
  const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
@@ -23,7 +42,9 @@ const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
23
42
  /**
24
43
  * `cortex apply [path]`: makes Cortex match a manifest in one
25
44
  * transaction, through the management API with the profile's key.
26
- * `--dry-run` answers what would change and changes nothing.
45
+ * `--dry-run` answers what would change and changes nothing. A field both
46
+ * the manifest and a live edit in Cortex changed is asked about, unless
47
+ * `--prefer` answers every one.
27
48
  */
28
49
  export function registerApply(program: Command<[], { verbose?: true; profile?: string }>) {
29
50
  const command = program
@@ -31,7 +52,13 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
31
52
  .helpGroup("Cortex:")
32
53
  .description("Make Cortex match a manifest")
33
54
  .argument("[path]", "The manifest, TypeScript or JSON", DEFAULT_MANIFEST)
34
- .option("--dry-run", "Show what would change, and change nothing");
55
+ .option("--dry-run", "Show what would change, and change nothing")
56
+ .addOption(
57
+ new Option(
58
+ "--prefer <side>",
59
+ "Keep the live or the manifest value of every field both changed, without asking",
60
+ ).choices(SIDES),
61
+ );
35
62
 
36
63
  command.action(async function (path, options) {
37
64
  const { verbose = false, profile: profileName } = program.opts();
@@ -40,50 +67,141 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
40
67
  const profile = requireProfile(profileName);
41
68
 
42
69
  report.header(dryRun ? "cortex apply --dry-run" : "cortex apply");
43
- const manifest = await loadManifest(file, verbose);
44
70
  report.input("Manifest", label(file));
45
71
  report.input("Cortex", profile.url);
46
72
 
47
- const answer = await callOrFail(profile, () =>
48
- callManagement(
49
- profile,
50
- "post",
51
- dryRun ? "/manifests/preview" : "/manifests/sync",
52
- manifest,
53
- ),
54
- );
55
- // The secrets are read apart from the changes: a sync has committed by
56
- // now, and a summary this CLI is too old to read must not lose them.
57
- const { triggerSecrets = {} } = appliedManifestSchema
58
- .pick({ triggerSecrets: true })
59
- .partial()
60
- .parse(answer);
61
- const applied = manifestChangesSchema.safeParse(answer);
62
- if (!applied.success) {
63
- printTriggerSecrets(triggerSecrets);
64
- report.fail({
65
- what: `${profile.url} answered with a summary this CLI cannot read`,
66
- blocks: [
67
- `The ${dryRun ? "preview" : "apply"} went through. Update the CLI to read its summary:`,
68
- " bun add -d @m6d/cortex-cli@latest",
69
- ],
70
- });
71
- }
72
- printChanges(applied.data);
73
- const created = countNames(applied.data.create);
74
- const updated = countNames(applied.data.update);
75
- const deleted = countNames(applied.data.delete);
73
+ const { changes, triggerSecrets, conflicts } = await applyManifest({
74
+ profile,
75
+ file,
76
+ dryRun,
77
+ verbose,
78
+ prefer: options.prefer,
79
+ interactive: process.stdin.isTTY === true,
80
+ });
81
+ const created = countNames(changes.create);
82
+ const updated = countNames(changes.update);
83
+ const deleted = countNames(changes.delete);
84
+ const conflicted =
85
+ conflicts.length > 0
86
+ ? `, ${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`
87
+ : "";
76
88
  report.footer(
77
- created + updated + deleted === 0
89
+ created + updated + deleted + conflicts.length === 0
78
90
  ? "no changes"
79
91
  : dryRun
80
- ? `would create ${created}, update ${updated}, delete ${deleted}`
92
+ ? `would create ${created}, update ${updated}, delete ${deleted}${conflicted}`
81
93
  : `created ${created}, updated ${updated}, deleted ${deleted}`,
82
94
  );
83
95
  printTriggerSecrets(triggerSecrets);
84
96
  });
85
97
  }
86
98
 
99
+ /**
100
+ * Loads the manifest at `file` and sends it, a preview under `dryRun`, then
101
+ * prints its changes one row per entry, and a preview's conflicts. Ends the
102
+ * run on an invalid file, a refusal, or a summary this CLI cannot read.
103
+ * `cortex apply` and `cortex setup` both apply through it; each prints the
104
+ * trigger secrets it returns.
105
+ */
106
+ export async function applyManifest(options: {
107
+ profile: ReturnType<typeof requireProfile>;
108
+ file: string;
109
+ dryRun: boolean;
110
+ verbose: boolean;
111
+ prefer?: Side;
112
+ interactive: boolean;
113
+ }) {
114
+ const { profile, file, dryRun, verbose } = options;
115
+ const answer = await sendManifest({
116
+ ...options,
117
+ // What to run again: `cortex apply`, with the path unless it is the default.
118
+ command: ["cortex apply", ...(label(file) === DEFAULT_MANIFEST ? [] : [label(file)])].join(
119
+ " ",
120
+ ),
121
+ manifest: await loadManifest(file, verbose),
122
+ path: dryRun ? "/manifests/preview" : "/manifests/sync",
123
+ });
124
+ // The secrets are read apart from the changes: a sync has committed by
125
+ // now, and a summary this CLI is too old to read must not lose them.
126
+ const { triggerSecrets = {} } = appliedManifestSchema
127
+ .pick({ triggerSecrets: true })
128
+ .partial()
129
+ .parse(answer);
130
+ const applied = manifestPreviewSchema.partial({ conflicts: true }).safeParse(answer);
131
+ if (!applied.success) {
132
+ printTriggerSecrets(triggerSecrets);
133
+ report.fail({
134
+ what: `${profile.url} answered with a summary this CLI cannot read`,
135
+ blocks: [
136
+ `The ${dryRun ? "preview" : "apply"} went through. Update the CLI to read its summary:`,
137
+ " bun add -d @m6d/cortex-cli@latest",
138
+ ],
139
+ });
140
+ }
141
+ const { conflicts = [], ...changes } = applied.data;
142
+ printChanges(changes, conflicts);
143
+ return { changes, triggerSecrets, conflicts };
144
+ }
145
+
146
+ /**
147
+ * Sends the manifest until nothing in it is left unanswered. A sync refused
148
+ * for fields both the manifest and a live edit changed asks about each one
149
+ * when it can, then goes again with the answers; one that cannot ask ends
150
+ * the run naming them. Any other refusal ends the run as well.
151
+ */
152
+ async function sendManifest(options: {
153
+ profile: ReturnType<typeof requireProfile>;
154
+ path: string;
155
+ command: string;
156
+ manifest: Record<string, unknown>;
157
+ prefer?: Side;
158
+ interactive: boolean;
159
+ }) {
160
+ const { profile, path, command, manifest, prefer, interactive } = options;
161
+ const resolutions: Resolution = [];
162
+ for (;;) {
163
+ try {
164
+ return await callManagement(profile, "post", path, {
165
+ ...manifest,
166
+ prefer,
167
+ resolutions,
168
+ });
169
+ } catch (error) {
170
+ const refused =
171
+ error instanceof ManagementError
172
+ ? unresolvedSchema.safeParse(error.detail)
173
+ : undefined;
174
+ if (!refused?.success) return failCall(profile, error);
175
+ if (!interactive) failUnresolved(refused.data.conflicts, command);
176
+ for (const conflict of refused.data.conflicts) {
177
+ const keep = await askChoice<Side>(`${conflictLabel(conflict)}. Keep which?`, [
178
+ { value: "live", label: "Keep live" },
179
+ { value: "manifest", label: "Use manifest" },
180
+ ]);
181
+ resolutions.push({ ...conflict, keep });
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ /** How a conflict reads: its entry and the field both sides changed. */
188
+ function conflictLabel({ kind, name, field }: Conflict) {
189
+ return `${kind} ${name}: ${field} changed in both places`;
190
+ }
191
+
192
+ /** Ends a run that cannot ask, naming every conflict and how to run `command` again to answer them. */
193
+ function failUnresolved(conflicts: ReadonlyArray<Conflict>, command: string): never {
194
+ const count = conflicts.length;
195
+ return report.fail({
196
+ what: `${count} field${count === 1 ? "" : "s"} changed both in Cortex and in the manifest`,
197
+ blocks: [
198
+ conflicts.map((conflict) => ` ${conflictLabel(conflict)}`).join("\n"),
199
+ "Keep one side for all of them, or run in a terminal to choose each:",
200
+ ` ${command} --prefer live\n ${command} --prefer manifest`,
201
+ ],
202
+ });
203
+ }
204
+
87
205
  /** How a manifest path prints: relative to the cwd, `./`-prefixed. */
88
206
  function label(path: string) {
89
207
  const relativePath = relative(process.cwd(), path);
@@ -91,7 +209,7 @@ function label(path: string) {
91
209
  }
92
210
 
93
211
  /** The manifest's absolute path; a missing file ends the run before anything prints. */
94
- function findManifest(path: string) {
212
+ export function findManifest(path: string) {
95
213
  const absolute = resolve(process.cwd(), path);
96
214
  if (existsSync(absolute)) return absolute;
97
215
  return report.fail({
@@ -123,17 +241,23 @@ async function loadManifest(path: string, verbose: boolean) {
123
241
  verbose,
124
242
  }),
125
243
  );
126
- if (!parsed.success) {
244
+ if (!parsed.success || !isRecord(written)) {
127
245
  report.fail({
128
246
  what: `${label(path)} is not a valid manifest`,
129
- blocks: [z.prettifyError(parsed.error)],
247
+ blocks: parsed.success ? [] : [z.prettifyError(parsed.error)],
130
248
  });
131
249
  }
132
250
  return written;
133
251
  }
134
252
 
135
- /** One row per changed entry under its kind: `+` created, `~` updated, `-` deleted. */
136
- function printChanges(changes: Record<(typeof CHANGES)[number], Record<string, string[]>>) {
253
+ /**
254
+ * One row per changed entry under its kind: `+` created, `~` updated, `-`
255
+ * deleted, and `!` a field both the manifest and a live edit changed.
256
+ */
257
+ function printChanges(
258
+ changes: Record<(typeof CHANGES)[number], Record<string, string[]>>,
259
+ conflicts: ReadonlyArray<Conflict>,
260
+ ) {
137
261
  const rows = new Map<string, string[]>();
138
262
  for (const change of CHANGES) {
139
263
  for (const [kind, names] of Object.entries(changes[change])) {
@@ -141,6 +265,9 @@ function printChanges(changes: Record<(typeof CHANGES)[number], Record<string, s
141
265
  rows.set(kind, [...(rows.get(kind) ?? []), ...marked]);
142
266
  }
143
267
  }
268
+ for (const { kind, name, field } of conflicts) {
269
+ rows.set(kind, [...(rows.get(kind) ?? []), `! ${name} ${field} changed in both places`]);
270
+ }
144
271
  for (const [kind, lines] of rows) {
145
272
  for (const [index, line] of lines.entries()) {
146
273
  report.row(index === 0 ? kind : "", line, 14);
@@ -156,7 +283,7 @@ function countNames(byKind: Record<string, string[]>) {
156
283
  * The fire secrets this apply minted, for each trigger it created or whose
157
284
  * `fireCredential` it dropped; nothing shows them again.
158
285
  */
159
- function printTriggerSecrets(secrets: Record<string, string>) {
286
+ export function printTriggerSecrets(secrets: Record<string, string>) {
160
287
  const entries = Object.entries(secrets);
161
288
  if (entries.length === 0) return;
162
289
  report.note(
@@ -4,7 +4,13 @@ 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
6
 
7
- import { callManagement, inputSchemaOf, loadOpenApi, ManagementError } from "@/management/client";
7
+ import {
8
+ callManagement,
9
+ inputSchemaOf,
10
+ loadOpenApi,
11
+ ManagementError,
12
+ UnreachableError,
13
+ } from "@/management/client";
8
14
  import type { OpenApiDocument, OpenApiOperation } from "@/management/client";
9
15
  import { flagsFor, parseAnswer, toOption } from "@/management/flags";
10
16
  import type { Flag } from "@/management/flags";
@@ -350,6 +356,13 @@ function declareCommand(
350
356
  return command;
351
357
  }
352
358
 
359
+ /** What a publish went ahead despite, such as a prompt naming a tool without an @-mention. */
360
+ function warningsOf(published: unknown) {
361
+ return isRecord(published) && Array.isArray(published.warnings)
362
+ ? published.warnings.filter((warning): warning is string => typeof warning === "string")
363
+ : [];
364
+ }
365
+
353
366
  /**
354
367
  * A write to a publishable router is a draft: publish it now, or say how to.
355
368
  * Fields Cortex reads live are already in effect and are named as such,
@@ -372,10 +385,11 @@ async function publishAfterWrite(
372
385
  );
373
386
  }
374
387
  if (publish) {
375
- await callOrFail(profile, () =>
388
+ const published = await callOrFail(profile, () =>
376
389
  callManagement(profile, publisher.method, publisher.path, { id }),
377
390
  );
378
391
  options.note(`Published ${id}.`);
392
+ for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
379
393
  } else {
380
394
  options.note(
381
395
  `${live ? "The rest is" : "Saved as"} a draft. Add --publish, or run: ${command}`,
@@ -383,30 +397,35 @@ async function publishAfterWrite(
383
397
  }
384
398
  }
385
399
 
386
- /**
387
- * A management refusal, or a Cortex that cannot be reached, is the
388
- * run's outcome, in the error grammar, with whatever the refusal names;
389
- * anything else is a bug.
390
- */
400
+ /** `call`'s result, or the run ended by `failCall`. */
391
401
  export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promise<T>) {
392
402
  try {
393
403
  return await call();
394
404
  } catch (error) {
395
- if (error instanceof ManagementError) {
396
- report.fail({
397
- what: `${error.status} ${error.code}: ${error.message}`,
398
- blocks: refusalBlocks(error.detail ?? {}),
399
- });
400
- }
401
- if (error instanceof TypeError) {
402
- report.fail({
403
- what: `Could not reach ${profile.url}`,
404
- error,
405
- blocks: ["Check the URL and that Cortex is up."],
406
- });
407
- }
408
- throw error;
405
+ return failCall(profile, error);
406
+ }
407
+ }
408
+
409
+ /**
410
+ * A management refusal, or a Cortex that cannot be reached, is the
411
+ * run's outcome, in the error grammar, with whatever the refusal names;
412
+ * anything else is a bug, and rethrown.
413
+ */
414
+ export function failCall(profile: { url: string }, error: unknown): never {
415
+ if (error instanceof ManagementError) {
416
+ report.fail({
417
+ what: `${error.status} ${error.code}: ${error.message}`,
418
+ blocks: refusalBlocks(error.detail ?? {}),
419
+ });
420
+ }
421
+ if (error instanceof UnreachableError) {
422
+ report.fail({
423
+ what: `Could not reach ${profile.url}`,
424
+ error,
425
+ blocks: ["Check the URL and that Cortex is up."],
426
+ });
409
427
  }
428
+ throw error;
410
429
  }
411
430
 
412
431
  /**
@@ -527,7 +546,7 @@ function readJsonBody(source: string) {
527
546
  return parsed;
528
547
  }
529
548
 
530
- async function walkPages(
549
+ export async function walkPages(
531
550
  profile: ResolvedProfile,
532
551
  method: "get" | "post",
533
552
  path: string,