@theronap/cortex-mcp 0.9.84 → 0.9.86

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.
@@ -236,7 +261,10 @@ export async function syncOrgSkills(opts = {}) {
236
261
 
237
262
  const { install, skipped } = planOrgInstall(served, bundledSkills().map((s) => s.name))
238
263
  summary.skipped = skipped
239
- const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
264
+ // Only a cache written by this personal-sync implementation owns local files it may retire. An
265
+ // older/cache-shaped-alike file is not proof of ownership; treating it as one deleted source
266
+ // skills that had not yet been accepted by the server. First sync after an upgrade is additive.
267
+ const prevNames = cache?.version === 1 && Array.isArray(cache?.installed) ? cache.installed : []
240
268
  const currentNames = install.map((s) => s.name)
241
269
  const removeNames = prevNames.filter((n) => !currentNames.includes(n))
242
270
 
@@ -265,6 +293,76 @@ export async function syncOrgSkills(opts = {}) {
265
293
  return summary
266
294
  }
267
295
 
296
+ /**
297
+ * Pull the caller's personal skill library and project it into every compatible local editor.
298
+ * It is deliberately fail-soft for the same reason as org sync: a network outage must not break a
299
+ * session start. A personal skill wins an unpinned org skill by being installed after org sync.
300
+ */
301
+ export async function syncPersonalSkills(opts = {}) {
302
+ const quiet = !!opts.quiet
303
+ const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
304
+ const summary = { installed: [], repaired: [], removed: [], skipped: [], source: 'none' }
305
+ const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
306
+ const token = process.env.CORTEX_TOKEN || readWiredToken()
307
+ if (!token) { log(' · personal skills: no token wired — skipped'); return summary }
308
+
309
+ let cache = null
310
+ try { cache = JSON.parse(readFileSync(PERSONAL_CACHE, 'utf8')) } catch { /* none */ }
311
+ let served = null
312
+ try {
313
+ const res = await fetchCortex(`${resolveBase(process.env.CORTEX_URL)}/api/personal-skills`, {
314
+ headers: { Authorization: `Bearer ${token}` },
315
+ })
316
+ if (res.ok) {
317
+ served = (await res.json())?.skills ?? []
318
+ summary.source = 'server'
319
+ } else {
320
+ log(` · personal skills: server said ${res.status} — using last-good cache. Run \`doctor\` if this persists.`)
321
+ }
322
+ } catch { /* fall through to cache */ }
323
+ if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
324
+ if (!served) { log(' · personal skills: unreachable and no cache — skipped'); return summary }
325
+
326
+ const bundledNames = bundledSkills().map((s) => s.name)
327
+ const { install, skipped } = planPersonalInstall(served, bundledNames)
328
+ summary.skipped = skipped
329
+ const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
330
+ const currentNames = install.map((s) => s.name)
331
+ const retiredNames = prevNames.filter((n) => !currentNames.includes(n))
332
+
333
+ // If a personal override was disabled, put back a cached org skill of that name rather than
334
+ // deleting it. The normal sync sequence has already refreshed ORG_CACHE before this function.
335
+ let orgCache = null
336
+ try { orgCache = JSON.parse(readFileSync(ORG_CACHE, 'utf8')) } catch { /* no org fallback */ }
337
+ const { install: orgInstall } = planOrgInstall(orgCache?.skills, bundledNames)
338
+ const orgByName = new Map(orgInstall.map((s) => [s.name, s]))
339
+ const fallback = retiredNames.map((name) => orgByName.get(name)).filter(Boolean)
340
+ const removeNames = retiredNames.filter((name) => !orgByName.has(name))
341
+
342
+ for (const cli of CLIS.filter((c) => existsSync(c.dir))) {
343
+ // Fallbacks first, personal second: personal > org, but bundled was installed before either.
344
+ const r = installInto(join(cli.dir, 'skills'), [...fallback, ...install], { removeNames })
345
+ summary.installed.push(...r.installed)
346
+ summary.repaired.push(...r.repaired)
347
+ summary.removed.push(...r.removed)
348
+ }
349
+ if (summary.source === 'server') {
350
+ ensureDir(dirname(PERSONAL_CACHE))
351
+ writeFileSync(PERSONAL_CACHE, JSON.stringify({ version: 1, fetchedAt: new Date().toISOString(), skills: served, installed: currentNames }, null, 2))
352
+ }
353
+
354
+ const changed = [...new Set([...summary.installed, ...summary.repaired])]
355
+ if (changed.length || summary.removed.length) {
356
+ const bits = []
357
+ if (changed.length) bits.push(`synced ${changed.join(', ')}`)
358
+ if (summary.removed.length) bits.push(`removed ${summary.removed.join(', ')}`)
359
+ process.stdout.write(`Agnoclast: personal skills — ${bits.join('; ')}${summary.source === 'cache' ? ' (offline cache)' : ''}.\n`)
360
+ } else if (!quiet) {
361
+ log(` ✓ personal skills up to date (${currentNames.length} private${skipped.length ? `, ${skipped.length} skipped` : ''})`)
362
+ }
363
+ return summary
364
+ }
365
+
268
366
  // Render a failed push. THE SERVER ALREADY ANSWERED THE QUESTION — brain_choice_response.ts builds a
269
367
  // body carrying a full `message` plus every brain's NAME, PAGE COUNT and SAMPLE TITLES, explicitly so
270
368
  // the caller can choose "by what each one HOLDS, not by which name sounds related". This function
@@ -355,10 +453,83 @@ export async function runSkillsPush(argv) {
355
453
  }
356
454
  }
