@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.
|
|
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
|
+
}
|