@theronap/cortex-mcp 0.9.39 → 0.9.41

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.
@@ -43,6 +43,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
43
43
  `Subcommands:\n` +
44
44
  ` setup <token> wire MCP server + capture hook into ~/.claude config\n` +
45
45
  ` repair re-run setup at the latest version using your existing token (no token needed)\n` +
46
+ ` uninstall remove ALL Cortex wiring (MCP, hooks, skills, launchd, cron). --dry-run to preview, --purge to also wipe ~/.cortex + npx cache\n` +
46
47
  ` doctor live health check — confirm your token works (no restart needed)\n` +
47
48
  ` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
48
49
  ` skills install/repair the managed Cortex skills — bundled + org-published (also wired by setup)\n` +
@@ -69,6 +70,12 @@ if (cmd === 'setup') {
69
70
  await runSetup(rest, VERSION)
70
71
  const { closeFetch } = await import('../lib/diagnose.mjs')
71
72
  await closeFetch()
73
+ } else if (cmd === 'uninstall' || cmd === 'remove') {
74
+ // Full reverse of setup: strip every Cortex touch-point (MCP entries, hooks, skills, launchd, cron).
75
+ // --dry-run prints the plan and changes nothing; --purge also removes ~/.cortex, the npx cache, and
76
+ // backups. No network — safe to run even when the token is dead or the server is unreachable.
77
+ const { runUninstall } = await import('../lib/uninstall.mjs')
78
+ runUninstall(rest)
72
79
  } else if (cmd === 'repair' || cmd === 'update') {
73
80
  // Re-run setup at THIS version using the already-wired token (no token arg needed). Fixes a
74
81
  // machine set up with an older version: re-pins MCP + hooks, reinstalls skills to the flat path.
package/lib/capture.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readFileSync } from 'fs'
2
+ import { spawn } from 'child_process'
2
3
  import { homedir } from 'os'
3
- import { resolve } from 'path'
4
+ import { resolve, dirname, join } from 'path'
5
+ import { fileURLToPath } from 'url'
4
6
  import { createHash } from 'crypto'
5
7
  import { fetchCortex, classify, resolveBase, readWiredToken } from './diagnose.mjs'
6
8
  import { extractSession } from './edge_extract.mjs'
@@ -80,12 +82,51 @@ function transcriptTail(path) {
80
82
  : tail
81
83
  }
82
84
 
85
+ // Stop-hook entry point. The Stop hook fires after EVERY assistant turn, and Claude Code BLOCKS the
86
+ // input box until this returns — so the heavy work (a synchronous `claude -p` edge summary, up to
87
+ // tens of seconds, and worse when it hangs under concurrent sessions) must NEVER run in-band here.
88
+ // Instead we read the (small) stdin payload, hand it to a DETACHED background worker, and return in
89
+ // ~milliseconds. The worker does the extraction + ingest out of band; the user's next prompt is never
90
+ // held. Set CORTEX_CAPTURE_SYNC=1 to force the old in-band behavior (tests / debugging).
83
91
  export async function runCapture() {
84
- // Recursion guard: edge extraction below shells out to `claude --print` with CORTEX_SUMMARIZING=1.
85
- // That headless session fires its own Stop hook → this same capture command. Without this guard it
86
- // would recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
92
+ // Recursion guard: edge extraction shells out to `claude --print` with CORTEX_SUMMARIZING=1. That
93
+ // headless session fires its own Stop hook → this same capture command. Without this guard it would
94
+ // recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
87
95
  if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
88
96
 
97
+ const stdinRaw = readStdin()
98
+
99
+ // Detach the slow work unless we ARE the detached worker (or a caller forced sync). A spawn failure
100
+ // falls through to the in-band path so a session is never dropped just because fork() failed.
101
+ if (!process.env.CORTEX_CAPTURE_DETACHED && !process.env.CORTEX_CAPTURE_SYNC) {
102
+ if (spawnDetachedWorker(stdinRaw)) return
103
+ }
104
+ await captureWork(stdinRaw)
105
+ }
106
+
107
+ // Re-invoke this same CLI as `capture` in a fully detached child (own session, stdio ignored) and
108
+ // feed it the hook payload on its stdin. Returns true if the child was launched (parent may return
109
+ // immediately), false if spawning failed (caller then does the work in-band). The child sees
110
+ // CORTEX_CAPTURE_DETACHED=1 so it runs captureWork() directly instead of forking again.
111
+ function spawnDetachedWorker(stdinRaw) {
112
+ try {
113
+ const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cortex-mcp.mjs')
114
+ const child = spawn(process.execPath, [bin, 'capture'], {
115
+ env: { ...process.env, CORTEX_CAPTURE_DETACHED: '1' },
116
+ detached: true,
117
+ stdio: ['pipe', 'ignore', 'ignore'],
118
+ })
119
+ child.on('error', () => {}) // never let an async spawn error crash the hook
120
+ child.stdin.on('error', () => {})
121
+ child.stdin.end(stdinRaw)
122
+ child.unref()
123
+ return true
124
+ } catch {
125
+ return false
126
+ }
127
+ }
128
+
129
+ async function captureWork(stdinRaw) {
89
130
  // Env first (explicit override / legacy inlined hooks), else the token wired into this machine's
90
131
  // MCP config — so hook commands carry no secret (token-hygiene, 2026-07-02).
91
132
  const token = process.env.CORTEX_TOKEN || readWiredToken()
@@ -93,7 +134,7 @@ export async function runCapture() {
93
134
  const base = resolveBase(process.env.CORTEX_URL)
94
135
 
95
136
  let hook = {}
96
- try { hook = JSON.parse(readStdin()) } catch { /* no/invalid stdin */ }
137
+ try { hook = JSON.parse(stdinRaw) } catch { /* no/invalid stdin */ }
97
138
  const repo = projectFrom(hook.cwd)
98
139
  // Redact credential-shaped strings BEFORE anything leaves the machine — this `transcript`
99
140
  // feeds local edge/typed extraction AND the fallback POST to the org. A secret in a
@@ -148,11 +189,14 @@ export async function runCapture() {
148
189
 
149
190
  let res
150
191
  try {
192
+ // Best-effort background ingest: bound it tight and retry once so a hanging/504-ing server
193
+ // makes a DETACHED worker self-terminate in ~20s instead of lingering on 3 unbounded attempts.
151
194
  res = await fetchCortex(`${base}/api/ingest`, {
152
195
  method: 'POST',
153
196
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
154
197
  body: JSON.stringify(ingestBody),
155
- })
198
+ timeoutMs: 10_000,
199
+ }, { retries: 1 })
156
200
  } catch (e) {
157
201
  // Never break a session — just report and move on.
158
202
  process.stderr.write(`cortex: ${e.message}\n`)
package/lib/diagnose.mjs CHANGED
@@ -68,20 +68,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
68
68
  // 'app' → a real Cortex API error with a JSON message (retriable only if 5xx)
69
69
  export function classify(status, contentType, bodyText, requestId) {
70
70
  const isJson = (contentType ?? '').includes('application/json')
71
- let body = null
72
- if (isJson) { try { body = JSON.parse(bodyText) } catch { /* not json after all */ } }
73
- const appError = body?.error ?? null
71
+ let appError = null
72
+ if (isJson) { try { appError = JSON.parse(bodyText)?.error ?? null } catch { /* not json after all */ } }
74
73
  const rid = requestId ? ` [request id: ${requestId}]` : ''
75
74
 
76
- // Multi-brain: 409 no_active_brain means the caller belongs to >1 brain and hasn't chosen one. It is
77
- // NOT an auth failure — surface the action (set_active_brain) directly, not a "Cortex API 409:" wrapper.
78
- if (status === 409 && body?.code === 'no_active_brain') {
79
- return {
80
- kind: 'app', retriable: false,
81
- message: `${appError ?? 'You belong to more than one brain — call set_active_brain to choose where your writes land.'} (see my_brains for your options)${rid}`,
82
- }
83
- }
84
-
85
75
  if (status === 401 || (isJson && appError === 'invalid token')) {
86
76
  return {
87
77
  kind: 'auth', retriable: false,
@@ -107,11 +97,23 @@ export function classify(status, contentType, bodyText, requestId) {
107
97
  // fetch with retry on TRANSIENT responses only (429, 5xx, and infra-style non-JSON 403).
108
98
  // App-level 401/403-with-JSON are returned immediately (retrying won't change the verdict).
109
99
  // Throws a clear network error if the host is unreachable after retries.
100
+ //
101
+ // PER-ATTEMPT TIMEOUT (added 2026-07-08): every attempt is bounded by an AbortSignal so a
102
+ // HANGING server (not a fast 5xx — an ingest endpoint that just never responds, observed this day)
103
+ // can't stall a hook indefinitely. Without it, `fetch` waits forever and the retry loop made it
104
+ // WORSE — 3 unbounded attempts stacked. Now worst case = (retries+1) * timeoutMs + backoff, and a
105
+ // timed-out attempt is treated as transient (retried, then surfaced as the reach error the callers
106
+ // already swallow). Tunable via CORTEX_HTTP_TIMEOUT_MS; per-call override via opts.timeoutMs.
110
107
  export async function fetchCortex(url, opts = {}, { retries = 2, baseDelayMs = 400 } = {}) {
108
+ const { timeoutMs: optTimeout, signal: callerSignal, ...fetchOpts } = opts
109
+ const timeoutMs = optTimeout ?? (Number(process.env.CORTEX_HTTP_TIMEOUT_MS) || 15_000)
111
110
  let lastErr
112
111
  for (let attempt = 0; attempt <= retries; attempt++) {
113
112
  try {
114
- const res = await fetch(url, opts)
113
+ // Fresh timeout signal per attempt; compose with any caller-supplied signal.
114
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
115
+ const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal
116
+ const res = await fetch(url, { ...fetchOpts, signal })
115
117
  const ct = res.headers.get('content-type') ?? ''
116
118
  const transient =
117
119
  res.status === 429 ||
@@ -1,4 +1,58 @@
1
1
  import { spawnSync } from 'child_process'
2
+ import { openSync, closeSync, writeSync, unlinkSync, readFileSync, statSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { homedir } from 'os'
5
+
6
+ // Single-flight lock so at most ONE edge summarizer (`claude -p`) runs on this machine at a time.
7
+ // Without it, N concurrent Claude sessions all fire their Stop hook at once → N simultaneous headless
8
+ // `claude --print` calls that contend on the same subscription-OAuth refresh and can deadlock (the
9
+ // "prompts hang forever" incident, 2026-07-08). If the lock is already held by a live, recent holder,
10
+ // the caller skips extraction and ships the transcript tail instead (the server summarizes async) —
11
+ // no session is dropped, just summarized server-side that turn. A stale lock (older than the summary
12
+ // timeout + grace, or whose PID is dead) is reclaimed.
13
+ const LOCK_PATH = join(homedir(), '.cortex', 'summarize.lock')
14
+
15
+ function summaryTimeoutMs() {
16
+ const n = Number(process.env.CORTEX_SUMMARY_TIMEOUT_MS)
17
+ return Number.isFinite(n) && n > 0 ? n : 45_000
18
+ }
19
+
20
+ // Try to acquire the lock. Returns true if acquired (caller must call releaseSummaryLock in a finally),
21
+ // false if another live holder has it. O_CREAT|O_EXCL is the atomic "create only if absent" primitive.
22
+ function acquireSummaryLock(_retried = false) {
23
+ try {
24
+ const fd = openSync(LOCK_PATH, 'wx') // wx = O_CREAT|O_EXCL|O_WRONLY — fails if it exists
25
+ try { writeSync(fd, String(process.pid)) } catch { /* PID write is best-effort */ }
26
+ closeSync(fd)
27
+ return true
28
+ } catch (e) {
29
+ if (e && e.code === 'EEXIST') {
30
+ // Someone holds it — reclaim only if it's stale (older than timeout + 15s grace, or PID dead).
31
+ if (_retried) return false // one reclaim attempt only; a live race means "ship the tail"
32
+ try {
33
+ const age = Date.now() - statSync(LOCK_PATH).mtimeMs
34
+ const holderDead = !pidAlive(readFileSync(LOCK_PATH, 'utf8').trim())
35
+ if (age > summaryTimeoutMs() + 15_000 || holderDead) {
36
+ try { unlinkSync(LOCK_PATH) } catch { /* raced with another reclaimer */ }
37
+ return acquireSummaryLock(true)
38
+ }
39
+ } catch { /* unreadable lock — treat as held; ship the tail this turn */ }
40
+ return false
41
+ }
42
+ // Any other error (e.g. ~/.cortex missing): don't block extraction, just run without the lock.
43
+ return true
44
+ }
45
+ }
46
+
47
+ function pidAlive(pid) {
48
+ const n = Number(pid)
49
+ if (!Number.isInteger(n) || n <= 0) return false
50
+ try { process.kill(n, 0); return true } catch (e) { return e && e.code === 'EPERM' }
51
+ }
52
+
53
+ function releaseSummaryLock() {
54
+ try { unlinkSync(LOCK_PATH) } catch { /* already gone */ }
55
+ }
2
56
 
3
57
  // Edge session extractor (slice 2c) — ONE `claude --print` call on the user's SUBSCRIPTION that
4
58
  // produces summary + people + non-person entities LOCALLY, so the cloud receives only the derived
@@ -62,16 +116,21 @@ export function extractSession(transcript) {
62
116
  PEOPLE_FRAGMENT + ',\n' +
63
117
  ENTITY_FRAGMENT +
64
118
  '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
119
+ // Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
120
+ // server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
121
+ if (!acquireSummaryLock()) return null
65
122
  try {
66
123
  const r = spawnSync(
67
124
  'claude',
68
125
  ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
69
- { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 90_000, maxBuffer: 4 * 1024 * 1024 },
126
+ { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
70
127
  )
71
128
  if (r.status !== 0 || !r.stdout) return null
72
129
  return parseEdgeJson(r.stdout.trim())
73
130
  } catch {
74
131
  return null
132
+ } finally {
133
+ releaseSummaryLock()
75
134
  }
76
135
  }
77
136
 
@@ -0,0 +1,190 @@
1
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, rmSync } from 'fs'
2
+ import { homedir } from 'os'
3
+ import { join } from 'path'
4
+ import { execFileSync } from 'child_process'
5
+
6
+ // Full uninstall — the reverse of setup.mjs. Removes EVERY touch-point Cortex writes onto a machine:
7
+ // 1. ~/.claude.json → mcpServers.cortex
8
+ // 2. ~/.claude/settings.json → Stop/SessionStart/PreCompact cortex hooks
9
+ // 3. ~/.codex/config.toml → [mcp_servers.cortex] + [mcp_servers.cortex.env]
10
+ // 4. ~/.codex/hooks.json → cortex capture Stop hook
11
+ // 5. managed skills → ~/.claude/skills/cortex* + ~/.codex/skills/cortex*
12
+ // 6. launchd agent → ~/Library/LaunchAgents/com.cortex.*.plist (unload + remove)
13
+ // 7. crontab → lines tagged "# cortex ..."
14
+ // 8. off-switch env → launchctl unsetenv CORTEX_SUMMARIZE_DISABLED (macOS)
15
+ // --purge also removes: ~/.cortex state dir, the npx cache, and every .cortex-bak backup.
16
+ //
17
+ // Safe by construction: --dry-run prints the plan and changes nothing; every JSON/TOML file is backed
18
+ // up to <file>.cortex-uninstall-bak before edit; JSON surgery filters only cortex-tagged entries and
19
+ // leaves every other MCP server / hook untouched. Idempotent — re-running is a no-op.
20
+
21
+ const HOME = homedir()
22
+ const CLAUDE_JSON = join(HOME, '.claude.json')
23
+ const SETTINGS = join(HOME, '.claude', 'settings.json')
24
+ const CODEX_TOML = join(HOME, '.codex', 'config.toml')
25
+ const CODEX_HOOKS = join(HOME, '.codex', 'hooks.json')
26
+ const CORTEX_DIR = join(HOME, '.cortex')
27
+ const LAUNCH_AGENTS = join(HOME, 'Library', 'LaunchAgents')
28
+ const CORTEX_RE = /cortex-mcp|capture-session-cloud|@theronap\/cortex/
29
+
30
+ export function runUninstall(argv = []) {
31
+ const dry = argv.includes('--dry-run') || argv.includes('-n')
32
+ const purge = argv.includes('--purge')
33
+ const plan = [] // human-readable action log
34
+ const act = (msg) => plan.push(msg)
35
+ const write = (path, data) => { if (!dry) { backup(path); writeFileSync(path, data) } }
36
+
37
+ process.stdout.write(dry ? '\nCortex uninstall — DRY RUN (nothing will change):\n\n' : '\nCortex uninstall — removing all wiring…\n\n')
38
+
39
+ // 1. MCP server out of ~/.claude.json
40
+ editJson(CLAUDE_JSON, (cfg) => {
41
+ if (cfg.mcpServers && cfg.mcpServers.cortex) { delete cfg.mcpServers.cortex; act(` - mcpServers.cortex ← ${CLAUDE_JSON}`); return true }
42
+ return false
43
+ }, write)
44
+
45
+ // 2. Hooks out of ~/.claude/settings.json (Stop, SessionStart, PreCompact)
46
+ editJson(SETTINGS, (s) => {
47
+ let changed = false
48
+ for (const evt of ['Stop', 'SessionStart', 'PreCompact']) {
49
+ if (!Array.isArray(s.hooks?.[evt])) continue
50
+ for (const grp of s.hooks[evt]) {
51
+ if (!Array.isArray(grp.hooks)) continue
52
+ const before = grp.hooks.length
53
+ grp.hooks = grp.hooks.filter((h) => !CORTEX_RE.test(h?.command ?? ''))
54
+ if (grp.hooks.length !== before) changed = true
55
+ }
56
+ // drop groups we emptied
57
+ s.hooks[evt] = s.hooks[evt].filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
58
+ if (s.hooks[evt].length === 0) delete s.hooks[evt]
59
+ }
60
+ if (changed) act(` - Stop/SessionStart/PreCompact cortex hooks ← ${SETTINGS}`)
61
+ return changed
62
+ }, write)
63
+
64
+ // 3. Codex MCP tables (strip [mcp_servers.cortex] + [mcp_servers.cortex.env])
65
+ if (existsSync(CODEX_TOML)) {
66
+ const text = readFileSync(CODEX_TOML, 'utf8')
67
+ const stripped = stripCodexCortexTables(text)
68
+ if (stripped !== text) { write(CODEX_TOML, stripped); act(` - [mcp_servers.cortex] tables ← ${CODEX_TOML}`) }
69
+ }
70
+
71
+ // 4. Codex capture hook
72
+ editJson(CODEX_HOOKS, (h) => {
73
+ let changed = false
74
+ if (Array.isArray(h.hooks?.Stop)) {
75
+ for (const grp of h.hooks.Stop) {
76
+ if (!Array.isArray(grp.hooks)) continue
77
+ const before = grp.hooks.length
78
+ grp.hooks = grp.hooks.filter((c) => !CORTEX_RE.test(c?.command ?? ''))
79
+ if (grp.hooks.length !== before) changed = true
80
+ }
81
+ h.hooks.Stop = h.hooks.Stop.filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
82
+ }
83
+ if (changed) act(` - cortex capture hook ← ${CODEX_HOOKS}`)
84
+ return changed
85
+ }, write)
86
+
87
+ // 5. Managed skills
88
+ for (const skillsRoot of [join(HOME, '.claude', 'skills'), join(HOME, '.codex', 'skills')]) {
89
+ for (const name of ['cortex', 'cortex-author-docs', 'cortex-context', 'cortex-log']) {
90
+ const p = join(skillsRoot, name)
91
+ if (existsSync(p)) { act(` - skill ${p}`); if (!dry) rmSync(p, { recursive: true, force: true }) }
92
+ }
93
+ }
94
+
95
+ // 6. launchd agents (com.cortex.*)
96
+ if (existsSync(LAUNCH_AGENTS)) {
97
+ for (const f of safeReaddir(LAUNCH_AGENTS)) {
98
+ if (!/^com\.cortex\..*\.plist$/.test(f)) continue
99
+ const p = join(LAUNCH_AGENTS, f)
100
+ act(` - launchd agent ${p} (unload + remove)`)
101
+ if (!dry) {
102
+ tryExec('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}/${f.replace(/\.plist$/, '')}`])
103
+ tryExec('launchctl', ['unload', p])
104
+ rmSync(p, { force: true })
105
+ }
106
+ }
107
+ }
108
+
109
+ // 7. crontab — drop cortex-tagged lines
110
+ const cron = tryExecOut('crontab', ['-l'])
111
+ if (cron != null) {
112
+ const kept = cron.split('\n').filter((l) => !CORTEX_RE.test(l) && !/\.cortex\//.test(l))
113
+ if (kept.join('\n') !== cron) {
114
+ act(' - crontab cortex entries')
115
+ if (!dry) tryExecIn('crontab', ['-'], kept.join('\n').replace(/\n+$/, '') + '\n')
116
+ }
117
+ }
118
+
119
+ // 8. off-switch env var (macOS launchd session env)
120
+ act(' - launchctl unsetenv CORTEX_SUMMARIZE_DISABLED')
121
+ if (!dry) tryExec('launchctl', ['unsetenv', 'CORTEX_SUMMARIZE_DISABLED'])
122
+
123
+ // --purge: state dir + npx cache + backups
124
+ if (purge) {
125
+ if (existsSync(CORTEX_DIR)) { act(` - PURGE state dir ${CORTEX_DIR}`); if (!dry) rmSync(CORTEX_DIR, { recursive: true, force: true }) }
126
+ for (const cache of npxCacheDirs()) { act(` - PURGE npx cache ${cache}`); if (!dry) rmSync(cache, { recursive: true, force: true }) }
127
+ for (const bak of [CLAUDE_JSON, SETTINGS, CODEX_TOML, CODEX_HOOKS]) {
128
+ for (const suffix of ['.cortex-bak', '.cortex-uninstall-bak']) {
129
+ const b = bak + suffix
130
+ if (existsSync(b)) { act(` - PURGE backup ${b}`); if (!dry) rmSync(b, { force: true }) }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (plan.length === 0) { process.stdout.write(' Nothing to remove — this machine has no Cortex wiring.\n\n'); return }
136
+ process.stdout.write(plan.join('\n') + '\n\n')
137
+ if (dry) {
138
+ process.stdout.write('DRY RUN — nothing changed. Re-run without --dry-run to apply.\n')
139
+ process.stdout.write(' Full wipe (state dir + npx cache + backups too): add --purge\n\n')
140
+ } else {
141
+ process.stdout.write('Done. Fully quit and reopen Claude Code (and Codex) to drop the server.\n')
142
+ if (!purge) process.stdout.write(' State dir ~/.cortex and .cortex-bak backups were KEPT. Remove them too with --purge.\n')
143
+ process.stdout.write('\n')
144
+ }
145
+ }
146
+
147
+ // ── helpers ──────────────────────────────────────────────────────────────────
148
+ function backup(path) {
149
+ if (!existsSync(path)) return
150
+ copyFileSync(path, `${path}.cortex-uninstall-bak`)
151
+ }
152
+
153
+ // Read JSON, run mutator (returns true if it changed anything), write back via `write` if so.
154
+ // Malformed JSON is left untouched (never risk corrupting a file we can't parse).
155
+ function editJson(path, mutate, write) {
156
+ if (!existsSync(path)) return
157
+ let obj
158
+ try { obj = JSON.parse(readFileSync(path, 'utf8') || '{}') } catch { process.stderr.write(` ! ${path} is not valid JSON — skipped (left as-is)\n`); return }
159
+ if (mutate(obj)) write(path, JSON.stringify(obj, null, 2) + '\n')
160
+ }
161
+
162
+ // Strip [mcp_servers.cortex] and [mcp_servers.cortex.env] tables from a Codex config.toml.
163
+ // Same table-skip logic as setup.mergeCodexToml, in reverse.
164
+ export function stripCodexCortexTables(text) {
165
+ const targets = new Set(['[mcp_servers.cortex]', '[mcp_servers.cortex.env]'])
166
+ const kept = []
167
+ let skipping = false
168
+ for (const line of (text || '').split('\n')) {
169
+ const t = line.trim()
170
+ if (t.startsWith('[') && t.endsWith(']')) skipping = targets.has(t)
171
+ if (!skipping) kept.push(line)
172
+ }
173
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n')
174
+ }
175
+
176
+ function safeReaddir(dir) {
177
+ try { return execFileSync('ls', ['-1', dir], { encoding: 'utf8' }).split('\n').filter(Boolean) } catch { return [] }
178
+ }
179
+ function npxCacheDirs() {
180
+ const base = join(HOME, '.npm', '_npx')
181
+ const out = []
182
+ for (const d of safeReaddir(base)) {
183
+ const dir = join(base, d)
184
+ try { if (execFileSync('grep', ['-rl', '@theronap/cortex-mcp', join(dir, 'package.json')], { encoding: 'utf8' }).trim()) out.push(dir) } catch { /* not a cortex cache */ }
185
+ }
186
+ return out
187
+ }
188
+ function tryExec(cmd, args) { try { execFileSync(cmd, args, { stdio: 'ignore' }) } catch { /* best-effort */ } }
189
+ function tryExecOut(cmd, args) { try { return execFileSync(cmd, args, { encoding: 'utf8' }) } catch { return null } }
190
+ function tryExecIn(cmd, args, input) { try { execFileSync(cmd, args, { input }) } catch { /* best-effort */ } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.39",
3
+ "version": "0.9.41",
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": {