@theronap/cortex-mcp 0.9.44 → 0.9.46

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.
@@ -0,0 +1,89 @@
1
+ import { spawnSync } from 'child_process'
2
+ import { existsSync, readFileSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
5
+
6
+ // `cortex-mcp graphify-sync [path]` — local producer: incrementally rebuild a repo's structural
7
+ // code graph (graphify — tree-sitter AST, no LLM) and log an evidence-tier timeline event via
8
+ // POST /api/timeline/graphify. LLM-free, idempotent (server dedupes on repo+commit), additive —
9
+ // never touches the wiki graph (cortex-wiki-primary-spec: extracted/structural data is evidence,
10
+ // never auto-promoted into an authored page).
11
+ //
12
+ // Meant to run from a periodic cron/launchd job per repo you want graphed — NOT a git post-commit
13
+ // hook. A hook fires synchronously inside `git commit`/`git push` and can race another session's
14
+ // git operations in a shared checkout (see reference-cortex-shared-checkout-hazards); a timer-based
15
+ // job just reads whatever the tree looks like at that moment, no hook into git operations at all.
16
+
17
+ function run(cmd, args, cwd) {
18
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: 120_000 })
19
+ return r.status === 0 ? (r.stdout || '').trim() : null
20
+ }
21
+
22
+ function repoFullNameFromRemote(cwd) {
23
+ const url = run('git', ['remote', 'get-url', 'origin'], cwd)
24
+ if (!url) return null
25
+ const m = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/i.exec(url)
26
+ return m ? `${m[1]}/${m[2]}` : null
27
+ }
28
+
29
+ export async function runGraphifySync(argv = []) {
30
+ const cwd = argv[0] && !argv[0].startsWith('-') ? argv[0] : process.cwd()
31
+ const TOKEN = process.env.CORTEX_TOKEN
32
+ const BASE = resolveBase(process.env.CORTEX_URL)
33
+ if (!TOKEN) {
34
+ process.stderr.write('cortex-mcp graphify-sync: CORTEX_TOKEN is required.\n')
35
+ return 1
36
+ }
37
+ const graphFile = join(cwd, 'graphify-out', 'graph.json')
38
+ if (!existsSync(graphFile)) {
39
+ process.stderr.write(`cortex-mcp graphify-sync: no graph at ${graphFile} — run the graphify skill (\`/graphify .\`) in this repo first.\n`)
40
+ return 1
41
+ }
42
+ if (!run('graphify', ['--version'], cwd)) {
43
+ process.stderr.write('cortex-mcp graphify-sync: graphify CLI not on PATH (`uv tool install graphifyy`).\n')
44
+ return 1
45
+ }
46
+
47
+ const update = spawnSync('graphify', ['update', '.'], { cwd, encoding: 'utf8', timeout: 300_000, maxBuffer: 8 * 1024 * 1024 })
48
+ if (update.status !== 0) {
49
+ process.stderr.write(`cortex-mcp graphify-sync: \`graphify update\` failed:\n${(update.stderr || update.stdout || '').trim()}\n`)
50
+ return 1
51
+ }
52
+ if (update.stdout) process.stdout.write(update.stdout.trim() + '\n')
53
+
54
+ let graph
55
+ try {
56
+ graph = JSON.parse(readFileSync(graphFile, 'utf8'))
57
+ } catch (e) {
58
+ process.stderr.write(`cortex-mcp graphify-sync: could not read ${graphFile}: ${e.message}\n`)
59
+ return 1
60
+ }
61
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : []
62
+ const nodeCount = nodes.length
63
+ const edgeCount = Array.isArray(graph.links) ? graph.links.length : 0
64
+ const communityCount = new Set(nodes.map((n) => n.community).filter((c) => c !== undefined)).size
65
+ const commitSha = typeof graph.built_at_commit === 'string' ? graph.built_at_commit : null
66
+
67
+ const repo = repoFullNameFromRemote(cwd)
68
+ if (!commitSha || !repo) {
69
+ process.stdout.write('cortex-mcp graphify-sync: graph updated locally; skipping timeline log (no git commit / no github.com origin found).\n')
70
+ return 0
71
+ }
72
+
73
+ const res = await fetchCortex(`${BASE}/api/timeline/graphify`, {
74
+ method: 'POST',
75
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ repo, commitSha, nodeCount, edgeCount, communityCount }),
77
+ })
78
+ if (!res.ok) {
79
+ const body = await res.text()
80
+ process.stderr.write(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message + '\n')
81
+ return 1
82
+ }
83
+ const payload = await res.json()
84
+ process.stdout.write(
85
+ `Logged to Cortex timeline: ${repo}@${commitSha.slice(0, 7)} (${nodeCount} nodes, ${edgeCount} edges, ${communityCount} communities)` +
86
+ `${payload.inserted ? '' : ' (already logged)'}\n`,
87
+ )
88
+ return 0
89
+ }
@@ -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
@@ -8,6 +8,7 @@ import { createHash, randomUUID } from 'crypto'
8
8
  import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
9
9
  import { runSendImessage } from './imessage_send.mjs'
10
10
  import { formatGrepHits } from './grep_cli.mjs'
11
+ import { runCodeGraphQuery } from './code_graph_cli.mjs'
11
12
 
12
13
  // The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
13
14
  // context to their AI assistant. CORTEX_TOKEN identifies the user + org.
@@ -284,6 +285,26 @@ export async function runServer(version) {
284
285
  },
285
286
  )
286
287
 
