@danielblomma/cortex-mcp 2.0.3 → 2.0.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danielblomma/cortex-mcp",
3
3
  "mcpName": "io.github.DanielBlomma/cortex",
4
- "version": "2.0.3",
4
+ "version": "2.0.4",
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",
@@ -27,6 +27,7 @@ import {
27
27
  emitTamperAudit,
28
28
  } from "./heartbeat-tracker.js";
29
29
  import { startSyncTimer } from "./sync-checker.js";
30
+ import { startSkillSyncTimer } from "./skill-sync-checker.js";
30
31
  import { startHostEventsPusher } from "./host-events-pusher.js";
31
32
  import { startEgressProxy } from "./egress-proxy.js";
32
33
  import { startHeartbeatPusher } from "./heartbeat-pusher.js";
@@ -345,6 +346,16 @@ async function main(): Promise<void> {
345
346
  if (process.env.CORTEX_DISABLE_HOST_EVENTS_PUSH !== "1") {
346
347
  startHostEventsPusher(process.cwd(), pushIntervalMs);
347
348
  }
349
+ // Skills v3: poll cortex-web for org-authored skills, write SKILL.md
350
+ // files into per-CLI user-scope directories. Runs at the same cadence
351
+ // as the govern-config sync check by default but is independently
352
+ // configurable.
353
+ const skillSyncRaw = parseInt(process.env.CORTEX_SKILL_SYNC_MS ?? "", 10);
354
+ const skillSyncMs =
355
+ Number.isFinite(skillSyncRaw) && skillSyncRaw > 0 ? skillSyncRaw : syncIntervalMs;
356
+ if (process.env.CORTEX_DISABLE_SKILL_SYNC !== "1") {
357
+ startSkillSyncTimer(process.cwd(), skillSyncMs);
358
+ }
348
359
 
349
360
  // Govern host heartbeat — fills host_enrollment on cortex-web so the
350
361
  // dashboard at /dashboard/govern actually shows this host.
@@ -0,0 +1,375 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { homedir, hostname } from "node:os";
9
+ import { join } from "node:path";
10
+ import { loadEnterpriseConfig } from "../core/config.js";
11
+ import { writeHostAuditEvent } from "./ungoverned-scanner.js";
12
+ import { daemonDir } from "./paths.js";
13
+
14
+ /**
15
+ * Skills v3 sync flow — daemon side.
16
+ *
17
+ * The daemon polls cortex-web /api/v1/govern/skills/manifest each tick to
18
+ * learn what skills the org has authored. It diffs against a local state
19
+ * file, then for each new/changed skill it fetches the assembled SKILL.md
20
+ * and writes it to the appropriate per-CLI skills directory. Removed
21
+ * skills are unlinked. Unlike govern-config sync, this does NOT need
22
+ * root: SKILL.md files live in user-owned directories the daemon can
23
+ * write to directly.
24
+ *
25
+ * Three audit outcomes per tick:
26
+ * - skills_unchanged — manifest matches local state
27
+ * - skills_synced — at least one skill was written or removed
28
+ * (metadata: added/changed/removed counts)
29
+ * - skills_sync_failed — network / auth / disk error
30
+ *
31
+ * When something changes, a notification file is written so
32
+ * 'cortex enterprise status' can prompt the user to restart Claude
33
+ * Code / Codex CLI to pick up the new skills.
34
+ */
35
+
36
+ const STATE_FILENAME = "skills.local.json";
37
+ const NOTIFICATION_FILENAME = ".skills-update-applied.json";
38
+
39
+ const SUPPORTED_CLIS = ["claude", "codex"] as const;
40
+ type SkillCli = (typeof SUPPORTED_CLIS)[number];
41
+
42
+ type ManifestEntry = {
43
+ name: string;
44
+ scope: string;
45
+ updated_at: string;
46
+ };
47
+
48
+ type LocalSkillRecord = {
49
+ scope: string;
50
+ updated_at: string;
51
+ path: string;
52
+ };
53
+
54
+ type LocalSkillsState = {
55
+ skills: Record<string, LocalSkillRecord>;
56
+ last_synced_at?: string;
57
+ };
58
+
59
+ export type SkillSyncOutcome =
60
+ | {
61
+ kind: "unchanged";
62
+ cli: SkillCli;
63
+ count: number;
64
+ }
65
+ | {
66
+ kind: "synced";
67
+ cli: SkillCli;
68
+ added: string[];
69
+ changed: string[];
70
+ removed: string[];
71
+ }
72
+ | {
73
+ kind: "failed";
74
+ cli: SkillCli;
75
+ error: string;
76
+ };
77
+
78
+ function stateFilePath(): string {
79
+ return join(daemonDir(), STATE_FILENAME);
80
+ }
81
+
82
+ function notificationFilePath(): string {
83
+ return join(daemonDir(), NOTIFICATION_FILENAME);
84
+ }
85
+
86
+ function readState(): LocalSkillsState {
87
+ const path = stateFilePath();
88
+ if (!existsSync(path)) return { skills: {} };
89
+ try {
90
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as LocalSkillsState;
91
+ return { skills: parsed.skills ?? {}, last_synced_at: parsed.last_synced_at };
92
+ } catch {
93
+ return { skills: {} };
94
+ }
95
+ }
96
+
97
+ function writeState(state: LocalSkillsState): void {
98
+ writeFileSync(
99
+ stateFilePath(),
100
+ JSON.stringify(state, null, 2) + "\n",
101
+ "utf8",
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Resolve the on-disk SKILL.md path for a skill. Global skills live under
107
+ * ~/.claude/skills (Claude Code's user-scope skills directory); cli:codex
108
+ * skills live under ~/.codex/skills. cli:claude scope is treated as
109
+ * Claude-only and lands in ~/.claude/skills.
110
+ */
111
+ function skillFilePath(scope: string, name: string): string {
112
+ const root =
113
+ scope === "cli:codex"
114
+ ? join(homedir(), ".codex", "skills")
115
+ : join(homedir(), ".claude", "skills");
116
+ return join(root, name, "SKILL.md");
117
+ }
118
+
119
+ function shouldSyncForCli(scope: string, cli: SkillCli): boolean {
120
+ if (scope === "global") return true;
121
+ return scope === `cli:${cli}`;
122
+ }
123
+
124
+ async function fetchManifest(
125
+ baseUrl: string,
126
+ apiKey: string,
127
+ cli: SkillCli,
128
+ ): Promise<ManifestEntry[]> {
129
+ const url = new URL(
130
+ baseUrl.replace(/\/$/, "") + "/api/v1/govern/skills/manifest",
131
+ );
132
+ url.searchParams.set("cli", cli);
133
+ const res = await fetch(url, {
134
+ headers: { Authorization: `Bearer ${apiKey}` },
135
+ });
136
+ if (!res.ok) {
137
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
138
+ }
139
+ const body = (await res.json()) as { skills?: ManifestEntry[] };
140
+ return body.skills ?? [];
141
+ }
142
+
143
+ async function fetchSkillBody(
144
+ baseUrl: string,
145
+ apiKey: string,
146
+ name: string,
147
+ ): Promise<string> {
148
+ const url = new URL(
149
+ baseUrl.replace(/\/$/, "") +
150
+ "/api/v1/govern/skills/" +
151
+ encodeURIComponent(name),
152
+ );
153
+ const res = await fetch(url, {
154
+ headers: { Authorization: `Bearer ${apiKey}` },
155
+ });
156
+ if (!res.ok) {
157
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
158
+ }
159
+ return res.text();
160
+ }
161
+
162
+ function writeSkillFile(path: string, content: string): void {
163
+ const dir = path.replace(/\/SKILL\.md$/, "");
164
+ mkdirSync(dir, { recursive: true });
165
+ writeFileSync(path, content, "utf8");
166
+ }
167
+
168
+ function removeSkillFile(path: string): void {
169
+ if (!existsSync(path)) return;
170
+ // Remove the per-skill directory (parent of SKILL.md). The skills root
171
+ // is shared with non-Cortex skills so we never recurse beyond the
172
+ // skill's own directory.
173
+ const dir = path.replace(/\/SKILL\.md$/, "");
174
+ rmSync(dir, { recursive: true, force: true });
175
+ }
176
+
177
+ function writeNotification(data: {
178
+ added: number;
179
+ changed: number;
180
+ removed: number;
181
+ cli: SkillCli;
182
+ detected_at: string;
183
+ }): void {
184
+ writeFileSync(
185
+ notificationFilePath(),
186
+ JSON.stringify(data, null, 2) + "\n",
187
+ "utf8",
188
+ );
189
+ }
190
+
191
+ export async function runSkillSyncForCli(
192
+ cwd: string,
193
+ cli: SkillCli,
194
+ ): Promise<SkillSyncOutcome> {
195
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
196
+ const apiKey = config.enterprise.api_key.trim();
197
+ const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
198
+ if (!apiKey || !baseUrl) {
199
+ return { kind: "failed", cli, error: "enterprise not configured" };
200
+ }
201
+
202
+ let manifest: ManifestEntry[];
203
+ try {
204
+ manifest = await fetchManifest(baseUrl, apiKey, cli);
205
+ } catch (err) {
206
+ return {
207
+ kind: "failed",
208
+ cli,
209
+ error: err instanceof Error ? err.message : String(err),
210
+ };
211
+ }
212
+
213
+ const state = readState();
214
+ const relevantManifest = manifest.filter((entry) =>
215
+ shouldSyncForCli(entry.scope, cli),
216
+ );
217
+ const remoteByName = new Map(relevantManifest.map((e) => [e.name, e]));
218
+
219
+ const added: string[] = [];
220
+ const changed: string[] = [];
221
+ const removed: string[] = [];
222
+
223
+ // Detect adds + changes
224
+ for (const entry of relevantManifest) {
225
+ const local = state.skills[entry.name];
226
+ const isNew = !local;
227
+ const isChanged =
228
+ Boolean(local) &&
229
+ (local.updated_at !== entry.updated_at || local.scope !== entry.scope);
230
+ if (!isNew && !isChanged) continue;
231
+
232
+ let body: string;
233
+ try {
234
+ body = await fetchSkillBody(baseUrl, apiKey, entry.name);
235
+ } catch (err) {
236
+ return {
237
+ kind: "failed",
238
+ cli,
239
+ error:
240
+ err instanceof Error
241
+ ? `fetch ${entry.name}: ${err.message}`
242
+ : `fetch ${entry.name}: ${String(err)}`,
243
+ };
244
+ }
245
+
246
+ const path = skillFilePath(entry.scope, entry.name);
247
+ try {
248
+ writeSkillFile(path, body);
249
+ } catch (err) {
250
+ return {
251
+ kind: "failed",
252
+ cli,
253
+ error:
254
+ err instanceof Error
255
+ ? `write ${entry.name}: ${err.message}`
256
+ : `write ${entry.name}: ${String(err)}`,
257
+ };
258
+ }
259
+
260
+ state.skills[entry.name] = {
261
+ scope: entry.scope,
262
+ updated_at: entry.updated_at,
263
+ path,
264
+ };
265
+ (isNew ? added : changed).push(entry.name);
266
+ }
267
+
268
+ // Detect removes — entries we have locally for this cli but the manifest
269
+ // dropped (or disabled). We only consider state entries whose scope
270
+ // matches this cli, so we don't accidentally remove the other CLI's
271
+ // skills when running a per-cli tick.
272
+ for (const [name, record] of Object.entries(state.skills)) {
273
+ if (!shouldSyncForCli(record.scope, cli)) continue;
274
+ if (remoteByName.has(name)) continue;
275
+ try {
276
+ removeSkillFile(record.path);
277
+ } catch {
278
+ // best-effort; if unlink fails the next tick will retry
279
+ }
280
+ delete state.skills[name];
281
+ removed.push(name);
282
+ }
283
+
284
+ const totalChanged = added.length + changed.length + removed.length;
285
+ if (totalChanged === 0) {
286
+ return { kind: "unchanged", cli, count: relevantManifest.length };
287
+ }
288
+
289
+ state.last_synced_at = new Date().toISOString();
290
+ writeState(state);
291
+ return { kind: "synced", cli, added, changed, removed };
292
+ }
293
+
294
+ export async function runSkillSyncOnce(
295
+ cwd: string,
296
+ clis: ReadonlyArray<SkillCli> = SUPPORTED_CLIS,
297
+ ): Promise<SkillSyncOutcome[]> {
298
+ const outcomes: SkillSyncOutcome[] = [];
299
+ const now = new Date().toISOString();
300
+
301
+ for (const cli of clis) {
302
+ const outcome = await runSkillSyncForCli(cwd, cli);
303
+ outcomes.push(outcome);
304
+
305
+ const eventBase = {
306
+ timestamp: now,
307
+ host_id: hostname(),
308
+ cli,
309
+ };
310
+
311
+ if (outcome.kind === "unchanged") {
312
+ await writeHostAuditEvent(cwd, {
313
+ ...eventBase,
314
+ event_type: "skills_unchanged",
315
+ count: outcome.count,
316
+ }).catch(() => undefined);
317
+ } else if (outcome.kind === "synced") {
318
+ await writeHostAuditEvent(cwd, {
319
+ ...eventBase,
320
+ event_type: "skills_synced",
321
+ added: outcome.added,
322
+ changed: outcome.changed,
323
+ removed: outcome.removed,
324
+ }).catch(() => undefined);
325
+ writeNotification({
326
+ added: outcome.added.length,
327
+ changed: outcome.changed.length,
328
+ removed: outcome.removed.length,
329
+ cli,
330
+ detected_at: now,
331
+ });
332
+ } else {
333
+ await writeHostAuditEvent(cwd, {
334
+ ...eventBase,
335
+ event_type: "skills_sync_failed",
336
+ error: outcome.error,
337
+ }).catch(() => undefined);
338
+ }
339
+ }
340
+
341
+ // We deliberately leave the notification file in place when this tick
342
+ // had no changes — it represents "restart pending" from a prior sync,
343
+ // not current drift. `cortex enterprise status --acknowledge-skills`
344
+ // (future CLI) will be the explicit clear path.
345
+
346
+ return outcomes;
347
+ }
348
+
349
+ export type SkillSyncTimerHandle = {
350
+ stop(): void;
351
+ };
352
+
353
+ export function startSkillSyncTimer(
354
+ cwd: string,
355
+ intervalMs: number,
356
+ ): SkillSyncTimerHandle {
357
+ const tick = () => {
358
+ void runSkillSyncOnce(cwd).catch((err) => {
359
+ process.stderr.write(
360
+ `[cortex-daemon] skill sync failed: ${
361
+ err instanceof Error ? err.message : String(err)
362
+ }\n`,
363
+ );
364
+ });
365
+ };
366
+
367
+ void Promise.resolve().then(tick);
368
+ const handle = setInterval(tick, intervalMs);
369
+ if (typeof handle.unref === "function") handle.unref();
370
+ return {
371
+ stop() {
372
+ clearInterval(handle);
373
+ },
374
+ };
375
+ }