@m6d/cortex-cli 0.15.0 → 0.17.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
@@ -48,11 +48,11 @@ manifest takes its id from the name and Cortex keeps the id `cortex` for its own
48
48
  bunx @m6d/cortex-cli new shop --datasets
49
49
  ```
50
50
 
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
51
+ On a terminal it asks only what the flags left open: the name, whether to set up the `helper` agent
52
+ on the first `bun run dev`, and if so the model provider (name, base URL, API key, model). It checks
53
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.
54
+ stack's embedding model. With no terminal (stdin and stderr) to ask on, it never prompts and behaves
55
+ as `--yes`, so a missing name is a hard error rather than a hang.
56
56
 
57
57
  | Flag | Default | Writes |
58
58
  | ------------------------ | ------- | ------------------------------------------------------------------------------------------- |
@@ -62,15 +62,15 @@ hard error rather than a hang.
62
62
 
63
63
  The image runs at the Cortex release that was current when this CLI was published
64
64
  (`CORTEX_VERSION` in the shell picks another); the backend depends on the `@m6d/cortex` release of
65
- that time. A server name stops at Cortex's 100 characters, so a longer project name is
65
+ that time. A server name stops at Cortex's 120 characters, so a longer project name is
66
66
  registered cut to that length.
67
67
 
68
68
  ## `cortex setup`
69
69
 
70
70
  Registers a `cortex new` project's backend in its Cortex, then applies its manifest. It reads the
71
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
72
+ `.env`, and goes in the one order Cortex accepts: the provider's key and the provider, the server
73
+ key's secret and the server on the provider's model, a sync of the server, then the apply. Each step
74
74
  creates what is missing and keeps what exists, so a second run changes nothing. A key's secret is
75
75
  named for its value (`openai-api-key-1a2b3c4d`), so a changed key gets a new secret the provider or
76
76
  server moves to, and whatever else presents the old one keeps it. A provider or server under
@@ -87,8 +87,14 @@ bun cortex setup --wait --yes # what `bun run dev` runs
87
87
  image. Without `-p`, setup sets up the Cortex `.env` names: a different `CORTEX_URL` exported in the
88
88
  shell, which Bun lets win over `.env`, ends the run. The retried sync, a minute while Cortex cannot
89
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.
90
+ registers the server without a model and still applies, since an agent may name its own; when
91
+ Cortex refuses an agent for want of one (`chat_model_required`), nothing is applied and setup says
92
+ how to add a provider. The apply
93
+ deletes what the manifest created and no longer lists, so like `cortex apply` it asks on a terminal
94
+ before it writes anything, and needs `--yes` without one. A field the manifest changes that was
95
+ also edited live is asked about on a terminal. Under `--yes`, as `bun run dev` runs it, such a
96
+ conflict stops every run until `bun cortex apply --yes --prefer live` (or `manifest`) settles it.
97
+ The run that first creates something says how to sign in, with the password only on a terminal.
92
98
 
93
99
  ## Management commands
94
100
 
@@ -121,17 +127,22 @@ field left out, `id` included, is asked for on a terminal and named as the
121
127
  missing flag or positional otherwise. Listings take `--pageIndex`, `--pageSize`, `--q`, and `--all` to
122
128
  walk every page. `-o json` prints the raw result for pipes.
123
129
 
124
- Writes to agents, tools, services, skills, triggers, and dataset hosts save
125
- drafts.
130
+ Writes to agents, tools, services, skills, triggers, MCP servers, and dataset
131
+ hosts save drafts.
126
132
  `--publish` on a write the API marks publishable (`update`, and a `create`
127
133
  whose result is complete enough) runs that entity's publish procedure right
128
134
  after; without it the command says so and names the command. Fields the API
129
135
  marks live (`x-live-fields`) take effect without a publish, from agents'
130
136
  next turn, and the command names them: a trigger's
131
137
  `credentialName`, `fireCredentialName` and model, say, or a dataset host's
132
- `url`, `credentials` and `timeoutMs`. `cortex datasetHosts sync <id>` reads a host's manifest, and a
138
+ `url`, `headers` and `timeoutMs`. `cortex datasetHosts sync <id>` reads a host's manifest, and a
133
139
  draft host publishes only after `datasetHosts test` reached it once.
134
140
 
141
+ A procedure the API marks destructive (`x-destructive`: delete, publish, rotate, toggle and the
142
+ like) asks before anything is sent, and so does a `--publish` that runs one. `--yes` skips the
143
+ question. With no terminal to ask on, the command fails unless `--yes` is given, so scripts and CI
144
+ pass it. Questions render on stderr, so `-o json` output stays parseable.
145
+
135
146
  ## `cortex apply`
136
147
 
137
148
  Makes Cortex match a manifest, in one transaction: what is new is
@@ -142,16 +153,16 @@ touched, and a name one of them holds fails the apply.
142
153
  ```sh
143
154
  cortex apply # ./cortex.manifest.ts
144
155
  cortex apply deploy/manifest.json --dry-run
145
- cortex apply -p prod
156
+ cortex apply -p prod --yes # no question, as CI needs
146
157
  cortex apply --prefer live # keep every live edit the manifest also changed
147
158
  ```
148
159
 
149
160
  An entry's `overridable` lists the fields people may change in Cortex, such
150
161
  as an agent's `prompt`. An apply keeps those edits. When the manifest changes
151
162
  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 `!`.
163
+ each one. `--prefer live` or `--prefer manifest` answers them all. `--yes`
164
+ never asks: under it, as without a terminal, an apply with conflicts and no
165
+ `--prefer` changes nothing and lists them. `--dry-run` marks each with `!`.
155
166
 
156
167
  The path defaults to exactly `./cortex.manifest.ts` in the directory you run
157
168
  in. TypeScript and JSON load the same way: the file's default export is the
@@ -189,7 +200,9 @@ openapi: [
189
200
  {
190
201
  name: "shop",
191
202
  url: "https://shop.example.com/openapi.json",
192
- credentials: [{ header: "Authorization", prefix: "Bearer ", credential: null }],
203
+ headers: [{ header: "Authorization", prefix: "Bearer ", from: { kind: "runToken" } }],
204
+ // For an API that wraps every answer: where each one keeps its result.
205
+ // resultPath: "/data",
193
206
  pinned: ["findOrder"],
194
207
  // A GET runs at once; any other method waits for the end user's approval.
195
208
  approval: { never: ["addOrderNote"], always: ["exportOrders"] },
@@ -198,10 +211,11 @@ openapi: [
198
211
  ],
199
212
  ```
200
213
 
201
- Every apply reads the document first, without the source's credentials. An
214
+ Every apply reads the document first, without the source's headers. An
202
215
  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
216
+ deleted. A source whose headers carry a credential or the run token is read
217
+ only over https, or from a host the deployment allows, since the document
218
+ says where they go. A source needs the `tools` write permission even when nothing
205
219
  changes. Each tool is named after its `operationId`, made an identifier of at
206
220
  most 64 characters; an operation Cortex cannot import is left
207
221
  out. Two operations that come out with one name are told apart by a number
@@ -215,16 +229,19 @@ The manifest is checked against the contract before anything is sent, then
215
229
  sent as written, so Cortex fills its own defaults. It goes to the
216
230
  management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
217
231
  which changes nothing) under the same profile the management commands use, so
218
- the key needs `publishing:publish` and the write permission of every kind the
232
+ the key needs `manifests:apply` and the write permission of every kind the
219
233
  apply changes. The output lists each changed entry under its kind: `+`
220
234
  created, `~` updated, `-` deleted. A trigger the apply creates, or whose
221
235
  `fireCredential` it drops, gets a fire secret, printed once below the summary
222
236
  and never again.
223
237
 
238
+ Since a sync deletes, `cortex apply` asks before sending it. `--yes` skips the
239
+ question, and is required with no terminal to ask on. `--dry-run` never asks.
240
+
224
241
  References between entries (mentions, service scopes, which role a
225
242
  credential or secret plays) are judged on the state the apply leaves, so
226
243
  the order the manifest lists them in does not matter. A dataset host's
227
- credentials are the exception: its catalog is read with them before the
244
+ headers are the exception: its catalog is read with them before the
228
245
  apply, so they are judged on the deployment as it stands. A credential a
229
246
  trigger fires with cannot go to a host in the same apply that drops
230
247
  it; drop it first, then apply again.
