@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.
- package/bin/cortex-mcp.mjs +17 -1
- package/lib/code_graph_cli.mjs +59 -0
- package/lib/editors/_fsutil.mjs +31 -0
- package/lib/editors/antigravity.mjs +123 -0
- package/lib/editors/claude.mjs +125 -0
- package/lib/editors/codex.mjs +111 -0
- package/lib/editors/cursor.mjs +77 -0
- package/lib/editors/index.mjs +42 -0
- package/lib/graphify_sync.mjs +89 -0
- package/lib/install.mjs +161 -0
- package/lib/server.mjs +48 -0
- package/lib/setup.mjs +13 -122
- package/package.json +3 -2
- package/lib/docs_scan.test.mjs +0 -73
- package/lib/grep_cli.test.mjs +0 -46
- package/lib/imessage_send.test.mjs +0 -35
- package/lib/redact.test.mjs +0 -88
- package/lib/skills.test.mjs +0 -39
|
@@ -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
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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.
|
|
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"
|
package/lib/docs_scan.test.mjs
DELETED
|
@@ -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
|
-
})
|
package/lib/grep_cli.test.mjs
DELETED
|
@@ -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
|
-
})
|