357
455
 
456
+ export function discoverSkillFiles(root) {
457
+ if (!root || !existsSync(root)) return []
458
+ const out = []
459
+ const walk = (dir) => {
460
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
461
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
462
+ const child = join(dir, entry.name)
463
+ const skill = join(child, 'SKILL.md')
464
+ if (existsSync(skill)) out.push(skill)
465
+ walk(child)
466
+ }
467
+ }
468
+ walk(root)
469
+ // Prefer a direct skill over an equally named nested vendor copy. This gives a user override
470
+ // priority while still discovering bundled sub-skills such as gstack's long-form workflows.
471
+ return out.sort((a, b) => {
472
+ const depth = (path) => path.slice(root.length).split('/').filter(Boolean).length
473
+ return depth(a) - depth(b) || a.localeCompare(b)
474
+ })
475
+ }
476
+
477
+ // `skills import --from claude` is the deliberate migration command. It uploads only the user's
478
+ // skill bodies to their private library, then projects the served result; it never publishes to an
479
+ // organization and it never treats a local copy as proof that server sync worked.
480
+ export async function runSkillsImport(argv) {
481
+ if (argv.length !== 2 || argv[0] !== '--from' || argv[1] !== 'claude') {
482
+ process.stderr.write('Usage: skills import --from claude\n')
483
+ return 1
484
+ }
485
+ const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
486
+ const token = process.env.CORTEX_TOKEN || readWiredToken()
487
+ if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
488
+ const files = discoverSkillFiles(join(homedir(), '.claude', 'skills'))
489
+ if (!files.length) { process.stderr.write('No Claude Code SKILL.md files found.\n'); return 1 }
490
+
491
+ const base = resolveBase(process.env.CORTEX_URL)
492
+ let imported = 0
493
+ const skipped = []
494
+ const seen = new Set()
495
+ for (const file of files) {
496
+ const body_md = readFileSync(file, 'utf8')
497
+ const name = frontmatterName(body_md, '').toLowerCase()
498
+ if (!NAME_RE.test(name)) { skipped.push(`${file}: invalid frontmatter name`); continue }
499
+ if (seen.has(name)) { skipped.push(`${file}: duplicate name ${name}`); continue }
500
+ seen.add(name)
501
+ try {
502
+ const res = await fetchCortex(`${base}/api/personal-skills`, {
503
+ method: 'POST',
504
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
505
+ body: JSON.stringify({ name, body_md, enabled: true }),
506
+ })
507
+ if (!res.ok) {
508
+ const response = await res.json().catch(() => ({}))
509
+ skipped.push(`${file}: ${response.error ?? `HTTP ${res.status}`}`)
510
+ continue
511
+ }
512
+ imported++
513
+ } catch (e) {
514
+ skipped.push(`${file}: network error: ${e.message}`)
515
+ }
516
+ }
517
+ process.stdout.write(`Agnoclast: imported ${imported}/${files.length} Claude Code skills into your private library.\n`)
518
+ for (const reason of skipped) process.stdout.write(` · skipped ${reason}\n`)
519
+ if (imported) await syncPersonalSkills({ quiet: false })
520
+ return skipped.length ? 1 : 0
521
+ }
522
+
358
523
  // CLI entry: `cortex-mcp skills [--repair] [--quiet] | skills push …`. (--repair and plain install
359
524
  // are the same idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
360
525
  export async function runSkills(argv = []) {
361
526
  if (argv[0] === 'push') return runSkillsPush(argv.slice(1))
527
+ if (argv[0] === 'import') return runSkillsImport(argv.slice(1))
528
+ const syncing = argv[0] === 'sync'
529
+ if (argv[0] && !syncing && argv[0] !== '--repair' && argv[0] !== '--quiet') {
530
+ process.stderr.write('Usage: skills [sync|--repair] | skills import --from claude | skills push <SKILL.md> [--brain <name>]\n')
531
+ return 1
532
+ }
362
533
 
363
534
  const quiet = argv.includes('--quiet')
364
535
  if (!quiet) process.stdout.write('\nAgnoclast skills — installing managed skills…\n')
@@ -369,6 +540,9 @@ export async function runSkills(argv = []) {
369
540
  process.stdout.write(` ✓ Up to date in ${r.targets.join(' + ')} (${[...new Set(r.unchanged)].join(', ') || 'none'}).\n`)
370
541
  }
371
542
  }
372
- if (r.targets.length) await syncOrgSkills({ quiet })
543
+ if (r.targets.length) {
544
+ await syncOrgSkills({ quiet })
545
+ await syncPersonalSkills({ quiet })
546
+ }
373
547
  return 0
374
548
  }
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.86",
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": {