@@ -240,9 +257,9 @@ it; drop it first, then apply again.
240
257
 
241
258
  ## Exit codes
242
259
 
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 |
260
+ | Command | 0 | 1 |
261
+ | ------- | ---------------------------------------------------------- | --------------------------------------------------------- |
262
+ | `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled |
263
+ | `setup` | Set up, or the server registered with no model to apply on | Not a project, Cortex refused or unreachable, or declined |
264
+ | `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, refused, or declined |
265
+ | 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.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Scaffold Cortex projects and manage a Cortex deployment",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,7 +16,7 @@
16
16
  ],
17
17
  "scripts": {
18
18
  "check": "tsc --noEmit",
19
- "prepack": "rm -rf src/contracts && cp -R ../../internal/contracts/src src/contracts",
19
+ "prepack": "rm -rf src/contracts && bun ../../tooling/semantic-release/release-dirs.js src/contracts",
20
20
  "postpack": "rm -rf src/contracts",
21
21
  "test": "bun test"
22
22
  },
@@ -37,16 +37,6 @@
37
37
  },
38
38
  "releaseWatchPaths": [
39
39
  "apps/cortex",
40
- "packages/cortex-typescript",
41
- "internal/contracts/src/client-tools",
42
- "internal/contracts/src/datasets",
43
- "internal/contracts/src/egress-policy",
44
- "internal/contracts/src/errors",
45
- "internal/contracts/src/json",
46
- "internal/contracts/src/manifest",
47
- "internal/contracts/src/manifest-file",
48
- "internal/contracts/src/server",
49
- "internal/contracts/src/triggers",
50
- "internal/contracts/src/wire"
40
+ "packages/cortex-typescript"
51
41
  ]
52
42
  }
@@ -13,8 +13,8 @@ import { loadManifestFile } from "@cortex/contracts/manifest-file";
13
13
  import { z } from "zod";
14
14
 
15
15
  import { callManagement, ManagementError } from "@/management/client";
16
- import { failCall, requireProfile } from "@/commands/management";
17
- import { askChoice } from "@/ui/prompts";
16
+ import { callOrFail, confirmDestructive, requireProfile } from "@/commands/management";
17
+ import { askChoice, canAsk } from "@/ui/prompts";
18
18
  import * as report from "@/ui/report";
19
19
 
20
20
  // Discovery is this exact name in the cwd, with no upward walk, so the
@@ -42,9 +42,11 @@ const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
42
42
  /**
43
43
  * `cortex apply [path]`: makes Cortex match a manifest in one
44
44
  * transaction, through the management API with the profile's key.
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.
45
+ * `--dry-run` answers what would change and changes nothing; anything
46
+ * else asks first, since a sync deletes. A field both the manifest and a
47
+ * live edit in Cortex changed is asked about too, unless `--prefer` answers
48
+ * every one. `--yes` never asks: the sync goes ahead, and a conflict
49
+ * `--prefer` leaves open ends the run.
48
50
  */