288
+ server.registerTool(
289
+ 'code_graph_query',
290
+ {
291
+ title: 'Query the local code structure graph (graphify)',
292
+ description:
293
+ 'Query a structural code graph for the repo at the CURRENT working directory, built locally by graphify (tree-sitter AST — deterministic, no LLM, no server round-trip; this is LOCAL MACHINE data, not org-shared Cortex content, and reflects a snapshot of one commit, not live files). Use for MULTI-HOP questions a single grep cannot answer: what calls/imports/depends on X, how A structurally reaches B, or a repo-wide overview (hub/community files). Do NOT use for single-hop lookups (does file X import Y) — grep is faster and always current. Structure only — it knows what imports/calls what, never WHY; read the actual files or authored Cortex pages for intent.',
294
+ inputSchema: {
295
+ action: z.enum(['query', 'path', 'explain']).describe('"query" = open-ended natural-language question (graph traversal); "path" = shortest structural path between two named nodes; "explain" = describe one node and list its direct connections'),
296
+ question: z.string().optional().describe('required for action:"query" — the natural-language question'),
297
+ from: z.string().optional().describe('required for action:"path" — the starting node name'),
298
+ to: z.string().optional().describe('required for action:"path" — the target node name'),
299
+ node: z.string().optional().describe('required for action:"explain" — the node name to describe'),
300
+ },
301
+ },
302
+ async ({ action, question, from, to, node }) => {
303
+ const result = runCodeGraphQuery({ action, question, from, to, node })
304
+ return { content: [{ type: 'text', text: result.text }] }
305
+ },
306
+ )
307
+
287
308
  server.registerTool(
288
309
  'project_status',
289
310
  {
@@ -643,6 +664,33 @@ export async function runServer(version) {
643
664
  },
644
665
  )
645
666
 
667
+ server.registerTool(
668
+ 'create_brain',
669
+ {
670
+ title: 'Create a new brain under your existing account',
671
+ 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).',
672
+ inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Cortex Codebase"') },
673
+ },
674
+ async ({ name }) => {
675
+ let res
676
+ try {
677
+ res = await fetchCortex(`${BASE}/api/brains`, {
678
+ method: 'POST',
679
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
680
+ body: JSON.stringify({ name }),
681
+ })
682
+ } catch (e) {
683
+ return { content: [{ type: 'text', text: `Could not create brain: ${e.message}` }] }
684
+ }
685
+ if (!res.ok) {
686
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
687
+ return { content: [{ type: 'text', text: `Could not create brain: ${d.message}` }] }
688
+ }
689
+ const r = await res.json()
690
+ 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.` }] }
691
+ },
692
+ )
693
+
646
694
  server.registerTool(
647
695
  'list_records',
648
696
  {
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.44",
3
+ "version": "0.9.46",
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,46 +0,0 @@
1
- import { describe, it, expect } from 'bun:test'
2
- import { parseGrepArgs, formatGrepHits } from './grep_cli.mjs'
3
-
4
- describe('parseGrepArgs', () => {
5
- // #220: default is 'fts' (ranked keyword); --substring/--literal opts back into exact matching.
6
- it('joins free terms into the query, defaults fts', () => {
7
- expect(parseGrepArgs(['hello', 'world'])).toEqual({ query: 'hello world', mode: 'fts', max: undefined })
8
- })
9
- it('honors --substring/--literal to force exact matching, else fts', () => {
10
- expect(parseGrepArgs(['q', '--substring']).mode).toBe('substring')
11
- expect(parseGrepArgs(['q', '--literal']).mode).toBe('substring')
12
- expect(parseGrepArgs(['q', '--mode', 'substring']).mode).toBe('substring')
13
- expect(parseGrepArgs(['q', '--mode', 'fts']).mode).toBe('fts')
14
- expect(parseGrepArgs(['--fts', 'q']).mode).toBe('fts')
15
- expect(parseGrepArgs(['q', '--mode', 'bogus']).mode).toBe('fts')
16
- })
17
- it('parses --max as an integer, ignores non-numeric', () => {
18
- expect(parseGrepArgs(['q', '--max', '25']).max).toBe(25)
19
- expect(parseGrepArgs(['q', '--max', 'abc']).max).toBeUndefined()
20
- })
21
- it('keeps the query when flags are interleaved', () => {
22
- expect(parseGrepArgs(['foo', '--max', '5', 'bar']).query).toBe('foo bar')
23
- })
24
- })
25
-
26
- describe('formatGrepHits', () => {
27
- it('reports no matches', () => {
28
- expect(formatGrepHits({ hits: [] }, 'xyz')).toBe('No matches for "xyz".')
29
- expect(formatGrepHits({}, 'xyz')).toBe('No matches for "xyz".')
30
- })
31
- it('renders ASCII hit lines with heading, tier, snippet, links', () => {
32
- const out = formatGrepHits(
33
- { hits: [{ title: 'Acme', heading: 'Current state', tier: 'accessible', snippet: 'big deal', links: ['Bob', 'Q3'] }] },
34
- 'deal',
35
- )
36
- expect(out).toContain('1 match for "deal":')
37
- expect(out).toContain('- Acme > Current state [accessible]')
38
- expect(out).toContain(' big deal')
39
- expect(out).toContain(' -> [[Bob]] [[Q3]]')
40
- })
41
- it('is ASCII-only', () => {
42
- const out = formatGrepHits({ hits: [{ title: 'X', heading: 'H', tier: 'scoped', snippet: 's', links: ['L'] }] }, 'q')
43
- // eslint-disable-next-line no-control-regex
44
- expect(/^[\x00-\x7F]*$/.test(out)).toBe(true)
45
- })
46
- })