@theronap/cortex-mcp 0.9.95 → 0.9.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/uninstall.mjs CHANGED
@@ -2,10 +2,12 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync, rmSync } from 'f
2
2
  import { homedir } from 'os'
3
3
  import { join } from 'path'
4
4
  import { execFileSync } from 'child_process'
5
+ import { isManagedCommand } from './managed.mjs'
5
6
 
6
7
  // Full uninstall — the reverse of setup.mjs. Removes EVERY touch-point Agnoclast writes onto a machine:
7
- // 1. ~/.claude.json → mcpServers.cortex
8
- // 2. ~/.claude/settings.json Stop/SessionStart/PreCompact cortex hooks
8
+ // 1. ~/.claude.json → mcpServers.cortex / .agnoclast
9
+ // 1b. ~/.cursor/mcp.json same shape (editors/cursor.mjs writes it; was never removed)
10
+ // 2. ~/.claude/settings.json → Stop/SessionStart/UserPromptSubmit/PreCompact hooks + allow rules
9
11
  // 3. ~/.codex/config.toml → [mcp_servers.cortex] + [mcp_servers.cortex.env]
10
12
  // 4. ~/.codex/hooks.json → cortex capture Stop hook
11
13
  // 5. managed skills → ~/.claude/skills/cortex* + ~/.codex/skills/cortex*
@@ -15,17 +17,21 @@ import { execFileSync } from 'child_process'
15
17
  // --purge also removes: ~/.cortex state dir, the npx cache, and every .cortex-bak backup.
16
18
  //
17
19
  // 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
+ // up to <file>.cortex-uninstall-bak before edit; JSON surgery filters only entries we wrote (see
21
+ // managed.mjs — marker first, legacy name match second) and leaves every other MCP server / hook
22
+ // untouched. Idempotent — re-running is a no-op.
20
23
 
21
24
  const HOME = homedir()
22
25
  const CLAUDE_JSON = join(HOME, '.claude.json')
23
26
  const SETTINGS = join(HOME, '.claude', 'settings.json')
24
27
  const CODEX_TOML = join(HOME, '.codex', 'config.toml')
25
28
  const CODEX_HOOKS = join(HOME, '.codex', 'hooks.json')
29
+ const CURSOR_JSON = join(HOME, '.cursor', 'mcp.json')
26
30
  const CORTEX_DIR = join(HOME, '.cortex')
27
31
  const LAUNCH_AGENTS = join(HOME, 'Library', 'LaunchAgents')
28
- const CORTEX_RE = /cortex-mcp|capture-session-cloud|@theronap\/cortex/
32
+ // Hook events the installer writes (editors/claude.mjs). UserPromptSubmit was MISSING here until
33
+ // 2026-08-19, so every uninstall left the `hydrate` hook behind while reporting success.
34
+ export const MANAGED_HOOK_EVENTS = ['Stop', 'SessionStart', 'UserPromptSubmit', 'PreCompact']
29
35
 
