@theronap/cortex-mcp 0.9.84 → 0.9.85
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-mcp.mjs +3 -2
- package/lib/editors/codex.mjs +1 -1
- package/lib/editors/cursor.mjs +1 -1
- package/lib/setup.mjs +6 -5
- package/lib/skills.mjs +161 -4
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -50,8 +50,9 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
50
50
|
` doctor live health check — confirm your token works (no restart needed)\n` +
|
|
51
51
|
` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
|
|
52
52
|
` use-brain [<brain>] where your session captures are saved — no arg shows the current setting\n` +
|
|
53
|
-
` skills install/repair
|
|
54
|
-
` skills
|
|
53
|
+
` skills [sync] install/repair bundled, org, and private cross-editor skills\n` +
|
|
54
|
+
` skills import --from claude import all local Claude Code skills into your private library\n` +
|
|
55
|
+
` skills push <file> [--brain <name>] publish a SKILL.md to your org (owner/manager/admin)\n` +
|
|
55
56
|
` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
|
|
56
57
|
` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
|
|
57
58
|
` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
|
package/lib/editors/codex.mjs
CHANGED
package/lib/editors/cursor.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export default {
|
|
|
32
32
|
promptTimeInjection: false,
|
|
33
33
|
sessionStart: false,
|
|
34
34
|
captureHook: true, // ~/.cursor/hooks/cortex-cursor.mjs
|
|
35
|
-
skillDir:
|
|
35
|
+
skillDir: '~/.cursor/skills', // Flat SKILL.md directory verified on the supported Cursor build.
|
|
36
36
|
docTrigger: 'hook',
|
|
37
37
|
},
|
|
38
38
|
|
package/lib/setup.mjs
CHANGED
|
@@ -141,15 +141,16 @@ export async function runSetup(argv, version) {
|
|
|
141
141
|
process.exit(1)
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// ── 3. Managed skills — installed flat into every
|
|
145
|
-
// Bundled first (sync, network-free), then
|
|
146
|
-
// seat has its
|
|
147
|
-
// only if the caller exported it;
|
|
144
|
+
// ── 3. Managed skills — installed flat into every compatible editor ──────────────────────────
|
|
145
|
+
// Bundled first (sync, network-free), then org + private served sets (fail-soft pulls), so a
|
|
146
|
+
// fresh seat has its skills at first session, not second. CORTEX_TOKEN is in env for the pull
|
|
147
|
+
// only if the caller exported it; each sync falls back to the token this setup just wired.
|
|
148
148
|
try {
|
|
149
149
|
installSkills({ quiet: false })
|
|
150
|
-
const { syncOrgSkills } = await import('./skills.mjs')
|
|
150
|
+
const { syncOrgSkills, syncPersonalSkills } = await import('./skills.mjs')
|
|
151
151
|
process.env.CORTEX_TOKEN = process.env.CORTEX_TOKEN || token
|
|
152
152
|
await syncOrgSkills({ quiet: false })
|
|
153
|
+
await syncPersonalSkills({ quiet: false })
|
|
153
154
|
} catch (e) {
|
|
154
155
|
// Non-fatal: a skills hiccup must never block the core connection. Repair runs each session.
|
|
155
156
|
log(` ⚠ skills install skipped: ${e.message} (will retry on next session)`)
|
package/lib/skills.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'
|
|
|
5
5
|
|
|
6
6
|
// Managed Agnoclast skills.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// Three sources, one install pipeline:
|
|
9
9
|
// 1. BUNDLED — shipped inside this package (cortex-log, cortex-context, cortex-author-docs).
|
|
10
10
|
// Inalterable: canonical content always wins; user edits are backed up + restored.
|
|
11
11
|
// 2. ORG-PUBLISHED — rows in the org's `org_skills` table (documentation-ingestion-spec.md
|
|
@@ -13,6 +13,9 @@ import { fileURLToPath } from 'url'
|
|
|
13
13
|
// SessionStart `skills --repair` hook pulls + installs it here. Bundled names WIN collisions,
|
|
14
14
|
// so an org can never shadow a core skill. The pull is fail-soft (offline → last-good cache
|
|
15
15
|
// at ~/.cortex/org-skills-cache.json → skip), because SessionStart must never break.
|
|
16
|
+
// 3. PERSONAL — rows in `personal_skills`, keyed by auth_id. These are private to one person and
|
|
17
|
+
// are projected into every compatible editor on their machines. Personal wins an org collision
|
|
18
|
+
// on that person's machine; bundled core still wins every collision.
|
|
16
19
|
//
|
|
17
20
|
// Installed into EVERY agent CLI present on the machine, at the flat layout each one discovers:
|
|
18
21
|
// ~/.claude/skills/<name>/SKILL.md (Claude Code)
|
|
@@ -30,13 +33,16 @@ import { fileURLToPath } from 'url'
|
|
|
30
33
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
31
34
|
const BUNDLED = join(HERE, '..', 'skills') // packages/cortex-mcp/skills/<name>/SKILL.md
|
|
32
35
|
const ORG_CACHE = join(homedir(), '.cortex', 'org-skills-cache.json')
|
|
36
|
+
const PERSONAL_CACHE = join(homedir(), '.cortex', 'personal-skills-cache.json')
|
|
33
37
|
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ // mirrors the org_skills check; also blocks path tricks
|
|
34
38
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
39
|
+
// Editor skill roots with the compatible flat SKILL.md discovery layout. Cursor's path was verified
|
|
40
|
+
// on-machine before declaring it supported; Antigravity has no documented skill surface yet, so we
|
|
41
|
+
// deliberately do not fabricate one.
|
|
37
42
|
const CLIS = [
|
|
38
43
|
{ id: 'Claude Code', dir: join(homedir(), '.claude') },
|
|
39
44
|
{ id: 'Codex', dir: join(homedir(), '.codex') },
|
|
45
|
+
{ id: 'Cursor', dir: join(homedir(), '.cursor') },
|
|
40
46
|
]
|
|
41
47
|
|
|
42
48
|
// djb2 — tiny, dependency-free content fingerprint for the manifest (drift detection, not security).
|
|
@@ -196,6 +202,25 @@ export function planOrgInstall(served, bundledNames) {
|
|
|
196
202
|
return { install, skipped }
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
// Personal skills share the same validation and bundled-collision rule as org skills, but never use
|
|
206
|
+
// the cross-brain collision rule: their auth_id uniqueness means only one personal body can exist per
|
|
207
|
+
// name. Keeping this pure makes the precedence contract testable without touching a real home dir.
|
|
208
|
+
export function planPersonalInstall(served, bundledNames) {
|
|
209
|
+
const bundled = new Set(bundledNames)
|
|
210
|
+
const install = []
|
|
211
|
+
const skipped = []
|
|
212
|
+
const seen = new Set()
|
|
213
|
+
for (const s of served ?? []) {
|
|
214
|
+
const name = (s?.name ?? '').trim()
|
|
215
|
+
if (!NAME_RE.test(name) || typeof s?.body_md !== 'string' || !s.body_md.trim()) { skipped.push({ name: name || '(unnamed)', why: 'invalid' }); continue }
|
|
216
|
+
if (bundled.has(name)) { skipped.push({ name, why: 'collides with a bundled core skill' }); continue }
|
|
217
|
+
if (seen.has(name)) { skipped.push({ name, why: 'duplicate personal skill' }); continue }
|
|
218
|
+
seen.add(name)
|
|
219
|
+
install.push({ name, source: s.body_md })
|
|
220
|
+
}
|
|
221
|
+
return { install, skipped }
|
|
222
|
+
}
|
|
223
|
+
|
|
199
224
|
/**
|
|
200
225
|
* Pull the org's published skills and install them beside the bundled ones. FAIL-SOFT by design:
|
|
201
226
|
* no token → skip; fetch failure → last-good cache; nothing → skip. SessionStart must never break.
|
|
@@ -265,6 +290,76 @@ export async function syncOrgSkills(opts = {}) {
|
|
|
265
290
|
return summary
|
|
266
291
|
}
|
|
267
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Pull the caller's personal skill library and project it into every compatible local editor.
|
|
295
|
+
* It is deliberately fail-soft for the same reason as org sync: a network outage must not break a
|
|
296
|
+
* session start. A personal skill wins an unpinned org skill by being installed after org sync.
|
|
297
|
+
*/
|
|
298
|
+
export async function syncPersonalSkills(opts = {}) {
|
|
299
|
+
const quiet = !!opts.quiet
|
|
300
|
+
const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
|
|
301
|
+
const summary = { installed: [], repaired: [], removed: [], skipped: [], source: 'none' }
|
|
302
|
+
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
303
|
+
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
304
|
+
if (!token) { log(' · personal skills: no token wired — skipped'); return summary }
|
|
305
|
+
|
|
306
|
+
let cache = null
|
|
307
|
+
try { cache = JSON.parse(readFileSync(PERSONAL_CACHE, 'utf8')) } catch { /* none */ }
|
|
308
|
+
let served = null
|
|
309
|
+
try {
|
|
310
|
+
const res = await fetchCortex(`${resolveBase(process.env.CORTEX_URL)}/api/personal-skills`, {
|
|
311
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
312
|
+
})
|
|
313
|
+
if (res.ok) {
|
|
314
|
+
served = (await res.json())?.skills ?? []
|
|
315
|
+
summary.source = 'server'
|
|
316
|
+
} else {
|
|
317
|
+
log(` · personal skills: server said ${res.status} — using last-good cache. Run \`doctor\` if this persists.`)
|
|
318
|
+
}
|
|
319
|
+
} catch { /* fall through to cache */ }
|
|
320
|
+
if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
|
|
321
|
+
if (!served) { log(' · personal skills: unreachable and no cache — skipped'); return summary }
|
|
322
|
+
|
|
323
|
+
const bundledNames = bundledSkills().map((s) => s.name)
|
|
324
|
+
const { install, skipped } = planPersonalInstall(served, bundledNames)
|
|
325
|
+
summary.skipped = skipped
|
|
326
|
+
const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
|
|
327
|
+
const currentNames = install.map((s) => s.name)
|
|
328
|
+
const retiredNames = prevNames.filter((n) => !currentNames.includes(n))
|
|
329
|
+
|
|
330
|
+
// If a personal override was disabled, put back a cached org skill of that name rather than
|
|
331
|
+
// deleting it. The normal sync sequence has already refreshed ORG_CACHE before this function.
|
|
332
|
+
let orgCache = null
|
|
333
|
+
try { orgCache = JSON.parse(readFileSync(ORG_CACHE, 'utf8')) } catch { /* no org fallback */ }
|
|
334
|
+
const { install: orgInstall } = planOrgInstall(orgCache?.skills, bundledNames)
|
|
335
|
+
const orgByName = new Map(orgInstall.map((s) => [s.name, s]))
|
|
336
|
+
const fallback = retiredNames.map((name) => orgByName.get(name)).filter(Boolean)
|
|
337
|
+
const removeNames = retiredNames.filter((name) => !orgByName.has(name))
|
|
338
|
+
|
|
339
|
+
for (const cli of CLIS.filter((c) => existsSync(c.dir))) {
|
|
340
|
+
// Fallbacks first, personal second: personal > org, but bundled was installed before either.
|
|
341
|
+
const r = installInto(join(cli.dir, 'skills'), [...fallback, ...install], { removeNames })
|
|
342
|
+
summary.installed.push(...r.installed)
|
|
343
|
+
summary.repaired.push(...r.repaired)
|
|
344
|
+
summary.removed.push(...r.removed)
|
|
345
|
+
}
|
|
346
|
+
if (summary.source === 'server') {
|
|
347
|
+
ensureDir(dirname(PERSONAL_CACHE))
|
|
348
|
+
writeFileSync(PERSONAL_CACHE, JSON.stringify({ fetchedAt: new Date().toISOString(), skills: served, installed: currentNames }, null, 2))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const changed = [...new Set([...summary.installed, ...summary.repaired])]
|
|
352
|
+
if (changed.length || summary.removed.length) {
|
|
353
|
+
const bits = []
|
|
354
|
+
if (changed.length) bits.push(`synced ${changed.join(', ')}`)
|
|
355
|
+
if (summary.removed.length) bits.push(`removed ${summary.removed.join(', ')}`)
|
|
356
|
+
process.stdout.write(`Agnoclast: personal skills — ${bits.join('; ')}${summary.source === 'cache' ? ' (offline cache)' : ''}.\n`)
|
|
357
|
+
} else if (!quiet) {
|
|
358
|
+
log(` ✓ personal skills up to date (${currentNames.length} private${skipped.length ? `, ${skipped.length} skipped` : ''})`)
|
|
359
|
+
}
|
|
360
|
+
return summary
|
|
361
|
+
}
|
|
362
|
+
|
|
268
363
|
// Render a failed push. THE SERVER ALREADY ANSWERED THE QUESTION — brain_choice_response.ts builds a
|
|
269
364
|
// body carrying a full `message` plus every brain's NAME, PAGE COUNT and SAMPLE TITLES, explicitly so
|
|
270
365
|
// the caller can choose "by what each one HOLDS, not by which name sounds related". This function
|
|
@@ -355,10 +450,69 @@ export async function runSkillsPush(argv) {
|
|
|
355
450
|
}
|
|
356
451
|
}
|
|
357
452
|
|
|
453
|
+
export function discoverSkillFiles(root) {
|
|
454
|
+
if (!root || !existsSync(root)) return []
|
|
455
|
+
return readdirSync(root, { withFileTypes: true })
|
|
456
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, 'SKILL.md')))
|
|
457
|
+
.map((entry) => join(root, entry.name, 'SKILL.md'))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// `skills import --from claude` is the deliberate migration command. It uploads only the user's
|
|
461
|
+
// skill bodies to their private library, then projects the served result; it never publishes to an
|
|
462
|
+
// organization and it never treats a local copy as proof that server sync worked.
|
|
463
|
+
export async function runSkillsImport(argv) {
|
|
464
|
+
if (argv.length !== 2 || argv[0] !== '--from' || argv[1] !== 'claude') {
|
|
465
|
+
process.stderr.write('Usage: skills import --from claude\n')
|
|
466
|
+
return 1
|
|
467
|
+
}
|
|
468
|
+
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
469
|
+
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
470
|
+
if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
|
|
471
|
+
const files = discoverSkillFiles(join(homedir(), '.claude', 'skills'))
|
|
472
|
+
if (!files.length) { process.stderr.write('No Claude Code SKILL.md files found.\n'); return 1 }
|
|
473
|
+
|
|
474
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
475
|
+
let imported = 0
|
|
476
|
+
const skipped = []
|
|
477
|
+
const seen = new Set()
|
|
478
|
+
for (const file of files) {
|
|
479
|
+
const body_md = readFileSync(file, 'utf8')
|
|
480
|
+
const name = frontmatterName(body_md, '').toLowerCase()
|
|
481
|
+
if (!NAME_RE.test(name)) { skipped.push(`${file}: invalid frontmatter name`); continue }
|
|
482
|
+
if (seen.has(name)) { skipped.push(`${file}: duplicate name ${name}`); continue }
|
|
483
|
+
seen.add(name)
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetchCortex(`${base}/api/personal-skills`, {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
488
|
+
body: JSON.stringify({ name, body_md, enabled: true }),
|
|
489
|
+
})
|
|
490
|
+
if (!res.ok) {
|
|
491
|
+
const response = await res.json().catch(() => ({}))
|
|
492
|
+
skipped.push(`${file}: ${response.error ?? `HTTP ${res.status}`}`)
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
imported++
|
|
496
|
+
} catch (e) {
|
|
497
|
+
skipped.push(`${file}: network error: ${e.message}`)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
process.stdout.write(`Agnoclast: imported ${imported}/${files.length} Claude Code skills into your private library.\n`)
|
|
501
|
+
for (const reason of skipped) process.stdout.write(` · skipped ${reason}\n`)
|
|
502
|
+
if (imported) await syncPersonalSkills({ quiet: false })
|
|
503
|
+
return skipped.length ? 1 : 0
|
|
504
|
+
}
|
|
505
|
+
|
|
358
506
|
// CLI entry: `cortex-mcp skills [--repair] [--quiet] | skills push …`. (--repair and plain install
|
|
359
507
|
// are the same idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
|
|
360
508
|
export async function runSkills(argv = []) {
|
|
361
509
|
if (argv[0] === 'push') return runSkillsPush(argv.slice(1))
|
|
510
|
+
if (argv[0] === 'import') return runSkillsImport(argv.slice(1))
|
|
511
|
+
const syncing = argv[0] === 'sync'
|
|
512
|
+
if (argv[0] && !syncing && argv[0] !== '--repair' && argv[0] !== '--quiet') {
|
|
513
|
+
process.stderr.write('Usage: skills [sync|--repair] | skills import --from claude | skills push <SKILL.md> [--brain <name>]\n')
|
|
514
|
+
return 1
|
|
515
|
+
}
|
|
362
516
|
|
|
363
517
|
const quiet = argv.includes('--quiet')
|
|
364
518
|
if (!quiet) process.stdout.write('\nAgnoclast skills — installing managed skills…\n')
|
|
@@ -369,6 +523,9 @@ export async function runSkills(argv = []) {
|
|
|
369
523
|
process.stdout.write(` ✓ Up to date in ${r.targets.join(' + ')} (${[...new Set(r.unchanged)].join(', ') || 'none'}).\n`)
|
|
370
524
|
}
|
|
371
525
|
}
|
|
372
|
-
if (r.targets.length)
|
|
526
|
+
if (r.targets.length) {
|
|
527
|
+
await syncOrgSkills({ quiet })
|
|
528
|
+
await syncPersonalSkills({ quiet })
|
|
529
|
+
}
|
|
373
530
|
return 0
|
|
374
531
|
}
|