@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
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` +
|
|
@@ -49,6 +50,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
49
50
|
` skills install/repair the managed Cortex skills — bundled + org-published (also wired by setup)\n` +
|
|
50
51
|
` skills push <file> publish a SKILL.md to your org (owner/manager/admin)\n` +
|
|
51
52
|
` docs-scan detect new/changed local docs pending Cortex authoring (used by /cortex-author-docs)\n` +
|
|
53
|
+
` graphify-sync [path] rebuild the local code graph (graphify) + log an evidence-tier timeline event\n` +
|
|
52
54
|
` snapshot-context save the exact startup context Cortex served to a local snapshot\n` +
|
|
53
55
|
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
54
56
|
` ingest-folder <path> ingest a local markdown folder as your authored records\n` +
|
|
@@ -70,6 +72,13 @@ if (cmd === 'setup') {
|
|
|
70
72
|
await runSetup(rest, VERSION)
|
|
71
73
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
72
74
|
await closeFetch()
|
|
75
|
+
} else if (cmd === 'install') {
|
|
76
|
+
// The cross-editor hub installer: wire Cortex into every detected editor via the adapter
|
|
77
|
+
// registry, then write the capability manifest (~/.cortex/editors.json). Superset of `setup`.
|
|
78
|
+
const { runInstall } = await import('../lib/install.mjs')
|
|
79
|
+
await runInstall(rest, VERSION)
|
|
80
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
81
|
+
await closeFetch()
|
|
73
82
|
} else if (cmd === 'uninstall' || cmd === 'remove') {
|
|
74
83
|
// Full reverse of setup: strip every Cortex touch-point (MCP entries, hooks, skills, launchd, cron).
|
|
75
84
|
// --dry-run prints the plan and changes nothing; --purge also removes ~/.cortex, the npx cache, and
|
|
@@ -120,6 +129,13 @@ if (cmd === 'setup') {
|
|
|
120
129
|
process.exitCode = await runGrep(rest)
|
|
121
130
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
122
131
|
await closeFetch()
|
|
132
|
+
} else if (cmd === 'graphify-sync') {
|
|
133
|
+
// Local producer: `graphify update` + log an evidence-tier timeline event. Run from a cron/
|
|
134
|
+
// launchd job per repo, not a git hook (shared-checkout hazard — see reference memory).
|
|
135
|
+
const { runGraphifySync } = await import('../lib/graphify_sync.mjs')
|
|
136
|
+
process.exitCode = await runGraphifySync(rest)
|
|
137
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
138
|
+
await closeFetch()
|
|
123
139
|
} else if (cmd === 'snapshot-context') {
|
|
124
140
|
const { runSnapshotContext } = await import('../lib/context_log.mjs')
|
|
125
141
|
process.exitCode = await runSnapshotContext()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
|
|
5
|
+
// Thin local wrapper around the `graphify` CLI's read-only query subcommands. Deliberately NOT a
|
|
6
|
+
// fetchCortex client like grep/read_page: this is LOCAL-MACHINE data (a tree-sitter AST graph of
|
|
7
|
+
// whatever repo the session's cwd happens to be in), not org-shared Cortex content, and it never
|
|
8
|
+
// becomes the wiki graph — see cortex-wiki-primary-spec (structural/extracted data is evidence,
|
|
9
|
+
// never auto-promoted into authored pages). No LLM, no network call; graphify already built the
|
|
10
|
+
// graph on disk, this just queries it.
|
|
11
|
+
|
|
12
|
+
function graphPath(cwd) {
|
|
13
|
+
return join(cwd, 'graphify-out', 'graph.json')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasGraphifyBinary() {
|
|
17
|
+
const r = spawnSync('graphify', ['--version'], { encoding: 'utf8', timeout: 10_000 })
|
|
18
|
+
return !r.error && r.status === 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pure-ish: build the argv for a given action, or return an error string if params are missing.
|
|
22
|
+
export function buildArgs({ action, question, from, to, node }) {
|
|
23
|
+
if (action === 'path') {
|
|
24
|
+
if (!from || !to) return { error: 'action:"path" requires both "from" and "to".' }
|
|
25
|
+
return { args: ['path', from, to] }
|
|
26
|
+
}
|
|
27
|
+
if (action === 'explain') {
|
|
28
|
+
if (!node) return { error: 'action:"explain" requires "node".' }
|
|
29
|
+
return { args: ['explain', node] }
|
|
30
|
+
}
|
|
31
|
+
if (!question) return { error: 'action:"query" requires "question".' }
|
|
32
|
+
return { args: ['query', question] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Effectful: run one of graphify's query/path/explain subcommands against the graph already built
|
|
36
|
+
// for `cwd`. Returns { ok, text } — never throws, always something readable to hand back to the model.
|
|
37
|
+
export function runCodeGraphQuery({ action, question, from, to, node }, cwd = process.cwd()) {
|
|
38
|
+
if (!existsSync(graphPath(cwd))) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
text: `No code graph found at ${graphPath(cwd)}. Run the graphify skill (\`/graphify .\`) in this repo first to build one.`,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!hasGraphifyBinary()) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
text: 'graphify CLI not found on PATH. Install it with `uv tool install graphifyy` (or `pipx install graphifyy`), then run the graphify skill to build a graph.',
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const built = buildArgs({ action, question, from, to, node })
|
|
51
|
+
if (built.error) return { ok: false, text: built.error }
|
|
52
|
+
|
|
53
|
+
const r = spawnSync('graphify', built.args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 4 * 1024 * 1024 })
|
|
54
|
+
if (r.error) return { ok: false, text: `graphify failed to run: ${r.error.message}` }
|
|
55
|
+
const out = (r.stdout || '').trim()
|
|
56
|
+
const err = (r.stderr || '').trim()
|
|
57
|
+
if (r.status !== 0) return { ok: false, text: err || out || `graphify exited with status ${r.status}` }
|
|
58
|
+
return { ok: true, text: out || '(no results)' }
|
|
59
|
+
}
|
|
@@ -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
|
+
}
|