30
36
  export function runUninstall(argv = []) {
31
37
  const dry = argv.includes('--dry-run') || argv.includes('-n')
@@ -36,41 +42,24 @@ export function runUninstall(argv = []) {
36
42
 
37
43
  process.stdout.write(dry ? '\nAgnoclast uninstall — DRY RUN (nothing will change):\n\n' : '\nAgnoclast uninstall — removing all wiring…\n\n')
38
44
 
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)
45
+ // 1. MCP server out of ~/.claude.json — and 1b, out of ~/.cursor/mcp.json, which uses the same
46
+ // shape (editors/cursor.mjs:15) and which uninstall never touched until 2026-08-19, so every
47
+ // `install --editor all` left Cursor permanently wired.
48
+ for (const path of [CLAUDE_JSON, CURSOR_JSON]) {
49
+ editJson(path, (cfg) => {
50
+ const removed = stripManagedMcpServers(cfg)
51
+ if (removed.length) act(` - mcpServers.${removed.join(' / ')} ← ${path}`)
52
+ return removed.length > 0
53
+ }, write)
54
+ }
44
55
 
45
- // 2. Hooks out of ~/.claude/settings.json (Stop, SessionStart, PreCompact)
56
+ // 2. Hooks + allow rules out of ~/.claude/settings.json
46
57
  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
- // cortex permission allowlist (setup wires CORTEX_ALLOWED_TOOLS so authoring never stalls on a
62
- // prompt) — strip every mcp__cortex__* allow rule; the user's deny list is never touched.
63
- if (Array.isArray(s.permissions?.allow)) {
64
- const before = s.permissions.allow.length
65
- s.permissions.allow = s.permissions.allow.filter((r) => !/^mcp__cortex__/.test(String(r)))
66
- if (s.permissions.allow.length !== before) {
67
- changed = true
68
- act(` - mcp__cortex__* permission allow rules ← ${SETTINGS}`)
69
- if (s.permissions.allow.length === 0) delete s.permissions.allow
70
- if (Object.keys(s.permissions).length === 0) delete s.permissions
71
- }
72
- }
73
- return changed
58
+ const hooks = stripManagedHooks(s)
59
+ const allows = stripManagedAllows(s)
60
+ if (hooks) act(` - ${MANAGED_HOOK_EVENTS.join('/')} hooks ← ${SETTINGS}`)
61
+ if (allows) act(` - mcp__cortex__* / mcp__agnoclast__* permission allow rules ← ${SETTINGS}`)
62
+ return hooks || allows
74
63
  }, write)
75
64
 
76
65
  // 3. Codex MCP tables (strip [mcp_servers.cortex] + [mcp_servers.cortex.env])
