@m6d/cortex-cli 0.1.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.
Files changed (61) hide show
  1. package/README.md +175 -0
  2. package/bin/cortex.js +52 -0
  3. package/package.json +41 -0
  4. package/src/cc/client.ts +295 -0
  5. package/src/cc/flags.ts +135 -0
  6. package/src/cc/output.ts +77 -0
  7. package/src/cc/profiles.ts +105 -0
  8. package/src/cli.ts +56 -0
  9. package/src/commands/cc.ts +504 -0
  10. package/src/commands/graph.ts +229 -0
  11. package/src/commands/new.ts +333 -0
  12. package/src/commands/swagger.ts +261 -0
  13. package/src/config/load.ts +122 -0
  14. package/src/config/validate.ts +46 -0
  15. package/src/contracts/charts/chart-spec.ts +201 -0
  16. package/src/contracts/charts/index.ts +7 -0
  17. package/src/contracts/client-tools/index.ts +82 -0
  18. package/src/contracts/egress/index.ts +161 -0
  19. package/src/contracts/egress/scrub.ts +59 -0
  20. package/src/contracts/graph/clients/embed.ts +45 -0
  21. package/src/contracts/graph/clients/neo4j.ts +85 -0
  22. package/src/contracts/graph/helpers.ts +57 -0
  23. package/src/contracts/graph/index.ts +30 -0
  24. package/src/contracts/graph/schema.ts +65 -0
  25. package/src/contracts/graph/types.ts +125 -0
  26. package/src/contracts/markdown/index.ts +39 -0
  27. package/src/contracts/rich-text/index.ts +220 -0
  28. package/src/contracts/runtime/api.ts +328 -0
  29. package/src/contracts/runtime/auth.ts +41 -0
  30. package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
  31. package/src/contracts/runtime/dataset-sql.ts +2522 -0
  32. package/src/contracts/runtime/datasets.fixtures.json +2849 -0
  33. package/src/contracts/runtime/datasets.ts +1391 -0
  34. package/src/contracts/runtime/grammar.ts +161 -0
  35. package/src/contracts/runtime/index.ts +14 -0
  36. package/src/contracts/runtime/signature.ts +237 -0
  37. package/src/contracts/runtime/sync.ts +172 -0
  38. package/src/contracts/runtime/triggers.ts +224 -0
  39. package/src/contracts/wire/index.ts +264 -0
  40. package/src/graph/expand-domains.ts +288 -0
  41. package/src/graph/generate-cypher.ts +201 -0
  42. package/src/graph/seed.ts +207 -0
  43. package/src/graph/validate.ts +78 -0
  44. package/src/scaffold/features.ts +190 -0
  45. package/src/scaffold/files.ts +411 -0
  46. package/src/scaffold/ports.ts +86 -0
  47. package/src/scaffold/templates/domains-README.md.txt +16 -0
  48. package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
  49. package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
  50. package/src/scaffold/templates/gitignore.txt +19 -0
  51. package/src/scaffold/templates/index.ts.txt +18 -0
  52. package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
  53. package/src/scaffold/templates/tsconfig.json.txt +25 -0
  54. package/src/swagger/extract-endpoints.ts +266 -0
  55. package/src/swagger/generated-block.ts +74 -0
  56. package/src/swagger/openapi-schema.ts +161 -0
  57. package/src/swagger/parse-response.ts +88 -0
  58. package/src/swagger/types.ts +25 -0
  59. package/src/ui/prompts.ts +125 -0
  60. package/src/ui/report.ts +107 -0
  61. package/tsconfig.json +46 -0
