@danielblomma/cortex-mcp 1.7.2 → 2.0.2

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 (75) hide show
  1. package/bin/cortex.mjs +679 -32
  2. package/bin/style.mjs +349 -0
  3. package/package.json +4 -3
  4. package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
  5. package/scaffold/mcp/src/cli/govern.ts +987 -0
  6. package/scaffold/mcp/src/cli/run.ts +306 -0
  7. package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
  8. package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
  9. package/scaffold/mcp/src/core/audit/query.ts +81 -0
  10. package/scaffold/mcp/src/core/audit/writer.ts +68 -0
  11. package/scaffold/mcp/src/core/config.ts +329 -0
  12. package/scaffold/mcp/src/core/index.ts +34 -0
  13. package/scaffold/mcp/src/core/license.ts +202 -0
  14. package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
  15. package/scaffold/mcp/src/core/policy/injection.ts +229 -0
  16. package/scaffold/mcp/src/core/policy/store.ts +197 -0
  17. package/scaffold/mcp/src/core/rbac/check.ts +40 -0
  18. package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
  19. package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
  20. package/scaffold/mcp/src/core/validators/config.ts +47 -0
  21. package/scaffold/mcp/src/core/validators/engine.ts +199 -0
  22. package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
  23. package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
  24. package/scaffold/mcp/src/daemon/client.ts +155 -0
  25. package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
  26. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
  27. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
  28. package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
  29. package/scaffold/mcp/src/daemon/main.ts +300 -0
  30. package/scaffold/mcp/src/daemon/paths.ts +41 -0
  31. package/scaffold/mcp/src/daemon/protocol.ts +101 -0
  32. package/scaffold/mcp/src/daemon/server.ts +227 -0
  33. package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
  34. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
  35. package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
  36. package/scaffold/mcp/src/enterprise/index.ts +415 -0
  37. package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
  38. package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
  39. package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
  40. package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
  41. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
  42. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
  43. package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
  44. package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
  45. package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
  46. package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
  47. package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
  48. package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
  49. package/scaffold/mcp/src/hooks/session-end.ts +73 -0
  50. package/scaffold/mcp/src/hooks/session-start.ts +78 -0
  51. package/scaffold/mcp/src/hooks/shared.ts +134 -0
  52. package/scaffold/mcp/src/hooks/stop.ts +60 -0
  53. package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
  54. package/scaffold/mcp/src/plugin.ts +150 -0
  55. package/scaffold/mcp/src/server.ts +218 -7
  56. package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
  57. package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
  58. package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
  59. package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
  60. package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
  61. package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
  62. package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
  63. package/scaffold/mcp/tests/govern.test.mjs +74 -0
  64. package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
  65. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
  66. package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
  67. package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
  68. package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
  69. package/scaffold/mcp/tests/run.test.mjs +109 -0
  70. package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
  71. package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
  72. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
  73. package/scaffold/scripts/bootstrap.sh +0 -11
  74. package/scaffold/scripts/doctor.sh +24 -4
  75. package/types.js +5 -0