49
51
  export function registerApply(program: Command<[], { verbose?: true; profile?: string }>) {
50
52
  const command = program
@@ -53,6 +55,10 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
53
55
  .description("Make Cortex match a manifest")
54
56
  .argument("[path]", "The manifest, TypeScript or JSON", DEFAULT_MANIFEST)
55
57
  .option("--dry-run", "Show what would change, and change nothing")
58
+ .option(
59
+ "--yes",
60
+ "Never ask: apply without confirming, and stop on conflicts --prefer leaves open",
61
+ )
56
62
  .addOption(
57
63
  new Option(
58
64
  "--prefer <side>",
@@ -63,21 +69,35 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
63
69
  command.action(async function (path, options) {
64
70
  const { verbose = false, profile: profileName } = program.opts();
65
71
  const dryRun = options.dryRun === true;
66
- const file = findManifest(path);
72
+ const file = findManifest(path, "cortex apply");
67
73
  const profile = requireProfile(profileName);
68
74
 
69
75
  report.header(dryRun ? "cortex apply --dry-run" : "cortex apply");
76
+ const manifest = await loadManifest(file, verbose);
70
77
  report.input("Manifest", label(file));
71
78
  report.input("Cortex", profile.url);
79
+ if (!dryRun) {
80
+ await confirmDestructive(
81
+ `Applying deletes what the manifest created on ${profile.url} and no longer lists.`,
82
+ options.yes,
83
+ );
84
+ }
72
85
 
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
- });
86
+ const { changes, triggerSecrets, conflicts } = await callOrFail(profile, () =>
87
+ applyManifest({
88
+ profile,
89
+ manifest,
90
+ dryRun,
91
+ prefer: options.prefer,
92
+ interactive: canAsk() && options.yes !== true,
93
+ // A run that does not ask confirmed its sync with --yes.
94
+ command: [
95
+ "cortex apply",
96
+ ...(label(file) === DEFAULT_MANIFEST ? [] : [label(file)]),
97
+ "--yes",
98
+ ].join(" "),
99
+ }),
100
+ );
81
101
  const created = countNames(changes.create);
82
102
  const updated = countNames(changes.update);
83
103
  const deleted = countNames(changes.delete);
@@ -97,28 +117,26 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
97
117
  }
98
118
 
99
119
  /**
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.
120
+ * Sends a manifest `loadManifest` read, a preview under `dryRun`, then
121
+ * prints its changes one row per entry, and a preview's conflicts. A sync
122
+ * deletes what the manifest created and no longer lists, so its caller
123
+ * confirms first (`confirmDestructive`). Ends the run on a summary this CLI
124
+ * cannot read; a refusal throws, for the caller to end the run on
125
+ * (`callOrFail`) or to answer itself. `cortex apply` and `cortex setup` both
126
+ * apply through it; each prints the trigger secrets it returns.
105
127
  */
106
128
  export async function applyManifest(options: {
107
129
  profile: ReturnType<typeof requireProfile>;
108
- file: string;
130
+ manifest: Record<string, unknown>;
109
131
  dryRun: boolean;
110
- verbose: boolean;
111
132
  prefer?: Side;
112
133
  interactive: boolean;
134
+ /** What to run again to answer the conflicts it could not ask about. */
135
+ command: string;
113
136
  }) {
114
- const { profile, file, dryRun, verbose } = options;
137
+ const { profile, dryRun } = options;
115
138
  const answer = await sendManifest({
116
139
  ...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
140
  path: dryRun ? "/manifests/preview" : "/manifests/sync",
123
141
  });
124
142
  // The secrets are read apart from the changes: a sync has committed by
@@ -146,8 +164,8 @@ export async function applyManifest(options: {
146
164
  /**
147
165
  * Sends the manifest until nothing in it is left unanswered. A sync refused
148
166
  * 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.
167
+ * when `interactive`, then goes again with the answers; otherwise it ends
168
+ * the run naming them. Any other refusal throws.
151
169
  */
152
170
  async function sendManifest(options: {
153
171
  profile: ReturnType<typeof requireProfile>;
@@ -171,7 +189,7 @@ async function sendManifest(options: {
171
189
  error instanceof ManagementError
172
190
  ? unresolvedSchema.safeParse(error.detail)
173
191
  : undefined;
174
- if (!refused?.success) return failCall(profile, error);
192
+ if (!refused?.success) throw error;
175
193
  if (!interactive) failUnresolved(refused.data.conflicts, command);
176
194
  for (const conflict of refused.data.conflicts) {
177
195
  const keep = await askChoice<Side>(`${conflictLabel(conflict)}. Keep which?`, [
@@ -189,14 +207,14 @@ function conflictLabel({ kind, name, field }: Conflict) {
189
207
  return `${kind} ${name}: ${field} changed in both places`;
190
208
  }
191
209
 
192
- /** Ends a run that cannot ask, naming every conflict and how to run `command` again to answer them. */
210
+ /** Ends a run that does not ask, naming every conflict and how to run `command` again to answer them. */
193
211
  function failUnresolved(conflicts: ReadonlyArray<Conflict>, command: string): never {
194
212
  const count = conflicts.length;
195
213
  return report.fail({
196
214
  what: `${count} field${count === 1 ? "" : "s"} changed both in Cortex and in the manifest`,
197
215
  blocks: [
198
216
  conflicts.map((conflict) => ` ${conflictLabel(conflict)}`).join("\n"),
199
- "Keep one side for all of them, or run in a terminal to choose each:",
217
+ "Keep one side for all of them, or run in a terminal without --yes to choose each:",
200
218
  ` ${command} --prefer live\n ${command} --prefer manifest`,
201
219
  ],
202
220
  });
@@ -208,8 +226,12 @@ function label(path: string) {
208
226
  return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
209
227
  }
210
228
 
211
- /** The manifest's absolute path; a missing file ends the run before anything prints. */
212
- export function findManifest(path: string) {
229
+ /**
230
+ * The manifest's absolute path; a missing file ends the run before anything
231
+ * prints. `command`, when the command also takes a path, is offered as the
232
+ * way to point at a manifest elsewhere.
233
+ */
234
+ export function findManifest(path: string, command?: string) {
213
235
  const absolute = resolve(process.cwd(), path);
214
236
  if (existsSync(absolute)) return absolute;
215
237
  return report.fail({
@@ -217,9 +239,10 @@ export function findManifest(path: string) {
217
239
  blocks:
218
240
  path === DEFAULT_MANIFEST
219
241
  ? [
220
- "cortex apply reads exactly ./cortex.manifest.ts in the directory you run in.\nIt does not search parent directories.",
221
- "Run it from the project root, or point at the file:",
222
- " cortex apply ./deploy/cortex.manifest.ts",
242
+ "The CLI reads exactly ./cortex.manifest.ts in the directory you run in.\nIt does not search parent directories.",
243
+ command
244
+ ? `Run it from the project root, or point at the file:\n ${command} ./deploy/cortex.manifest.ts`
245
+ : "Run it from the project root.",
223
246
  ]
224
247
  : ["The path is resolved from the directory you run in."],
225
248
  });
@@ -230,7 +253,7 @@ export function findManifest(path: string) {
230
253
  * a mistake is named before anything is sent, then sent as written: Cortex
231
254
  * fills its own defaults, which may be newer than this CLI's.
232
255
  */
233
- async function loadManifest(path: string, verbose: boolean) {
256
+ export async function loadManifest(path: string, verbose: boolean) {
234
257
  const { written, parsed } = await loadManifestFile(path).catch((error: unknown) =>
235
258
  report.fail({
236
259
  what: `Could not load ${label(path)}`,
@@ -88,18 +88,22 @@ export function registerManagement(program: Program, argv: string[]) {
88
88
  }
89
89
 
90
90
  /**
91
- * The key is never a flag (argv is visible to other processes and lands in
92
- * shell history): it is prompted for masked, or read from `CORTEX_KEY`
93
- * when there is no terminal to ask.
91
+ * The URL is `--url`, or asked for on a terminal. The key is never a flag
92
+ * (argv is visible to other processes and lands in shell history): it is
93
+ * prompted for masked, or read from `CORTEX_KEY` when there is no terminal
94
+ * to ask.
94
95
  */
95
96
  async function login(given: string | undefined, name: string) {
96
- const url = requireTls(
97
- (given ?? (await prompts.askValue("Cortex URL", "https://cortex.example.com"))).replace(
98
- /\/+$/,
99
- "",
100
- ),
101
- );
102
- const key = process.stdin.isTTY
97
+ const typed =
98
+ given ??
99
+ (prompts.canAsk()
100
+ ? await prompts.askValue("Cortex URL", "https://cortex.example.com")
101
+ : report.fail({
102
+ what: "No terminal to ask for the Cortex URL",
103
+ blocks: ["Pass it with --url."],
104
+ }));
105
+ const url = requireTls(typed.replace(/\/+$/, ""));
106
+ const key = prompts.canAsk()
103
107
  ? await prompts.askSecret("Management key")
104
108
  : (process.env["CORTEX_KEY"] ??
105
109
  report.fail({
@@ -248,7 +252,8 @@ function groupByRouter(document: OpenApiDocument) {
248
252
  /**
249
253
  * One procedure as one command: a positional for `id`, a typed flag per
250
254
  * field, `--json` for a whole body, `--all` on paginated reads, `--publish`
251
- * on writes to a publishable router.
255
+ * on writes to a publishable router, `--yes` wherever a destructive
256
+ * procedure (`x-destructive`) would run.
252
257
  */
253
258
  function registerOperation(scope: {
254
259
  group: Command;
@@ -273,10 +278,13 @@ function registerOperation(scope: {
273
278
  const publisher = publishWith === undefined ? undefined : byProcedure.get(publishWith);
274
279
  const canPublish = publisher !== undefined;
275
280
  const liveFields = operation["x-live-fields"] ?? [];
281
+ const destructive = operation["x-destructive"] === true;
282
+ const publishDestructive = publisher?.operation["x-destructive"] === true;
276
283
 
277
284
  const command = declareCommand(group, name, operation.summary ?? "", flags, {
278
285
  paginated,
279
286
  canPublish,
287
+ confirms: destructive || publishDestructive,
280
288
  });
281
289
 
282
290
  command.action(async function (...args) {
@@ -285,11 +293,12 @@ function registerOperation(scope: {
285
293
  json?: string;
286
294
  all?: boolean;
287
295
  publish?: boolean;
296
+ yes?: boolean;
288
297
  };
289
298
  // Commander passes the options object first when the command declares
290
299
  // no argument, so only a string is the `[id]` positional.
291
300
  const positional = typeof args[0] === "string" ? args[0] : undefined;
292
- const { output: format, json, all, publish, ...given } = options;
301
+ const { output: format, json, all, publish, yes, ...given } = options;
293
302
  const body = await collectInput(flags, given, json, positional, name);
294
303
  // `-o json` promises stdout holds only the result; prose moves to stderr.
295
304
  const note = format === "json" ? report.aside : report.note;
@@ -297,6 +306,20 @@ function registerOperation(scope: {
297
306
  report.header(`cortex ${router} ${name}`);
298
307
  report.input("Cortex", profile.url);
299
308
  }
309
+ // One question before anything is sent, covering the publish too, so
310
+ // a no leaves no draft behind.
311
+ const confirming = [
312
+ ...(destructive ? [`${router} ${name}`] : []),
313
+ ...(publish === true && publishDestructive
314
+ ? [publisher.operation.operationId.replace(".", " ")]
315
+ : []),
316
+ ];
317
+ if (confirming.length > 0) {
318
+ await confirmDestructive(
319
+ `Running ${confirming.join(" and ")} on ${profile.url} replaces or removes state.`,
320
+ yes,
321
+ );
322
+ }
300
323
  const result = await callOrFail(profile, () =>
301
324
  all
302
325
  ? walkPages(profile, method, path, body)
@@ -320,7 +343,7 @@ function declareCommand(
320
343
  name: string,
321
344
  summary: string,
322
345
  flags: Flag[],
323
- shape: { paginated: boolean; canPublish: boolean },
346
+ shape: { paginated: boolean; canPublish: boolean; confirms: boolean },
324
347
  ) {
325
348
  const command = group
326
349
  .command(name)
@@ -353,6 +376,8 @@ function declareCommand(
353
376
  }
354
377
  if (shape.paginated) command.option("--all", "Walk every page");
355
378
  if (shape.canPublish) command.option("--publish", "Publish the entity right after saving");
379
+ if (shape.confirms)
380
+ command.option("--yes", "Skip the confirmation; required without a terminal");
356
381
  return command;
357
382
  }
358
383
 
@@ -363,6 +388,22 @@ function warningsOf(published: unknown) {
363
388
  : [];
364
389
  }
365
390
 
391
+ /**
392
+ * The go-ahead for a call that replaces or removes state, which `effect`
393
+ * names: `--yes`, or a yes on a terminal. With no terminal to ask, the run
394
+ * fails before anything is sent, so a script never hangs on the question.
395
+ * The hint does not echo argv, which may hold values that must not reach a
396
+ * CI log.
397
+ */
398
+ export async function confirmDestructive(effect: string, yes: boolean | undefined) {
399
+ if (yes === true) return;
400
+ if (prompts.canAsk()) return prompts.askToProceed(`${effect} Continue?`);
401
+ report.fail({
402
+ what: "No terminal to confirm this call",
403
+ blocks: [effect, "Rerun the command with --yes to run it without asking."],
404
+ });
405
+ }
406
+
366
407
  /**
367
408
  * A write to a publishable router is a draft: publish it now, or say how to.
368
409
  * Fields Cortex reads live are already in effect and are named as such,
@@ -408,14 +449,18 @@ export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promis
408
449
 
409
450
  /**
410
451
  * 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.
452
+ * run's outcome, in the error grammar: the refusal's literal, the sentence
453
+ * Cortex words it in, then whatever it names; anything else is a bug, and
454
+ * rethrown.
413
455
  */
414
456
  export function failCall(profile: { url: string }, error: unknown): never {
415
457
  if (error instanceof ManagementError) {
416
458
  report.fail({
417
459
  what: `${error.status} ${error.code}: ${error.message}`,
418
- blocks: refusalBlocks(error.detail ?? {}),
460
+ blocks: [
461
+ ...(error.refusal === undefined ? [] : [error.refusal]),
462
+ ...refusalBlocks(error.detail ?? {}),
463
+ ],
419
464
  });
420
465
  }
421
466
  if (error instanceof UnreachableError) {
@@ -459,8 +504,8 @@ function issueLine(issue: unknown) {
459
504
 
460
505
  /**
461
506
  * The body: `--json` first, flags layered over it, the positional as `id`.
462
- * A required field left missing is asked for on a TTY and named as the
463
- * flag, or the positional, to add otherwise.
507
+ * A required field left missing is asked for on a terminal and named as
508
+ * the flag, or the positional, to add otherwise.
464
509
  */
465
510
  async function collectInput(
466
511
  flags: Flag[],
@@ -476,7 +521,7 @@ async function collectInput(
476
521
  if (positional !== undefined) body.set("id", positional);
477
522
 
478
523
  const missing = flags.filter((flag) => flag.required && body.get(flag.name) === undefined);
479
- if (missing.length > 0 && !process.stdin.isTTY) {
524
+ if (missing.length > 0 && !prompts.canAsk()) {
480
525
  const named = (flag: Flag) =>
481
526
  flag.name === "id"
482
527
  ? "<id>"
@@ -9,7 +9,7 @@ import pkg from "../../package.json";
9
9
  import { askProvider } from "@/commands/setup";
10
10
  import { AGENT_SLUG, manifestIdOf, scaffoldFiles } from "@/scaffold/files";
11
11
  import { allocatePorts, type Ports } from "@/scaffold/ports";
12
- import { askConfirm, askName, intro, log, outro, runSteps } from "@/ui/prompts";
12
+ import { askConfirm, askName, canAsk, intro, log, outro, runSteps } from "@/ui/prompts";
13
13
  import * as report from "@/ui/report";
14
14
 
15
15
  /** npm's rule for an unscoped package name. The name is the directory too. */
@@ -46,9 +46,8 @@ type Options = { datasets?: true; setup?: boolean; yes?: true; verbose: boolean
46
46
  async function scaffold(name: string | undefined, options: Options) {
47
47
  const { verbose } = options;
48
48
 
49
- // No TTY ⇒ never prompt and behave as `--yes`. On Bun a piped stdin reports
50
- // `undefined` rather than `false`, which is why this compares against `true`.
51
- const interactive = process.stdin.isTTY === true && options.yes !== true;
49
+ // No terminal ⇒ never prompt and behave as `--yes`.
50
+ const interactive = canAsk() && options.yes !== true;
52
51
 
53
52
  if (name === undefined && !interactive) missingName();
54
53
 
@@ -280,14 +279,13 @@ function isInsideWorkTree(target: string) {
280
279
  }
281
280
 
282
281
  function missingName(): never {
283
- // No TTY ⇒ never prompt, so a missing required argument is a hard error.
284
- // With a TTY this branch is only reached under `--yes`, which answers every
282
+ // No terminal ⇒ never prompt, so a missing required argument is a hard error.
283
+ // With one, this branch is only reached under `--yes`, which answers every
285
284
  // question including the one that has no default.
286
285
  report.fail({
287
- what:
288
- process.stdin.isTTY === true
289
- ? "cortex new needs a project name."
290
- : "cortex new needs a project name when stdin is not a TTY.",
286
+ what: canAsk()
287
+ ? "cortex new needs a project name."
288
+ : "cortex new needs a project name when there is no terminal to ask on.",
291
289
  blocks: [
292
290
  "Pass it as an argument, and --yes to accept defaults for the rest:",
293
291
  " cortex new my-app --yes",