@m6d/cortex-cli 1.0.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.
@@ -0,0 +1,333 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ import { Option, type Command } from "@commander-js/extra-typings";
6
+
7
+ import pkg from "../../package.json";
8
+ import { FEATURE_NAMES, FEATURES, type Feature } from "@/scaffold/features";
9
+ import { scaffoldFiles, type Database } from "@/scaffold/files";
10
+ import { allocatePorts } from "@/scaffold/ports";
11
+ import { askDatabase, askFeatures, askName, intro, log, outro, runSteps } from "@/ui/prompts";
12
+ import * as report from "@/ui/report";
13
+
14
+ /** npm's rule for an unscoped package name. The name is the directory too. */
15
+ const VALID_NAME = /^[a-z0-9][a-z0-9._-]*$/;
16
+ const MAX_NAME = 214;
17
+
18
+ /** The two names npm rejects outright, however well-formed they otherwise are. */
19
+ const RESERVED_NAMES = ["node_modules", "favicon.ico"];
20
+
21
+ export function registerNew(program: Command) {
22
+ const command = program
23
+ .command("new")
24
+ .helpGroup("Project:")
25
+ .description("Scaffold a new Cortex server")
26
+ .argument("[name]", "Directory to create, and the generated package name")
27
+ .addOption(
28
+ new Option("--database <type>", "Database the project is configured for")
29
+ .choices(["postgres", "mssql"] as const)
30
+ .default("postgres" as const),
31
+ )
32
+ // Both halves of every toggle, so a flag can answer its question either
33
+ // way and `--yes` is not the only way to say "no". Declaring the positive
34
+ // first leaves the value `undefined` when neither is passed, which is
35
+ // what marks the question as still unanswered.
36
+ .option("--attachments", FEATURES.attachments.hint)
37
+ .option("--no-attachments", "Leave attachments out")
38
+ .option("--redis", FEATURES.redis.hint)
39
+ .option("--no-redis", "Leave Redis out")
40
+ .option("--auth", FEATURES.auth.hint)
41
+ .option("--no-auth", "Leave auth out")
42
+ .option("--knowledge", FEATURES.knowledge.hint)
43
+ .option("--no-knowledge", "Leave the knowledge graph out")
44
+ .option("--control-center", FEATURES.controlCenter.hint)
45
+ .option("--no-control-center", "Leave the Control Center out")
46
+ .option("--yes", "Accept the defaults instead of asking");
47
+
48
+ command.action(async function (name, options) {
49
+ // The globals live on the root command, which reaches this file untyped —
50
+ // `Command` here is the parameter type, not cli.ts's inferred one.
51
+ const { verbose = false } = program.opts() as { verbose?: boolean };
52
+
53
+ await scaffold(name, {
54
+ ...options,
55
+ verbose,
56
+ databasePassed: command.getOptionValueSource("database") !== "default",
57
+ });
58
+ });
59
+ }
60
+
61
+ type Options = {
62
+ database: Database;
63
+ yes?: boolean;
64
+ verbose: boolean;
65
+ databasePassed: boolean;
66
+ } & Partial<Record<Feature, boolean>>;
67
+
68
+ async function scaffold(name: string | undefined, options: Options) {
69
+ const { verbose, databasePassed } = options;
70
+
71
+ // No TTY ⇒ never prompt and behave as `--yes` (design spec §5.3). On Bun a
72
+ // piped stdin reports `undefined` rather than `false`, which is why this
73
+ // compares against `true`.
74
+ const interactive = process.stdin.isTTY === true && options.yes !== true;
75
+
76
+ if (name === undefined && !interactive) missingName();
77
+
78
+ // A name that came from a flag is checked before anything prints; one that
79
+ // is prompted for is checked by the prompt, which can ask again.
80
+ if (name !== undefined && !isValidName(name)) failInvalidName(name);
81
+ if (name !== undefined && isTaken(resolve(process.cwd(), name))) failTaken(name);
82
+
83
+ const started = Date.now();
84
+ intro(`cortex new · ${pkg.name} ${pkg.version}`);
85
+
86
+ const project = name ?? (await askName(checkName)).trim();
87
+
88
+ const database = interactive && !databasePassed ? await askDatabase() : options.database;
89
+ const features = await chooseFeatures(options, interactive);
90
+
91
+ const target = resolve(process.cwd(), project);
92
+
93
+ // Allocated here rather than inside the generators, which stay pure: picking a
94
+ // port means probing the machine for a free one.
95
+ const ports = allocatePorts();
96
+ const files = scaffoldFiles(project, database, features, ports);
97
+ let failedInstall: string | undefined;
98
+
99
+ await runSteps([
100
+ {
101
+ title: `Writing ${Object.keys(files).length} files`,
102
+ run: function () {
103
+ for (const [path, contents] of Object.entries(files)) {
104
+ const file = join(target, path);
105
+ mkdirSync(dirname(file), { recursive: true });
106
+ writeFileSync(file, contents);
107
+ }
108
+
109
+ const summary = [database, ...features, "model"].join(" · ");
110
+ return `Wrote ${Object.keys(files).length} files to ./${project}\n${summary}`;
111
+ },
112
+ },
113
+ {
114
+ // Always, no flag: running it is what separates a scaffold from a
115
+ // template dump.
116
+ title: "Installing dependencies",
117
+ run: async function () {
118
+ // Timed from here, not from the run: the prompts above are the
119
+ // user's time, and "in 41.0s" of which 38 were spent answering
120
+ // questions is a number nobody can act on.
121
+ const startedInstall = Date.now();
122
+ const result = await install(target);
123
+
124
+ if (!result.ok) {
125
+ failedInstall = result.output;
126
+ // What went wrong is reported below, once the run is over;
127
+ // --verbose still owes the reader the command that failed.
128
+ return verbose ? "$ bun install" : undefined;
129
+ }
130
+
131
+ const packages = /(\d+) packages? installed/.exec(result.output)?.[1];
132
+ const what = packages === undefined ? "dependencies" : `${packages} packages`;
133
+ const done = `Installed ${what} in ${seconds(startedInstall)}`;
134
+ return verbose ? `$ bun install\n${done}` : done;
135
+ },
136
+ },
137
+ {
138
+ // A repo nested inside another is a mess to discover later, and the
139
+ // .gitignore just written is inert without one. Silent either way,
140
+ // which is why this step reports nothing.
141
+ title: "Initializing git",
142
+ run: function () {
143
+ if (isInsideWorkTree(target)) {
144
+ return verbose ? "git init skipped — already inside a work tree" : undefined;
145
+ }
146
+
147
+ spawnSync("git", ["init"], { cwd: target, stdio: "ignore" });
148
+ return verbose ? "$ git init" : undefined;
149
+ },
150
+ },
151
+ ]);
152
+
153
+ if (failedInstall !== undefined) warnInstall(project, failedInstall, verbose);
154
+
155
+ outro(`Done in ${seconds(started)}`);
156
+ process.stdout.write(nextSteps(project, features, files));
157
+ }
158
+
159
+ /**
160
+ * The files are correct; only the install did not happen, so this warns and the
161
+ * run still exits 0. What bun said is the whole diagnosis, so it is kept: the
162
+ * last line by default, the captured log under --verbose.
163
+ */
164
+ function warnInstall(project: string, output: string, verbose: boolean) {
165
+ const detail = verbose ? output.trim() : lastLine(output);
166
+
167
+ log.warn(
168
+ // clack indents continuation lines by 3, so 2 more puts the command in
169
+ // the same column the error grammar uses.
170
+ `bun install did not finish. The files are written — run it yourself:\n\n` +
171
+ ` cd ${project} && bun install` +
172
+ (detail === "" ? "" : `\n\n${detail}`),
173
+ );
174
+ }
175
+
176
+ function nextSteps(project: string, features: Feature[], files: ReturnType<typeof scaffoldFiles>) {
177
+ const keys = [
178
+ "CORTEX_DATABASE_URL",
179
+ "CORTEX_MODEL_*",
180
+ ...features.map((feature) => FEATURES[feature].secrets).filter((key) => key !== undefined),
181
+ ];
182
+
183
+ return (
184
+ `\nNext steps:\n\n` +
185
+ step(`cd ${project}`) +
186
+ step("edit .env", `fill in ${keys.length === 2 ? keys.join(" and ") : keys.join(", ")}`) +
187
+ (files["docker-compose.yml"] === undefined ? "" : step("docker compose up -d")) +
188
+ // The `port` the generated cortex.config.ts sets, which is the server's
189
+ // own default. Service ports move at scaffold time; this one does not.
190
+ step("bun run dev", "→ http://localhost:3331") +
191
+ `\n`
192
+ );
193
+ }
194
+
195
+ /**
196
+ * One multi-select for the whole catalogue, minus whatever the flags already
197
+ * settled: a passed flag suppresses its question, the rest are still asked.
198
+ */
199
+ async function chooseFeatures(options: Options, interactive: boolean) {
200
+ const chosen = new Set(FEATURE_NAMES.filter((feature) => options[feature] === true));
201
+ const undecided = FEATURE_NAMES.filter((feature) => options[feature] === undefined);
202
+
203
+ if (interactive && undecided.length > 0) {
204
+ for (const feature of await askFeatures(undecided)) chosen.add(feature);
205
+ }
206
+
207
+ // Catalogue order, not the order the flags or the checkboxes came in.
208
+ return FEATURE_NAMES.filter((feature) => chosen.has(feature));
209
+ }
210
+
211
+ async function install(cwd: string) {
212
+ try {
213
+ // Piped rather than inherited: the spinner owns the screen while this runs.
214
+ // bun prints its summary to stderr, so both streams are read.
215
+ const child = Bun.spawn(["bun", "install"], { cwd, stdout: "pipe", stderr: "pipe" });
216
+ const output = await Promise.all([
217
+ new Response(child.stdout).text(),
218
+ new Response(child.stderr).text(),
219
+ ]);
220
+
221
+ return { ok: (await child.exited) === 0, output: output.join("") };
222
+ } catch (error) {
223
+ // A spawn that never starts — EACCES, EMFILE — is still just an install
224
+ // that did not happen, and the files are already written. It takes the
225
+ // same warn-and-exit-0 path as a bun install that ran and failed, rather
226
+ // than surfacing as "cortex hit an unexpected error".
227
+ return { ok: false, output: error instanceof Error ? error.message : String(error) };
228
+ }
229
+ }
230
+
231
+ function step(command: string, annotation?: string) {
232
+ // 27 puts the annotation column where cli-output.md §3 has it.
233
+ return annotation ? ` ${command.padEnd(27)}${annotation}\n` : ` ${command}\n`;
234
+ }
235
+
236
+ function seconds(started: number) {
237
+ return `${((Date.now() - started) / 1000).toFixed(1)}s`;
238
+ }
239
+
240
+ /** bun puts the reason last, and that one line is what gets pasted into a search. */
241
+ function lastLine(output: string) {
242
+ return output.trim().split("\n").at(-1)?.trim() ?? "";
243
+ }
244
+
245
+ function isValidName(name: string) {
246
+ return name.length <= MAX_NAME && VALID_NAME.test(name) && !RESERVED_NAMES.includes(name);
247
+ }
248
+
249
+ /** The prompt's rules, which are the flag path's two failures asked one at a time. */
250
+ function checkName(candidate: string) {
251
+ // One line each: clack prints them inside the prompt, which will not wrap.
252
+ if (RESERVED_NAMES.includes(candidate)) return `npm does not allow the name ${candidate}.`;
253
+ if (!isValidName(candidate)) return "Lowercase letters, digits, and . _ - only.";
254
+ if (isTaken(resolve(process.cwd(), candidate))) {
255
+ return `./${candidate} already exists and is not an empty directory.`;
256
+ }
257
+ return undefined;
258
+ }
259
+
260
+ /** `My App` → `my-app`, so the suggested command is one the user can paste. */
261
+ function slugify(name: string) {
262
+ const slug = name
263
+ .trim()
264
+ .toLowerCase()
265
+ .replace(/[^a-z0-9._-]+/g, "-")
266
+ .replace(/^[^a-z0-9]+/, "")
267
+ .slice(0, MAX_NAME);
268
+
269
+ return slug === "" || RESERVED_NAMES.includes(slug) ? "my-app" : slug;
270
+ }
271
+
272
+ /**
273
+ * A missing path is the only clean go-ahead, and an empty directory the one
274
+ * tolerated exception. Anything else — a regular file of that name, a directory
275
+ * with contents, one we lack permission to read — is a conflict: swallowing it
276
+ * scaffolds halfway and then dies on the first write.
277
+ */
278
+ function isTaken(target: string) {
279
+ try {
280
+ return readdirSync(target).length > 0;
281
+ } catch (error) {
282
+ return !(error instanceof Error && "code" in error && error.code === "ENOENT");
283
+ }
284
+ }
285
+
286
+ function isInsideWorkTree(target: string) {
287
+ return (
288
+ spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
289
+ cwd: target,
290
+ stdio: "ignore",
291
+ }).status === 0
292
+ );
293
+ }
294
+
295
+ function missingName(): never {
296
+ // No TTY ⇒ never prompt, so a missing required argument is a hard error
297
+ // (design spec §5.3). With a TTY this branch is only reached under `--yes`,
298
+ // which answers every question including the one that has no default.
299
+ report.fail({
300
+ what:
301
+ process.stdin.isTTY === true
302
+ ? "cortex new needs a project name."
303
+ : "cortex new needs a project name when stdin is not a TTY.",
304
+ blocks: [
305
+ "Pass it as an argument, and --yes to accept defaults for the rest:",
306
+ " cortex new my-app --yes",
307
+ ],
308
+ });
309
+ }
310
+
311
+ function failTaken(name: string): never {
312
+ report.fail({
313
+ what: `./${name} already exists and is not an empty directory.`,
314
+ blocks: [
315
+ "cortex new never merges into an existing project. Pick another name,\nor remove it first:",
316
+ ` rm -rf ${name}`,
317
+ ],
318
+ });
319
+ }
320
+
321
+ /**
322
+ * The name is the directory *and* the generated `package.json` name, so an
323
+ * illegal one produces a project npm refuses and a `cd` that does not parse.
324
+ */
325
+ function failInvalidName(name: string): never {
326
+ report.fail({
327
+ what: `"${name}" is not a usable project name`,
328
+ blocks: [
329
+ "It becomes the directory and the generated package.json `name`, so it has\nto be one npm accepts: lowercase, no spaces, not a name npm reserves. Try:",
330
+ ` cortex new ${slugify(name)}`,
331
+ ],
332
+ });
333
+ }
@@ -0,0 +1,285 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ import { type Command, Option } from "@commander-js/extra-typings";
5
+
6
+ import { configLabel, findConfig, loadConfig } from "@/config/load";
7
+ import { validateConfig } from "@/config/validate";
8
+ import { extractEndpoints } from "@/swagger/extract-endpoints";
9
+ import * as report from "@/ui/report";
10
+
11
+ /** A convention, not config: `KnowledgeConfig` has no `domainsDir` (design spec §5.5). */
12
+ const DEFAULT_DOMAINS_DIR = "src/domains";
13
+
14
+ /** Only the parts of a loaded config `swagger` reads to report its inputs. */
15
+ type ProjectConfig = {
16
+ knowledge?: { swagger?: { url?: string } };
17
+ agents?: Record<string, { knowledge?: { swagger?: { url?: string } } | null } | undefined>;
18
+ };
19
+
20
+ export function registerSwagger(program: Command) {
21
+ const swagger = program
22
+ .command("swagger")
23
+ .helpGroup("Resources:")
24
+ .summary("Endpoint schemas, from your Swagger spec")
25
+ .description(
26
+ "Endpoint schemas, from your Swagger spec.\n\nReads `knowledge.swagger.url` from your cortex.config.ts and the .endpoint.ts\nfiles under src/domains.",
27
+ )
28
+ // Declared once, on the group: the two verbs read the same directory, and
29
+ // commander hands a verb's unknown options back to its parent, so
30
+ // `swagger -d x sync` and `swagger sync -d x` both land here.
31
+ .addOption(
32
+ new Option(
33
+ "-d, --domains-dir <path>",
34
+ "Domains root, relative to the config file",
35
+ ).default(DEFAULT_DOMAINS_DIR, DEFAULT_DOMAINS_DIR),
36
+ )
37
+ .helpCommand(false);
38
+
39
+ for (const verb of ["sync", "check"] as const) {
40
+ swagger
41
+ .command(verb)
42
+ .description(
43
+ verb === "sync"
44
+ ? "Update .endpoint.ts files in place"
45
+ : "Report drift without writing (exit 2 if stale)",
46
+ )
47
+ .action(async function () {
48
+ // The globals live on the root command, which reaches this file
49
+ // untyped — `Command` here is the parameter type, not cli.ts's
50
+ // inferred one.
51
+ const { config: path, verbose = false } = program.opts() as {
52
+ config: string;
53
+ verbose?: boolean;
54
+ };
55
+
56
+ await run({
57
+ verb,
58
+ path,
59
+ explicit: program.getOptionValueSource("config") !== "default",
60
+ verbose,
61
+ domainsDir: swagger.opts().domainsDir,
62
+ });
63
+ });
64
+ }
65
+ }
66
+
67
+ async function run(options: {
68
+ verb: "sync" | "check";
69
+ path: string;
70
+ explicit: boolean;
71
+ verbose: boolean;
72
+ domainsDir: string;
73
+ }) {
74
+ const { verb, verbose } = options;
75
+ const title = `cortex swagger ${verb}`;
76
+
77
+ // Discovery runs before the box opens: with no config there is no run to
78
+ // report on, only the reason there isn't one.
79
+ const configPath = findConfig({ ...options, command: title });
80
+
81
+ report.header(title);
82
+ const loaded = await loadConfig(configPath, verbose);
83
+ validateConfig(loaded, configPath);
84
+ report.input("Config", configLabel(configPath));
85
+
86
+ // Relative to the config file, never to the cwd — the domains tree belongs to
87
+ // the project, and `--config ../other/cortex.config.ts` must still find it.
88
+ const domainsDir = resolve(dirname(configPath), options.domainsDir);
89
+ if (!existsSync(domainsDir)) failMissingDomainsDir(domainsDir, verb);
90
+
91
+ const urls = swaggerUrls(loaded);
92
+ for (const url of urls) report.input("Swagger", url);
93
+
94
+ const startedAt = Date.now();
95
+ const result = await runExtract(urls, domainsDir, verb).catch(function (error: unknown): never {
96
+ return failExtract({ error, configPath, verbose, verb });
97
+ });
98
+
99
+ // Reported once the run is over because that is when the count is known —
100
+ // the total is every file the walk considered, however it ended up.
101
+ const total =
102
+ result.matched + result.updated.length + result.drift.length + result.missing.length;
103
+ report.input(
104
+ "Domains",
105
+ `${configLabel(domainsDir)} — ${report.plural(total, "endpoint file")}`,
106
+ );
107
+ report.blank();
108
+
109
+ const elapsed = `(${((Date.now() - startedAt) / 1000).toFixed(1)}s)`;
110
+ if (verb === "sync") return renderSync(result, domainsDir, elapsed);
111
+ renderCheck(result, domainsDir, elapsed, syncCommand(options));
112
+ }
113
+
114
+ /**
115
+ * The `sync` that fixes what `check` found has to act on the tree `check` read,
116
+ * so it carries whatever flags got it there. Defaults stay off the line — a hint
117
+ * is something to paste, not a transcript.
118
+ */
119
+ function syncCommand(options: { path: string; explicit: boolean; domainsDir: string }) {
120
+ return [
121
+ "cortex swagger sync",
122
+ ...(options.explicit ? [`--config ${options.path}`] : []),
123
+ ...(options.domainsDir === DEFAULT_DOMAINS_DIR
124
+ ? []
125
+ : [`--domains-dir ${options.domainsDir}`]),
126
+ ].join(" ");
127
+ }
128
+
129
+ type Report = {
130
+ matched: number;
131
+ missing: string[];
132
+ drift: string[];
133
+ updated: string[];
134
+ };
135
+
136
+ /**
137
+ * `sync` says what it did. Only changed files are printed — at 37 files a
138
+ * per-file log is 34 lines of `ok` — and `MISSING` goes last, because it is the
139
+ * one thing left for a human. Writing is the job, so it still exits 0.
140
+ */
141
+ function renderSync(result: Report, domainsDir: string, elapsed: string) {
142
+ for (const file of result.updated) report.row("updated", file);
143
+ for (const file of result.missing) {
144
+ report.row("missing", file);
145
+ report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
146
+ }
147
+ if (result.updated.length || result.missing.length) report.blank();
148
+
149
+ const parts = [`${result.updated.length} updated`];
150
+ if (result.missing.length) parts.push(`${result.missing.length} missing`);
151
+ parts.push(`${result.matched} unchanged`);
152
+ if (result.updated.length) parts.push("formatted with prettier");
153
+ report.footer(`${parts.join(" · ")} ${elapsed}`);
154
+
155
+ if (result.missing.length) {
156
+ report.note(
157
+ `${missingLead(result)} no match in the spec. Either the route moved, or the file is\nstale — \`cortex swagger check\` will keep failing until it is resolved.`,
158
+ );
159
+ }
160
+ }
161
+
162
+ /**
163
+ * `check` says what is wrong, and writes nothing. `drift` and `MISSING` are both
164
+ * findings — one is fixed by a tool, the other by a person — so either exits 2.
165
+ */
166
+ function renderCheck(result: Report, domainsDir: string, elapsed: string, sync: string) {
167
+ for (const file of result.drift) {
168
+ report.row("drift", file);
169
+ report.row("", `${routeOf(join(domainsDir, file))} — no longer matches the spec`);
170
+ }
171
+ for (const file of result.missing) {
172
+ report.row("missing", file);
173
+ report.row("", `${routeOf(join(domainsDir, file))} — no match in the spec`);
174
+ }
175
+ if (result.drift.length || result.missing.length) report.blank();
176
+
177
+ const parts: string[] = [];
178
+ if (result.drift.length) parts.push(`${result.drift.length} drifted`);
179
+ if (result.missing.length) parts.push(`${result.missing.length} missing`);
180
+ parts.push(`${result.matched} up to date`);
181
+ report.footer(`${parts.join(" · ")} ${elapsed}`);
182
+
183
+ if (result.drift.length) {
184
+ report.note(
185
+ `Run \`${sync}\` to update the ${report.plural(result.drift.length, "drifted file")}.`,
186
+ );
187
+ }
188
+ if (result.missing.length) {
189
+ report.note(
190
+ `${missingLead(result)} no match in the spec. Either the route moved, or the file is\nstale — \`cortex swagger sync\` cannot fix it; it needs a human.`,
191
+ );
192
+ }
193
+ if (result.drift.length || result.missing.length) process.exit(2);
194
+ }
195
+
196
+ /**
197
+ * The route a finding is about, read back the way the server's own file walk
198
+ * reads it. The report is a list of paths; the route is what the reader has to
199
+ * act on, and it lives in the file.
200
+ */
201
+ function routeOf(filePath: string) {
202
+ const source = readFileSync(filePath, "utf8");
203
+ const method = source.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2] ?? "?";
204
+ const route = source.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2] ?? "?";
205
+ return `${method} ${route}`;
206
+ }
207
+
208
+ /**
209
+ * Unlike `graph seed`, this needs nothing from the project's server — not even a
210
+ * version handshake. What it writes is `.endpoint.ts` source whose generated block
211
+ * has to satisfy that server's own `EndpointDef`, so a codegen that outran the
212
+ * installed server is a type error in the project's `bun run check`, loudly, rather
213
+ * than something to detect here.
214
+ */
215
+ async function runExtract(swaggerUrls: string[], domainsDir: string, verb: "sync" | "check") {
216
+ if (!swaggerUrls.length) throw new Error("No swagger URLs found in knowledge config");
217
+
218
+ return await extractEndpoints({ swaggerUrls, domainsDir, write: verb === "sync" });
219
+ }
220
+
221
+ /** Server level, then per agent, deduped — per-agent docs override on a key clash. */
222
+ function swaggerUrls(config: ProjectConfig) {
223
+ const urls = new Set<string>();
224
+ if (config.knowledge?.swagger?.url) urls.add(config.knowledge.swagger.url);
225
+
226
+ for (const agent of Object.values(config.agents ?? {})) {
227
+ if (agent?.knowledge?.swagger?.url) urls.add(agent.knowledge.swagger.url);
228
+ }
229
+
230
+ return [...urls];
231
+ }
232
+
233
+ /** `1 endpoint file has` / `3 endpoint files have` — both notes open with it. */
234
+ function missingLead(result: Report) {
235
+ return `${report.plural(result.missing.length, "endpoint file")} ${result.missing.length === 1 ? "has" : "have"}`;
236
+ }
237
+
238
+ /**
239
+ * An absent domains dir is *couldn't run*, not "nothing to do": `extractEndpoints`
240
+ * walks a missing directory as an empty one and would report a clean 0 of 0.
241
+ */
242
+ function failMissingDomainsDir(domainsDir: string, verb: string): never {
243
+ report.fail({
244
+ what: `No domains directory at ${configLabel(domainsDir)}`,
245
+ blocks: [
246
+ `\`swagger ${verb}\` reads .endpoint.ts files under a domains root — ${DEFAULT_DOMAINS_DIR} by\ndefault — resolved relative to the config file. Point it somewhere else:`,
247
+ ` cortex swagger ${verb} --domains-dir ./lib/domains`,
248
+ ],
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Section requirements are `runExtract`'s to enforce — it already throws
254
+ * `"No swagger URLs found in knowledge config"`. This renders what it threw.
255
+ */
256
+ function failExtract(options: {
257
+ error: unknown;
258
+ configPath: string;
259
+ verbose: boolean;
260
+ verb: string;
261
+ }): never {
262
+ const { error, configPath, verbose, verb } = options;
263
+ const label = configLabel(configPath);
264
+ const message = error instanceof Error ? error.message : String(error);
265
+
266
+ if (message === "No swagger URLs found in knowledge config") {
267
+ report.fail({
268
+ what: `${label} has no knowledge.swagger.url`,
269
+ blocks: [
270
+ `\`swagger ${verb}\` generates endpoint schemas from a Swagger spec. Add one:`,
271
+ ' knowledge: { swagger: { url: "https://api.example.com/swagger/v1/swagger.json" } }',
272
+ "See https://github.com/boring91/cortex#file-structure-convention",
273
+ ],
274
+ });
275
+ }
276
+
277
+ // The `◇ Swagger` lines above name every URL that was tried, so the error
278
+ // states the failure and points at the key behind it.
279
+ report.fail({
280
+ what: "Could not read the Swagger spec",
281
+ error,
282
+ blocks: [`Is the API running? Otherwise check knowledge.swagger.url in\n${label}.`],
283
+ verbose,
284
+ });
285
+ }