package/bin/style.mjs ADDED
@@ -0,0 +1,349 @@
1
+ // Cortex CLI style helpers.
2
+ //
3
+ // Pure ANSI / Unicode utilities — no external deps. The brand language is:
4
+ // GitHub CLI meets cyberpunk compliance system.
5
+ // Dark background, neon gradient (purple → indigo → blue → cyan), thin lines,
6
+ // subtle motion. "Cortex is in control."
7
+ //
8
+ // Every helper degrades to plain text when:
9
+ // - process.stdout.isTTY is false (piped, CI, file redirect), or
10
+ // - NO_COLOR is set (https://no-color.org/), or
11
+ // - CORTEX_NO_COLOR is set (escape hatch).
12
+ //
13
+ // Unicode glyphs degrade to ASCII when CORTEX_NO_UNICODE is set or LANG/LC_ALL
14
+ // look non-UTF-8.
15
+
16
+ const NO_COLOR =
17
+ typeof process.env.NO_COLOR === "string" && process.env.NO_COLOR.length > 0;
18
+ const CORTEX_NO_COLOR =
19
+ typeof process.env.CORTEX_NO_COLOR === "string" &&
20
+ process.env.CORTEX_NO_COLOR.length > 0;
21
+
22
+ function streamSupportsColor(stream) {
23
+ if (NO_COLOR || CORTEX_NO_COLOR) return false;
24
+ if (!stream) return false;
25
+ if (stream.isTTY === false) return false;
26
+ if (process.env.TERM === "dumb") return false;
27
+ return Boolean(stream.isTTY);
28
+ }
29
+
30
+ export function supportsColor(stream = process.stdout) {
31
+ return streamSupportsColor(stream);
32
+ }
33
+
34
+ function unicodeOk() {
35
+ if (process.env.CORTEX_NO_UNICODE) return false;
36
+ const locale =
37
+ process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "";
38
+ if (!locale) return process.platform !== "win32";
39
+ return /UTF-?8/i.test(locale);
40
+ }
41
+
42
+ const ESC = "\x1b[";
43
+ const RESET = `${ESC}0m`;
44
+ const DIM = `${ESC}2m`;
45
+ const BOLD = `${ESC}1m`;
46
+
47
+ // Cortex neon palette (256-color). Picked from xterm-256 to read as
48
+ // purple → indigo → blue → cyan on a dark terminal.
49
+ const GRADIENT_COLORS = [
50
+ 141, // light purple
51
+ 99, // indigo
52
+ 63, // deep indigo / blue
53
+ 69, // mid blue
54
+ 75, // bright blue
55
+ 81, // cyan-blue
56
+ 45, // cyan
57
+ 51 // bright cyan
58
+ ];
59
+
60
+ const SEMANTIC = {
61
+ ok: 84, // green
62
+ warn: 215, // amber
63
+ fail: 203, // red
64
+ info: 81, // cyan
65
+ alert: 197, // hot pink-red (cyberpunk alert)
66
+ muted: 244 // soft grey
67
+ };
68
+
69
+ function fg256(code) {
70
+ return `${ESC}38;5;${code}m`;
71
+ }
72
+
73
+ function colorize(text, code, stream = process.stdout) {
74
+ if (!supportsColor(stream)) return text;
75
+ return `${fg256(code)}${text}${RESET}`;
76
+ }
77
+
78
+ export function dim(text, stream = process.stdout) {
79
+ if (!supportsColor(stream)) return text;
80
+ return `${DIM}${text}${RESET}`;
81
+ }
82
+
83
+ export function bold(text, stream = process.stdout) {
84
+ if (!supportsColor(stream)) return text;
85
+ return `${BOLD}${text}${RESET}`;
86
+ }
87
+
88
+ export function muted(text, stream = process.stdout) {
89
+ return colorize(text, SEMANTIC.muted, stream);
90
+ }
91
+
92
+ export function accent(text, stream = process.stdout) {
93
+ return colorize(text, SEMANTIC.info, stream);
94
+ }
95
+
96
+ // Render text letter-by-letter through the neon gradient. Whitespace is
97
+ // preserved verbatim so layout stays aligned.
98
+ export function gradient(text, stream = process.stdout) {
99
+ if (!supportsColor(stream)) return text;
100
+ const chars = [...text];
101
+ const visible = chars.filter((ch) => ch.trim().length > 0).length;
102
+ if (visible === 0) return text;
103
+
104
+ let visibleIndex = 0;
105
+ let out = "";
106
+ for (const ch of chars) {
107
+ if (ch.trim().length === 0) {
108
+ out += ch;
109
+ continue;
110
+ }
111
+ const t = visible === 1 ? 0 : visibleIndex / (visible - 1);
112
+ const slot = Math.min(
113
+ GRADIENT_COLORS.length - 1,
114
+ Math.round(t * (GRADIENT_COLORS.length - 1))
115
+ );
116
+ out += `${fg256(GRADIENT_COLORS[slot])}${ch}${RESET}`;
117
+ visibleIndex += 1;
118
+ }
119
+ return out;
120
+ }
121
+
122
+ const BULLET_GLYPHS_UTF8 = {
123
+ ok: "✔",
124
+ warn: "!",
125
+ fail: "✗",
126
+ info: "▸",
127
+ alert: "●"
128
+ };
129
+
130
+ const BULLET_GLYPHS_ASCII = {
131
+ ok: "v",
132
+ warn: "!",
133
+ fail: "x",
134
+ info: ">",
135
+ alert: "*"
136
+ };
137
+
138
+ function bulletGlyph(state) {
139
+ const map = unicodeOk() ? BULLET_GLYPHS_UTF8 : BULLET_GLYPHS_ASCII;
140
+ return map[state] ?? map.info;
141
+ }
142
+
143
+ export function bullet(state, text, stream = process.stdout) {
144
+ const glyph = bulletGlyph(state);
145
+ const code = SEMANTIC[state] ?? SEMANTIC.info;
146
+ if (!supportsColor(stream)) {
147
+ return `${glyph} ${text}`;
148
+ }
149
+ return `${fg256(code)}${glyph}${RESET} ${text}`;
150
+ }
151
+
152
+ export function printBullet(state, text, stream = process.stdout) {
153
+ stream.write(`${bullet(state, text, stream)}\n`);
154
+ }
155
+
156
+ // Spinner: Braille pattern in UTF-8 mode, ASCII clock fallback otherwise.
157
+ const SPINNER_FRAMES_UTF8 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
158
+ const SPINNER_FRAMES_ASCII = ["|", "/", "-", "\\"];
159
+
160
+ function spinnerFrames() {
161
+ return unicodeOk() ? SPINNER_FRAMES_UTF8 : SPINNER_FRAMES_ASCII;
162
+ }
163
+
164
+ // Returns a controller: { stop(finalState, finalText), update(text) }.
165
+ // In non-TTY mode the spinner just writes the static label as a "▸ label"
166
+ // info bullet and returns a no-op stop().
167
+ export function spinner(label, stream = process.stdout) {
168
+ const useTTY = supportsColor(stream) && Boolean(stream.isTTY);
169
+ const frames = spinnerFrames();
170
+ let i = 0;
171
+ let currentLabel = label;
172
+
173
+ if (!useTTY) {
174
+ stream.write(`${bullet("info", currentLabel, stream)}\n`);
175
+ return {
176
+ update(next) {
177
+ currentLabel = next;
178
+ },
179
+ stop(finalState = "ok", finalText) {
180
+ const text = finalText ?? currentLabel;
181
+ stream.write(`${bullet(finalState, text, stream)}\n`);
182
+ }
183
+ };
184
+ }
185
+
186
+ const hideCursor = `${ESC}?25l`;
187
+ const showCursor = `${ESC}?25h`;
188
+ const clearLine = `\r${ESC}2K`;
189
+
190
+ stream.write(hideCursor);
191
+ const render = () => {
192
+ const frame = frames[i % frames.length];
193
+ i += 1;
194
+ stream.write(`${clearLine}${fg256(SEMANTIC.info)}${frame}${RESET} ${currentLabel}`);
195
+ };
196
+ render();
197
+ const interval = setInterval(render, 80);
198
+ if (typeof interval.unref === "function") {
199
+ interval.unref();
200
+ }
201
+
202
+ let stopped = false;
203
+ return {
204
+ update(next) {
205
+ currentLabel = next;
206
+ },
207
+ stop(finalState = "ok", finalText) {
208
+ if (stopped) return;
209
+ stopped = true;
210
+ clearInterval(interval);
211
+ const text = finalText ?? currentLabel;
212
+ stream.write(`${clearLine}${bullet(finalState, text, stream)}\n${showCursor}`);
213
+ }
214
+ };
215
+ }
216
+
217
+ // Box-drawing wrapper. UTF-8: rounded corners + thin lines. ASCII fallback uses
218
+ // + - | corners. Optional title and accent color (256-color code).
219
+ const BOX_UTF8 = {
220
+ tl: "╭",
221
+ tr: "╮",
222
+ bl: "╰",
223
+ br: "╯",
224
+ h: "─",
225
+ v: "│"
226
+ };
227
+ const BOX_ASCII = {
228
+ tl: "+",
229
+ tr: "+",
230
+ bl: "+",
231
+ br: "+",
232
+ h: "-",
233
+ v: "|"
234
+ };
235
+
236
+ function visibleLength(line) {
237
+ // strip ANSI escapes for width calculation
238
+ // eslint-disable-next-line no-control-regex
239
+ return line.replace(/\x1b\[[0-9;]*m/g, "").length;
240
+ }
241
+
242
+ function padRight(line, width) {
243
+ const len = visibleLength(line);
244
+ if (len >= width) return line;
245
+ return line + " ".repeat(width - len);
246
+ }
247
+
248
+ export function box(content, opts = {}) {
249
+ const stream = opts.stream ?? process.stdout;
250
+ const useColor = supportsColor(stream);
251
+ const glyphs = unicodeOk() ? BOX_UTF8 : BOX_ASCII;
252
+ const accentCode = opts.accent ?? GRADIENT_COLORS[2];
253
+ const tint = (text) => (useColor ? `${fg256(accentCode)}${text}${RESET}` : text);
254
+
255
+ const lines = String(content).split("\n");
256
+ const titleRaw = opts.title ?? "";
257
+ const padding = opts.padding ?? 1;
258
+
259
+ const inner = Math.max(
260
+ visibleLength(titleRaw) + 4,
261
+ ...lines.map(visibleLength)
262
+ );
263
+ const width = inner + padding * 2;
264
+
265
+ let topMid = glyphs.h.repeat(width);
266
+ if (titleRaw) {
267
+ const titleText = useColor ? bold(titleRaw, stream) : titleRaw;
268
+ const lhs = glyphs.h.repeat(2);
269
+ const rhs = glyphs.h.repeat(Math.max(1, width - visibleLength(titleRaw) - 4));
270
+ topMid = `${lhs} ${titleText} ${rhs}`;
271
+ }
272
+
273
+ const top = tint(`${glyphs.tl}${topMid}${glyphs.tr}`);
274
+ const bottom = tint(`${glyphs.bl}${glyphs.h.repeat(width)}${glyphs.br}`);
275
+ const pad = " ".repeat(padding);
276
+
277
+ const body = lines.map(
278
+ (line) => `${tint(glyphs.v)}${pad}${padRight(line, inner)}${pad}${tint(glyphs.v)}`
279
+ );
280
+
281
+ return [top, ...body, bottom].join("\n");
282
+ }
283
+
284
+ // Header banner: stylized CORTEX wordmark + a "node network" motif.
285
+ // The motif is a thin row of dot-and-line glyphs framing a central core dot —
286
+ // the same idea as the orbiting nodes in the brand brief, reduced to one line.
287
+ //
288
+ // Wordmark uses block characters that fit ~6 lines tall, kept narrow so it
289
+ // doesn't wrap on 80-col terminals.
290
+ const CORTEX_WORDMARK = [
291
+ " ██████╗ ██████╗ ██████╗ ████████╗███████╗██╗ ██╗",
292
+ " ██╔════╝██╔═══██╗██╔══██╗╚══██╔══╝██╔════╝╚██╗██╔╝",
293
+ " ██║ ██║ ██║██████╔╝ ██║ █████╗ ╚███╔╝ ",
294
+ " ██║ ██║ ██║██╔══██╗ ██║ ██╔══╝ ██╔██╗ ",
295
+ " ╚██████╗╚██████╔╝██║ ██║ ██║ ███████╗██╔╝ ██╗",
296
+ " ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝"
297
+ ];
298
+
299
+ const CORTEX_WORDMARK_ASCII = [
300
+ " CCCCC OOO RRRR TTTTT EEEEE X X",
301
+ " C O O R R T E X X ",
302
+ " C O O RRRR T EEEE X ",
303
+ " C O O R R T E X X ",
304
+ " CCCCC OOO R R T EEEEE X X"
305
+ ];
306
+
307
+ function networkMotif(width) {
308
+ if (!unicodeOk()) {
309
+ const dots = ".".repeat(Math.max(0, Math.floor((width - 7) / 2)));
310
+ return `${dots} [ core ] ${dots}`;
311
+ }
312
+ const dots = "·".repeat(Math.max(0, Math.floor((width - 7) / 2)));
313
+ // ⟢ / ⟣ are subtle "incoming/outgoing" arrows around a core node ◉.
314
+ return `${dots} ⟢ ◉ ⟣ ${dots}`;
315
+ }
316
+
317
+ export function headerBanner(opts = {}) {
318
+ const stream = opts.stream ?? process.stdout;
319
+ const tagline = opts.tagline ?? "";
320
+ const wordmark = unicodeOk() ? CORTEX_WORDMARK : CORTEX_WORDMARK_ASCII;
321
+ const width = Math.max(...wordmark.map((line) => line.length));
322
+
323
+ const lines = [];
324
+ lines.push("");
325
+ for (const line of wordmark) {
326
+ lines.push(gradient(line, stream));
327
+ }
328
+ const motif = networkMotif(width);
329
+ lines.push(muted(motif, stream));
330
+ if (tagline) {
331
+ lines.push(muted(tagline, stream));
332
+ }
333
+ lines.push("");
334
+ return lines.join("\n");
335
+ }
336
+
337
+ export function printHeaderBanner(opts = {}) {
338
+ const stream = opts.stream ?? process.stdout;
339
+ stream.write(headerBanner(opts));
340
+ }
341
+
342
+ // Convenience: compose a labelled status line (used by `cortex run` header).
343
+ export function runHeader(label, stream = process.stdout) {
344
+ const glyph = unicodeOk() ? "▸" : ">";
345
+ if (!supportsColor(stream)) {
346
+ return `${glyph} ${label}`;
347
+ }
348
+ return `${fg256(SEMANTIC.info)}${glyph}${RESET} ${label}`;
349
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danielblomma/cortex-mcp",
3
3
  "mcpName": "io.github.DanielBlomma/cortex",
4
- "version": "1.7.2",
4
+ "version": "2.0.2",
5
5
  "description": "Local, repo-scoped context platform for coding assistants. Semantic search, graph relationships, and architectural rule context.",
6
6
  "type": "module",
7
7
  "author": "Daniel Blomma",
@@ -26,13 +26,14 @@
26
26
  ],
27
27
  "exports": {
28
28
  ".": "./bin/cortex.mjs",
29
- "./types": "./mcp/dist/types.js"
29
+ "./types": "./types.js"
30
30
  },
31
31
  "bin": {
32
32
  "cortex": "bin/cortex.mjs"
33
33
  },
34
34
  "files": [
35
35
  "bin",
36
+ "types.js",
36
37
  "scaffold/.context",
37
38
  "scaffold/.githooks",
38
39
  "scaffold/docs",
@@ -52,6 +53,6 @@
52
53
  "prepublishOnly": "echo 'Ready to publish to npm'"
53
54
  },
54
55
  "engines": {
55
- "node": ">=18"
56
+ "node": ">=20"
56
57
  }
57
58
  }
