@danielblomma/cortex-mcp 2.0.3 → 2.0.5
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/bin/cortex.mjs +50 -25
- package/package.json +1 -1
- package/scaffold/mcp/src/daemon/main.ts +11 -0
- package/scaffold/mcp/src/daemon/skill-sync-checker.ts +375 -0
- package/scaffold/scripts/bootstrap.sh +1 -1
- package/scaffold/scripts/doctor.sh +6 -6
- package/scaffold/scripts/embed.sh +2 -2
- package/scaffold/scripts/load-ryu.sh +3 -3
- package/scaffold/scripts/memory-compile.mjs +1 -1
- package/scaffold/scripts/memory-lint.mjs +1 -1
- package/scaffold/scripts/watch.sh +2 -7
package/bin/cortex.mjs
CHANGED
|
@@ -22,17 +22,20 @@ const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
|
22
22
|
const SCAFFOLD_ROOT = path.join(PACKAGE_ROOT, "scaffold");
|
|
23
23
|
const PACKAGE_JSON_PATH = path.join(PACKAGE_ROOT, "package.json");
|
|
24
24
|
|
|
25
|
+
// v2.0.5: project layout moved mcp/ under .context/mcp/, and the
|
|
26
|
+
// gitignore policy flipped to "ignore everything in .context/, whitelist
|
|
27
|
+
// only the three editable config files". Generated artifacts (db,
|
|
28
|
+
// embeddings, cache, hooks, mcp/, govern.local.json) never land in git.
|
|
29
|
+
const MCP_PROJECT_REL = path.join(".context", "mcp");
|
|
30
|
+
|
|
25
31
|
const GITIGNORE_LINES = [
|
|
26
32
|
"",
|
|
27
33
|
"# Cortex local storage",
|
|
28
|
-
".context/
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
".npm-cache/"
|
|
33
|
-
"mcp/.npm-cache/",
|
|
34
|
-
"mcp/dist/",
|
|
35
|
-
"mcp/node_modules/"
|
|
34
|
+
".context/",
|
|
35
|
+
"!.context/config.yaml",
|
|
36
|
+
"!.context/rules.yaml",
|
|
37
|
+
"!.context/ontology.cypher",
|
|
38
|
+
".npm-cache/"
|
|
36
39
|
];
|
|
37
40
|
|
|
38
41
|
function printBanner(title) {
|
|
@@ -199,7 +202,6 @@ const INIT_SKIP_DIRECTORIES = new Set([
|
|
|
199
202
|
".cache",
|
|
200
203
|
".context",
|
|
201
204
|
"scripts",
|
|
202
|
-
"mcp",
|
|
203
205
|
".githooks",
|
|
204
206
|
"bin",
|
|
205
207
|
"obj"
|
|
@@ -439,11 +441,26 @@ function mergeGitignore(targetDir) {
|
|
|
439
441
|
fs.writeFileSync(gitignorePath, merged, "utf8");
|
|
440
442
|
}
|
|
441
443
|
|
|
444
|
+
function migrateLegacyMcpLocation(targetDir) {
|
|
445
|
+
const legacyMcp = path.join(targetDir, "mcp");
|
|
446
|
+
const newMcp = path.join(targetDir, MCP_PROJECT_REL);
|
|
447
|
+
if (!fs.existsSync(legacyMcp)) return;
|
|
448
|
+
if (fs.existsSync(newMcp)) return;
|
|
449
|
+
fs.mkdirSync(path.join(targetDir, ".context"), { recursive: true });
|
|
450
|
+
fs.renameSync(legacyMcp, newMcp);
|
|
451
|
+
console.log(
|
|
452
|
+
"[cortex] migrated legacy mcp/ → .context/mcp/ to keep project root clean. " +
|
|
453
|
+
"Re-run 'cortex connect' if Claude/Codex MCP registrations need to be refreshed.",
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
442
457
|
function installScaffold(targetDir, force) {
|
|
458
|
+
migrateLegacyMcpLocation(targetDir);
|
|
459
|
+
|
|
443
460
|
const copyMap = [
|
|
444
461
|
[path.join(SCAFFOLD_ROOT, ".context"), path.join(targetDir, ".context")],
|
|
445
462
|
[path.join(SCAFFOLD_ROOT, "scripts"), path.join(targetDir, "scripts")],
|
|
446
|
-
[path.join(SCAFFOLD_ROOT, "mcp"), path.join(targetDir,
|
|
463
|
+
[path.join(SCAFFOLD_ROOT, "mcp"), path.join(targetDir, MCP_PROJECT_REL)],
|
|
447
464
|
[path.join(SCAFFOLD_ROOT, ".githooks"), path.join(targetDir, ".githooks")]
|
|
448
465
|
];
|
|
449
466
|
|
|
@@ -633,7 +650,7 @@ async function connectClaude(targetDir) {
|
|
|
633
650
|
}
|
|
634
651
|
|
|
635
652
|
const serverName = "cortex";
|
|
636
|
-
const projectServerEntry = path.join(
|
|
653
|
+
const projectServerEntry = path.join(MCP_PROJECT_REL, "dist", "server.js");
|
|
637
654
|
await runCommandResult("claude", ["mcp", "remove", "-s", "project", serverName], targetDir, "ignore");
|
|
638
655
|
await runCommand(
|
|
639
656
|
"claude",
|
|
@@ -646,7 +663,7 @@ async function connectClaude(targetDir) {
|
|
|
646
663
|
|
|
647
664
|
async function connectMcpClients(targetDir, options = {}) {
|
|
648
665
|
const { skipBuild = false } = options;
|
|
649
|
-
const mcpDir = path.join(targetDir,
|
|
666
|
+
const mcpDir = path.join(targetDir, MCP_PROJECT_REL);
|
|
650
667
|
const packageJson = path.join(mcpDir, "package.json");
|
|
651
668
|
const nodeModules = path.join(mcpDir, "node_modules");
|
|
652
669
|
const serverEntry = path.join(mcpDir, "dist", "server.js");
|
|
@@ -662,7 +679,7 @@ async function connectMcpClients(targetDir, options = {}) {
|
|
|
662
679
|
console.log(`[cortex] MCP build failed, continuing with existing dist output: ${toErrorMessage(error)}`);
|
|
663
680
|
}
|
|
664
681
|
} else if (!skipBuild) {
|
|
665
|
-
console.log("[cortex] mcp/node_modules not found, skipping build (run cortex bootstrap first)");
|
|
682
|
+
console.log("[cortex] .context/mcp/node_modules not found, skipping build (run cortex bootstrap first)");
|
|
666
683
|
}
|
|
667
684
|
|
|
668
685
|
if (!fs.existsSync(serverEntry)) {
|
|
@@ -716,7 +733,7 @@ async function maybeInstallGitHooks(targetDir) {
|
|
|
716
733
|
}
|
|
717
734
|
|
|
718
735
|
function ensureProjectInitialized(targetDir) {
|
|
719
|
-
const mcpPackageJson = path.join(targetDir,
|
|
736
|
+
const mcpPackageJson = path.join(targetDir, MCP_PROJECT_REL, "package.json");
|
|
720
737
|
if (!fs.existsSync(mcpPackageJson)) {
|
|
721
738
|
throw new Error(`Missing ${mcpPackageJson}. Run 'cortex init --bootstrap' first.`);
|
|
722
739
|
}
|
|
@@ -731,7 +748,9 @@ function isTruthyEnv(value) {
|
|
|
731
748
|
}
|
|
732
749
|
|
|
733
750
|
function canAutoInitialize(targetDir) {
|
|
734
|
-
|
|
751
|
+
// Legacy mcp/ at root no longer counted — pre-v2.0.5 projects are migrated
|
|
752
|
+
// by installScaffold rather than blocking auto-init.
|
|
753
|
+
const scaffoldPaths = [".context", "scripts", ".githooks"].map((entry) => path.join(targetDir, entry));
|
|
735
754
|
return scaffoldPaths.every((entryPath) => !fs.existsSync(entryPath));
|
|
736
755
|
}
|
|
737
756
|
|
|
@@ -744,7 +763,12 @@ function isScaffoldOutOfDate(targetDir) {
|
|
|
744
763
|
if (!fs.existsSync(doctorScript)) {
|
|
745
764
|
return true;
|
|
746
765
|
}
|
|
747
|
-
|
|
766
|
+
// Treat legacy mcp/ at project root as out-of-date so existing installs
|
|
767
|
+
// get migrated into .context/mcp/ on the next bootstrap.
|
|
768
|
+
if (fs.existsSync(path.join(targetDir, "mcp", "package.json"))) {
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
const mcpPackage = path.join(targetDir, MCP_PROJECT_REL, "package.json");
|
|
748
772
|
if (!fs.existsSync(mcpPackage)) {
|
|
749
773
|
return true;
|
|
750
774
|
}
|
|
@@ -780,7 +804,8 @@ async function maybeMigrateScaffold(targetDir, command) {
|
|
|
780
804
|
|
|
781
805
|
console.error(
|
|
782
806
|
`[cortex] scaffold in ${targetDir} is out of date ` +
|
|
783
|
-
`(missing scripts/doctor.sh, mcp/package.json,
|
|
807
|
+
`(missing scripts/doctor.sh, .context/mcp/package.json, doctor subcommand in context.sh, ` +
|
|
808
|
+
`or carries a legacy mcp/ directory at the project root).`
|
|
784
809
|
);
|
|
785
810
|
|
|
786
811
|
let proceed = autoYes;
|
|
@@ -808,8 +833,8 @@ async function maybeMigrateScaffold(targetDir, command) {
|
|
|
808
833
|
}
|
|
809
834
|
|
|
810
835
|
async function ensureProjectInitializedForMcp(targetDir) {
|
|
811
|
-
const mcpPackageJson = path.join(targetDir,
|
|
812
|
-
const serverEntry = path.join(targetDir,
|
|
836
|
+
const mcpPackageJson = path.join(targetDir, MCP_PROJECT_REL, "package.json");
|
|
837
|
+
const serverEntry = path.join(targetDir, MCP_PROJECT_REL, "dist", "server.js");
|
|
813
838
|
|
|
814
839
|
if (fs.existsSync(mcpPackageJson) && fs.existsSync(serverEntry)) {
|
|
815
840
|
return;
|
|
@@ -947,7 +972,7 @@ async function run() {
|
|
|
947
972
|
process.env.CORTEX_PROJECT_ROOT = target;
|
|
948
973
|
await ensureProjectInitializedForMcp(target);
|
|
949
974
|
ensureProjectInitialized(target);
|
|
950
|
-
const serverEntry = path.join(target,
|
|
975
|
+
const serverEntry = path.join(target, MCP_PROJECT_REL, "dist", "server.js");
|
|
951
976
|
if (!fs.existsSync(serverEntry)) {
|
|
952
977
|
throw new Error(`Missing ${serverEntry}. Run 'cortex bootstrap' in ${target} first.`);
|
|
953
978
|
}
|
|
@@ -1038,12 +1063,12 @@ function isPidAlive(pid) {
|
|
|
1038
1063
|
}
|
|
1039
1064
|
|
|
1040
1065
|
function resolveProjectMcpDist() {
|
|
1041
|
-
// v2.0.
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1066
|
+
// v2.0.5: project layout was moved from <cwd>/mcp/ to <cwd>/.context/mcp/.
|
|
1067
|
+
// PACKAGE_ROOT/scaffold/mcp/ is still the source tree the scaffold is
|
|
1068
|
+
// copied from; the actual built code lives in each project's
|
|
1069
|
+
// <cwd>/.context/mcp/dist/ after bootstrap.
|
|
1045
1070
|
const target = process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
|
|
1046
|
-
return path.join(target,
|
|
1071
|
+
return path.join(target, MCP_PROJECT_REL, "dist");
|
|
1047
1072
|
}
|
|
1048
1073
|
|
|
1049
1074
|
function resolveDaemonEntry() {
|
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.5",
|
|
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
|
+
}
|
|
@@ -3,7 +3,7 @@ set -euo pipefail
|
|
|
3
3
|
|
|
4
4
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
5
|
CONTEXT_DIR="$REPO_ROOT/.context"
|
|
6
|
-
MCP_DIR="$
|
|
6
|
+
MCP_DIR="$CONTEXT_DIR/mcp"
|
|
7
7
|
|
|
8
8
|
PASS=0
|
|
9
9
|
FAIL=0
|
|
@@ -165,15 +165,15 @@ echo ""
|
|
|
165
165
|
echo " MCP Server"
|
|
166
166
|
|
|
167
167
|
if [[ -f "$MCP_DIR/dist/server.js" ]]; then
|
|
168
|
-
pass "mcp/dist/server.js exists"
|
|
168
|
+
pass ".context/mcp/dist/server.js exists"
|
|
169
169
|
else
|
|
170
|
-
fail "mcp/dist/server.js missing — run: cd mcp && npm run build"
|
|
170
|
+
fail ".context/mcp/dist/server.js missing — run: cd .context/mcp && npm run build"
|
|
171
171
|
fi
|
|
172
172
|
|
|
173
173
|
if [[ -d "$MCP_DIR/node_modules" ]]; then
|
|
174
|
-
pass "mcp/node_modules present"
|
|
174
|
+
pass ".context/mcp/node_modules present"
|
|
175
175
|
else
|
|
176
|
-
fail "mcp/node_modules missing — run: cd mcp && npm install"
|
|
176
|
+
fail ".context/mcp/node_modules missing — run: cd .context/mcp && npm install"
|
|
177
177
|
fi
|
|
178
178
|
|
|
179
179
|
# Quick MCP import check
|
|
@@ -181,7 +181,7 @@ if [[ -f "$MCP_DIR/dist/server.js" ]] && [[ -d "$MCP_DIR/node_modules" ]]; then
|
|
|
181
181
|
MCP_CHECK=$(cd "$REPO_ROOT" && timeout 10 node -e '
|
|
182
182
|
const start = Date.now();
|
|
183
183
|
try {
|
|
184
|
-
require("
|
|
184
|
+
require("./.context/mcp/dist/graph.js");
|
|
185
185
|
console.log("ok " + (Date.now() - start));
|
|
186
186
|
} catch(e) {
|
|
187
187
|
console.log("fail " + e.message);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
4
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
-
MCP_DIR="$REPO_ROOT/mcp"
|
|
5
|
+
MCP_DIR="$REPO_ROOT/.context/mcp"
|
|
6
6
|
|
|
7
7
|
if ! command -v npm >/dev/null 2>&1; then
|
|
8
8
|
echo "[embed] npm is required but not found on PATH"
|
|
@@ -11,5 +11,5 @@ fi
|
|
|
11
11
|
|
|
12
12
|
mkdir -p "$MCP_DIR/.npm-cache"
|
|
13
13
|
|
|
14
|
-
echo "[embed] generating embeddings via mcp/embed"
|
|
14
|
+
echo "[embed] generating embeddings via .context/mcp/embed"
|
|
15
15
|
NPM_CONFIG_CACHE="$MCP_DIR/.npm-cache" npm --prefix "$MCP_DIR" run embed --silent -- "$@"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
4
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
-
MCP_DIR="$REPO_ROOT/mcp"
|
|
5
|
+
MCP_DIR="$REPO_ROOT/.context/mcp"
|
|
6
6
|
|
|
7
7
|
if [[ ! -f "$MCP_DIR/package.json" ]]; then
|
|
8
8
|
echo "[graph-load] missing $MCP_DIR/package.json"
|
|
@@ -10,8 +10,8 @@ if [[ ! -f "$MCP_DIR/package.json" ]]; then
|
|
|
10
10
|
fi
|
|
11
11
|
|
|
12
12
|
if [[ ! -d "$MCP_DIR/node_modules" ]]; then
|
|
13
|
-
echo "[graph-load] node_modules missing in mcp/"
|
|
14
|
-
echo "[graph-load] run: cd mcp && NPM_CONFIG_CACHE=$MCP_DIR/.npm-cache npm install"
|
|
13
|
+
echo "[graph-load] node_modules missing in .context/mcp/"
|
|
14
|
+
echo "[graph-load] run: cd .context/mcp && NPM_CONFIG_CACHE=$MCP_DIR/.npm-cache npm install"
|
|
15
15
|
exit 1
|
|
16
16
|
fi
|
|
17
17
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { parseFrontmatter, parseStringList } from "
|
|
5
|
+
import { parseFrontmatter, parseStringList } from "../.context/mcp/dist/frontmatter.js";
|
|
6
6
|
|
|
7
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
8
|
const __dirname = path.dirname(__filename);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { parseFrontmatter, parseStringList } from "
|
|
5
|
+
import { parseFrontmatter, parseStringList } from "../.context/mcp/dist/frontmatter.js";
|
|
6
6
|
|
|
7
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
8
|
const __dirname = path.dirname(__filename);
|
|
@@ -146,11 +146,9 @@ status_digest() {
|
|
|
146
146
|
fi
|
|
147
147
|
|
|
148
148
|
# Fallback for non-git directories.
|
|
149
|
+
# .context/ excludes the relocated .context/mcp/ tree as well.
|
|
149
150
|
find "$REPO_ROOT" -type f \
|
|
150
151
|
! -path "$REPO_ROOT/.context/*" \
|
|
151
|
-
! -path "$REPO_ROOT/mcp/node_modules/*" \
|
|
152
|
-
! -path "$REPO_ROOT/mcp/dist/*" \
|
|
153
|
-
! -path "$REPO_ROOT/mcp/.npm-cache/*" \
|
|
154
152
|
! -path "$REPO_ROOT/scripts/parsers/node_modules/*" \
|
|
155
153
|
! -path "$REPO_ROOT/scripts/parsers/.npm-cache/*" \
|
|
156
154
|
-print \
|
|
@@ -201,16 +199,13 @@ wait_for_change_event() {
|
|
|
201
199
|
inotifywait)
|
|
202
200
|
inotifywait -q -r \
|
|
203
201
|
-e modify,create,delete,move \
|
|
204
|
-
--exclude '(^|/)\\.git(/|$)|(^|/)\\.context(/|$)|(^|/)
|
|
202
|
+
--exclude '(^|/)\\.git(/|$)|(^|/)\\.context(/|$)|(^|/)scripts/parsers/(node_modules|\\.npm-cache)(/|$)' \
|
|
205
203
|
"$REPO_ROOT" >/dev/null 2>&1 || true
|
|
206
204
|
;;
|
|
207
205
|
fswatch)
|
|
208
206
|
fswatch -1 -r \
|
|
209
207
|
--exclude '(^|/)\\.git(/|$)' \
|
|
210
208
|
--exclude '(^|/)\\.context(/|$)' \
|
|
211
|
-
--exclude '(^|/)mcp/node_modules(/|$)' \
|
|
212
|
-
--exclude '(^|/)mcp/dist(/|$)' \
|
|
213
|
-
--exclude '(^|/)mcp/\\.npm-cache(/|$)' \
|
|
214
209
|
--exclude '(^|/)scripts/parsers/node_modules(/|$)' \
|
|
215
210
|
--exclude '(^|/)scripts/parsers/\\.npm-cache(/|$)' \
|
|
216
211
|
"$REPO_ROOT" >/dev/null 2>&1 || true
|