@@ -87,7 +76,7 @@ export function runUninstall(argv = []) {
87
76
  for (const grp of h.hooks.Stop) {
88
77
  if (!Array.isArray(grp.hooks)) continue
89
78
  const before = grp.hooks.length
90
- grp.hooks = grp.hooks.filter((c) => !CORTEX_RE.test(c?.command ?? ''))
79
+ grp.hooks = grp.hooks.filter((c) => !isManagedCommand(c?.command))
91
80
  if (grp.hooks.length !== before) changed = true
92
81
  }
93
82
  h.hooks.Stop = h.hooks.Stop.filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
@@ -98,7 +87,8 @@ export function runUninstall(argv = []) {
98
87
 
99
88
  // 5. Managed skills
100
89
  for (const skillsRoot of [join(HOME, '.claude', 'skills'), join(HOME, '.codex', 'skills')]) {
101
- for (const name of ['cortex', 'cortex-author-docs', 'cortex-context', 'cortex-log']) {
90
+ for (const name of ['cortex', 'cortex-author-docs', 'cortex-context', 'cortex-log', 'cortex-walkthrough',
91
+ 'agnoclast', 'agnoclast-author-docs', 'agnoclast-context', 'agnoclast-log', 'agnoclast-walkthrough']) {
102
92
  const p = join(skillsRoot, name)
103
93
  if (existsSync(p)) { act(` - skill ${p}`); if (!dry) rmSync(p, { recursive: true, force: true }) }
104
94
  }
@@ -107,7 +97,7 @@ export function runUninstall(argv = []) {
107
97
  // 6. launchd agents (com.cortex.*)
108
98
  if (existsSync(LAUNCH_AGENTS)) {
109
99
  for (const f of safeReaddir(LAUNCH_AGENTS)) {
110
- if (!/^com\.cortex\..*\.plist$/.test(f)) continue
100
+ if (!/^com\.(cortex|agnoclast)\..*\.plist$/.test(f)) continue
111
101
  const p = join(LAUNCH_AGENTS, f)
112
102
  act(` - launchd agent ${p} (unload + remove)`)
113
103
  if (!dry) {
@@ -121,7 +111,7 @@ export function runUninstall(argv = []) {
121
111
  // 7. crontab — drop cortex-tagged lines
122
112
  const cron = tryExecOut('crontab', ['-l'])
123
113
  if (cron != null) {
124
- const kept = cron.split('\n').filter((l) => !CORTEX_RE.test(l) && !/\.cortex\//.test(l))
114
+ const kept = cron.split('\n').filter((l) => !isManagedCommand(l) && !/\.cortex\//.test(l))
125
115
  if (kept.join('\n') !== cron) {
126
116
  act(' - crontab cortex entries')
127
117
  if (!dry) tryExecIn('crontab', ['-'], kept.join('\n').replace(/\n+$/, '') + '\n')
@@ -171,10 +161,55 @@ function editJson(path, mutate, write) {
171
161
  if (mutate(obj)) write(path, JSON.stringify(obj, null, 2) + '\n')
172
162
  }
173
163
 
164
+ // PURE. Removes our MCP server entry under EITHER spelling. uninstall.mjs:45 used to delete only
165
+ // `cortex`, so after the Phase C key flip (ADR-0033 §5) the server entry — the single most important
166
+ // thing uninstall removes — would survive a "successful" uninstall. Returns the keys it removed so
167
+ // the caller can report which spelling this machine actually carried.
168
+ export function stripManagedMcpServers(cfg) {
169
+ const removed = []
170
+ for (const key of ['cortex', 'agnoclast']) {
171
+ if (cfg?.mcpServers && cfg.mcpServers[key]) { delete cfg.mcpServers[key]; removed.push(key) }
172
+ }
173
+ return removed
174
+ }
175
+
176
+ // PURE (mutates the passed object, returns whether anything changed) so uninstall's riskiest logic
177
+ // is testable without touching a real filesystem. Empty hook groups and empty events are pruned so
178
+ // a fully-uninstalled settings.json is indistinguishable from one we never touched.
179
+ export function stripManagedHooks(s) {
180
+ let changed = false
181
+ for (const evt of MANAGED_HOOK_EVENTS) {
182
+ if (!Array.isArray(s?.hooks?.[evt])) continue
183
+ for (const grp of s.hooks[evt]) {
184
+ if (!Array.isArray(grp.hooks)) continue
185
+ const before = grp.hooks.length
186
+ grp.hooks = grp.hooks.filter((h) => !isManagedCommand(h?.command))
187
+ if (grp.hooks.length !== before) changed = true
188
+ }
189
+ s.hooks[evt] = s.hooks[evt].filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
190
+ if (s.hooks[evt].length === 0) delete s.hooks[evt]
191
+ }
192
+ return changed
193
+ }
194
+
195
+ // Strip every allow rule for OUR tool namespaces. Both spellings: a machine mid-migration can carry
196
+ // mcp__cortex__* and mcp__agnoclast__* at once, because C1 adds the new rules without removing the
197
+ // old ones (ADR-0033 §5). The user's `deny` list is never touched — a user deny always wins.
198
+ export function stripManagedAllows(s) {
199
+ if (!Array.isArray(s?.permissions?.allow)) return false
200
+ const before = s.permissions.allow.length
201
+ s.permissions.allow = s.permissions.allow.filter((r) => !/^mcp__(cortex|agnoclast)__/.test(String(r)))
202
+ if (s.permissions.allow.length === before) return false
203
+ if (s.permissions.allow.length === 0) delete s.permissions.allow
204
+ if (Object.keys(s.permissions).length === 0) delete s.permissions
205
+ return true
206
+ }
207
+
174
208
  // Strip [mcp_servers.cortex] and [mcp_servers.cortex.env] tables from a Codex config.toml.
175
209
  // Same table-skip logic as setup.mergeCodexToml, in reverse.
176
210
  export function stripCodexCortexTables(text) {
177
- const targets = new Set(['[mcp_servers.cortex]', '[mcp_servers.cortex.env]'])
211
+ const targets = new Set(['[mcp_servers.cortex]', '[mcp_servers.cortex.env]',
212
+ '[mcp_servers.agnoclast]', '[mcp_servers.agnoclast.env]'])
178
213
  const kept = []
179
214
  let skipping = false
180
215
  for (const line of (text || '').split('\n')) {
@@ -193,7 +228,7 @@ function npxCacheDirs() {
193
228
  const out = []
194
229
  for (const d of safeReaddir(base)) {
195
230
  const dir = join(base, d)
196
- try { if (execFileSync('grep', ['-rl', '@theronap/cortex-mcp', join(dir, 'package.json')], { encoding: 'utf8' }).trim()) out.push(dir) } catch { /* not a cortex cache */ }
231
+ try { if (execFileSync('grep', ['-rlE', '@theronap/(cortex|agnoclast)-mcp', join(dir, 'package.json')], { encoding: 'utf8' }).trim()) out.push(dir) } catch { /* not one of ours */ }
197
232
  }
198
233
  return out
199
234
  }
@@ -0,0 +1,66 @@
1
+ import { spawnSync } from 'child_process'
2
+ import { resolveTokenSource, TOKEN_ENV_VARS } from './diagnose.mjs'
3
+
4
+ // `with-token -- <command…>` — run a command with the resolved Agnoclast token in its environment.
5
+ //
6
+ // Why this exists (ADR-0033 D9). Generated helper scripts used to inline their own config parse:
7
+ //
8
+ // TOKEN="$(node -e "…JSON.parse(…'/.claude.json')?.mcpServers?.cortex?.env?.CORTEX_TOKEN…")"
9
+ // CORTEX_TOKEN="$TOKEN" bun run …
10
+ //
11
+ // Those scripts are written to disk once and FROZEN. Upgrading the package never rewrites a file
12
+ // that already exists, so any change to config shape strands them — and the Phase C key flip would
13
+ // have done exactly that, silently, because the script's own `catch { exit 0 }` swallows it.
14
+ // Delegating means the resolution rule lives in one versioned place and a script written today keeps
15
+ // working through arbitrary future config changes.
16
+ //
17
+ // It also removes the secret from the shell. Previously the token landed in a shell variable and was
18
+ // re-exported by the caller; here it goes straight from the resolver into the child's environment,
19
+ // so it is never a shell value, never in a log line, and never in an argv the process table shows.
20
+ //
21
+ // Both variable names are injected so the child works whichever one it reads — the whole point of a
22
+ // transition period is that callers need not be updated in lockstep.
23
+
24
+ /** Split `with-token -- cmd args` (or `with-token cmd args`) into the command to run. */
25
+ export function parseWithTokenArgs(rest = []) {
26
+ const args = rest.filter((a) => a !== undefined && a !== null)
27
+ const sep = args.indexOf('--')
28
+ const cmd = sep === -1 ? args : args.slice(sep + 1)
29
+ if (!cmd.length) {
30
+ return { error: 'Usage: cortex-mcp with-token -- <command> [args…]\n Runs <command> with the wired Agnoclast token in its environment.\n' }
31
+ }
32
+ return { command: cmd[0], args: cmd.slice(1) }
33
+ }
34
+
35
+ /** Build the child environment. Exported so a test can assert it without spawning anything. */
36
+ export function tokenEnv(token, baseEnv = process.env) {
37
+ const out = { ...baseEnv }
38
+ for (const name of TOKEN_ENV_VARS) out[name] = token
39
+ return out
40
+ }
41
+
42
+ // Exit codes, distinct so a caller's log can tell them apart:
43
+ // 3 — no token wired (the "skip quietly" case a cron wrapper wants)
44
+ // 2 — bad usage
45
+ // otherwise the child's own code
46
+ export const EXIT_NO_TOKEN = 3
47
+ export const EXIT_USAGE = 2
48
+
49
+ export function runWithToken(rest = [], deps = {}) {
50
+ const { resolve = resolveTokenSource, spawn = spawnSync, stderr = (m) => process.stderr.write(m) } = deps
51
+ const parsed = parseWithTokenArgs(rest)
52
+ if (parsed.error) { stderr(parsed.error); return EXIT_USAGE }
53
+
54
+ const { token } = resolve()
55
+ if (!token) {
56
+ stderr('cortex-mcp with-token: no Agnoclast token in the environment or wired config — skipping.\n')
57
+ return EXIT_NO_TOKEN
58
+ }
59
+
60
+ const res = spawn(parsed.command, parsed.args, { stdio: 'inherit', env: tokenEnv(token) })
61
+ if (res?.error) {
62
+ stderr(`cortex-mcp with-token: could not run ${parsed.command}: ${res.error.message}\n`)
63
+ return 1
64
+ }
65
+ return res?.status ?? 1
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.95",
3
+ "version": "0.9.96",
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": {