@@ -0,0 +1,78 @@
1
+ import type { ConceptDef, DomainDef, RuleDef } from "@cortex/contracts/graph";
2
+
3
+ /**
4
+ * Checks a domain for the two mistakes that survive the type system: a name used
5
+ * twice, and an entity nothing points at.
6
+ *
7
+ * Cross-domain references are allowed — a concept, endpoint or service may be
8
+ * defined in another module — so local membership is deliberately not enforced.
9
+ */
10
+ export function validateDomain(data: DomainDef) {
11
+ const referenced = collectReferences(data);
12
+
13
+ return [
14
+ ...duplicateNames(data.concepts, "concept"),
15
+ ...duplicateNames(data.endpoints, "endpoint"),
16
+ ...duplicateNames(data.services, "service"),
17
+ ...duplicateNames(data.rules, "rule"),
18
+ ...unreferenced(
19
+ data.concepts,
20
+ referenced.concepts,
21
+ (name) => `Concept "${name}" is never referenced by any endpoint or service`,
22
+ ),
23
+ ...unreferenced(
24
+ data.rules,
25
+ referenced.rules,
26
+ (name) => `Rule "${name}" is never referenced by any endpoint, service, or concept`,
27
+ ),
28
+ ];
29
+ }
30
+
31
+ function duplicateNames(items: { name: string }[] | undefined, kind: string) {
32
+ const seen = new Set<string>();
33
+
34
+ return (items ?? []).flatMap((item) => {
35
+ const duplicate = seen.has(item.name);
36
+ seen.add(item.name);
37
+
38
+ return duplicate ? [`Duplicate ${kind} name: "${item.name}"`] : [];
39
+ });
40
+ }
41
+
42
+ function unreferenced(
43
+ items: { name: string }[] | undefined,
44
+ referenced: Set<string>,
45
+ message: (name: string) => string,
46
+ ) {
47
+ return (items ?? [])
48
+ .filter((item) => !referenced.has(item.name))
49
+ .map((item) => message(item.name));
50
+ }
51
+
52
+ /** Gathers every name pointed at from anywhere in the domain. */
53
+ function collectReferences(data: DomainDef) {
54
+ const concepts = new Set<string>();
55
+ const rules = new Set<string>();
56
+ const addRules = (governedBy: RuleDef[] | undefined) =>
57
+ governedBy?.forEach((rule) => rules.add(rule.name));
58
+ const addConcept = (concept: ConceptDef | undefined) => concept && concepts.add(concept.name);
59
+
60
+ for (const endpoint of data.endpoints ?? []) {
61
+ endpoint.queries?.forEach(addConcept);
62
+ endpoint.mutates?.forEach(addConcept);
63
+ endpoint.returns?.forEach((returned) => addConcept(returned.concept));
64
+ addRules(endpoint.governedBy);
65
+ }
66
+
67
+ for (const service of data.services ?? []) {
68
+ addConcept(service.belongsTo);
69
+ addRules(service.governedBy);
70
+ }
71
+
72
+ for (const concept of data.concepts ?? []) {
73
+ addConcept(concept.parentConcept);
74
+ addRules(concept.governedBy);
75
+ }
76
+
77
+ return { concepts, rules };
78
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The five features `cortex new` toggles. A feature is not a
3
+ * config key: it is a bundle of a `cortex.config.ts` fragment, a block of env
4
+ * keys, and sometimes a compose service. Fragments concatenate — there is no
5
+ * merge engine — so every field here is plain text that gets joined.
6
+ *
7
+ * The grouping is what the server code already enforces. `vision` is inert
8
+ * without `storage`, so attachments writes both; `seedGraph` throws unless it
9
+ * has all of `neo4j`, `embedding` and `knowledge.domains`, so knowledge writes
10
+ * all three plus the `src/domains/` tree the domainsDir convention assumes.
11
+ */
12
+
13
+ export type Feature = "attachments" | "redis" | "auth" | "knowledge" | "controlCenter";
14
+
15
+ type Definition = {
16
+ /** One line, shown in `--help` and next to the option in the multi-select. */
17
+ hint: string;
18
+ /** Sits above the fragment in the generated config, commented either way. */
19
+ note: string;
20
+ /**
21
+ * The `cortex.config.ts` fragment, written at zero indentation: the assembler
22
+ * indents it into the object literal, or comments it out for a feature that
23
+ * was not selected. Conditional spread throughout, so an unfilled env var
24
+ * leaves the section *absent* rather than present-and-undefined.
25
+ */
26
+ config: string;
27
+ /** The `.env` block, identical in `.env` and `.env.example`. */
28
+ env: string;
29
+ /** The `docker-compose.yml` service, indented under `services:`. */
30
+ compose?: string;
31
+ /** The named volume that service mounts. */
32
+ volume?: string;
33
+ /** What `cd`-then-edit has to fill in before the project boots. */
34
+ secrets?: string;
35
+ };
36
+
37
+ export const FEATURES: Record<Feature, Definition> = {
38
+ attachments: {
39
+ hint: "MinIO storage for uploads, described by a vision model",
40
+ note: "Attachments: an S3-compatible bucket for uploads, plus an image-capable\nmodel that describes them. Vision does nothing without storage, which is\nwhy one toggle writes both.",
41
+ config: `...(process.env["CORTEX_MINIO_ENDPOINT"]
42
+ ? {
43
+ storage: {
44
+ endPoint: process.env["CORTEX_MINIO_ENDPOINT"],
45
+ port: Number(process.env["CORTEX_MINIO_PORT"] ?? {{port.minio}}),
46
+ useSSL: process.env["CORTEX_MINIO_USE_SSL"] === "true",
47
+ accessKey: process.env["CORTEX_MINIO_ACCESS_KEY"]!,
48
+ secretKey: process.env["CORTEX_MINIO_SECRET_KEY"]!,
49
+ },
50
+ vision: {
51
+ baseURL: process.env["CORTEX_VISION_URL"]!,
52
+ apiKey: process.env["CORTEX_VISION_KEY"]!,
53
+ modelName: process.env["CORTEX_VISION_MODEL"]!,
54
+ },
55
+ }
56
+ : {}),`,
57
+ env: `# --- Attachments -------------------------------------------------------------
58
+ # S3-compatible object storage. docker-compose.yml runs MinIO on these values.
59
+ CORTEX_MINIO_ENDPOINT=localhost
60
+ CORTEX_MINIO_PORT={{port.minio}}
61
+ CORTEX_MINIO_USE_SSL=false
62
+ CORTEX_MINIO_ACCESS_KEY=minio
63
+ CORTEX_MINIO_SECRET_KEY=minio-secret-key
64
+
65
+ # An image-capable, OpenAI-compatible model. It describes uploaded images so the
66
+ # agent can read them.
67
+ CORTEX_VISION_URL=https://api.openai.com/v1
68
+ CORTEX_VISION_KEY=your-vision-api-key
69
+ CORTEX_VISION_MODEL=your-vision-model`,
70
+ compose: ` minio:
71
+ image: minio/minio:RELEASE.2025-04-22T22-12-26Z
72
+ command: server /data --console-address ":9001"
73
+ environment:
74
+ MINIO_ROOT_USER: minio
75
+ MINIO_ROOT_PASSWORD: minio-secret-key
76
+ ports:
77
+ - "{{port.minio}}:9000"
78
+ - "{{port.minioConsole}}:9001"
79
+ volumes:
80
+ - minio-data:/data`,
81
+ volume: "minio-data",
82
+ secrets: "CORTEX_VISION_*",
83
+ },
84
+
85
+ redis: {
86
+ hint: "resumable streams across restarts",
87
+ note: "Redis makes live runs survive a restart and reach every instance. Without\nit, an in-memory fallback covers this single process.",
88
+ config: `...(process.env["CORTEX_REDIS_URL"] ? { redis: { url: process.env["CORTEX_REDIS_URL"] } } : {}),`,
89
+ env: `# --- Redis -------------------------------------------------------------------
90
+ # Resumable streams. docker-compose.yml runs one on this URL.
91
+ CORTEX_REDIS_URL=redis://localhost:{{port.redis}}`,
92
+ compose: ` redis:
93
+ image: redis:7-alpine
94
+ ports:
95
+ - "{{port.redis}}:6379"`,
96
+ },
97
+
98
+ auth: {
99
+ hint: "JWT verification via JWKS",
100
+ note: "Bearer tokens verified against an identity provider's JWKS endpoint. The\nprovider is external, so there is no service for it.",
101
+ config: `...(process.env["CORTEX_AUTH_JWKS_URI"]
102
+ ? {
103
+ auth: {
104
+ kind: "jwks" as const,
105
+ jwksUri: process.env["CORTEX_AUTH_JWKS_URI"],
106
+ issuer: process.env["CORTEX_AUTH_ISSUER"]!,
107
+ },
108
+ }
109
+ : {}),`,
110
+ env: `# --- Auth --------------------------------------------------------------------
111
+ # Your identity provider's JWKS endpoint, and the issuer it stamps on tokens.
112
+ CORTEX_AUTH_JWKS_URI=https://your-idp.example.com/.well-known/jwks.json
113
+ CORTEX_AUTH_ISSUER=https://your-idp.example.com/`,
114
+ secrets: "CORTEX_AUTH_*",
115
+ },
116
+
117
+ knowledge: {
118
+ hint: "Swagger + domains + Neo4j",
119
+ note: "The knowledge graph, which is three keys rather than one: Neo4j stores it,\nthe embedding endpoint makes it searchable, and the domain definitions under\nsrc/domains/ are what `cortex graph seed` loads into it.",
120
+ config: `...(process.env["CORTEX_NEO4J_URL"]
121
+ ? {
122
+ neo4j: {
123
+ url: process.env["CORTEX_NEO4J_URL"],
124
+ user: process.env["CORTEX_NEO4J_USER"]!,
125
+ password: process.env["CORTEX_NEO4J_PASSWORD"]!,
126
+ },
127
+ embedding: {
128
+ baseURL: process.env["CORTEX_EMBEDDING_URL"]!,
129
+ apiKey: process.env["CORTEX_EMBEDDING_KEY"]!,
130
+ modelName: process.env["CORTEX_EMBEDDING_MODEL"]!,
131
+ dimension: Number(process.env["CORTEX_EMBEDDING_DIMENSION"] ?? 1024),
132
+ },
133
+ knowledge: {
134
+ // swagger: { url: "https://api.example.com/swagger/v1/swagger.json" },
135
+ // Import the domains you write under src/domains/ and list them here.
136
+ domains: {},
137
+ },
138
+ }
139
+ : {}),`,
140
+ env: `# --- Knowledge graph ---------------------------------------------------------
141
+ # Neo4j. docker-compose.yml runs one with exactly these credentials. The URL is
142
+ # the HTTP endpoint, not the bolt one: the server talks to Neo4j over its HTTP
143
+ # transaction API.
144
+ CORTEX_NEO4J_URL=http://localhost:{{port.neo4j}}
145
+ CORTEX_NEO4J_USER=neo4j
146
+ CORTEX_NEO4J_PASSWORD=cortex-dev-password
147
+
148
+ # An OpenAI-compatible embeddings endpoint — external, like the main model.
149
+ # CORTEX_EMBEDDING_DIMENSION has to match what the model returns.
150
+ CORTEX_EMBEDDING_URL=https://api.openai.com/v1
151
+ CORTEX_EMBEDDING_KEY=your-embedding-api-key
152
+ CORTEX_EMBEDDING_MODEL=your-embedding-model
153
+ CORTEX_EMBEDDING_DIMENSION=1024`,
154
+ compose: ` neo4j:
155
+ image: neo4j:5-community
156
+ environment:
157
+ NEO4J_AUTH: neo4j/cortex-dev-password
158
+ # Neo4j Browser, served on the HTTP port, opens bolt at whatever this
159
+ # advertises. Without it the browser would offer 7687, which is the
160
+ # container's port and not the one published below.
161
+ NEO4J_server_bolt_advertised__address: ":{{port.neo4jBolt}}"
162
+ ports:
163
+ - "{{port.neo4j}}:7474"
164
+ - "{{port.neo4jBolt}}:7687"
165
+ volumes:
166
+ - neo4j-data:/data`,
167
+ volume: "neo4j-data",
168
+ secrets: "CORTEX_EMBEDDING_*",
169
+ },
170
+
171
+ controlCenter: {
172
+ hint: "serve agents published on a Control Center instance",
173
+ note: "The Control Center you point this server at. External, so no service here.",
174
+ config: `...(process.env["CORTEX_CONTROL_CENTER_URL"]
175
+ ? {
176
+ controlCenter: {
177
+ url: process.env["CORTEX_CONTROL_CENTER_URL"],
178
+ apiKey: process.env["CORTEX_CONTROL_CENTER_API_KEY"]!,
179
+ },
180
+ }
181
+ : {}),`,
182
+ env: `# --- Control Center ----------------------------------------------------------
183
+ CORTEX_CONTROL_CENTER_URL=http://localhost:20000
184
+ CORTEX_CONTROL_CENTER_API_KEY=cc_live_...`,
185
+ secrets: "CORTEX_CONTROL_CENTER_API_KEY",
186
+ },
187
+ };
188
+
189
+ /** Catalogue order, which is the order every generated file lists them in. */
190
+ export const FEATURE_NAMES = Object.keys(FEATURES) as Feature[];
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Every file `cortex new` writes, as one pure function. Files with nothing to
3
+ * fill in are verbatim text under templates/; the ones that vary with the
4
+ * selection are assembled here. The tooling configs are inlined rather than
5
+ * pulled from `@m6d/eslint-config`, `@m6d/prettier-config` and `@m6d/tsconfig`:
6
+ * those are `private: true` at `0.0.0` and resolvable only through this
7
+ * workspace, and a standalone scaffold has no workspace.
8
+ */
9
+
10
+ import { FEATURE_NAMES, FEATURES, type Feature } from "@/scaffold/features";
11
+ import indexTs from "@/scaffold/templates/index.ts.txt";
12
+ import domainsReadme from "@/scaffold/templates/domains-README.md.txt";
13
+ import tsconfigJson from "@/scaffold/templates/tsconfig.json.txt";
14
+ import gitignoreText from "@/scaffold/templates/gitignore.txt";
15
+ import eslintConfig from "@/scaffold/templates/eslint.config.mjs.txt";
16
+ import prettierConfig from "@/scaffold/templates/prettier.config.mjs.txt";
17
+ import fallowrc from "@/scaffold/templates/fallowrc.jsonc.txt";
18
+ import { applyPorts, type Ports } from "@/scaffold/ports";
19
+
20
+ export type Database = "postgres" | "mssql";
21
+
22
+ // The generated project depends on these directly, not transitively: its own code
23
+ // imports `zod` for tool schemas and `hono` for extra routes on `server.app`.
24
+ // The ranges are packages/cortex-server's own peers, which is the compatibility
25
+ // statement the server itself publishes, and a server range pinned to its
26
+ // current minor, since a minor bump is where the server breaks; the release
27
+ // sync (scripts/sync-package-versions.ts) restamps it and files.test.ts holds
28
+ // them to that.
29
+ export const DEPENDENCIES = {
30
+ "@m6d/cortex-server": "~0.1.0",
31
+ "@tanstack/ai": "^0.55.0",
32
+ "@tanstack/ai-openai": "^0.22.8",
33
+ hono: "^4.12.3",
34
+ jose: "^6.1.3",
35
+ openai: "^6",
36
+ zod: "^4.1.8",
37
+ };
38
+
39
+ const DEV_DEPENDENCIES = {
40
+ "@types/bun": "latest",
41
+ cspell: "^9.6.4",
42
+ eslint: "^9",
43
+ "eslint-config-prettier": "^10.1.8",
44
+ "eslint-plugin-prettier": "^5.5.5",
45
+ fallow: "^3.9.1",
46
+ prettier: "^3.6.2",
47
+ typescript: "^5",
48
+ "typescript-eslint": "^8.65.0",
49
+ };
50
+
51
+ // Postgres is the one we run, so it gets a scaffolded port. MSSQL is normally an
52
+ // existing instance nobody moved, which is why it keeps 1433 and has no service.
53
+ const CONNECTION_STRING: Record<Database, string> = {
54
+ postgres: "postgres://postgres:postgres@localhost:{{port.postgres}}/cortex",
55
+ mssql: "Server=localhost,1433;Database=cortex;User Id=sa;Password=your-password;Encrypt=false",
56
+ };
57
+
58
+ function packageJson(name: string) {
59
+ return `${JSON.stringify(
60
+ {
61
+ name,
62
+ version: "0.1.0",
63
+ private: true,
64
+ type: "module",
65
+ scripts: {
66
+ // Not `fallow audit`: it scopes itself to files changed against a
67
+ // base branch, and a project on line one has no commits and no
68
+ // remote to derive one from. Bare `fallow` runs the same three
69
+ // analyses — dead code, duplication, complexity — over everything.
70
+ audit: "fallow",
71
+ check: "tsc --noEmit",
72
+ dev: "bun run --hot index.ts",
73
+ format: "prettier --write .",
74
+ "format:check": "prettier --check .",
75
+ lint: "eslint",
76
+ spellcheck: 'cspell "**/*.{ts,mjs,json,md}"',
77
+ verify: "bun run format:check && bun run lint && bun run spellcheck && bun run check && bun run audit",
78
+ },
79
+ dependencies: DEPENDENCIES,
80
+ devDependencies: DEV_DEPENDENCIES,
81
+ },
82
+ null,
83
+ 4,
84
+ )}\n`;
85
+ }
86
+
87
+ /**
88
+ * Never toggled, so they only ever appear commented out: `context` is fully
89
+ * defaulted, and the other two are what you reach for once a real problem shows
90
+ * up — noisy graph retrieval, a cheap-model path — not on day one.
91
+ */
92
+ const NEVER_SCAFFOLDED: [note: string, config: string][] = [
93
+ [
94
+ "A cheaper, faster model for auxiliary passes. Same shape as `model`.",
95
+ `fastModel: {
96
+ baseURL: process.env["CORTEX_FAST_MODEL_URL"]!,
97
+ apiKey: process.env["CORTEX_FAST_MODEL_KEY"]!,
98
+ modelName: process.env["CORTEX_FAST_MODEL_NAME"]!,
99
+ },`,
100
+ ],
101
+ [
102
+ "Re-ranks knowledge-graph retrieval when it comes back noisy.",
103
+ `reranker: {
104
+ url: process.env["CORTEX_RERANKER_URL"]!,
105
+ apiKey: process.env["CORTEX_RERANKER_KEY"]!,
106
+ },`,
107
+ ],
108
+ [
109
+ "Context-window tuning. Every field is defaulted, so override only what you\nhave measured a reason to change.",
110
+ `context: {
111
+ maxContextTokens: 120_000,
112
+ recentMessagesToKeep: 6,
113
+ },`,
114
+ ],
115
+ ];
116
+
117
+ /** Fragments are authored at zero indentation and placed by these two. */
118
+ function indented(block: string) {
119
+ return block.replace(/^(?!$)/gm, " ");
120
+ }
121
+
122
+ function commented(block: string) {
123
+ return block.replace(/^(?!$)/gm, " // ");
124
+ }
125
+
126
+ /** A note, then its fragment — written live, or written disabled. */
127
+ function block(note: string, config: string, enabled: boolean) {
128
+ return `\n${commented(note)}\n${enabled ? indented(config) : commented(config)}\n`;
129
+ }
130
+
131
+ function cortexConfig(database: Database, features: Feature[]) {
132
+ const fragment = (feature: Feature) =>
133
+ block(FEATURES[feature].note, FEATURES[feature].config, features.includes(feature));
134
+
135
+ const on = FEATURE_NAMES.filter((feature) => features.includes(feature))
136
+ .map(fragment)
137
+ .join("");
138
+ const off = FEATURE_NAMES.filter((feature) => !features.includes(feature))
139
+ .map(fragment)
140
+ .join("");
141
+ const never = NEVER_SCAFFOLDED.map(([note, config]) => block(note, config, false)).join("");
142
+
143
+ // cspell.json ships an empty word list on purpose, so the Cortex jargon the
144
+ // config names teaches the checker from the file that uses it.
145
+ return `import { defineAgent } from "@m6d/cortex-server";
146
+ import type { CortexConfig } from "@m6d/cortex-server";
147
+
148
+ // cspell:words reranker
149
+
150
+ /**
151
+ * Inert on purpose: this file default-exports data and nothing else, so importing
152
+ * it never opens a connection or binds a port. Construction lives in index.ts,
153
+ * which is what lets tooling read this config without booting the server.
154
+ */
155
+ export default {
156
+ port: 3331,
157
+ database: {
158
+ type: "${database}",
159
+ connectionString: process.env["CORTEX_DATABASE_URL"]!,
160
+ },
161
+ model: {
162
+ baseURL: process.env["CORTEX_MODEL_URL"]!,
163
+ apiKey: process.env["CORTEX_MODEL_KEY"]!,
164
+ modelName: process.env["CORTEX_MODEL_NAME"]!,
165
+ },
166
+ ${on === "" ? "" : `${on}\n`} agents: {
167
+ assistant: defineAgent({
168
+ systemPrompt: "You are a helpful assistant.",
169
+ // Applications that serve datasets for grouped questions (see the
170
+ // README's "Dataset hosts"): the host applies its own access rules,
171
+ // the agent only picks which datasets to expose and pin.
172
+ // datasetHosts: [{ name: "store", url: "https://store.example/api/datasets", pinned: ["orders"] }],
173
+ }),
174
+ },
175
+
176
+ // Everything below is off, written out so the file documents what else the
177
+ // server does. Uncomment a block and fill in its keys in .env to enable it.
178
+ ${off}${never}} satisfies CortexConfig;
179
+ `;
180
+ }
181
+
182
+ /**
183
+ * Whether this selection runs anything of its own — which is the same question as
184
+ * "did any port get chosen", since a chosen port is always some service's.
185
+ */
186
+ function hasServices(database: Database, features: Feature[]) {
187
+ return (
188
+ database === "postgres" ||
189
+ features.some((feature) => FEATURES[feature].compose !== undefined)
190
+ );
191
+ }
192
+
193
+ function env(database: Database, features: Feature[]) {
194
+ const blocks = FEATURE_NAMES.filter((feature) => features.includes(feature)).map(
195
+ (feature) => `\n${FEATURES[feature].env}\n`,
196
+ );
197
+
198
+ // Only the URLs pointing at scaffolded services carry a chosen port, and those
199
+ // are exactly the ones docker-compose.yml publishes.
200
+ const note = hasServices(database, features)
201
+ ? `#
202
+ # The service ports below are not the canonical ones. They were picked when this
203
+ # project was scaffolded, from what was free on the machine, so it does not
204
+ # collide with anything else you run. Change them freely — in step with
205
+ # docker-compose.yml.
206
+ `
207
+ : "";
208
+
209
+ return `# Environment for this Cortex server. Bun loads this file from the directory you
210
+ # run in, so it has to sit next to cortex.config.ts.
211
+ #
212
+ # .env.example is the committed copy. Keep the two in step; keep secrets in .env.
213
+ ${note}
214
+ # --- Model -------------------------------------------------------------------
215
+ # Any OpenAI-compatible endpoint: base URL, key, and model name.
216
+ CORTEX_MODEL_URL=https://api.openai.com/v1
217
+ CORTEX_MODEL_KEY=your-model-api-key
218
+ CORTEX_MODEL_NAME=your-model-name
219
+
220
+ # --- Database ----------------------------------------------------------------
221
+ # The dialect is ${database}, set in cortex.config.ts. Change it there, not here.
222
+ CORTEX_DATABASE_URL=${CONNECTION_STRING[database]}
223
+ ${blocks.join("")}`;
224
+ }
225
+
226
+ /**
227
+ * Services the selections actually need, and nothing else. `auth` and the
228
+ * Control Center are external, the embedding endpoint is a URL like the main
229
+ * model, and MSSQL is normally an existing instance — so with `--yes --database
230
+ * mssql` there is no service at all, and no file is written.
231
+ */
232
+ function dockerCompose(name: string, database: Database, features: Feature[]) {
233
+ const services: string[] = [];
234
+ const volumes: string[] = [];
235
+
236
+ if (database === "postgres") {
237
+ services.push(` postgres:
238
+ image: postgres:17-alpine
239
+ environment:
240
+ POSTGRES_USER: postgres
241
+ POSTGRES_PASSWORD: postgres
242
+ POSTGRES_DB: cortex
243
+ ports:
244
+ - "{{port.postgres}}:5432"
245
+ volumes:
246
+ - postgres-data:/var/lib/postgresql/data`);
247
+ volumes.push("postgres-data");
248
+ }
249
+
250
+ for (const feature of FEATURE_NAMES.filter((feature) => features.includes(feature))) {
251
+ const { compose, volume } = FEATURES[feature];
252
+ if (compose === undefined) continue;
253
+ services.push(compose);
254
+ if (volume !== undefined) volumes.push(volume);
255
+ }
256
+
257
+ if (services.length === 0) return undefined;
258
+
259
+ const volumeBlock =
260
+ volumes.length === 0
261
+ ? ""
262
+ : `\nvolumes:\n${volumes.map((name) => ` ${name}:\n`).join("")}`;
263
+
264
+ return `# Local infrastructure for this project. The .env this was scaffolded with
265
+ # already points at these, so \`docker compose up -d\` is enough.
266
+ #
267
+ # The host ports were picked at scaffold time from what was free on the machine —
268
+ # the container ports next to them are the canonical ones and should stay put.
269
+ # Move a host port and .env has to follow.
270
+
271
+ # Explicit so the project never collides with another checkout whose directory
272
+ # happens to share this one's name (Compose defaults to the directory name, and
273
+ # a name clash silently replaces the other project's containers and volumes).
274
+ name: ${name}-cortex-server
275
+
276
+ services:
277
+ ${services.join("\n\n")}
278
+ ${volumeBlock}`;
279
+ }
280
+
281
+ /**
282
+ * `--knowledge` scaffolds the directory `domainsDir` defaults to, which is what
283
+ * makes the convention true by construction: `cortex swagger sync` finds it with
284
+ * no config key.
285
+ */
286
+
287
+ function cspellConfig() {
288
+ return `${JSON.stringify(
289
+ {
290
+ version: "0.2",
291
+ language: "en",
292
+ // Yours to fill in. Project jargon belongs here; nothing is seeded,
293
+ // because a stranger's vocabulary is noise in your repo.
294
+ words: [],
295
+ ignorePaths: [
296
+ "node_modules/**",
297
+ "out/**",
298
+ "build/**",
299
+ "**/dist/**",
300
+ "coverage/**",
301
+ "*.tsbuildinfo",
302
+ "bun.lock",
303
+ "package-lock.json",
304
+ "pnpm-lock.yaml",
305
+ "yarn.lock",
306
+ "*.db",
307
+ "*.sqlite",
308
+ "scratch.*",
309
+ ".env*",
310
+ "**/*.min.js",
311
+ "**/*.min.css",
312
+ "**/db/migrations/**",
313
+ "**/i18n/**",
314
+ "docs",
315
+ ".claude",
316
+ ".angular",
317
+ ".vercel/**",
318
+ "vendor/**",
319
+ ".gitignore",
320
+ "**/domains/**/*.concept.ts",
321
+ ],
322
+ dictionaries: [
323
+ "typescript",
324
+ "node",
325
+ "npm",
326
+ "html",
327
+ "css",
328
+ "bash",
329
+ "companies",
330
+ "softwareTerms",
331
+ ],
332
+ },
333
+ null,
334
+ 4,
335
+ )}\n`;
336
+ }
337
+
338
+ function readme(name: string, database: Database, features: Feature[], compose: boolean) {
339
+ const enabled = FEATURE_NAMES.filter((feature) => features.includes(feature));
340
+
341
+ return `# ${name}
342
+
343
+ A Cortex server: one agent, a ${database} database, and an OpenAI-compatible model.${
344
+ enabled.length === 0 ? "" : ` Also on: ${enabled.join(", ")}.`
345
+ }
346
+
347
+ ## Getting started
348
+
349
+ \`\`\`sh
350
+ bun install
351
+ ${compose ? "docker compose up -d\n" : ""}# fill in CORTEX_MODEL_* in .env
352
+ bun run dev
353
+ \`\`\`
354
+
355
+ The server listens on http://localhost:3331. Change \`port\` in \`cortex.config.ts\` to move it.${
356
+ compose
357
+ ? `
358
+
359
+ The service ports in \`docker-compose.yml\` are not the canonical ones: they were picked at scaffold
360
+ time from what was free on this machine, so this project does not fight whatever else you run.
361
+ \`.env\` already points at them.`
362
+ : ""
363
+ }
364
+
365
+ ## Layout
366
+
367
+ - \`cortex.config.ts\` — inert data. It default-exports a \`CortexConfig\` and never constructs
368
+ anything, so tooling can import it without booting a server.
369
+ - \`index.ts\` — construction. It reads the config, calls \`createCortex\`, serves, and is where your
370
+ own routes go.
371
+ - \`.env\` — local secrets, loaded by Bun from the directory you run in. \`.env.example\` is the
372
+ committed copy.
373
+
374
+ Commented-out sections in \`cortex.config.ts\` document what else the server can do.
375
+
376
+ ## Checks
377
+
378
+ \`bun run verify\` runs formatting, lint, spelling, types, and the fallow audit — the same set a
379
+ pre-commit hook or CI job should run. Each is also available on its own: \`format:check\`, \`lint\`,
380
+ \`spellcheck\`, \`check\`, \`audit\`.
381
+ `;
382
+ }
383
+
384
+ /** Relative path → contents. Nothing here touches the filesystem. */
385
+ export function scaffoldFiles(name: string, database: Database, features: Feature[], ports: Ports) {
386
+ const envFile = env(database, features);
387
+ const compose = dockerCompose(name, database, features);
388
+
389
+ const files: Record<string, string> = {
390
+ "cortex.config.ts": cortexConfig(database, features),
391
+ "index.ts": indexTs,
392
+ "package.json": packageJson(name),
393
+ "tsconfig.json": tsconfigJson,
394
+ ".env": envFile,
395
+ ".env.example": envFile,
396
+ ".gitignore": gitignoreText,
397
+ "README.md": readme(name, database, features, compose !== undefined),
398
+ "eslint.config.mjs": eslintConfig,
399
+ "prettier.config.mjs": prettierConfig,
400
+ "cspell.json": cspellConfig(),
401
+ ".fallowrc.jsonc": fallowrc,
402
+ ...(compose === undefined ? {} : { "docker-compose.yml": compose }),
403
+ ...(features.includes("knowledge") ? { "src/domains/README.md": domainsReadme } : {}),
404
+ };
405
+
406
+ // One pass at the end, over everything: no generator holds a port number of
407
+ // its own, so compose, .env and the README cannot disagree about one.
408
+ return Object.fromEntries(
409
+ Object.entries(files).map(([path, contents]) => [path, applyPorts(contents, ports)]),
410
+ );
411
+ }