@theronap/cortex-mcp 0.9.44 → 0.9.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cortex-mcp.mjs +9 -1
- 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/install.mjs +161 -0
- package/lib/server.mjs +27 -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
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -41,7 +41,8 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
41
41
|
`This wires your Claude config so your AI sees your Cortex context and your\n` +
|
|
42
42
|
`sessions flow into the org automatically. Restart Claude Code after.\n\n` +
|
|
43
43
|
`Subcommands:\n` +
|
|
44
|
-
` setup <token> wire MCP server + capture hook into ~/.claude config\n` +
|
|
44
|
+
` setup <token> wire MCP server + capture hook into ~/.claude config (single editor)\n` +
|
|
45
|
+
` install [<token>] [--editor auto|all|<id,...>] wire Cortex into EVERY detected editor + write the capability manifest\n` +
|
|
45
46
|
` repair re-run setup at the latest version using your existing token (no token needed)\n` +
|
|
46
47
|
` uninstall remove ALL Cortex wiring (MCP, hooks, skills, launchd, cron). --dry-run to preview, --purge to also wipe ~/.cortex + npx cache\n` +
|
|
47
48
|
` doctor live health check — confirm your token works (no restart needed)\n` +
|
|
@@ -70,6 +71,13 @@ if (cmd === 'setup') {
|
|
|
70
71
|
await runSetup(rest, VERSION)
|
|
71
72
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
72
73
|
await closeFetch()
|
|
74
|
+
} else if (cmd === 'install') {
|
|
75
|
+
// The cross-editor hub installer: wire Cortex into every detected editor via the adapter
|
|
76
|
+
// registry, then write the capability manifest (~/.cortex/editors.json). Superset of `setup`.
|
|
77
|
+
const { runInstall } = await import('../lib/install.mjs')
|
|
78
|
+
await runInstall(rest, VERSION)
|
|
79
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
80
|
+
await closeFetch()
|
|
73
81
|
} else if (cmd === 'uninstall' || cmd === 'remove') {
|
|
74
82
|
// Full reverse of setup: strip every Cortex touch-point (MCP entries, hooks, skills, launchd, cron).
|
|
75
83
|
// --dry-run prints the plan and changes nothing; --purge also removes ~/.cortex, the npx cache, and
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Shared file helpers for editor adapters. These MIRROR setup.mjs's private helpers exactly so an
|
|
2
|
+
// extracted wire() writes byte-identical output (same backup name, same JSON formatting).
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/** Parse a JSON config, tolerating absent/empty files; THROWS on malformed (caller decides). */
|
|
7
|
+
export function readJson(path) {
|
|
8
|
+
if (!existsSync(path)) return {}
|
|
9
|
+
const raw = readFileSync(path, 'utf8').trim()
|
|
10
|
+
if (!raw) return {}
|
|
11
|
+
return JSON.parse(raw)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Copy path → path.cortex-bak before mutating. Returns the backup path, or null if nothing to back up. */
|
|
15
|
+
export function backupFile(path) {
|
|
16
|
+
if (!existsSync(path)) return null
|
|
17
|
+
const bak = `${path}.cortex-bak`
|
|
18
|
+
copyFileSync(path, bak)
|
|
19
|
+
return bak
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Ensure the parent directory of `path` exists. */
|
|
23
|
+
export function ensureDir(path) {
|
|
24
|
+
const dir = dirname(path)
|
|
25
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Write an object as pretty JSON — the exact formatting setup.mjs uses (2-space indent). */
|
|
29
|
+
export function writeJson(path, obj) {
|
|
30
|
+
writeFileSync(path, JSON.stringify(obj, null, 2))
|
|
31
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Editor adapter: Antigravity. P0-b task 4 — deterministic artifact generators + wire().
|
|
2
|
+
//
|
|
3
|
+
// Antigravity has no in-editor hook; sync is a launchd agent (com.cortex.antigravity-sync) that
|
|
4
|
+
// WatchPaths the trajectory store and runs a one-shot sync. wire() generates the two artifacts
|
|
5
|
+
// (the sync.sh + the launchd plist) deterministically. It does NOT run `launchctl load` here
|
|
6
|
+
// (an unverifiable system mutation) and it flags the PORTABILITY BLOCKER below.
|
|
7
|
+
//
|
|
8
|
+
// ⚠ BLOCKER (surfaced P0-b task 4): antigravity-sync.sh runs the sync from a LOCAL DEV CHECKOUT
|
|
9
|
+
// ($HOME/dev/cortex/packages/cortex-agent via `bun run src/index.ts antigravity-sync`). A coworker
|
|
10
|
+
// has no ~/dev/cortex, so Antigravity capture is bespoke to the author's machine and is NOT yet
|
|
11
|
+
// installable for a real seat. Making it portable requires a published `antigravity-sync` entrypoint
|
|
12
|
+
// (npx @theronap/cortex-mcp antigravity-sync, or a published cortex-agent) — a prerequisite this
|
|
13
|
+
// adapter cannot satisfy alone. Until then wire() writes the artifacts + returns a blocking warning.
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const AG_STATE = ['Library', 'Application Support', 'Antigravity']
|
|
19
|
+
const AG_WATCH = [...AG_STATE, 'User', 'globalStorage']
|
|
20
|
+
|
|
21
|
+
/** The launchd plist, home-substituted. Deterministic — golden-testable byte-for-byte. */
|
|
22
|
+
export function renderAntigravityPlist({ home = homedir() } = {}) {
|
|
23
|
+
const sh = join(home, '.cortex', 'antigravity-sync.sh')
|
|
24
|
+
const watch = join(home, ...AG_WATCH)
|
|
25
|
+
const logp = join(home, '.cortex', 'antigravity-sync.launchd.log')
|
|
26
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
|
+
<plist version="1.0">
|
|
29
|
+
<dict>
|
|
30
|
+
<key>Label</key>
|
|
31
|
+
<string>com.cortex.antigravity-sync</string>
|
|
32
|
+
<key>ProgramArguments</key>
|
|
33
|
+
<array>
|
|
34
|
+
<string>/bin/bash</string>
|
|
35
|
+
<string>${sh}</string>
|
|
36
|
+
</array>
|
|
37
|
+
<key>WatchPaths</key>
|
|
38
|
+
<array>
|
|
39
|
+
<string>${watch}</string>
|
|
40
|
+
</array>
|
|
41
|
+
<key>RunAtLoad</key>
|
|
42
|
+
<false/>
|
|
43
|
+
<key>ThrottleInterval</key>
|
|
44
|
+
<integer>30</integer>
|
|
45
|
+
<key>StandardOutPath</key>
|
|
46
|
+
<string>${logp}</string>
|
|
47
|
+
<key>StandardErrorPath</key>
|
|
48
|
+
<string>${logp}</string>
|
|
49
|
+
</dict>
|
|
50
|
+
</plist>
|
|
51
|
+
`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The sync.sh. Self-resolves paths via $HOME at runtime, so it is a static template.
|
|
55
|
+
* NOTE the AGENT_DIR line is the portability blocker documented above. */
|
|
56
|
+
export function renderAntigravitySyncSh() {
|
|
57
|
+
return `#!/bin/bash
|
|
58
|
+
# Cortex ⇄ Antigravity one-shot sync, triggered by launchd WatchPaths on the Antigravity
|
|
59
|
+
# trajectory store. There is no resident daemon — launchd wakes this on change and it exits.
|
|
60
|
+
# Token is resolved from the wired MCP config at runtime (never stored in plist/script).
|
|
61
|
+
# ⚠ AGENT_DIR points at a local dev checkout — see antigravity.mjs BLOCKER note (not coworker-portable).
|
|
62
|
+
set -o pipefail
|
|
63
|
+
LOG="$HOME/.cortex/antigravity-sync.log"
|
|
64
|
+
NODE="/usr/local/bin/node"
|
|
65
|
+
BUN="$HOME/.bun/bin/bun"
|
|
66
|
+
AGENT_DIR="$HOME/dev/cortex/packages/cortex-agent"
|
|
67
|
+
|
|
68
|
+
stamp() { date -u +%FT%TZ; }
|
|
69
|
+
|
|
70
|
+
TOKEN="$("$NODE" -e "try{console.log(JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.claude.json','utf8'))?.mcpServers?.cortex?.env?.CORTEX_TOKEN||'')}catch(e){process.exit(0)}" 2>/dev/null)"
|
|
71
|
+
if [ -z "$TOKEN" ]; then echo "$(stamp) no CORTEX_TOKEN wired, skipping" >> "$LOG"; exit 0; fi
|
|
72
|
+
if [ ! -d "$AGENT_DIR" ]; then echo "$(stamp) agent dir missing: $AGENT_DIR" >> "$LOG"; exit 0; fi
|
|
73
|
+
|
|
74
|
+
cd "$AGENT_DIR" || exit 0
|
|
75
|
+
CORTEX_TOKEN="$TOKEN" CORTEX_WATCH="$HOME/.cortex-agent" "$BUN" run src/index.ts antigravity-sync >> "$LOG" 2>&1
|
|
76
|
+
echo "$(stamp) exit=$?" >> "$LOG"
|
|
77
|
+
exit 0
|
|
78
|
+
`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @type {import('./index.mjs').EditorAdapter} */
|
|
82
|
+
export default {
|
|
83
|
+
id: 'antigravity',
|
|
84
|
+
displayName: 'Antigravity',
|
|
85
|
+
|
|
86
|
+
detect({ home = homedir(), exists = existsSync } = {}) {
|
|
87
|
+
return exists(join(home, ...AG_STATE))
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
capabilities: {
|
|
91
|
+
promptTimeInjection: false, // no in-editor injection possible today
|
|
92
|
+
sessionStart: false,
|
|
93
|
+
captureHook: true, // post-sync, via antigravity-sync.sh
|
|
94
|
+
skillDir: null,
|
|
95
|
+
docTrigger: 'watch',
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// Writes the two artifacts deterministically. Does NOT launchctl-load (unverifiable side effect) and
|
|
99
|
+
// returns a BLOCKING warning about the dev-checkout dependency — honest about not-yet-installable.
|
|
100
|
+
async wire({ home = homedir(), log = () => {} } = {}) {
|
|
101
|
+
const wrote = [], skipped = [], warnings = []
|
|
102
|
+
const cortexDir = join(home, '.cortex')
|
|
103
|
+
const shPath = join(cortexDir, 'antigravity-sync.sh')
|
|
104
|
+
const plistPath = join(home, 'Library', 'LaunchAgents', 'com.cortex.antigravity-sync.plist')
|
|
105
|
+
|
|
106
|
+
try { // non-fatal: an IO error must not abort sibling editors
|
|
107
|
+
mkdirSync(cortexDir, { recursive: true })
|
|
108
|
+
writeFileSync(shPath, renderAntigravitySyncSh())
|
|
109
|
+
try { chmodSync(shPath, 0o755) } catch { /* non-fatal on non-posix */ }
|
|
110
|
+
wrote.push(shPath)
|
|
111
|
+
|
|
112
|
+
mkdirSync(join(home, 'Library', 'LaunchAgents'), { recursive: true })
|
|
113
|
+
writeFileSync(plistPath, renderAntigravityPlist({ home }))
|
|
114
|
+
wrote.push(plistPath)
|
|
115
|
+
log(` ✓ Antigravity artifacts → ${shPath}, ${plistPath}`)
|
|
116
|
+
} catch (e) {
|
|
117
|
+
warnings.push(`Antigravity artifact write skipped: ${e.message}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
warnings.push('Antigravity NOT fully wired: (1) run `launchctl load ' + plistPath + '` to activate the watcher; (2) BLOCKER — the sync runs from a local dev checkout (~/dev/cortex/packages/cortex-agent), so it will not work on a machine without that checkout until a published antigravity-sync entrypoint exists.')
|
|
121
|
+
return { wrote, skipped, warnings }
|
|
122
|
+
},
|
|
123
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Editor adapter: Claude Code. P0-b task 2 — wire() + the pure merge functions extracted from
|
|
2
|
+
// setup.mjs. setup.mjs imports THESE SAME functions (and re-exports them), so the config bytes are
|
|
3
|
+
// structurally identical, not merely "supposed to match".
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { readJson, backupFile, ensureDir, writeJson } from './_fsutil.mjs'
|
|
8
|
+
|
|
9
|
+
/** Merge the Cortex MCP server into a ~/.claude.json object. Pure + idempotent: sets only the
|
|
10
|
+
* `cortex` entry (type:'stdio'), preserves every other server. `spec` = the package@dist-tag string. */
|
|
11
|
+
export function mergeClaudeMcp(existing, spec, token) {
|
|
12
|
+
const cfg = existing && typeof existing === 'object' ? { ...existing } : {}
|
|
13
|
+
cfg.mcpServers = cfg.mcpServers && typeof cfg.mcpServers === 'object' ? { ...cfg.mcpServers } : {}
|
|
14
|
+
cfg.mcpServers.cortex = { type: 'stdio', command: 'npx', args: ['-y', spec], env: { CORTEX_TOKEN: token } }
|
|
15
|
+
return cfg
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Merge Cortex's Claude Code hooks into a ~/.claude/settings.json object. Pure + idempotent: drops
|
|
19
|
+
* any prior cortex entry (old token/path/version) from each hook array before appending the current
|
|
20
|
+
* one — capture (Stop), status + skills-repair + snapshot-context (SessionStart), precompact
|
|
21
|
+
* (PreCompact). Commands carry NO inline token (each subcommand self-resolves it). Mirrors setup.mjs
|
|
22
|
+
* step 2 exactly; foreign hooks are never touched. */
|
|
23
|
+
export function mergeClaudeSettings(existing, spec) {
|
|
24
|
+
const s = existing && typeof existing === 'object' ? existing : {}
|
|
25
|
+
s.hooks = s.hooks ?? {}
|
|
26
|
+
|
|
27
|
+
// Stop — capture.
|
|
28
|
+
s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
|
|
29
|
+
const captureCmd = `npx -y ${spec} capture`
|
|
30
|
+
for (const grp of s.hooks.Stop) {
|
|
31
|
+
if (Array.isArray(grp.hooks)) grp.hooks = grp.hooks.filter((h) => !/cortex-mcp.*capture|capture-session-cloud/.test(h.command ?? ''))
|
|
32
|
+
}
|
|
33
|
+
let grp = s.hooks.Stop.find((g) => (g.matcher ?? '') === '')
|
|
34
|
+
if (!grp) { grp = { matcher: '', hooks: [] }; s.hooks.Stop.push(grp) }
|
|
35
|
+
grp.hooks = grp.hooks ?? []
|
|
36
|
+
grp.hooks.push({ type: 'command', command: captureCmd })
|
|
37
|
+
|
|
38
|
+
// SessionStart — status, then skills self-heal, then snapshot-context (order matters: matches setup).
|
|
39
|
+
s.hooks.SessionStart = Array.isArray(s.hooks.SessionStart) ? s.hooks.SessionStart : []
|
|
40
|
+
const statusCmd = `npx -y ${spec} status`
|
|
41
|
+
for (const sg of s.hooks.SessionStart) {
|
|
42
|
+
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? status/.test(h.command ?? ''))
|
|
43
|
+
}
|
|
44
|
+
let sgrp = s.hooks.SessionStart.find((g) => (g.matcher ?? '') === '')
|
|
45
|
+
if (!sgrp) { sgrp = { matcher: '', hooks: [] }; s.hooks.SessionStart.push(sgrp) }
|
|
46
|
+
sgrp.hooks = sgrp.hooks ?? []
|
|
47
|
+
sgrp.hooks.push({ type: 'command', command: statusCmd })
|
|
48
|
+
|
|
49
|
+
const skillsCmd = `npx -y ${spec} skills --repair --quiet`
|
|
50
|
+
for (const sg of s.hooks.SessionStart) {
|
|
51
|
+
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? skills/.test(h.command ?? ''))
|
|
52
|
+
}
|
|
53
|
+
sgrp.hooks.push({ type: 'command', command: skillsCmd })
|
|
54
|
+
|
|
55
|
+
const snapshotCmd = `npx -y ${spec} snapshot-context`
|
|
56
|
+
for (const sg of s.hooks.SessionStart) {
|
|
57
|
+
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? snapshot-context/.test(h.command ?? ''))
|
|
58
|
+
}
|
|
59
|
+
sgrp.hooks.push({ type: 'command', command: snapshotCmd })
|
|
60
|
+
|
|
61
|
+
// PreCompact — "author now" reminder.
|
|
62
|
+
s.hooks.PreCompact = Array.isArray(s.hooks.PreCompact) ? s.hooks.PreCompact : []
|
|
63
|
+
const precompactCmd = `npx -y ${spec} precompact`
|
|
64
|
+
for (const pg of s.hooks.PreCompact) {
|
|
65
|
+
if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? precompact/.test(h.command ?? ''))
|
|
66
|
+
}
|
|
67
|
+
let pgrp = s.hooks.PreCompact.find((g) => (g.matcher ?? '') === '')
|
|
68
|
+
if (!pgrp) { pgrp = { matcher: '', hooks: [] }; s.hooks.PreCompact.push(pgrp) }
|
|
69
|
+
pgrp.hooks = pgrp.hooks ?? []
|
|
70
|
+
pgrp.hooks.push({ type: 'command', command: precompactCmd })
|
|
71
|
+
|
|
72
|
+
return s
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @type {import('./index.mjs').EditorAdapter} */
|
|
76
|
+
export default {
|
|
77
|
+
id: 'claude-code',
|
|
78
|
+
displayName: 'Claude Code',
|
|
79
|
+
|
|
80
|
+
detect({ home = homedir(), exists = existsSync } = {}) {
|
|
81
|
+
return exists(join(home, '.claude')) || exists(join(home, '.claude.json'))
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
capabilities: {
|
|
85
|
+
promptTimeInjection: true,
|
|
86
|
+
sessionStart: true,
|
|
87
|
+
captureHook: true,
|
|
88
|
+
skillDir: '~/.claude/skills',
|
|
89
|
+
docTrigger: 'stop-hook',
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// Two independent, non-fatal blocks (mirrors codex.mjs): a malformed file OR an IO error is
|
|
93
|
+
// skipped-with-warning, never a throw that aborts the other block or a sibling editor. readJson
|
|
94
|
+
// throws on malformed → caught here → that file is left untouched (never overwritten). The WRITTEN
|
|
95
|
+
// bytes come from the shared pure functions above, so they are identical to setup's output.
|
|
96
|
+
async wire({ home = homedir(), token, spec, log = () => {} } = {}) {
|
|
97
|
+
const wrote = [], skipped = [], warnings = []
|
|
98
|
+
if (!token) { warnings.push('no token — skipped Claude Code'); return { wrote, skipped, warnings } }
|
|
99
|
+
|
|
100
|
+
const claudeJson = join(home, '.claude.json')
|
|
101
|
+
try {
|
|
102
|
+
const cfg = readJson(claudeJson) // {} if absent/empty; THROWS (→ skip) if malformed
|
|
103
|
+
const bak = backupFile(claudeJson)
|
|
104
|
+
ensureDir(claudeJson)
|
|
105
|
+
writeJson(claudeJson, mergeClaudeMcp(cfg, spec, token))
|
|
106
|
+
wrote.push(claudeJson)
|
|
107
|
+
log(` ✓ MCP server → ${claudeJson}${bak ? ' (backup saved)' : ''}`)
|
|
108
|
+
} catch (e) {
|
|
109
|
+
warnings.push(`${claudeJson} wiring skipped: ${e.message}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const settingsJson = join(home, '.claude', 'settings.json')
|
|
113
|
+
try {
|
|
114
|
+
const s = readJson(settingsJson)
|
|
115
|
+
const bak = backupFile(settingsJson)
|
|
116
|
+
ensureDir(settingsJson)
|
|
117
|
+
writeJson(settingsJson, mergeClaudeSettings(s, spec))
|
|
118
|
+
wrote.push(settingsJson)
|
|
119
|
+
log(` ✓ Capture hook + status line → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
|
|
120
|
+
} catch (e) {
|
|
121
|
+
warnings.push(`${settingsJson} wiring skipped: ${e.message}`)
|
|
122
|
+
}
|
|
123
|
+
return { wrote, skipped, warnings }
|
|
124
|
+
},
|
|
125
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Editor adapter: OpenAI Codex. P0-b task 3 — wire() + the pure merge functions, moved here verbatim
|
|
2
|
+
// from setup.mjs (which now imports + re-exports them). No caller imported them from setup except
|
|
3
|
+
// setup itself, so this move is transparent.
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { readJson, backupFile, ensureDir, writeJson } from './_fsutil.mjs'
|
|
8
|
+
|
|
9
|
+
/** Merge the Cortex MCP server into a Codex config.toml. Pure + idempotent: strips any existing
|
|
10
|
+
* [mcp_servers.cortex] / [mcp_servers.cortex.env] tables (so re-runs update in place rather than
|
|
11
|
+
* duplicating — a duplicate TOML table would break Codex's parser), preserves every other table,
|
|
12
|
+
* and appends a fresh block. Only touches the cortex tables; never rewrites the user's config. */
|
|
13
|
+
export function mergeCodexToml(text, spec, token) {
|
|
14
|
+
const targets = new Set(['[mcp_servers.cortex]', '[mcp_servers.cortex.env]'])
|
|
15
|
+
const kept = []
|
|
16
|
+
let skipping = false
|
|
17
|
+
for (const line of (text || '').split('\n')) {
|
|
18
|
+
const t = line.trim()
|
|
19
|
+
if (t.startsWith('[') && t.endsWith(']')) skipping = targets.has(t)
|
|
20
|
+
if (!skipping) kept.push(line)
|
|
21
|
+
}
|
|
22
|
+
while (kept.length && kept[kept.length - 1].trim() === '') kept.pop() // drop trailing blanks
|
|
23
|
+
const block = [
|
|
24
|
+
'',
|
|
25
|
+
'[mcp_servers.cortex]',
|
|
26
|
+
'command = "npx"',
|
|
27
|
+
`args = ["-y", "${spec}"]`,
|
|
28
|
+
'startup_timeout_sec = 60', // first npx fetch can be slow; don't time out the server on cold start
|
|
29
|
+
'',
|
|
30
|
+
'[mcp_servers.cortex.env]',
|
|
31
|
+
`CORTEX_TOKEN = "${token}"`,
|
|
32
|
+
'',
|
|
33
|
+
]
|
|
34
|
+
return [...kept, ...block].join('\n')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Merge the capture Stop hook into a Codex hooks.json object. Pure + idempotent: drops any prior
|
|
38
|
+
* cortex capture entry (old token / old path / old pinned version) before appending the current one,
|
|
39
|
+
* so re-running never duplicates and never leaves a stale version pinned behind a live one.
|
|
40
|
+
* Codex's hook runtime has NO SessionStart — only capture (Stop) is wired here. */
|
|
41
|
+
export function mergeCodexHooks(existing, captureCmd) {
|
|
42
|
+
const h = existing && typeof existing === 'object' ? existing : {}
|
|
43
|
+
h.hooks = h.hooks && typeof h.hooks === 'object' ? h.hooks : {}
|
|
44
|
+
h.hooks.Stop = Array.isArray(h.hooks.Stop) ? h.hooks.Stop : []
|
|
45
|
+
for (const grp of h.hooks.Stop) {
|
|
46
|
+
if (Array.isArray(grp.hooks)) {
|
|
47
|
+
grp.hooks = grp.hooks.filter((c) => !/cortex-mcp.*capture|capture-session-cloud/.test(c.command ?? ''))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let grp = h.hooks.Stop.find((g) => (g.matcher ?? '') === '')
|
|
51
|
+
if (!grp) { grp = { matcher: '', hooks: [] }; h.hooks.Stop.push(grp) }
|
|
52
|
+
grp.hooks = grp.hooks ?? []
|
|
53
|
+
grp.hooks.push({ type: 'command', command: captureCmd })
|
|
54
|
+
return h
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @type {import('./index.mjs').EditorAdapter} */
|
|
58
|
+
export default {
|
|
59
|
+
id: 'codex',
|
|
60
|
+
displayName: 'OpenAI Codex',
|
|
61
|
+
|
|
62
|
+
detect({ home = homedir(), exists = existsSync } = {}) {
|
|
63
|
+
return exists(join(home, '.codex'))
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// Codex's hook runtime recognizes PreToolUse/PostToolUse/PreCompact/UserPromptSubmit/Stop — notably
|
|
67
|
+
// NO SessionStart. So status/skills-repair/snapshot-context stay Claude-only; the manifest must
|
|
68
|
+
// encode sessionStart:false so P1/P2 don't assume it.
|
|
69
|
+
capabilities: {
|
|
70
|
+
promptTimeInjection: true,
|
|
71
|
+
sessionStart: false,
|
|
72
|
+
captureHook: true,
|
|
73
|
+
skillDir: null,
|
|
74
|
+
docTrigger: 'stop-hook',
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// Two independent, non-fatal blocks (config.toml + hooks.json) — mirrors setup.mjs steps 1b/1c.
|
|
78
|
+
// Written bytes come from the shared pure functions above → identical to setup's output.
|
|
79
|
+
async wire({ home = homedir(), token, spec, log = () => {} } = {}) {
|
|
80
|
+
const wrote = [], skipped = [], warnings = []
|
|
81
|
+
if (!token) { warnings.push('no token — skipped Codex'); return { wrote, skipped, warnings } }
|
|
82
|
+
const codexDir = join(home, '.codex')
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const codexToml = join(codexDir, 'config.toml')
|
|
86
|
+
const existing = existsSync(codexToml) ? readFileSync(codexToml, 'utf8') : ''
|
|
87
|
+
const bak = backupFile(codexToml)
|
|
88
|
+
ensureDir(codexToml)
|
|
89
|
+
writeFileSync(codexToml, mergeCodexToml(existing, spec, token)) // raw TOML text, not JSON
|
|
90
|
+
wrote.push(codexToml)
|
|
91
|
+
log(` ✓ MCP server → ${codexToml}${bak ? ' (backup saved)' : ''}`)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
warnings.push(`Codex MCP wiring skipped: ${e.message}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const codexHooks = join(codexDir, 'hooks.json')
|
|
98
|
+
let existingHooks
|
|
99
|
+
try { existingHooks = readJson(codexHooks) } catch { existingHooks = {} } // malformed → start fresh, don't block
|
|
100
|
+
const bak = backupFile(codexHooks)
|
|
101
|
+
const captureCmd = `npx -y ${spec} capture`
|
|
102
|
+
ensureDir(codexHooks)
|
|
103
|
+
writeJson(codexHooks, mergeCodexHooks(existingHooks, captureCmd))
|
|
104
|
+
wrote.push(codexHooks)
|
|
105
|
+
log(` ✓ Capture hook → ${codexHooks}${bak ? ' (backup saved)' : ''}`)
|
|
106
|
+
} catch (e) {
|
|
107
|
+
warnings.push(`Codex capture hook skipped: ${e.message}`)
|
|
108
|
+
}
|
|
109
|
+
return { wrote, skipped, warnings }
|
|
110
|
+
},
|
|
111
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Editor adapter: Cursor. P0-b task 4 (+ review fixes).
|
|
2
|
+
// ~/.cursor/mcp.json uses the same mcpServers.cortex shape as Claude (minus the `type` field the real
|
|
3
|
+
// Cursor config omits), so wiring Cursor is a pure JSON merge (mergeCursorMcp).
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { backupFile } from './_fsutil.mjs'
|
|
8
|
+
|
|
9
|
+
/** Merge the Cortex MCP server into a Cursor mcp.json object. Pure + idempotent: sets only the
|
|
10
|
+
* `cortex` entry, preserves every other server. `spec` is the package@dist-tag STRING — the same
|
|
11
|
+
* convention claude/codex/setup.mjs use, so one install driver can pass a single spec to every adapter. */
|
|
12
|
+
export function mergeCursorMcp(existing, spec, token) {
|
|
13
|
+
const cfg = existing && typeof existing === 'object' ? { ...existing } : {}
|
|
14
|
+
cfg.mcpServers = cfg.mcpServers && typeof cfg.mcpServers === 'object' ? { ...cfg.mcpServers } : {}
|
|
15
|
+
cfg.mcpServers.cortex = { command: 'npx', args: ['-y', spec], env: { CORTEX_TOKEN: token } }
|
|
16
|
+
return cfg
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @type {import('./index.mjs').EditorAdapter} */
|
|
20
|
+
export default {
|
|
21
|
+
id: 'cursor',
|
|
22
|
+
displayName: 'Cursor',
|
|
23
|
+
|
|
24
|
+
detect({ home = homedir(), exists = existsSync } = {}) {
|
|
25
|
+
return exists(join(home, '.cursor'))
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
// Today Cursor is capture-only: the hook buffers turns and POSTs to /api/ingest, but does not
|
|
29
|
+
// inject sibling-session context at prompt time. promptTimeInjection stays false until the
|
|
30
|
+
// Pillar-1 Cursor inject hook is built (a named gap in cross-app-session-visibility-spec.md).
|
|
31
|
+
capabilities: {
|
|
32
|
+
promptTimeInjection: false,
|
|
33
|
+
sessionStart: false,
|
|
34
|
+
captureHook: true, // ~/.cursor/hooks/cortex-cursor.mjs
|
|
35
|
+
skillDir: null, // Cursor rules/commands dir — revisit in Pillar 2
|
|
36
|
+
docTrigger: 'hook',
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// Non-fatal by contract: a Cursor failure (parse OR IO) must never abort the other editors' wiring.
|
|
40
|
+
async wire({ home = homedir(), token, spec, log = () => {} } = {}) {
|
|
41
|
+
const wrote = [], skipped = [], warnings = []
|
|
42
|
+
if (!token) { warnings.push('no token — skipped Cursor'); return { wrote, skipped, warnings } }
|
|
43
|
+
|
|
44
|
+
const cursorDir = join(home, '.cursor')
|
|
45
|
+
const mcpPath = join(cursorDir, 'mcp.json')
|
|
46
|
+
try {
|
|
47
|
+
let existing = {}
|
|
48
|
+
if (existsSync(mcpPath)) {
|
|
49
|
+
try {
|
|
50
|
+
existing = JSON.parse(readFileSync(mcpPath, 'utf8'))
|
|
51
|
+
} catch {
|
|
52
|
+
// Data-loss guard: a malformed mcp.json may still hold the user's other MCP servers we can't
|
|
53
|
+
// safely parse. Do NOT overwrite it with a cortex-only config — skip with a warning.
|
|
54
|
+
warnings.push(`${mcpPath} is not valid JSON — left untouched (fix it, then re-run) to avoid dropping your other MCP servers`)
|
|
55
|
+
return { wrote, skipped, warnings }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const bak = backupFile(mcpPath)
|
|
59
|
+
mkdirSync(cursorDir, { recursive: true })
|
|
60
|
+
writeFileSync(mcpPath, JSON.stringify(mergeCursorMcp(existing, spec, token), null, 2))
|
|
61
|
+
wrote.push(mcpPath)
|
|
62
|
+
log(` ✓ MCP server → ${mcpPath}${bak ? ' (backup saved)' : ''}`)
|
|
63
|
+
} catch (e) {
|
|
64
|
+
warnings.push(`Cursor MCP wiring skipped: ${e.message}`) // IO error → non-fatal, siblings unaffected
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Capture hook: detect, don't fabricate. The cortex-cursor.mjs hook isn't bundled in this package
|
|
68
|
+
// yet (it was hand-installed), so warn rather than write a guessed file. Bundling it is follow-up.
|
|
69
|
+
const hookPath = join(cursorDir, 'hooks', 'cortex-cursor.mjs')
|
|
70
|
+
if (!existsSync(hookPath)) {
|
|
71
|
+
warnings.push('Cursor capture hook (~/.cursor/hooks/cortex-cursor.mjs) not found — Cursor sessions will not be captured until it is installed')
|
|
72
|
+
} else {
|
|
73
|
+
skipped.push(`${hookPath} (already present)`)
|
|
74
|
+
}
|
|
75
|
+
return { wrote, skipped, warnings }
|
|
76
|
+
},
|
|
77
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Editor adapter registry (P0-b task 1). One place that knows every editor Cortex can wire.
|
|
2
|
+
// Adding an editor = add a module here; the install driver + capability manifest fall out of it.
|
|
3
|
+
//
|
|
4
|
+
// EditorAdapter shape (see the four modules):
|
|
5
|
+
// id stable string id (used in the manifest + `--editor <id>`)
|
|
6
|
+
// displayName human label
|
|
7
|
+
// detect(env) → boolean; env = { home?, exists? } is injectable for tests
|
|
8
|
+
// capabilities { promptTimeInjection, sessionStart, captureHook, skillDir, docTrigger }
|
|
9
|
+
// wire(ctx) → performs the install for this editor (stubbed in task 1)
|
|
10
|
+
//
|
|
11
|
+
/** @typedef {Object} EditorAdapter */
|
|
12
|
+
|
|
13
|
+
import claude from './claude.mjs'
|
|
14
|
+
import codex from './codex.mjs'
|
|
15
|
+
import cursor from './cursor.mjs'
|
|
16
|
+
import antigravity from './antigravity.mjs'
|
|
17
|
+
|
|
18
|
+
export const ADAPTERS = [claude, codex, cursor, antigravity]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve which editors an install targets.
|
|
22
|
+
* 'auto' → every adapter whose detect() is true on this machine (the default)
|
|
23
|
+
* 'all' → every adapter, regardless of detection
|
|
24
|
+
* 'id'|'a,b' → the named adapter(s); throws on an unknown id (fail loud, don't silently skip)
|
|
25
|
+
*
|
|
26
|
+
* @param {string} [flag='auto']
|
|
27
|
+
* @param {{ adapters?: EditorAdapter[], env?: { home?: string, exists?: (p: string) => boolean } }} [opts]
|
|
28
|
+
* @returns {EditorAdapter[]}
|
|
29
|
+
*/
|
|
30
|
+
export function resolveEditors(flag = 'auto', { adapters = ADAPTERS, env } = {}) {
|
|
31
|
+
if (flag === 'all') return [...adapters]
|
|
32
|
+
if (flag === 'auto') return adapters.filter((a) => a.detect(env))
|
|
33
|
+
|
|
34
|
+
const ids = String(flag).split(',').map((s) => s.trim()).filter(Boolean)
|
|
35
|
+
const known = new Set(adapters.map((a) => a.id))
|
|
36
|
+
const unknown = ids.filter((id) => !known.has(id))
|
|
37
|
+
if (unknown.length) {
|
|
38
|
+
throw new Error(`Unknown editor(s): ${unknown.join(', ')}. Known: ${adapters.map((a) => a.id).join(', ')}`)
|
|
39
|
+
}
|
|
40
|
+
// Preserve registry order, dedupe.
|
|
41
|
+
return adapters.filter((a) => ids.includes(a.id))
|
|
42
|
+
}
|
package/lib/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
|
@@ -643,6 +643,33 @@ export async function runServer(version) {
|
|
|
643
643
|
},
|
|
644
644
|
)
|
|
645
645
|
|
|
646
|
+
server.registerTool(
|
|
647
|
+
'create_brain',
|
|
648
|
+
{
|
|
649
|
+
title: 'Create a new brain under your existing account',
|
|
650
|
+
description: 'Create a brand-new brain (org/workspace) — a fully independent knowledge graph — under your EXISTING account. No new login, no new email/password: this adds a second membership to the account you are already using. Reads never cross brains; use set_active_brain afterward to point writes at it (creating it does not switch your active write brain automatically).',
|
|
651
|
+
inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Cortex Codebase"') },
|
|
652
|
+
},
|
|
653
|
+
async ({ name }) => {
|
|
654
|
+
let res
|
|
655
|
+
try {
|
|
656
|
+
res = await fetchCortex(`${BASE}/api/brains`, {
|
|
657
|
+
method: 'POST',
|
|
658
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
659
|
+
body: JSON.stringify({ name }),
|
|
660
|
+
})
|
|
661
|
+
} catch (e) {
|
|
662
|
+
return { content: [{ type: 'text', text: `Could not create brain: ${e.message}` }] }
|
|
663
|
+
}
|
|
664
|
+
if (!res.ok) {
|
|
665
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
666
|
+
return { content: [{ type: 'text', text: `Could not create brain: ${d.message}` }] }
|
|
667
|
+
}
|
|
668
|
+
const r = await res.json()
|
|
669
|
+
return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}]. Run set_active_brain with this org_id to start writing to it — reads already span it automatically.` }] }
|
|
670
|
+
},
|
|
671
|
+
)
|
|
672
|
+
|
|
646
673
|
server.registerTool(
|
|
647
674
|
'list_records',
|
|
648
675
|
{
|
package/lib/setup.mjs
CHANGED
|
@@ -3,6 +3,10 @@ import { homedir } from 'os'
|
|
|
3
3
|
import { join, dirname } from 'path'
|
|
4
4
|
import { checkToken, resolveBase, readWiredToken } from './diagnose.mjs'
|
|
5
5
|
import { installSkills } from './skills.mjs'
|
|
6
|
+
// Pure config-merge functions live in the editor adapters; setup imports (and re-exports) THE SAME
|
|
7
|
+
// functions the `cortex install` path uses, so both write byte-identical config.
|
|
8
|
+
import { mergeClaudeMcp, mergeClaudeSettings } from './editors/claude.mjs'
|
|
9
|
+
import { mergeCodexToml, mergeCodexHooks } from './editors/codex.mjs'
|
|
6
10
|
|
|
7
11
|
// One-command employee onboarding. Wires both:
|
|
8
12
|
// 1. ~/.claude.json → the cortex MCP server (context-serving)
|
|
@@ -14,33 +18,7 @@ import { installSkills } from './skills.mjs'
|
|
|
14
18
|
|
|
15
19
|
const PKG = '@theronap/cortex-mcp'
|
|
16
20
|
|
|
17
|
-
//
|
|
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.45",
|
|
4
4
|
"description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
11
|
"lib",
|
|
12
|
-
"skills"
|
|
12
|
+
"skills",
|
|
13
|
+
"!lib/**/*.test.mjs"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=18"
|
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
|
-
})
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { test, expect } from 'bun:test'
|
|
2
|
-
import { decideSend, loadSendAllowlist, phoneKey } from './imessage_send.mjs'
|
|
3
|
-
|
|
4
|
-
const allow = loadSendAllowlist('+18015551234, mom@example.com')
|
|
5
|
-
|
|
6
|
-
test('draft-by-default: no send flag → draft, never sends (D3)', () => {
|
|
7
|
-
const d = decideSend({ recipient: '+18015551234', message: 'hi', send: false, allowlist: allow, confirmSecret: null })
|
|
8
|
-
expect(d.action).toBe('draft')
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
test('allowlisted recipient + send:true → send (D10)', () => {
|
|
12
|
-
expect(decideSend({ recipient: '+1 (801) 555-1234', message: 'hi', send: true, allowlist: allow, confirmSecret: null }).action).toBe('send')
|
|
13
|
-
expect(decideSend({ recipient: 'MOM@example.com', message: 'hi', send: true, allowlist: allow, confirmSecret: null }).action).toBe('send')
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
test('off-list recipient + send:true, no secret → BLOCKED (D10 prompt-injection guard)', () => {
|
|
17
|
-
const d = decideSend({ recipient: '+19998887777', message: 'hi', send: true, allowlist: allow, confirmSecret: null })
|
|
18
|
-
expect(d.action).toBe('blocked')
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
test('off-list recipient sends only with the correct out-of-band confirm secret', () => {
|
|
22
|
-
expect(decideSend({ recipient: '+19998887777', message: 'hi', send: true, confirm: 'wrong', allowlist: allow, confirmSecret: 'sesame' }).action).toBe('blocked')
|
|
23
|
-
expect(decideSend({ recipient: '+19998887777', message: 'hi', send: true, confirm: 'sesame', allowlist: allow, confirmSecret: 'sesame' }).action).toBe('send')
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test('empty recipient/message is blocked', () => {
|
|
27
|
-
expect(decideSend({ recipient: '', message: 'hi', send: true, allowlist: allow, confirmSecret: null }).action).toBe('blocked')
|
|
28
|
-
expect(decideSend({ recipient: '+18015551234', message: ' ', send: true, allowlist: allow, confirmSecret: null }).action).toBe('blocked')
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
test('phoneKey + allowlist normalization', () => {
|
|
32
|
-
expect(phoneKey('+1 (801) 555-1234')).toBe('8015551234')
|
|
33
|
-
expect(allow.has('8015551234')).toBe(true)
|
|
34
|
-
expect(allow.has('mom@example.com')).toBe(true)
|
|
35
|
-
})
|
package/lib/redact.test.mjs
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { test, expect } from 'bun:test'
|
|
2
|
-
import { redactSecrets } from './redact.mjs'
|
|
3
|
-
|
|
4
|
-
// The 2026-06-22 finding: a Claude Code login token (sk-ant-oat01-…) was sitting inlined
|
|
5
|
-
// in a Stop-hook command and could ride a transcript tail to the org. Capture must never
|
|
6
|
-
// transmit a credential. These assert known secret shapes are stripped — and that ordinary
|
|
7
|
-
// prose / record UUIDs are left intact (no false positives that would mangle real content).
|
|
8
|
-
|
|
9
|
-
test('redacts an Anthropic OAuth (Claude Code login) token', () => {
|
|
10
|
-
const s = 'CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-oyo53qZ8m_WSrYZU8rrpWrxmgtbO1T8hSOhhpicMqeZ4Ixtwzbj0qwb in the hook'
|
|
11
|
-
const out = redactSecrets(s)
|
|
12
|
-
expect(out).not.toContain('sk-ant-oat01-oyo53qZ8m')
|
|
13
|
-
expect(out).toContain('[REDACTED:anthropic-oauth]')
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
test('redacts a range of credential shapes', () => {
|
|
17
|
-
const cases = [
|
|
18
|
-
['sk-ant-api03-' + 'a'.repeat(40), 'anthropic-key'],
|
|
19
|
-
['sk-proj-' + 'b'.repeat(40), 'openai'],
|
|
20
|
-
['sk-' + 'c'.repeat(40), 'openai'],
|
|
21
|
-
['ghp_' + 'd'.repeat(36), 'github'],
|
|
22
|
-
['github_pat_' + 'e'.repeat(30), 'github-pat'],
|
|
23
|
-
['AKIA' + 'ABCDEFGHIJKLMNOP', 'aws-akid'],
|
|
24
|
-
['AIza' + 'f'.repeat(35), 'google'],
|
|
25
|
-
['xoxb-' + '1234567890-abcdef', 'slack'],
|
|
26
|
-
['eyJ' + 'a'.repeat(20) + '.' + 'b'.repeat(20) + '.' + 'c'.repeat(20), 'jwt'],
|
|
27
|
-
]
|
|
28
|
-
for (const [secret, tag] of cases) {
|
|
29
|
-
const out = redactSecrets(`token: ${secret} end`)
|
|
30
|
-
expect(out).toContain(`[REDACTED:${tag}]`)
|
|
31
|
-
expect(out).not.toContain(secret)
|
|
32
|
-
}
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('redacts a Bearer token but keeps the scheme', () => {
|
|
36
|
-
const out = redactSecrets('Authorization: Bearer abcdef0123456789ABCDEFxyz')
|
|
37
|
-
expect(out).toBe('Authorization: Bearer [REDACTED]')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('redacts a PEM private key block', () => {
|
|
41
|
-
const out = redactSecrets('-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----')
|
|
42
|
-
expect(out).toContain('[REDACTED:private-key]')
|
|
43
|
-
expect(out).not.toContain('MIIE')
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('leaves ordinary prose and record UUIDs untouched', () => {
|
|
47
|
-
const prose = 'Fixed the ingest latency; record 5203eacd-321b-43f7-bae2-4d6e7cae96ab landed in ~0.3s.'
|
|
48
|
-
expect(redactSecrets(prose)).toBe(prose)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
// The 2026-07-02 finding: a grep of ~/.codex/hooks.json printed the live CORTEX_TOKEN (a bare
|
|
52
|
-
// UUID — no pattern above catches those) into a transcript. The inline form is gone from hooks
|
|
53
|
-
// (capture self-resolves now), but transcripts can still carry config lines from ANY tool read.
|
|
54
|
-
test('redacts CORTEX_TOKEN in env-assignment and TOML/JSON forms', () => {
|
|
55
|
-
const uuid = 'ee0455d0-49bd-4141-984d-ece9311f9407'
|
|
56
|
-
const cases = [
|
|
57
|
-
`CORTEX_TOKEN=${uuid} npx -y @theronap/cortex-mcp@latest capture`, // legacy hook command
|
|
58
|
-
`"CORTEX_TOKEN": "${uuid}"`, // ~/.claude.json
|
|
59
|
-
`CORTEX_TOKEN = "${uuid}"`, // config.toml
|
|
60
|
-
]
|
|
61
|
-
for (const s of cases) {
|
|
62
|
-
const out = redactSecrets(s)
|
|
63
|
-
expect(out).toContain('[REDACTED:cortex-token]')
|
|
64
|
-
expect(out).not.toContain(uuid)
|
|
65
|
-
}
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test('redacts generic secret-shaped env assignments', () => {
|
|
69
|
-
const out = redactSecrets('export SUPABASE_SERVICE_SECRET=abcDEF0123456789xyz && run')
|
|
70
|
-
expect(out).toBe('export SUPABASE_SERVICE_SECRET=[REDACTED:env] && run')
|
|
71
|
-
const out2 = redactSecrets('MY_API_KEY: "0123456789abcdef0123"')
|
|
72
|
-
expect(out2).toContain('[REDACTED:env]')
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
test('generic env pattern leaves short placeholders and prose intact', () => {
|
|
76
|
-
const cases = [
|
|
77
|
-
'set YOUR_TOKEN=xxx in the env', // short placeholder value
|
|
78
|
-
'the CORTEX_TOKEN is stored in ~/.claude.json', // prose, no assignment value
|
|
79
|
-
'record 5203eacd-321b-43f7-bae2-4d6e7cae96ab', // bare UUID, no key name
|
|
80
|
-
]
|
|
81
|
-
for (const s of cases) expect(redactSecrets(s)).toBe(s)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
test('handles empty / non-string input safely', () => {
|
|
85
|
-
expect(redactSecrets('')).toBe('')
|
|
86
|
-
expect(redactSecrets(null)).toBe(null)
|
|
87
|
-
expect(redactSecrets(undefined)).toBe(undefined)
|
|
88
|
-
})
|
package/lib/skills.test.mjs
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'bun:test'
|
|
2
|
-
import { planOrgInstall } from './skills.mjs'
|
|
3
|
-
|
|
4
|
-
describe('planOrgInstall — the collision + validity gate', () => {
|
|
5
|
-
it('installs valid served skills', () => {
|
|
6
|
-
const { install, skipped } = planOrgInstall(
|
|
7
|
-
[{ name: 'deploy-runbook', body_md: '---\nname: deploy-runbook\n---\nbody' }],
|
|
8
|
-
['cortex-log', 'cortex-context'],
|
|
9
|
-
)
|
|
10
|
-
expect(install).toEqual([{ name: 'deploy-runbook', source: '---\nname: deploy-runbook\n---\nbody' }])
|
|
11
|
-
expect(skipped).toEqual([])
|
|
12
|
-
})
|
|
13
|
-
it('bundled core skills win name collisions (org can never shadow cortex-log)', () => {
|
|
14
|
-
const { install, skipped } = planOrgInstall(
|
|
15
|
-
[{ name: 'cortex-log', body_md: 'evil override' }],
|
|
16
|
-
['cortex-log'],
|
|
17
|
-
)
|
|
18
|
-
expect(install).toEqual([])
|
|
19
|
-
expect(skipped[0]).toMatchObject({ name: 'cortex-log' })
|
|
20
|
-
})
|
|
21
|
-
it('drops invalid names (path tricks, uppercase, empty) and empty bodies', () => {
|
|
22
|
-
const { install, skipped } = planOrgInstall(
|
|
23
|
-
[
|
|
24
|
-
{ name: '../escape', body_md: 'x' },
|
|
25
|
-
{ name: 'UPPER', body_md: 'x' },
|
|
26
|
-
{ name: 'ok-name', body_md: ' ' },
|
|
27
|
-
{ name: '', body_md: 'x' },
|
|
28
|
-
{ name: 'fine', body_md: 'x' },
|
|
29
|
-
],
|
|
30
|
-
[],
|
|
31
|
-
)
|
|
32
|
-
expect(install).toEqual([{ name: 'fine', source: 'x' }])
|
|
33
|
-
expect(skipped.length).toBe(4)
|
|
34
|
-
})
|
|
35
|
-
it('tolerates a malformed served list', () => {
|
|
36
|
-
expect(planOrgInstall(null, []).install).toEqual([])
|
|
37
|
-
expect(planOrgInstall([null, {}, { name: 'a' }], []).install).toEqual([])
|
|
38
|
-
})
|
|
39
|
-
})
|