@@ -0,0 +1,124 @@
1
+ import { join } from "node:path";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { verifyLicense } from "../core/license.js";
4
+
5
+ /**
6
+ * One-liner enterprise onboarding.
7
+ *
8
+ * $ cortex enterprise <api-key> [--endpoint <url>]
9
+ *
10
+ * Replaces the manual `.context/enterprise.yml` editing flow that's been
11
+ * the friction point for new users. Validates the key against the
12
+ * license endpoint before writing config — so a typo'd key fails fast
13
+ * with a clear error rather than going silently into community-mode.
14
+ */
15
+
16
+ const DEFAULT_ENDPOINT = "https://cortex-web-rho.vercel.app";
17
+
18
+ export type EnterpriseSetupOptions = {
19
+ apiKey: string;
20
+ endpoint?: string;
21
+ cwd?: string;
22
+ };
23
+
24
+ export type EnterpriseSetupResult = {
25
+ ok: boolean;
26
+ message: string;
27
+ configPath?: string;
28
+ edition?: string;
29
+ expiresAt?: string;
30
+ };
31
+
32
+ const API_KEY_RE = /^(?:ctx|ent)_[A-Za-z0-9._-]{8,}$/;
33
+
34
+ function buildEnterpriseYaml(baseUrl: string, apiKey: string): string {
35
+ const lines = [
36
+ "# Cortex enterprise configuration. Generated by `cortex enterprise <key>`.",
37
+ "# Single api_key is used for telemetry, policy, audit and govern services.",
38
+ "enterprise:",
39
+ ` api_key: ${apiKey}`,
40
+ ` base_url: ${baseUrl}`,
41
+ "",
42
+ "telemetry:",
43
+ " enabled: true",
44
+ " interval_minutes: 1",
45
+ "",
46
+ "compliance:",
47
+ " frameworks: [iso27001, iso42001, soc2]",
48
+ " eu_addons: false",
49
+ "",
50
+ "govern:",
51
+ " mode: off",
52
+ " sync_on_startup: true",
53
+ " sync_interval_minutes: 60",
54
+ " tier_claude: prevent",
55
+ " tier_codex: prevent",
56
+ " tier_copilot: wrap",
57
+ " detect_ungoverned: true",
58
+ "",
59
+ ];
60
+ return lines.join("\n");
61
+ }
62
+
63
+ export async function runEnterpriseSetup(
64
+ options: EnterpriseSetupOptions,
65
+ ): Promise<EnterpriseSetupResult> {
66
+ const cwd = options.cwd ?? process.cwd();
67
+ const endpoint = (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
68
+ const apiKey = options.apiKey.trim();
69
+
70
+ if (!API_KEY_RE.test(apiKey)) {
71
+ return {
72
+ ok: false,
73
+ message:
74
+ "API key must start with 'ctx_' or 'ent_' followed by at least 8 alphanumeric/._- chars.",
75
+ };
76
+ }
77
+
78
+ if (!/^https?:\/\//.test(endpoint)) {
79
+ return {
80
+ ok: false,
81
+ message: `Endpoint must be http(s) URL: ${endpoint}`,
82
+ };
83
+ }
84
+
85
+ const contextDir = join(cwd, ".context");
86
+ if (!existsSync(contextDir)) {
87
+ return {
88
+ ok: false,
89
+ message: `No .context/ at ${cwd}. Run 'cortex init --bootstrap' first.`,
90
+ };
91
+ }
92
+
93
+ // Validate key BEFORE writing config — fail fast, no half-configured state.
94
+ const license = await verifyLicense(contextDir, endpoint, apiKey, {
95
+ client_version: process.env.CORTEX_VERSION,
96
+ });
97
+
98
+ if (!license.valid) {
99
+ return {
100
+ ok: false,
101
+ message: `License rejected: ${license.reason} (source=${license.source}). Verify the API key and endpoint are correct.`,
102
+ };
103
+ }
104
+
105
+ // Write enterprise.yml.
106
+ const configPath = join(contextDir, "enterprise.yml");
107
+ try {
108
+ mkdirSync(contextDir, { recursive: true });
109
+ writeFileSync(configPath, buildEnterpriseYaml(endpoint, apiKey), "utf8");
110
+ } catch (err) {
111
+ return {
112
+ ok: false,
113
+ message: `Failed to write ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
114
+ };
115
+ }
116
+
117
+ return {
118
+ ok: true,
119
+ message: "Enterprise configuration written.",
120
+ configPath,
121
+ edition: license.edition,
122
+ expiresAt: license.expires_at,
123
+ };
124
+ }