@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.
@@ -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 the managed Agnoclast skills — bundled + org-published (also wired by setup)\n` +
54
- ` skills push <file> publish a SKILL.md to your org (owner/manager/admin)\n` +
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` +
@@ -70,7 +70,7 @@ export default {
70
70
  promptTimeInjection: true,
71
71
  sessionStart: false,
72
72
  captureHook: true,
73
- skillDir: null,
73
+ skillDir: '~/.codex/skills',
74
74
  docTrigger: 'stop-hook',
75
75
  },
76
76
 
@@ -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: null, // Cursor rules/commands dir revisit in Pillar 2
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 agent CLI present (Claude + Codex) ──
145
- // Bundled first (sync, network-free), then the org-published set (fail-soft pull), so a fresh
146
- // seat has its org's skills at first session, not second. CORTEX_TOKEN is in env for the pull
147
- // only if the caller exported it; syncOrgSkills falls back to the token this setup just wired.
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
- // Two sources, one install pipeline:
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
- // Agent CLIs we install skills into. Claude is primary; Codex is included whenever it's present.
36
- // Both use the same flat <cli>/skills/<name>/SKILL.md discovery layout.
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) await syncOrgSkills({ quiet })
526
+ if (r.targets.length) {
527
+ await syncOrgSkills({ quiet })
528
+ await syncPersonalSkills({ quiet })
529
+ }
373
530
  return 0
374
531
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.84",
3
+ "version": "0.9.85",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {