@theronap/cortex-mcp 0.9.43 → 0.9.45

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.
@@ -41,7 +41,8 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
41
41
  `This wires your Claude config so your AI sees your Cortex context and your\n` +
42
42
  `sessions flow into the org automatically. Restart Claude Code after.\n\n` +
43
43
  `Subcommands:\n` +
44
- ` setup <token> wire MCP server + capture hook into ~/.claude config\n` +
44
+ ` setup <token> wire MCP server + capture hook into ~/.claude config (single editor)\n` +
45
+ ` install [<token>] [--editor auto|all|<id,...>] wire Cortex into EVERY detected editor + write the capability manifest\n` +
45
46
  ` repair re-run setup at the latest version using your existing token (no token needed)\n` +
46
47
  ` uninstall remove ALL Cortex wiring (MCP, hooks, skills, launchd, cron). --dry-run to preview, --purge to also wipe ~/.cortex + npx cache\n` +
47
48
  ` doctor live health check — confirm your token works (no restart needed)\n` +
@@ -70,6 +71,13 @@ if (cmd === 'setup') {
70
71
  await runSetup(rest, VERSION)
71
72
  const { closeFetch } = await import('../lib/diagnose.mjs')
72
73
  await closeFetch()
74
+ } else if (cmd === 'install') {
75
+ // The cross-editor hub installer: wire Cortex into every detected editor via the adapter
76
+ // registry, then write the capability manifest (~/.cortex/editors.json). Superset of `setup`.
77
+ const { runInstall } = await import('../lib/install.mjs')
78
+ await runInstall(rest, VERSION)
79
+ const { closeFetch } = await import('../lib/diagnose.mjs')
80
+ await closeFetch()
73
81
  } else if (cmd === 'uninstall' || cmd === 'remove') {
74
82
  // Full reverse of setup: strip every Cortex touch-point (MCP entries, hooks, skills, launchd, cron).
75
83
  // --dry-run prints the plan and changes nothing; --purge also removes ~/.cortex, the npx cache, and
@@ -115,7 +123,7 @@ if (cmd === 'setup') {
115
123
  const { closeFetch } = await import('../lib/diagnose.mjs')
116
124
  await closeFetch()
117
125
  } else if (cmd === 'grep') {
118
- // Literal substring search over the viewer's visible brain wiki (thin client of /api/grep).
126
+ // Ranked keyword (fts) search by default; --substring/--literal for exact lookups. Thin client of /api/grep.
119
127
  const { runGrep } = await import('../lib/grep_cli.mjs')
120
128
  process.exitCode = await runGrep(rest)
121
129
  const { closeFetch } = await import('../lib/diagnose.mjs')
@@ -0,0 +1,31 @@
1
+ // Shared file helpers for editor adapters. These MIRROR setup.mjs's private helpers exactly so an
2
+ // extracted wire() writes byte-identical output (same backup name, same JSON formatting).
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'
4
+ import { dirname } from 'node:path'
5
+
6
+ /** Parse a JSON config, tolerating absent/empty files; THROWS on malformed (caller decides). */
7
+ export function readJson(path) {
8
+ if (!existsSync(path)) return {}
9
+ const raw = readFileSync(path, 'utf8').trim()
10
+ if (!raw) return {}
11
+ return JSON.parse(raw)
12
+ }
13
+
14
+ /** Copy path → path.cortex-bak before mutating. Returns the backup path, or null if nothing to back up. */
15
+ export function backupFile(path) {
16
+ if (!existsSync(path)) return null
17
+ const bak = `${path}.cortex-bak`
18
+ copyFileSync(path, bak)
19
+ return bak
20
+ }
21
+
22
+ /** Ensure the parent directory of `path` exists. */
23
+ export function ensureDir(path) {
24
+ const dir = dirname(path)
25
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
26
+ }
27
+
28
+ /** Write an object as pretty JSON — the exact formatting setup.mjs uses (2-space indent). */
29
+ export function writeJson(path, obj) {
30
+ writeFileSync(path, JSON.stringify(obj, null, 2))
31
+ }
@@ -0,0 +1,123 @@
1
+ // Editor adapter: Antigravity. P0-b task 4 — deterministic artifact generators + wire().
2
+ //
3
+ // Antigravity has no in-editor hook; sync is a launchd agent (com.cortex.antigravity-sync) that
4
+ // WatchPaths the trajectory store and runs a one-shot sync. wire() generates the two artifacts
5
+ // (the sync.sh + the launchd plist) deterministically. It does NOT run `launchctl load` here
6
+ // (an unverifiable system mutation) and it flags the PORTABILITY BLOCKER below.
7
+ //
8
+ // ⚠ BLOCKER (surfaced P0-b task 4): antigravity-sync.sh runs the sync from a LOCAL DEV CHECKOUT
9
+ // ($HOME/dev/cortex/packages/cortex-agent via `bun run src/index.ts antigravity-sync`). A coworker
10
+ // has no ~/dev/cortex, so Antigravity capture is bespoke to the author's machine and is NOT yet
11
+ // installable for a real seat. Making it portable requires a published `antigravity-sync` entrypoint
12
+ // (npx @theronap/cortex-mcp antigravity-sync, or a published cortex-agent) — a prerequisite this
13
+ // adapter cannot satisfy alone. Until then wire() writes the artifacts + returns a blocking warning.
14
+ import { homedir } from 'node:os'
15
+ import { writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+
18
+ const AG_STATE = ['Library', 'Application Support', 'Antigravity']
19
+ const AG_WATCH = [...AG_STATE, 'User', 'globalStorage']
20
+
21
+ /** The launchd plist, home-substituted. Deterministic — golden-testable byte-for-byte. */
22
+ export function renderAntigravityPlist({ home = homedir() } = {}) {
23
+ const sh = join(home, '.cortex', 'antigravity-sync.sh')
24
+ const watch = join(home, ...AG_WATCH)
25
+ const logp = join(home, '.cortex', 'antigravity-sync.launchd.log')
26
+ return `<?xml version="1.0" encoding="UTF-8"?>
27
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
28
+ <plist version="1.0">
29
+ <dict>
30
+ <key>Label</key>
31
+ <string>com.cortex.antigravity-sync</string>
32
+ <key>ProgramArguments</key>
33
+ <array>
34
+ <string>/bin/bash</string>
35
+ <string>${sh}</string>
36
+ </array>
37
+ <key>WatchPaths</key>
38
+ <array>
39
+ <string>${watch}</string>
40
+ </array>
41
+ <key>RunAtLoad</key>
42
+ <false/>
43
+ <key>ThrottleInterval</key>
44
+ <integer>30</integer>
45
+ <key>StandardOutPath</key>
46
+ <string>${logp}</string>
47
+ <key>StandardErrorPath</key>
48
+ <string>${logp}</string>
49
+ </dict>
50
+ </plist>
51
+ `
52
+ }
53
+
54
+ /** The sync.sh. Self-resolves paths via $HOME at runtime, so it is a static template.
55
+ * NOTE the AGENT_DIR line is the portability blocker documented above. */
56
+ export function renderAntigravitySyncSh() {
57
+ return `#!/bin/bash
58
+ # Cortex ⇄ Antigravity one-shot sync, triggered by launchd WatchPaths on the Antigravity
59
+ # trajectory store. There is no resident daemon — launchd wakes this on change and it exits.
60
+ # Token is resolved from the wired MCP config at runtime (never stored in plist/script).
61
+ # ⚠ AGENT_DIR points at a local dev checkout — see antigravity.mjs BLOCKER note (not coworker-portable).
62
+ set -o pipefail
63
+ LOG="$HOME/.cortex/antigravity-sync.log"
64
+ NODE="/usr/local/bin/node"
65
+ BUN="$HOME/.bun/bin/bun"
66
+ AGENT_DIR="$HOME/dev/cortex/packages/cortex-agent"
67
+
68
+ stamp() { date -u +%FT%TZ; }
69
+
70
+ TOKEN="$("$NODE" -e "try{console.log(JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.claude.json','utf8'))?.mcpServers?.cortex?.env?.CORTEX_TOKEN||'')}catch(e){process.exit(0)}" 2>/dev/null)"
71
+ if [ -z "$TOKEN" ]; then echo "$(stamp) no CORTEX_TOKEN wired, skipping" >> "$LOG"; exit 0; fi
72
+ if [ ! -d "$AGENT_DIR" ]; then echo "$(stamp) agent dir missing: $AGENT_DIR" >> "$LOG"; exit 0; fi
73
+
74
+ cd "$AGENT_DIR" || exit 0
75
+ CORTEX_TOKEN="$TOKEN" CORTEX_WATCH="$HOME/.cortex-agent" "$BUN" run src/index.ts antigravity-sync >> "$LOG" 2>&1
76
+ echo "$(stamp) exit=$?" >> "$LOG"
77
+ exit 0
78
+ `
79
+ }
80
+
81
+ /** @type {import('./index.mjs').EditorAdapter} */
82
+ export default {
83
+ id: 'antigravity',
84
+ displayName: 'Antigravity',
85
+
86
+ detect({ home = homedir(), exists = existsSync } = {}) {
87
+ return exists(join(home, ...AG_STATE))
88
+ },
89
+
90
+ capabilities: {
91
+ promptTimeInjection: false, // no in-editor injection possible today
92
+ sessionStart: false,
93
+ captureHook: true, // post-sync, via antigravity-sync.sh
94
+ skillDir: null,
95
+ docTrigger: 'watch',
96
+ },
97
+
98
+ // Writes the two artifacts deterministically. Does NOT launchctl-load (unverifiable side effect) and
99
+ // returns a BLOCKING warning about the dev-checkout dependency — honest about not-yet-installable.
100
+ async wire({ home = homedir(), log = () => {} } = {}) {
101
+ const wrote = [], skipped = [], warnings = []
102
+ const cortexDir = join(home, '.cortex')
103
+ const shPath = join(cortexDir, 'antigravity-sync.sh')
104
+ const plistPath = join(home, 'Library', 'LaunchAgents', 'com.cortex.antigravity-sync.plist')
105
+
106
+ try { // non-fatal: an IO error must not abort sibling editors
107
+ mkdirSync(cortexDir, { recursive: true })
108
+ writeFileSync(shPath, renderAntigravitySyncSh())
109
+ try { chmodSync(shPath, 0o755) } catch { /* non-fatal on non-posix */ }
110
+ wrote.push(shPath)
111
+
112
+ mkdirSync(join(home, 'Library', 'LaunchAgents'), { recursive: true })
113
+ writeFileSync(plistPath, renderAntigravityPlist({ home }))
114
+ wrote.push(plistPath)
115
+ log(` ✓ Antigravity artifacts → ${shPath}, ${plistPath}`)
116
+ } catch (e) {
117
+ warnings.push(`Antigravity artifact write skipped: ${e.message}`)
118
+ }
119
+
120
+ warnings.push('Antigravity NOT fully wired: (1) run `launchctl load ' + plistPath + '` to activate the watcher; (2) BLOCKER — the sync runs from a local dev checkout (~/dev/cortex/packages/cortex-agent), so it will not work on a machine without that checkout until a published antigravity-sync entrypoint exists.')
121
+ return { wrote, skipped, warnings }
122
+ },
123
+ }
@@ -0,0 +1,125 @@
1
+ // Editor adapter: Claude Code. P0-b task 2 — wire() + the pure merge functions extracted from
2
+ // setup.mjs. setup.mjs imports THESE SAME functions (and re-exports them), so the config bytes are
3
+ // structurally identical, not merely "supposed to match".
4
+ import { homedir } from 'node:os'
5
+ import { existsSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { readJson, backupFile, ensureDir, writeJson } from './_fsutil.mjs'
8
+
9
+ /** Merge the Cortex MCP server into a ~/.claude.json object. Pure + idempotent: sets only the
10
+ * `cortex` entry (type:'stdio'), preserves every other server. `spec` = the package@dist-tag string. */
11
+ export function mergeClaudeMcp(existing, spec, token) {
12
+ const cfg = existing && typeof existing === 'object' ? { ...existing } : {}
13
+ cfg.mcpServers = cfg.mcpServers && typeof cfg.mcpServers === 'object' ? { ...cfg.mcpServers } : {}
14
+ cfg.mcpServers.cortex = { type: 'stdio', command: 'npx', args: ['-y', spec], env: { CORTEX_TOKEN: token } }
15
+ return cfg
16
+ }
17
+
18
+ /** Merge Cortex's Claude Code hooks into a ~/.claude/settings.json object. Pure + idempotent: drops
19
+ * any prior cortex entry (old token/path/version) from each hook array before appending the current
20
+ * one — capture (Stop), status + skills-repair + snapshot-context (SessionStart), precompact
21
+ * (PreCompact). Commands carry NO inline token (each subcommand self-resolves it). Mirrors setup.mjs
22
+ * step 2 exactly; foreign hooks are never touched. */
23
+ export function mergeClaudeSettings(existing, spec) {
24
+ const s = existing && typeof existing === 'object' ? existing : {}
25
+ s.hooks = s.hooks ?? {}
26
+
27
+ // Stop — capture.
28
+ s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
29
+ const captureCmd = `npx -y ${spec} capture`
30
+ for (const grp of s.hooks.Stop) {
31
+ if (Array.isArray(grp.hooks)) grp.hooks = grp.hooks.filter((h) => !/cortex-mcp.*capture|capture-session-cloud/.test(h.command ?? ''))
32
+ }
33
+ let grp = s.hooks.Stop.find((g) => (g.matcher ?? '') === '')
34
+ if (!grp) { grp = { matcher: '', hooks: [] }; s.hooks.Stop.push(grp) }
35
+ grp.hooks = grp.hooks ?? []
36
+ grp.hooks.push({ type: 'command', command: captureCmd })
37
+
38
+ // SessionStart — status, then skills self-heal, then snapshot-context (order matters: matches setup).
39
+ s.hooks.SessionStart = Array.isArray(s.hooks.SessionStart) ? s.hooks.SessionStart : []
40
+ const statusCmd = `npx -y ${spec} status`
41
+ for (const sg of s.hooks.SessionStart) {
42
+ if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? status/.test(h.command ?? ''))
43
+ }
44
+ let sgrp = s.hooks.SessionStart.find((g) => (g.matcher ?? '') === '')
45
+ if (!sgrp) { sgrp = { matcher: '', hooks: [] }; s.hooks.SessionStart.push(sgrp) }
46
+ sgrp.hooks = sgrp.hooks ?? []
47
+ sgrp.hooks.push({ type: 'command', command: statusCmd })
48
+
49
+ const skillsCmd = `npx -y ${spec} skills --repair --quiet`
50
+ for (const sg of s.hooks.SessionStart) {
51
+ if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? skills/.test(h.command ?? ''))
52
+ }
53
+ sgrp.hooks.push({ type: 'command', command: skillsCmd })
54
+
55
+ const snapshotCmd = `npx -y ${spec} snapshot-context`
56
+ for (const sg of s.hooks.SessionStart) {
57
+ if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? snapshot-context/.test(h.command ?? ''))
58
+ }
59
+ sgrp.hooks.push({ type: 'command', command: snapshotCmd })
60
+
61
+ // PreCompact — "author now" reminder.
62
+ s.hooks.PreCompact = Array.isArray(s.hooks.PreCompact) ? s.hooks.PreCompact : []
63
+ const precompactCmd = `npx -y ${spec} precompact`
64
+ for (const pg of s.hooks.PreCompact) {
65
+ if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? precompact/.test(h.command ?? ''))
66
+ }
67
+ let pgrp = s.hooks.PreCompact.find((g) => (g.matcher ?? '') === '')
68
+ if (!pgrp) { pgrp = { matcher: '', hooks: [] }; s.hooks.PreCompact.push(pgrp) }
69
+ pgrp.hooks = pgrp.hooks ?? []
70
+ pgrp.hooks.push({ type: 'command', command: precompactCmd })
71
+
72
+ return s
73
+ }
74
+
75
+ /** @type {import('./index.mjs').EditorAdapter} */
76
+ export default {
77
+ id: 'claude-code',
78
+ displayName: 'Claude Code',
79
+
80
+ detect({ home = homedir(), exists = existsSync } = {}) {
81
+ return exists(join(home, '.claude')) || exists(join(home, '.claude.json'))
82
+ },
83
+
84
+ capabilities: {
85
+ promptTimeInjection: true,
86
+ sessionStart: true,
87
+ captureHook: true,
88
+ skillDir: '~/.claude/skills',
89
+ docTrigger: 'stop-hook',
90
+ },
91
+
92
+ // Two independent, non-fatal blocks (mirrors codex.mjs): a malformed file OR an IO error is
93
+ // skipped-with-warning, never a throw that aborts the other block or a sibling editor. readJson
94
+ // throws on malformed → caught here → that file is left untouched (never overwritten). The WRITTEN
95
+ // bytes come from the shared pure functions above, so they are identical to setup's output.
96
+ async wire({ home = homedir(), token, spec, log = () => {} } = {}) {
97
+ const wrote = [], skipped = [], warnings = []
98
+ if (!token) { warnings.push('no token — skipped Claude Code'); return { wrote, skipped, warnings } }
99
+
100
+ const claudeJson = join(home, '.claude.json')
101
+ try {
102
+ const cfg = readJson(claudeJson) // {} if absent/empty; THROWS (→ skip) if malformed
103
+ const bak = backupFile(claudeJson)
104
+ ensureDir(claudeJson)
105
+ writeJson(claudeJson, mergeClaudeMcp(cfg, spec, token))
106
+ wrote.push(claudeJson)
107
+ log(` ✓ MCP server → ${claudeJson}${bak ? ' (backup saved)' : ''}`)
108
+ } catch (e) {
109
+ warnings.push(`${claudeJson} wiring skipped: ${e.message}`)
110
+ }
111
+
112
+ const settingsJson = join(home, '.claude', 'settings.json')
113
+ try {
114
+ const s = readJson(settingsJson)
115
+ const bak = backupFile(settingsJson)
116
+ ensureDir(settingsJson)
117
+ writeJson(settingsJson, mergeClaudeSettings(s, spec))
118
+ wrote.push(settingsJson)
119
+ log(` ✓ Capture hook + status line → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
120
+ } catch (e) {
121
+ warnings.push(`${settingsJson} wiring skipped: ${e.message}`)
122
+ }
123
+ return { wrote, skipped, warnings }
124
+ },
125
+ }
@@ -0,0 +1,111 @@
1
+ // Editor adapter: OpenAI Codex. P0-b task 3 — wire() + the pure merge functions, moved here verbatim
2
+ // from setup.mjs (which now imports + re-exports them). No caller imported them from setup except
3
+ // setup itself, so this move is transparent.
4
+ import { homedir } from 'node:os'
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { readJson, backupFile, ensureDir, writeJson } from './_fsutil.mjs'
8
+
9
+ /** Merge the Cortex MCP server into a Codex config.toml. Pure + idempotent: strips any existing
10
+ * [mcp_servers.cortex] / [mcp_servers.cortex.env] tables (so re-runs update in place rather than
11
+ * duplicating — a duplicate TOML table would break Codex's parser), preserves every other table,
12
+ * and appends a fresh block. Only touches the cortex tables; never rewrites the user's config. */
13
+ export function mergeCodexToml(text, spec, token) {
14
+ const targets = new Set(['[mcp_servers.cortex]', '[mcp_servers.cortex.env]'])
15
+ const kept = []
16
+ let skipping = false
17
+ for (const line of (text || '').split('\n')) {
18
+ const t = line.trim()
19
+ if (t.startsWith('[') && t.endsWith(']')) skipping = targets.has(t)
20
+ if (!skipping) kept.push(line)
21
+ }
22
+ while (kept.length && kept[kept.length - 1].trim() === '') kept.pop() // drop trailing blanks
23
+ const block = [
24
+ '',
25
+ '[mcp_servers.cortex]',
26
+ 'command = "npx"',
27
+ `args = ["-y", "${spec}"]`,
28
+ 'startup_timeout_sec = 60', // first npx fetch can be slow; don't time out the server on cold start
29
+ '',
30
+ '[mcp_servers.cortex.env]',
31
+ `CORTEX_TOKEN = "${token}"`,
32
+ '',
33
+ ]
34
+ return [...kept, ...block].join('\n')
35
+ }
36
+
37
+ /** Merge the capture Stop hook into a Codex hooks.json object. Pure + idempotent: drops any prior
38
+ * cortex capture entry (old token / old path / old pinned version) before appending the current one,
39
+ * so re-running never duplicates and never leaves a stale version pinned behind a live one.
40
+ * Codex's hook runtime has NO SessionStart — only capture (Stop) is wired here. */
41
+ export function mergeCodexHooks(existing, captureCmd) {
42
+ const h = existing && typeof existing === 'object' ? existing : {}
43
+ h.hooks = h.hooks && typeof h.hooks === 'object' ? h.hooks : {}
44
+ h.hooks.Stop = Array.isArray(h.hooks.Stop) ? h.hooks.Stop : []
45
+ for (const grp of h.hooks.Stop) {
46
+ if (Array.isArray(grp.hooks)) {
47
+ grp.hooks = grp.hooks.filter((c) => !/cortex-mcp.*capture|capture-session-cloud/.test(c.command ?? ''))
48
+ }
49
+ }
50
+ let grp = h.hooks.Stop.find((g) => (g.matcher ?? '') === '')
51
+ if (!grp) { grp = { matcher: '', hooks: [] }; h.hooks.Stop.push(grp) }
52
+ grp.hooks = grp.hooks ?? []
53
+ grp.hooks.push({ type: 'command', command: captureCmd })
54
+ return h
55
+ }
56
+
57
+ /** @type {import('./index.mjs').EditorAdapter} */
58
+ export default {
59
+ id: 'codex',
60
+ displayName: 'OpenAI Codex',
61
+
62
+ detect({ home = homedir(), exists = existsSync } = {}) {
63
+ return exists(join(home, '.codex'))
64
+ },
65
+
66
+ // Codex's hook runtime recognizes PreToolUse/PostToolUse/PreCompact/UserPromptSubmit/Stop — notably
67
+ // NO SessionStart. So status/skills-repair/snapshot-context stay Claude-only; the manifest must
68
+ // encode sessionStart:false so P1/P2 don't assume it.
69
+ capabilities: {
70
+ promptTimeInjection: true,
71
+ sessionStart: false,
72
+ captureHook: true,
73
+ skillDir: null,
74
+ docTrigger: 'stop-hook',
75
+ },
76
+
77
+ // Two independent, non-fatal blocks (config.toml + hooks.json) — mirrors setup.mjs steps 1b/1c.
78
+ // Written bytes come from the shared pure functions above → identical to setup's output.
79
+ async wire({ home = homedir(), token, spec, log = () => {} } = {}) {
80
+ const wrote = [], skipped = [], warnings = []
81
+ if (!token) { warnings.push('no token — skipped Codex'); return { wrote, skipped, warnings } }
82
+ const codexDir = join(home, '.codex')
83
+
84
+ try {
85
+ const codexToml = join(codexDir, 'config.toml')
86
+ const existing = existsSync(codexToml) ? readFileSync(codexToml, 'utf8') : ''
87
+ const bak = backupFile(codexToml)
88
+ ensureDir(codexToml)
89
+ writeFileSync(codexToml, mergeCodexToml(existing, spec, token)) // raw TOML text, not JSON
90
+ wrote.push(codexToml)
91
+ log(` ✓ MCP server → ${codexToml}${bak ? ' (backup saved)' : ''}`)
92
+ } catch (e) {
93
+ warnings.push(`Codex MCP wiring skipped: ${e.message}`)
94
+ }
95
+
96
+ try {
97
+ const codexHooks = join(codexDir, 'hooks.json')
98
+ let existingHooks
99
+ try { existingHooks = readJson(codexHooks) } catch { existingHooks = {} } // malformed → start fresh, don't block
100
+ const bak = backupFile(codexHooks)
101
+ const captureCmd = `npx -y ${spec} capture`
102
+ ensureDir(codexHooks)
103
+ writeJson(codexHooks, mergeCodexHooks(existingHooks, captureCmd))
104
+ wrote.push(codexHooks)
105
+ log(` ✓ Capture hook → ${codexHooks}${bak ? ' (backup saved)' : ''}`)
106
+ } catch (e) {
107
+ warnings.push(`Codex capture hook skipped: ${e.message}`)
108
+ }
109
+ return { wrote, skipped, warnings }
110
+ },
111
+ }
@@ -0,0 +1,77 @@
1
+ // Editor adapter: Cursor. P0-b task 4 (+ review fixes).
2
+ // ~/.cursor/mcp.json uses the same mcpServers.cortex shape as Claude (minus the `type` field the real
3
+ // Cursor config omits), so wiring Cursor is a pure JSON merge (mergeCursorMcp).
4
+ import { homedir } from 'node:os'
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { backupFile } from './_fsutil.mjs'
8
+
9
+ /** Merge the Cortex MCP server into a Cursor mcp.json object. Pure + idempotent: sets only the
10
+ * `cortex` entry, preserves every other server. `spec` is the package@dist-tag STRING — the same
11
+ * convention claude/codex/setup.mjs use, so one install driver can pass a single spec to every adapter. */
12
+ export function mergeCursorMcp(existing, spec, token) {
13
+ const cfg = existing && typeof existing === 'object' ? { ...existing } : {}
14
+ cfg.mcpServers = cfg.mcpServers && typeof cfg.mcpServers === 'object' ? { ...cfg.mcpServers } : {}
15
+ cfg.mcpServers.cortex = { command: 'npx', args: ['-y', spec], env: { CORTEX_TOKEN: token } }
16
+ return cfg
17
+ }
18
+
19
+ /** @type {import('./index.mjs').EditorAdapter} */
20
+ export default {
21
+ id: 'cursor',
22
+ displayName: 'Cursor',
23
+
24
+ detect({ home = homedir(), exists = existsSync } = {}) {
25
+ return exists(join(home, '.cursor'))
26
+ },
27
+
28
+ // Today Cursor is capture-only: the hook buffers turns and POSTs to /api/ingest, but does not
29
+ // inject sibling-session context at prompt time. promptTimeInjection stays false until the
30
+ // Pillar-1 Cursor inject hook is built (a named gap in cross-app-session-visibility-spec.md).
31
+ capabilities: {
32
+ promptTimeInjection: false,
33
+ sessionStart: false,
34
+ captureHook: true, // ~/.cursor/hooks/cortex-cursor.mjs
35
+ skillDir: null, // Cursor rules/commands dir — revisit in Pillar 2
36
+ docTrigger: 'hook',
37
+ },
38
+
39
+ // Non-fatal by contract: a Cursor failure (parse OR IO) must never abort the other editors' wiring.
40
+ async wire({ home = homedir(), token, spec, log = () => {} } = {}) {
41
+ const wrote = [], skipped = [], warnings = []
42
+ if (!token) { warnings.push('no token — skipped Cursor'); return { wrote, skipped, warnings } }
43
+
44
+ const cursorDir = join(home, '.cursor')
45
+ const mcpPath = join(cursorDir, 'mcp.json')
46
+ try {
47
+ let existing = {}
48
+ if (existsSync(mcpPath)) {
49
+ try {
50
+ existing = JSON.parse(readFileSync(mcpPath, 'utf8'))
51
+ } catch {
52
+ // Data-loss guard: a malformed mcp.json may still hold the user's other MCP servers we can't
53
+ // safely parse. Do NOT overwrite it with a cortex-only config — skip with a warning.
54
+ warnings.push(`${mcpPath} is not valid JSON — left untouched (fix it, then re-run) to avoid dropping your other MCP servers`)
55
+ return { wrote, skipped, warnings }
56
+ }
57
+ }
58
+ const bak = backupFile(mcpPath)
59
+ mkdirSync(cursorDir, { recursive: true })
60
+ writeFileSync(mcpPath, JSON.stringify(mergeCursorMcp(existing, spec, token), null, 2))
61
+ wrote.push(mcpPath)
62
+ log(` ✓ MCP server → ${mcpPath}${bak ? ' (backup saved)' : ''}`)
63
+ } catch (e) {
64
+ warnings.push(`Cursor MCP wiring skipped: ${e.message}`) // IO error → non-fatal, siblings unaffected
65
+ }
66
+
67
+ // Capture hook: detect, don't fabricate. The cortex-cursor.mjs hook isn't bundled in this package
68
+ // yet (it was hand-installed), so warn rather than write a guessed file. Bundling it is follow-up.
69
+ const hookPath = join(cursorDir, 'hooks', 'cortex-cursor.mjs')
70
+ if (!existsSync(hookPath)) {
71
+ warnings.push('Cursor capture hook (~/.cursor/hooks/cortex-cursor.mjs) not found — Cursor sessions will not be captured until it is installed')
72
+ } else {
73
+ skipped.push(`${hookPath} (already present)`)
74
+ }
75
+ return { wrote, skipped, warnings }
76
+ },
77
+ }
@@ -0,0 +1,42 @@
1
+ // Editor adapter registry (P0-b task 1). One place that knows every editor Cortex can wire.
2
+ // Adding an editor = add a module here; the install driver + capability manifest fall out of it.
3
+ //
4
+ // EditorAdapter shape (see the four modules):
5
+ // id stable string id (used in the manifest + `--editor <id>`)
6
+ // displayName human label
7
+ // detect(env) → boolean; env = { home?, exists? } is injectable for tests
8
+ // capabilities { promptTimeInjection, sessionStart, captureHook, skillDir, docTrigger }
9
+ // wire(ctx) → performs the install for this editor (stubbed in task 1)
10
+ //
11
+ /** @typedef {Object} EditorAdapter */
12
+
13
+ import claude from './claude.mjs'
14
+ import codex from './codex.mjs'
15
+ import cursor from './cursor.mjs'
16
+ import antigravity from './antigravity.mjs'
17
+
18
+ export const ADAPTERS = [claude, codex, cursor, antigravity]
19
+
20
+ /**
21
+ * Resolve which editors an install targets.
22
+ * 'auto' → every adapter whose detect() is true on this machine (the default)
23
+ * 'all' → every adapter, regardless of detection
24
+ * 'id'|'a,b' → the named adapter(s); throws on an unknown id (fail loud, don't silently skip)
25
+ *
26
+ * @param {string} [flag='auto']
27
+ * @param {{ adapters?: EditorAdapter[], env?: { home?: string, exists?: (p: string) => boolean } }} [opts]
28
+ * @returns {EditorAdapter[]}
29
+ */
30
+ export function resolveEditors(flag = 'auto', { adapters = ADAPTERS, env } = {}) {
31
+ if (flag === 'all') return [...adapters]
32
+ if (flag === 'auto') return adapters.filter((a) => a.detect(env))
33
+
34
+ const ids = String(flag).split(',').map((s) => s.trim()).filter(Boolean)
35
+ const known = new Set(adapters.map((a) => a.id))
36
+ const unknown = ids.filter((id) => !known.has(id))
37
+ if (unknown.length) {
38
+ throw new Error(`Unknown editor(s): ${unknown.join(', ')}. Known: ${adapters.map((a) => a.id).join(', ')}`)
39
+ }
40
+ // Preserve registry order, dedupe.
41
+ return adapters.filter((a) => ids.includes(a.id))
42
+ }
package/lib/grep_cli.mjs CHANGED
@@ -5,16 +5,20 @@ import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
5
5
  // Output is ASCII-only (outbound-message convention).
6
6
 
7
7
  // Pure: parse argv after `grep` → { query, mode, max }.
8
- // cortex grep <terms...> [--mode fts | --fts] [--max N]
8
+ // cortex grep <terms...> [--substring | --literal] [--max N]
9
+ // Default mode is 'fts' (ranked keyword — multi-word queries work); pass --substring/--literal for
10
+ // an exact literal match (identifiers, [[links]], code). (#220)
9
11
  export function parseGrepArgs(argv = []) {
10
- const out = { query: '', mode: 'substring', max: undefined }
12
+ const out = { query: '', mode: 'fts', max: undefined }
11
13
  const terms = []
12
14
  for (let i = 0; i < argv.length; i++) {
13
15
  const a = argv[i]
14
16
  if (a === '--mode') {
15
- out.mode = argv[++i] === 'fts' ? 'fts' : 'substring'
17
+ out.mode = argv[++i] === 'substring' ? 'substring' : 'fts'
16
18
  } else if (a === '--fts') {
17
19
  out.mode = 'fts'
20
+ } else if (a === '--substring' || a === '--literal') {
21
+ out.mode = 'substring'
18
22
  } else if (a === '--max') {
19
23
  const n = Number(argv[++i])
20
24
  if (Number.isFinite(n)) out.max = Math.trunc(n)
@@ -50,7 +54,7 @@ export async function runGrep(rest = []) {
50
54
  }
51
55
  const { query, mode, max } = parseGrepArgs(rest)
52
56
  if (!query) {
53
- process.stderr.write('usage: cortex grep <query> [--mode fts] [--max N]\n')
57
+ process.stderr.write('usage: cortex grep <query> [--substring|--literal] [--max N]\n')
54
58
  return 1
55
59
  }
56
60
  const qs = new URLSearchParams({ q: query, mode })
@@ -0,0 +1,161 @@
1
+ // `cortex install` — the cross-editor hub installer (P0-b). One command that wires the Cortex MCP
2
+ // server + client shim into EVERY detected editor via the adapter registry, then writes a capability
3
+ // manifest so later pillars (session sync, skill sync, doc sync) know which channels each editor
4
+ // supports. `setup <token>` stays as the single-editor path the console prints; `install` is the
5
+ // multi-editor superset that drives the SAME adapter `wire()` functions.
6
+ import { homedir } from 'node:os'
7
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
8
+ import { join, dirname } from 'node:path'
9
+ import { ADAPTERS, resolveEditors } from './editors/index.mjs'
10
+ import { checkToken, resolveBase, readWiredToken } from './diagnose.mjs'
11
+
12
+ const PKG = '@theronap/cortex-mcp'
13
+ export const MANIFEST_PATH = join(homedir(), '.cortex', 'editors.json')
14
+
15
+ /** Parse install argv: an optional positional <token> + `--editor auto|all|<id,...>` (also `--all`,
16
+ * `--editor=<v>`, `-e <v>`). Unknown flags are ignored (fail-soft — never mistaken for the token). */
17
+ export function parseInstallArgs(argv = []) {
18
+ let editor = 'auto'
19
+ let token
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i]
22
+ if (a === '--editor' || a === '-e') { editor = argv[++i] ?? editor; continue }
23
+ if (a.startsWith('--editor=')) { editor = a.slice('--editor='.length); continue }
24
+ if (a === '--all') { editor = 'all'; continue }
25
+ if (a.startsWith('-')) continue // ignore unknown flags, don't treat as token
26
+ if (token === undefined) token = a
27
+ }
28
+ return { token, editor: editor || 'auto' }
29
+ }
30
+
31
+ /** Pure: build the capability manifest from resolved per-editor entries. Injectable `now`/`cortexMcp`
32
+ * keep it deterministic for tests. Each entry = { adapter, detected, wired, wrote?, warnings? }. */
33
+ export function buildManifest(entries, { now = new Date().toISOString(), cortexMcp = null } = {}) {
34
+ return {
35
+ schema: 1,
36
+ generatedAt: now,
37
+ cortexMcp,
38
+ editors: entries.map((e) => ({
39
+ id: e.adapter.id,
40
+ displayName: e.adapter.displayName,
41
+ detected: !!e.detected,
42
+ wired: !!e.wired,
43
+ capabilities: e.adapter.capabilities,
44
+ wrote: e.wrote ?? [],
45
+ warnings: e.warnings ?? [],
46
+ })),
47
+ }
48
+ }
49
+
50
+ /** Read the existing manifest ({} if absent/malformed — never throws). */
51
+ export function readManifest({ path = MANIFEST_PATH } = {}) {
52
+ try {
53
+ if (!existsSync(path)) return {}
54
+ const raw = readFileSync(path, 'utf8').trim()
55
+ return raw ? JSON.parse(raw) : {}
56
+ } catch {
57
+ return {} // malformed manifest is not fatal — a fresh install rewrites it
58
+ }
59
+ }
60
+
61
+ export function writeManifest(manifest, { path = MANIFEST_PATH } = {}) {
62
+ const dir = dirname(path)
63
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
64
+ writeFileSync(path, JSON.stringify(manifest, null, 2))
65
+ return path
66
+ }
67
+
68
+ export async function runInstall(argv, version) {
69
+ const { token: argToken, editor } = parseInstallArgs(argv)
70
+ const token = argToken || readWiredToken()
71
+ const spec = `${PKG}@stable`
72
+ const home = homedir()
73
+ const log = (m) => process.stdout.write(m + '\n')
74
+
75
+ if (!token) {
76
+ process.stderr.write(
77
+ 'Usage: npx @theronap/cortex-mcp install [<CORTEX_TOKEN>] [--editor auto|all|<id,...>]\n\n' +
78
+ 'No token was given and none is already wired.\n' +
79
+ 'Get your token from the Cortex console → Connect your AI.\n',
80
+ )
81
+ process.exit(1)
82
+ }
83
+
84
+ let targets
85
+ try {
86
+ targets = resolveEditors(editor)
87
+ } catch (e) {
88
+ process.stderr.write(`\n✗ ${e.message}\n`)
89
+ process.exit(1)
90
+ }
91
+
92
+ log('')
93
+ log('Cortex install — wiring your AI editors…')
94
+ if (editor === 'auto' && targets.length === 0) {
95
+ log(' (no supported editors detected — pass --editor all to force, or install an editor first)')
96
+ }
97
+
98
+ // Carry forward prior manifest state for editors NOT targeted this run, so a single-editor
99
+ // `install --editor cursor` doesn't wipe the "wired" record of editors a previous run set up.
100
+ const priorById = new Map((readManifest().editors ?? []).map((e) => [e.id, e]))
101
+ const targetIds = new Set(targets.map((a) => a.id))
102
+
103
+ const entries = []
104
+ for (const adapter of ADAPTERS) {
105
+ const detected = safeDetect(adapter)
106
+ if (!targetIds.has(adapter.id)) {
107
+ const p = priorById.get(adapter.id)
108
+ entries.push({ adapter, detected, wired: p?.wired ?? false, wrote: p?.wrote ?? [], warnings: [] })
109
+ continue
110
+ }
111
+ log(`\n${adapter.displayName}:`)
112
+ let res = { wrote: [], skipped: [], warnings: [] }
113
+ try {
114
+ res = (await adapter.wire({ home, token, spec, log })) ?? res
115
+ } catch (e) {
116
+ // A single editor's failure must never abort the others (fail-soft, like setup's Codex block).
117
+ res.warnings = [...(res.warnings ?? []), `wire failed: ${e.message}`]
118
+ log(` ⚠ ${adapter.displayName} wiring failed: ${e.message}`)
119
+ }
120
+ for (const w of res.warnings ?? []) log(` ⚠ ${w}`)
121
+ entries.push({
122
+ adapter,
123
+ detected,
124
+ wired: (res.wrote ?? []).length > 0,
125
+ wrote: res.wrote ?? [],
126
+ warnings: res.warnings ?? [],
127
+ })
128
+ }
129
+
130
+ const mpath = writeManifest(buildManifest(entries, { cortexMcp: version ?? null }))
131
+ log(`\n ✓ Capability manifest → ${mpath}`)
132
+
133
+ // Verify the token against the live API — writing config proves "files written", not "it works".
134
+ const base = resolveBase(process.env.CORTEX_URL)
135
+ log('')
136
+ log('Verifying your token against Cortex…')
137
+ try {
138
+ const health = await checkToken(token, base)
139
+ if (health.ok) {
140
+ const n = health.projectCount
141
+ log(` ✓ Verified — your token works${typeof n === 'number' ? ` (you can see ${n} project${n === 1 ? '' : 's'})` : ''}.`)
142
+ } else {
143
+ log(' ⚠ Config written, but the live check did NOT pass:')
144
+ log(` ${health.diagnosis?.message ?? 'unknown error'}`)
145
+ log(' The files are in place; fix the above, then re-check with `doctor`.')
146
+ }
147
+ } catch (e) {
148
+ log(` ⚠ Could not reach Cortex to verify (${e.message}). Config is written; re-check with 'doctor'.`)
149
+ }
150
+
151
+ const wired = entries.filter((e) => targetIds.has(e.adapter.id) && e.wired).map((e) => e.adapter.displayName)
152
+ log('')
153
+ log(`⟳ Wired ${wired.length} editor(s): ${wired.join(', ') || '(none)'}`)
154
+ log(' Fully quit and reopen each editor to load the Cortex server.')
155
+ log(` Console: ${base}`)
156
+ log('')
157
+ }
158
+
159
+ function safeDetect(adapter) {
160
+ try { return !!adapter.detect() } catch { return false }
161
+ }
package/lib/server.mjs CHANGED
@@ -266,14 +266,14 @@ export async function runServer(version) {
266
266
  {
267
267
  title: 'Grep the brain wiki',
268
268
  description:
269
- 'Literal substring search across your visible brain wiki pages (matches symbols, identifiers, [[links]]). Returns matching sections with a context snippet and their outbound [[links]].',
269
+ 'Ranked keyword search across your visible brain wiki pages. Multi-word natural-language queries work (results are ranked by relevance). Pass mode:"substring" for an exact literal match of symbols, identifiers, or [[links]]. Returns matching sections with a context snippet and their outbound [[links]].',
270
270
  inputSchema: {
271
- query: z.string().describe('literal substring to find'),
272
- mode: z.enum(['substring', 'fts']).optional().describe("'substring' (default, grep-like) or 'fts' (ranked keyword)"),
271
+ query: z.string().describe('search terms (natural language is fine)'),
272
+ mode: z.enum(['substring', 'fts']).optional().describe("'fts' (default, ranked keyword) or 'substring' (exact literal — for identifiers / [[links]] / code)"),
273
273
  },
274
274
  },
275
275
  async ({ query, mode }) => {
276
- const qs = new URLSearchParams({ q: query, mode: mode === 'fts' ? 'fts' : 'substring' })
276
+ const qs = new URLSearchParams({ q: query, mode: mode === 'substring' ? 'substring' : 'fts' })
277
277
  const res = await fetchCortex(`${BASE}/api/grep?${qs.toString()}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
278
278
  if (!res.ok) {
279
279
  const body = await res.text()
@@ -643,6 +643,33 @@ export async function runServer(version) {
643
643
  },
644
644
  )
645
645
 
646
+ server.registerTool(
647
+ 'create_brain',
648
+ {
649
+ title: 'Create a new brain under your existing account',
650
+ description: 'Create a brand-new brain (org/workspace) — a fully independent knowledge graph — under your EXISTING account. No new login, no new email/password: this adds a second membership to the account you are already using. Reads never cross brains; use set_active_brain afterward to point writes at it (creating it does not switch your active write brain automatically).',
651
+ inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Cortex Codebase"') },
652
+ },
653
+ async ({ name }) => {
654
+ let res
655
+ try {
656
+ res = await fetchCortex(`${BASE}/api/brains`, {
657
+ method: 'POST',
658
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
659
+ body: JSON.stringify({ name }),
660
+ })
661
+ } catch (e) {
662
+ return { content: [{ type: 'text', text: `Could not create brain: ${e.message}` }] }
663
+ }
664
+ if (!res.ok) {
665
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
666
+ return { content: [{ type: 'text', text: `Could not create brain: ${d.message}` }] }
667
+ }
668
+ const r = await res.json()
669
+ return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}]. Run set_active_brain with this org_id to start writing to it — reads already span it automatically.` }] }
670
+ },
671
+ )
672
+
646
673
  server.registerTool(
647
674
  'list_records',
648
675
  {
package/lib/setup.mjs CHANGED
@@ -3,6 +3,10 @@ import { homedir } from 'os'
3
3
  import { join, dirname } from 'path'
4
4
  import { checkToken, resolveBase, readWiredToken } from './diagnose.mjs'
5
5
  import { installSkills } from './skills.mjs'
6
+ // Pure config-merge functions live in the editor adapters; setup imports (and re-exports) THE SAME
7
+ // functions the `cortex install` path uses, so both write byte-identical config.
8
+ import { mergeClaudeMcp, mergeClaudeSettings } from './editors/claude.mjs'
9
+ import { mergeCodexToml, mergeCodexHooks } from './editors/codex.mjs'
6
10
 
7
11
  // One-command employee onboarding. Wires both:
8
12
  // 1. ~/.claude.json → the cortex MCP server (context-serving)
@@ -14,33 +18,7 @@ import { installSkills } from './skills.mjs'
14
18
 
15
19
  const PKG = '@theronap/cortex-mcp'
16
20
 
17
- // Merge the Cortex MCP server into a Codex config.toml. Pure + idempotent: strips any existing
18
- // [mcp_servers.cortex] / [mcp_servers.cortex.env] tables (so re-runs update in place rather than
19
- // duplicating — a duplicate TOML table would break Codex's parser), preserves every other table,
20
- // and appends a fresh block. Only touches the cortex tables; never rewrites the user's config.
21
- export function mergeCodexToml(text, spec, token) {
22
- const targets = new Set(['[mcp_servers.cortex]', '[mcp_servers.cortex.env]'])
23
- const kept = []
24
- let skipping = false
25
- for (const line of (text || '').split('\n')) {
26
- const t = line.trim()
27
- if (t.startsWith('[') && t.endsWith(']')) skipping = targets.has(t)
28
- if (!skipping) kept.push(line)
29
- }
30
- while (kept.length && kept[kept.length - 1].trim() === '') kept.pop() // drop trailing blanks
31
- const block = [
32
- '',
33
- '[mcp_servers.cortex]',
34
- 'command = "npx"',
35
- `args = ["-y", "${spec}"]`,
36
- 'startup_timeout_sec = 60', // first npx fetch can be slow; don't time out the server on cold start
37
- '',
38
- '[mcp_servers.cortex.env]',
39
- `CORTEX_TOKEN = "${token}"`,
40
- '',
41
- ]
42
- return [...kept, ...block].join('\n')
43
- }
21
+ // mergeCodexToml / mergeCodexHooks moved to ./editors/codex.mjs (imported above, re-exported below).
44
22
 
45
23
  function readJson(path) {
46
24
  if (!existsSync(path)) return {}
@@ -61,28 +39,6 @@ function ensureDir(path) {
61
39
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
62
40
  }
63
41
 
64
- // Merge the capture Stop hook into a Codex hooks.json object. Pure + idempotent: drops any prior
65
- // cortex capture entry (old token / old path / old pinned version) before appending the current one,
66
- // so re-running never duplicates and never leaves a stale version pinned behind a live one.
67
- // Mirrors the Claude Stop-hook merge in step 2 below, minus the SessionStart/PreCompact hooks Codex
68
- // doesn't support (confirmed: Codex's hook runtime only recognizes PreToolUse/PostToolUse/PreCompact/
69
- // UserPromptSubmit/Stop — no SessionStart, so status/skills-repair/snapshot-context stay Claude-only).
70
- export function mergeCodexHooks(existing, captureCmd) {
71
- const h = existing && typeof existing === 'object' ? existing : {}
72
- h.hooks = h.hooks && typeof h.hooks === 'object' ? h.hooks : {}
73
- h.hooks.Stop = Array.isArray(h.hooks.Stop) ? h.hooks.Stop : []
74
- for (const grp of h.hooks.Stop) {
75
- if (Array.isArray(grp.hooks)) {
76
- grp.hooks = grp.hooks.filter((c) => !/cortex-mcp.*capture|capture-session-cloud/.test(c.command ?? ''))
77
- }
78
- }
79
- let grp = h.hooks.Stop.find((g) => (g.matcher ?? '') === '')
80
- if (!grp) { grp = { matcher: '', hooks: [] }; h.hooks.Stop.push(grp) }
81
- grp.hooks = grp.hooks ?? []
82
- grp.hooks.push({ type: 'command', command: captureCmd })
83
- return h
84
- }
85
-
86
42
  export async function runSetup(argv, version) {
87
43
  const token = argv[0]
88
44
  if (!token || token.startsWith('-')) {
@@ -115,13 +71,7 @@ export async function runSetup(argv, version) {
115
71
  process.exit(1)
116
72
  }
117
73
  const bak = backup(claudeJson)
118
- cfg.mcpServers = cfg.mcpServers ?? {}
119
- cfg.mcpServers.cortex = {
120
- type: 'stdio',
121
- command: 'npx',
122
- args: ['-y', spec],
123
- env: { CORTEX_TOKEN: token },
124
- }
74
+ cfg = mergeClaudeMcp(cfg, spec, token)
125
75
  ensureDir(claudeJson)
126
76
  writeFileSync(claudeJson, JSON.stringify(cfg, null, 2))
127
77
  log(` ✓ MCP server → ${claudeJson}${bak ? ' (backup saved)' : ''}`)
@@ -176,72 +126,9 @@ export async function runSetup(argv, version) {
176
126
  process.exit(1)
177
127
  }
178
128
  const bak = backup(settingsJson)
179
- s.hooks = s.hooks ?? {}
180
- s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
181
-
182
- // NO inline token — same hygiene as the Codex hook above; `capture` self-resolves from
183
- // ~/.claude.json (which this setup run writes). The filter below drops the old inlined form.
184
- const captureCmd = `npx -y ${spec} capture`
185
- // Remove any prior cortex capture hook (idempotent: drop old token / old path forms).
186
- for (const grp of s.hooks.Stop) {
187
- if (Array.isArray(grp.hooks)) {
188
- grp.hooks = grp.hooks.filter((h) => !/cortex-mcp.*capture|capture-session-cloud/.test(h.command ?? ''))
189
- }
190
- }
191
- // Find or create a matcher:"" group and append.
192
- let grp = s.hooks.Stop.find((g) => (g.matcher ?? '') === '')
193
- if (!grp) { grp = { matcher: '', hooks: [] }; s.hooks.Stop.push(grp) }
194
- grp.hooks = grp.hooks ?? []
195
- grp.hooks.push({ type: 'command', command: captureCmd })
196
-
197
- // Connected-status SessionStart hook: one line inside Claude Code itself saying whether
198
- // this machine's sessions are flowing to the org (dry-run finding: silence is unreadable).
199
- // `status` reads the token from ~/.claude.json, so the command carries no secret.
200
- s.hooks.SessionStart = Array.isArray(s.hooks.SessionStart) ? s.hooks.SessionStart : []
201
- const statusCmd = `npx -y ${spec} status`
202
- for (const sg of s.hooks.SessionStart) {
203
- if (Array.isArray(sg.hooks)) {
204
- sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? status/.test(h.command ?? ''))
205
- }
206
- }
207
- let sgrp = s.hooks.SessionStart.find((g) => (g.matcher ?? '') === '')
208
- if (!sgrp) { sgrp = { matcher: '', hooks: [] }; s.hooks.SessionStart.push(sgrp) }
209
- sgrp.hooks = sgrp.hooks ?? []
210
- sgrp.hooks.push({ type: 'command', command: statusCmd })
211
-
212
- // Skills self-heal: every session start, restore any drifted managed Cortex skill (quiet — only
213
- // speaks up if it actually changed something). This is what makes the core skills "inalterable".
214
- const skillsCmd = `npx -y ${spec} skills --repair --quiet`
215
- for (const sg of s.hooks.SessionStart) {
216
- if (Array.isArray(sg.hooks)) {
217
- sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? skills/.test(h.command ?? ''))
218
- }
219
- }
220
- sgrp.hooks.push({ type: 'command', command: skillsCmd })
221
-
222
- const snapshotCmd = `npx -y ${spec} snapshot-context`
223
- for (const sg of s.hooks.SessionStart) {
224
- if (Array.isArray(sg.hooks)) {
225
- sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? snapshot-context/.test(h.command ?? ''))
226
- }
227
- }
228
- sgrp.hooks.push({ type: 'command', command: snapshotCmd })
229
-
230
- // PreCompact "author now" reminder (③ live wiki authoring, best-effort). Merges into the PreCompact
231
- // array WITHOUT clobbering other hooks (filters only prior cortex entries, then appends to the ''
232
- // matcher group). A hook can't force a turn — this just nudges the session to sweep understanding
233
- // into the wiki before compaction; the /log skill is the hard backstop.
234
- s.hooks.PreCompact = Array.isArray(s.hooks.PreCompact) ? s.hooks.PreCompact : []
235
- const precompactCmd = `npx -y ${spec} precompact`
236
- for (const pg of s.hooks.PreCompact) {
237
- if (Array.isArray(pg.hooks)) {
238
- pg.hooks = pg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? precompact/.test(h.command ?? ''))
239
- }
240
- }
241
- let pgrp = s.hooks.PreCompact.find((g) => (g.matcher ?? '') === '')
242
- if (!pgrp) { pgrp = { matcher: '', hooks: [] }; s.hooks.PreCompact.push(pgrp) }
243
- pgrp.hooks = pgrp.hooks ?? []
244
- pgrp.hooks.push({ type: 'command', command: precompactCmd })
129
+ // Capture (Stop) + status/skills/snapshot (SessionStart) + precompact (PreCompact), idempotent
130
+ // merge the same pure function the `cortex install` Claude adapter uses.
131
+ s = mergeClaudeSettings(s, spec)
245
132
 
246
133
  ensureDir(settingsJson)
247
134
  writeFileSync(settingsJson, JSON.stringify(s, null, 2))
@@ -291,6 +178,10 @@ export async function runSetup(argv, version) {
291
178
  // resolver — hook commands no longer inline the token. Re-exported for compatibility.
292
179
  export { readWiredToken }
293
180
 
181
+ // Re-export the pure merge functions from their adapter homes so existing importers of setup.mjs keep
182
+ // working and tests can assert setup uses the SAME function the install path does (byte-identity).
183
+ export { mergeClaudeMcp, mergeClaudeSettings, mergeCodexToml, mergeCodexHooks }
184
+
294
185
  // `repair`: re-run the FULL setup at THIS version using the already-wired token. The one-command fix
295
186
  // for a machine set up with an older version (e.g. when skills were installed to the old nested path,
296
187
  // or the hooks are pinned to a stale version). No token argument needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.43",
3
+ "version": "0.9.45",
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": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "lib",
12
- "skills"
12
+ "skills",
13
+ "!lib/**/*.test.mjs"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=18"
@@ -1,73 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'bun:test'
2
- import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'
3
- import { tmpdir } from 'os'
4
- import { join } from 'path'
5
- import { loadState, saveState, scanRoots, markFiles, collectMarkdown, hashContent } from './docs_scan.mjs'
6
-
7
- const tmp = () => mkdtempSync(join(tmpdir(), 'docs-scan-'))
8
-
9
- describe('state round-trip', () => {
10
- it('missing/corrupt state loads as empty and saves back', () => {
11
- const dir = tmp()
12
- const path = join(dir, 'nested', 'docs-sync.json')
13
- expect(loadState(path)).toEqual({ version: 1, roots: [], files: {} })
14
- const s = { version: 1, roots: ['/a'], files: { '/a/x.md': { hash: 'h', markedAt: 't' } } }
15
- saveState(s, path)
16
- expect(loadState(path)).toEqual(s)
17
- writeFileSync(path, '{not json')
18
- expect(loadState(path)).toEqual({ version: 1, roots: [], files: {} })
19
- })
20
- })
21
-
22
- describe('collectMarkdown skip rules', () => {
23
- let root
24
- beforeEach(() => {
25
- root = tmp()
26
- writeFileSync(join(root, 'spec.md'), '# spec')
27
- writeFileSync(join(root, 'notes.txt'), 'not md')
28
- writeFileSync(join(root, 'old.md.bak'), 'backup')
29
- mkdirSync(join(root, 'node_modules', 'pkg'), { recursive: true })
30
- writeFileSync(join(root, 'node_modules', 'pkg', 'README.md'), 'dep readme')
31
- mkdirSync(join(root, '.git'))
32
- writeFileSync(join(root, '.git', 'x.md'), 'git internals')
33
- mkdirSync(join(root, 'sub'))
34
- writeFileSync(join(root, 'sub', 'plan.md'), '# plan')
35
- writeFileSync(join(root, 'big.md'), 'x'.repeat(256 * 1024 + 1))
36
- })
37
- it('finds nested .md, skips node_modules/.git/.bak/non-md/oversize', () => {
38
- const found = collectMarkdown(root)
39
- expect(found).toEqual([join(root, 'spec.md'), join(root, 'sub', 'plan.md')])
40
- })
41
- })
42
-
43
- describe('scan + mark lifecycle', () => {
44
- it('new → pending; mark → clean; edit → changed; re-mark → clean', () => {
45
- const root = tmp()
46
- const doc = join(root, 'design.md')
47
- writeFileSync(doc, 'v1')
48
- const state = { version: 1, roots: [root], files: {} }
49
-
50
- let r = scanRoots(state)
51
- expect(r.pending).toEqual([{ path: doc, status: 'new' }])
52
-
53
- const m = markFiles(state, [doc], '2026-07-02T00:00:00Z')
54
- expect(m.marked).toEqual([doc])
55
- expect(state.files[doc].hash).toBe(hashContent('v1'))
56
- expect(scanRoots(state).pending).toEqual([])
57
-
58
- writeFileSync(doc, 'v2')
59
- r = scanRoots(state)
60
- expect(r.pending).toEqual([{ path: doc, status: 'changed' }])
61
-
62
- markFiles(state, [doc])
63
- expect(scanRoots(state).pending).toEqual([])
64
- })
65
- it('mark of an unreadable file reports missing and records nothing', () => {
66
- const state = { version: 1, roots: [], files: {} }
67
- const gone = join(tmp(), 'nope.md')
68
- const m = markFiles(state, [gone])
69
- expect(m.marked).toEqual([])
70
- expect(m.missing).toEqual([gone])
71
- expect(state.files[gone]).toBeUndefined()
72
- })
73
- })
@@ -1,42 +0,0 @@
1
- import { describe, it, expect } from 'bun:test'
2
- import { parseGrepArgs, formatGrepHits } from './grep_cli.mjs'
3
-
4
- describe('parseGrepArgs', () => {
5
- it('joins free terms into the query, defaults substring', () => {
6
- expect(parseGrepArgs(['hello', 'world'])).toEqual({ query: 'hello world', mode: 'substring', max: undefined })
7
- })
8
- it('honors --mode fts and --fts', () => {
9
- expect(parseGrepArgs(['q', '--mode', 'fts']).mode).toBe('fts')
10
- expect(parseGrepArgs(['--fts', 'q']).mode).toBe('fts')
11
- expect(parseGrepArgs(['q', '--mode', 'bogus']).mode).toBe('substring')
12
- })
13
- it('parses --max as an integer, ignores non-numeric', () => {
14
- expect(parseGrepArgs(['q', '--max', '25']).max).toBe(25)
15
- expect(parseGrepArgs(['q', '--max', 'abc']).max).toBeUndefined()
16
- })
17
- it('keeps the query when flags are interleaved', () => {
18
- expect(parseGrepArgs(['foo', '--max', '5', 'bar']).query).toBe('foo bar')
19
- })
20
- })
21
-
22
- describe('formatGrepHits', () => {
23
- it('reports no matches', () => {
24
- expect(formatGrepHits({ hits: [] }, 'xyz')).toBe('No matches for "xyz".')
25
- expect(formatGrepHits({}, 'xyz')).toBe('No matches for "xyz".')
26
- })
27
- it('renders ASCII hit lines with heading, tier, snippet, links', () => {
28
- const out = formatGrepHits(
29
- { hits: [{ title: 'Acme', heading: 'Current state', tier: 'accessible', snippet: 'big deal', links: ['Bob', 'Q3'] }] },
30
- 'deal',
31
- )
32
- expect(out).toContain('1 match for "deal":')
33
- expect(out).toContain('- Acme > Current state [accessible]')
34
- expect(out).toContain(' big deal')
35
- expect(out).toContain(' -> [[Bob]] [[Q3]]')
36
- })
37
- it('is ASCII-only', () => {
38
- const out = formatGrepHits({ hits: [{ title: 'X', heading: 'H', tier: 'scoped', snippet: 's', links: ['L'] }] }, 'q')
39
- // eslint-disable-next-line no-control-regex
40
- expect(/^[\x00-\x7F]*$/.test(out)).toBe(true)
41
- })
42
- })
@@ -1,35 +0,0 @@
1
- import { test, expect } from 'bun:test'
2
- import { decideSend, loadSendAllowlist, phoneKey } from './imessage_send.mjs'
3
-
4
- const allow = loadSendAllowlist('+18015551234, mom@example.com')
5
-
6
- test('draft-by-default: no send flag → draft, never sends (D3)', () => {
7
- const d = decideSend({ recipient: '+18015551234', message: 'hi', send: false, allowlist: allow, confirmSecret: null })
8
- expect(d.action).toBe('draft')
9
- })
10
-
11
- test('allowlisted recipient + send:true → send (D10)', () => {
12
- expect(decideSend({ recipient: '+1 (801) 555-1234', message: 'hi', send: true, allowlist: allow, confirmSecret: null }).action).toBe('send')
13
- expect(decideSend({ recipient: 'MOM@example.com', message: 'hi', send: true, allowlist: allow, confirmSecret: null }).action).toBe('send')
14
- })
15
-
16
- test('off-list recipient + send:true, no secret → BLOCKED (D10 prompt-injection guard)', () => {
17
- const d = decideSend({ recipient: '+19998887777', message: 'hi', send: true, allowlist: allow, confirmSecret: null })
18
- expect(d.action).toBe('blocked')
19
- })
20
-
21
- test('off-list recipient sends only with the correct out-of-band confirm secret', () => {
22
- expect(decideSend({ recipient: '+19998887777', message: 'hi', send: true, confirm: 'wrong', allowlist: allow, confirmSecret: 'sesame' }).action).toBe('blocked')
23
- expect(decideSend({ recipient: '+19998887777', message: 'hi', send: true, confirm: 'sesame', allowlist: allow, confirmSecret: 'sesame' }).action).toBe('send')
24
- })
25
-
26
- test('empty recipient/message is blocked', () => {
27
- expect(decideSend({ recipient: '', message: 'hi', send: true, allowlist: allow, confirmSecret: null }).action).toBe('blocked')
28
- expect(decideSend({ recipient: '+18015551234', message: ' ', send: true, allowlist: allow, confirmSecret: null }).action).toBe('blocked')
29
- })
30
-
31
- test('phoneKey + allowlist normalization', () => {
32
- expect(phoneKey('+1 (801) 555-1234')).toBe('8015551234')
33
- expect(allow.has('8015551234')).toBe(true)
34
- expect(allow.has('mom@example.com')).toBe(true)
35
- })
@@ -1,88 +0,0 @@
1
- import { test, expect } from 'bun:test'
2
- import { redactSecrets } from './redact.mjs'
3
-
4
- // The 2026-06-22 finding: a Claude Code login token (sk-ant-oat01-…) was sitting inlined
5
- // in a Stop-hook command and could ride a transcript tail to the org. Capture must never
6
- // transmit a credential. These assert known secret shapes are stripped — and that ordinary
7
- // prose / record UUIDs are left intact (no false positives that would mangle real content).
8
-
9
- test('redacts an Anthropic OAuth (Claude Code login) token', () => {
10
- const s = 'CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-oyo53qZ8m_WSrYZU8rrpWrxmgtbO1T8hSOhhpicMqeZ4Ixtwzbj0qwb in the hook'
11
- const out = redactSecrets(s)
12
- expect(out).not.toContain('sk-ant-oat01-oyo53qZ8m')
13
- expect(out).toContain('[REDACTED:anthropic-oauth]')
14
- })
15
-
16
- test('redacts a range of credential shapes', () => {
17
- const cases = [
18
- ['sk-ant-api03-' + 'a'.repeat(40), 'anthropic-key'],
19
- ['sk-proj-' + 'b'.repeat(40), 'openai'],
20
- ['sk-' + 'c'.repeat(40), 'openai'],
21
- ['ghp_' + 'd'.repeat(36), 'github'],
22
- ['github_pat_' + 'e'.repeat(30), 'github-pat'],
23
- ['AKIA' + 'ABCDEFGHIJKLMNOP', 'aws-akid'],
24
- ['AIza' + 'f'.repeat(35), 'google'],
25
- ['xoxb-' + '1234567890-abcdef', 'slack'],
26
- ['eyJ' + 'a'.repeat(20) + '.' + 'b'.repeat(20) + '.' + 'c'.repeat(20), 'jwt'],
27
- ]
28
- for (const [secret, tag] of cases) {
29
- const out = redactSecrets(`token: ${secret} end`)
30
- expect(out).toContain(`[REDACTED:${tag}]`)
31
- expect(out).not.toContain(secret)
32
- }
33
- })
34
-
35
- test('redacts a Bearer token but keeps the scheme', () => {
36
- const out = redactSecrets('Authorization: Bearer abcdef0123456789ABCDEFxyz')
37
- expect(out).toBe('Authorization: Bearer [REDACTED]')
38
- })
39
-
40
- test('redacts a PEM private key block', () => {
41
- const out = redactSecrets('-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----')
42
- expect(out).toContain('[REDACTED:private-key]')
43
- expect(out).not.toContain('MIIE')
44
- })
45
-
46
- test('leaves ordinary prose and record UUIDs untouched', () => {
47
- const prose = 'Fixed the ingest latency; record 5203eacd-321b-43f7-bae2-4d6e7cae96ab landed in ~0.3s.'
48
- expect(redactSecrets(prose)).toBe(prose)
49
- })
50
-
51
- // The 2026-07-02 finding: a grep of ~/.codex/hooks.json printed the live CORTEX_TOKEN (a bare
52
- // UUID — no pattern above catches those) into a transcript. The inline form is gone from hooks
53
- // (capture self-resolves now), but transcripts can still carry config lines from ANY tool read.
54
- test('redacts CORTEX_TOKEN in env-assignment and TOML/JSON forms', () => {
55
- const uuid = 'ee0455d0-49bd-4141-984d-ece9311f9407'
56
- const cases = [
57
- `CORTEX_TOKEN=${uuid} npx -y @theronap/cortex-mcp@latest capture`, // legacy hook command
58
- `"CORTEX_TOKEN": "${uuid}"`, // ~/.claude.json
59
- `CORTEX_TOKEN = "${uuid}"`, // config.toml
60
- ]
61
- for (const s of cases) {
62
- const out = redactSecrets(s)
63
- expect(out).toContain('[REDACTED:cortex-token]')
64
- expect(out).not.toContain(uuid)
65
- }
66
- })
67
-
68
- test('redacts generic secret-shaped env assignments', () => {
69
- const out = redactSecrets('export SUPABASE_SERVICE_SECRET=abcDEF0123456789xyz && run')
70
- expect(out).toBe('export SUPABASE_SERVICE_SECRET=[REDACTED:env] && run')
71
- const out2 = redactSecrets('MY_API_KEY: "0123456789abcdef0123"')
72
- expect(out2).toContain('[REDACTED:env]')
73
- })
74
-
75
- test('generic env pattern leaves short placeholders and prose intact', () => {
76
- const cases = [
77
- 'set YOUR_TOKEN=xxx in the env', // short placeholder value
78
- 'the CORTEX_TOKEN is stored in ~/.claude.json', // prose, no assignment value
79
- 'record 5203eacd-321b-43f7-bae2-4d6e7cae96ab', // bare UUID, no key name
80
- ]
81
- for (const s of cases) expect(redactSecrets(s)).toBe(s)
82
- })
83
-
84
- test('handles empty / non-string input safely', () => {
85
- expect(redactSecrets('')).toBe('')
86
- expect(redactSecrets(null)).toBe(null)
87
- expect(redactSecrets(undefined)).toBe(undefined)
88
- })
@@ -1,39 +0,0 @@
1
- import { describe, it, expect } from 'bun:test'
2
- import { planOrgInstall } from './skills.mjs'
3
-
4
- describe('planOrgInstall — the collision + validity gate', () => {
5
- it('installs valid served skills', () => {
6
- const { install, skipped } = planOrgInstall(
7
- [{ name: 'deploy-runbook', body_md: '---\nname: deploy-runbook\n---\nbody' }],
8
- ['cortex-log', 'cortex-context'],
9
- )
10
- expect(install).toEqual([{ name: 'deploy-runbook', source: '---\nname: deploy-runbook\n---\nbody' }])
11
- expect(skipped).toEqual([])
12
- })
13
- it('bundled core skills win name collisions (org can never shadow cortex-log)', () => {
14
- const { install, skipped } = planOrgInstall(
15
- [{ name: 'cortex-log', body_md: 'evil override' }],
16
- ['cortex-log'],
17
- )
18
- expect(install).toEqual([])
19
- expect(skipped[0]).toMatchObject({ name: 'cortex-log' })
20
- })
21
- it('drops invalid names (path tricks, uppercase, empty) and empty bodies', () => {
22
- const { install, skipped } = planOrgInstall(
23
- [
24
- { name: '../escape', body_md: 'x' },
25
- { name: 'UPPER', body_md: 'x' },
26
- { name: 'ok-name', body_md: ' ' },
27
- { name: '', body_md: 'x' },
28
- { name: 'fine', body_md: 'x' },
29
- ],
30
- [],
31
- )
32
- expect(install).toEqual([{ name: 'fine', source: 'x' }])
33
- expect(skipped.length).toBe(4)
34
- })
35
- it('tolerates a malformed served list', () => {
36
- expect(planOrgInstall(null, []).install).toEqual([])
37
- expect(planOrgInstall([null, {}, { name: 'a' }], []).install).toEqual([])
38
- })
39
- })