@theronap/agnoclast-mcp 0.9.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/bin/cortex-mcp.mjs +223 -0
- package/lib/capture.mjs +470 -0
- package/lib/code_graph_cli.mjs +59 -0
- package/lib/context_log.mjs +92 -0
- package/lib/diagnose.mjs +360 -0
- package/lib/docs_scan.mjs +171 -0
- package/lib/doctor.mjs +117 -0
- package/lib/edge_extract.mjs +156 -0
- package/lib/editors/_fsutil.mjs +31 -0
- package/lib/editors/antigravity.mjs +130 -0
- package/lib/editors/claude.mjs +202 -0
- package/lib/editors/codex.mjs +111 -0
- package/lib/editors/cursor.mjs +77 -0
- package/lib/editors/index.mjs +42 -0
- package/lib/extract_typed.mjs +68 -0
- package/lib/graphify_sync.mjs +134 -0
- package/lib/grep_cli.mjs +82 -0
- package/lib/hydrate.mjs +181 -0
- package/lib/imessage_send.mjs +88 -0
- package/lib/ingest_folder.mjs +170 -0
- package/lib/install.mjs +163 -0
- package/lib/login.mjs +148 -0
- package/lib/managed.mjs +49 -0
- package/lib/migrate_key.mjs +139 -0
- package/lib/presence.mjs +226 -0
- package/lib/publish_targets.mjs +51 -0
- package/lib/red_link_triage.mjs +37 -0
- package/lib/redact.mjs +40 -0
- package/lib/rename_notice.mjs +31 -0
- package/lib/resolve.mjs +153 -0
- package/lib/server.mjs +2986 -0
- package/lib/session_key.mjs +37 -0
- package/lib/setup.mjs +215 -0
- package/lib/skills.mjs +374 -0
- package/lib/statusline.mjs +67 -0
- package/lib/uninstall.mjs +237 -0
- package/lib/use_brain.mjs +82 -0
- package/lib/with_token.mjs +66 -0
- package/package.json +36 -0
- package/skills/author-docs/SKILL.md +74 -0
- package/skills/context/SKILL.md +25 -0
- package/skills/log/SKILL.md +114 -0
- package/skills/walkthrough/SKILL.md +189 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// send_imessage — outbound iMessage via Messages.app. This is a personal automation, NOT org
|
|
2
|
+
// intelligence: it writes nothing to Agnoclast. Three layers of safety (eng-review D3/D6/D10):
|
|
3
|
+
// D6 argv-safe: recipient + body are passed as osascript `on run argv` arguments, NEVER
|
|
4
|
+
// interpolated into the script source → no AppleScript injection, no quote/newline breakage.
|
|
5
|
+
// D3 draft-by-default: nothing sends unless the caller explicitly passes send:true.
|
|
6
|
+
// D10 recipient gating: an MCP send tool is reachable by any agent context (prompt-injection
|
|
7
|
+
// surface), so a boolean alone is not enough. Allowlisted recipients send on send:true;
|
|
8
|
+
// OFF-list recipients additionally require an out-of-band confirm secret the agent context
|
|
9
|
+
// doesn't have (CORTEX_IMESSAGE_SEND_CONFIRM), else the send is BLOCKED.
|
|
10
|
+
import { spawn } from 'child_process'
|
|
11
|
+
|
|
12
|
+
// AppleScript reads its inputs from argv — the message text is DATA, never code.
|
|
13
|
+
const SEND_SCRIPT = `on run argv
|
|
14
|
+
set targetId to item 1 of argv
|
|
15
|
+
set targetMessage to item 2 of argv
|
|
16
|
+
tell application "Messages"
|
|
17
|
+
set targetService to 1st account whose service type = iMessage
|
|
18
|
+
set targetBuddy to participant targetId of targetService
|
|
19
|
+
send targetMessage to targetBuddy
|
|
20
|
+
end tell
|
|
21
|
+
end run`
|
|
22
|
+
|
|
23
|
+
export function phoneKey(raw) {
|
|
24
|
+
const d = String(raw).replace(/[^0-9]/g, '')
|
|
25
|
+
return d.length < 7 ? null : d.slice(-10)
|
|
26
|
+
}
|
|
27
|
+
function handleKey(h) {
|
|
28
|
+
return h.includes('@') ? h.trim().toLowerCase() : phoneKey(h)
|
|
29
|
+
}
|
|
30
|
+
export function loadSendAllowlist(raw = process.env.CORTEX_IMESSAGE_SEND_ALLOWLIST ?? '') {
|
|
31
|
+
const set = new Set()
|
|
32
|
+
for (const item of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
33
|
+
set.add(item.includes('@') ? item.toLowerCase() : (phoneKey(item) ?? item))
|
|
34
|
+
}
|
|
35
|
+
return set
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Pure decision: what should happen, given inputs + config. Testable without Messages.app.
|
|
39
|
+
// → { action: 'draft' | 'send' | 'blocked', reason }
|
|
40
|
+
export function decideSend({ recipient, message, send, confirm, allowlist, confirmSecret }) {
|
|
41
|
+
if (!recipient || !String(recipient).trim()) return { action: 'blocked', reason: 'no recipient' }
|
|
42
|
+
if (!message || !String(message).trim()) return { action: 'blocked', reason: 'empty message' }
|
|
43
|
+
if (!send) return { action: 'draft', reason: 'draft-by-default — re-call with send:true to actually send' }
|
|
44
|
+
const key = handleKey(String(recipient))
|
|
45
|
+
if (key && allowlist.has(key)) return { action: 'send', reason: 'allowlisted recipient' }
|
|
46
|
+
// off-list: require the out-of-band confirm secret
|
|
47
|
+
if (confirmSecret && confirm && confirm === confirmSecret) return { action: 'send', reason: 'off-list send authorized by confirm secret' }
|
|
48
|
+
return {
|
|
49
|
+
action: 'blocked',
|
|
50
|
+
reason: confirmSecret
|
|
51
|
+
? 'recipient not allowlisted — pass the out-of-band confirm secret to send, or add them to CORTEX_IMESSAGE_SEND_ALLOWLIST'
|
|
52
|
+
: 'recipient not allowlisted — add them to CORTEX_IMESSAGE_SEND_ALLOWLIST (or set CORTEX_IMESSAGE_SEND_CONFIRM for an out-of-band override)',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function sendViaOsascript(recipient, message) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
let err = ''
|
|
59
|
+
let proc
|
|
60
|
+
try {
|
|
61
|
+
proc = spawn('osascript', ['-', String(recipient), String(message)], { stdio: ['pipe', 'ignore', 'pipe'] })
|
|
62
|
+
} catch (e) {
|
|
63
|
+
resolve({ ok: false, error: String(e) }); return
|
|
64
|
+
}
|
|
65
|
+
proc.stderr.on('data', (d) => { err += d })
|
|
66
|
+
proc.on('error', (e) => resolve({ ok: false, error: String(e) }))
|
|
67
|
+
proc.on('close', (code) => resolve({ ok: code === 0, error: err.trim() }))
|
|
68
|
+
proc.stdin.write(SEND_SCRIPT)
|
|
69
|
+
proc.stdin.end()
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Tool entry: decide, then act. Returns MCP content text.
|
|
74
|
+
export async function runSendImessage({ recipient, message, send, confirm }) {
|
|
75
|
+
const decision = decideSend({
|
|
76
|
+
recipient, message, send, confirm,
|
|
77
|
+
allowlist: loadSendAllowlist(),
|
|
78
|
+
confirmSecret: process.env.CORTEX_IMESSAGE_SEND_CONFIRM ?? null,
|
|
79
|
+
})
|
|
80
|
+
if (decision.action === 'draft') {
|
|
81
|
+
return `📝 DRAFT (not sent) — to ${recipient}:\n${message}\n\n(${decision.reason})`
|
|
82
|
+
}
|
|
83
|
+
if (decision.action === 'blocked') {
|
|
84
|
+
return `🚫 Not sent: ${decision.reason}`
|
|
85
|
+
}
|
|
86
|
+
const r = await sendViaOsascript(recipient, message)
|
|
87
|
+
return r.ok ? `✅ Sent to ${recipient}.` : `❌ Send failed: ${r.error || 'Messages.app returned an error (is it signed in to iMessage? is Automation permission granted?)'}`
|
|
88
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative, basename, extname } from 'node:path'
|
|
3
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken } from './diagnose.mjs'
|
|
4
|
+
|
|
5
|
+
// `cortex-mcp ingest-folder <path>` — walk a local markdown folder and upsert each file as an
|
|
6
|
+
// AUTHORED record in Agnoclast (source='brain'), so a user's personal digest is built from THEIR
|
|
7
|
+
// notes (Robin parity). Only an EXCERPT of each file leaves the machine (frontmatter + first
|
|
8
|
+
// ~800 chars), never the full body. The server clamps privacy for source='brain' and treats
|
|
9
|
+
// sessionId (the file's relative path) as the stable dedupe key, so re-running updates in place.
|
|
10
|
+
|
|
11
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
12
|
+
|
|
13
|
+
const MD_EXT = new Set(['.md', '.markdown'])
|
|
14
|
+
// Directories that are never user notes — skip them and any dotfile dir.
|
|
15
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.obsidian', '.trash'])
|
|
16
|
+
|
|
17
|
+
// Recursively collect markdown file paths under `root`. Uses withFileTypes so we can prune
|
|
18
|
+
// skipped directories without stat-ing every entry.
|
|
19
|
+
function walkMarkdown(root) {
|
|
20
|
+
const out = []
|
|
21
|
+
let entries
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
24
|
+
} catch {
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
for (const ent of entries) {
|
|
28
|
+
const full = join(root, ent.name)
|
|
29
|
+
if (ent.isDirectory()) {
|
|
30
|
+
if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
|
|
31
|
+
out.push(...walkMarkdown(full))
|
|
32
|
+
} else if (ent.isFile() && MD_EXT.has(extname(ent.name).toLowerCase())) {
|
|
33
|
+
out.push(full)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Tiny hand-rolled YAML frontmatter parser. Only the leading `---\n…\n---` block; flat
|
|
40
|
+
// `key: value` lines. Collects title/tags/project/people if present. No dependencies — this is
|
|
41
|
+
// deliberately minimal (not a full YAML parser). Returns { frontmatter, frontLines, bodyStart }.
|
|
42
|
+
function parseFrontmatter(content) {
|
|
43
|
+
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
|
|
44
|
+
return { frontmatter: {}, frontLines: [], bodyStart: 0 }
|
|
45
|
+
}
|
|
46
|
+
const lines = content.split('\n')
|
|
47
|
+
// lines[0] is the opening '---'; find the closing '---'.
|
|
48
|
+
let end = -1
|
|
49
|
+
for (let i = 1; i < lines.length; i++) {
|
|
50
|
+
if (lines[i].trim() === '---') { end = i; break }
|
|
51
|
+
}
|
|
52
|
+
if (end === -1) return { frontmatter: {}, frontLines: [], bodyStart: 0 }
|
|
53
|
+
|
|
54
|
+
const frontLines = lines.slice(1, end)
|
|
55
|
+
const frontmatter = {}
|
|
56
|
+
for (const raw of frontLines) {
|
|
57
|
+
const line = raw.replace(/\r$/, '')
|
|
58
|
+
const idx = line.indexOf(':')
|
|
59
|
+
if (idx === -1) continue
|
|
60
|
+
const key = line.slice(0, idx).trim()
|
|
61
|
+
let value = line.slice(idx + 1).trim()
|
|
62
|
+
if (!key) continue
|
|
63
|
+
// Strip wrapping quotes.
|
|
64
|
+
value = value.replace(/^["']|["']$/g, '')
|
|
65
|
+
// Inline list form: [a, b, c]
|
|
66
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
67
|
+
const items = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
|
|
68
|
+
frontmatter[key] = items
|
|
69
|
+
} else if (value === '') {
|
|
70
|
+
frontmatter[key] = ''
|
|
71
|
+
} else {
|
|
72
|
+
frontmatter[key] = value
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// bodyStart = char offset just past the closing '---' line + its newline.
|
|
76
|
+
const consumed = lines.slice(0, end + 1).join('\n')
|
|
77
|
+
const bodyStart = consumed.length + 1
|
|
78
|
+
return { frontmatter, frontLines: frontLines.map((l) => l.replace(/\r$/, '')), bodyStart }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// title: frontmatter.title → first `# ` heading in body → filename without extension.
|
|
82
|
+
function deriveTitle(frontmatter, body, filePath) {
|
|
83
|
+
if (typeof frontmatter.title === 'string' && frontmatter.title.trim()) return frontmatter.title.trim()
|
|
84
|
+
for (const line of body.split('\n')) {
|
|
85
|
+
const m = line.match(/^#\s+(.+?)\s*$/)
|
|
86
|
+
if (m) return m[1].trim()
|
|
87
|
+
}
|
|
88
|
+
return basename(filePath, extname(filePath))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// summary: first ~800 chars of the BODY (prose). Bounded so the raw full file never leaves the
|
|
92
|
+
// machine. Frontmatter is NOT prepended — it rides structured in payload, and the title is derived
|
|
93
|
+
// separately, so the summary reads as content (what the digest summarizes), not "key: value" noise.
|
|
94
|
+
function deriveSummary(body) {
|
|
95
|
+
return body.trim().slice(0, 800)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runIngestFolder(argv) {
|
|
99
|
+
const path = argv[0]
|
|
100
|
+
if (!path) {
|
|
101
|
+
process.stderr.write(
|
|
102
|
+
'usage: cortex-mcp ingest-folder <path>\n' +
|
|
103
|
+
' Ingest a local markdown folder as your authored Agnoclast records.\n',
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const token = resolveEnvToken().token
|
|
109
|
+
if (!token) {
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
'cortex: CORTEX_TOKEN not set. Run setup first, or export CORTEX_TOKEN=<your-token>.\n',
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
116
|
+
|
|
117
|
+
const files = walkMarkdown(path)
|
|
118
|
+
process.stdout.write(`Ingesting ${files.length} markdown files from ${path} …\n`)
|
|
119
|
+
if (files.length === 0) {
|
|
120
|
+
process.stdout.write('✓ ingested 0, updated 0, failed 0 (no markdown files found)\n')
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let inserted = 0
|
|
125
|
+
let updated = 0
|
|
126
|
+
let failed = 0
|
|
127
|
+
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
try {
|
|
130
|
+
const content = readFileSync(file, 'utf8')
|
|
131
|
+
const { frontmatter, bodyStart } = parseFrontmatter(content)
|
|
132
|
+
const body = content.slice(bodyStart)
|
|
133
|
+
|
|
134
|
+
const relpath = relative(path, file)
|
|
135
|
+
const title = deriveTitle(frontmatter, body, file)
|
|
136
|
+
const summary = deriveSummary(body)
|
|
137
|
+
const occurredAt = statSync(file).mtime.toISOString()
|
|
138
|
+
|
|
139
|
+
const res = await fetchCortex(`${base}/api/ingest`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
source: 'brain',
|
|
144
|
+
sessionId: relpath,
|
|
145
|
+
title,
|
|
146
|
+
summary,
|
|
147
|
+
privacy: 'scoped',
|
|
148
|
+
occurredAt,
|
|
149
|
+
payload: { path: relpath, frontmatter },
|
|
150
|
+
}),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if (res.ok) {
|
|
154
|
+
const j = await res.json().catch(() => ({}))
|
|
155
|
+
if (j.inserted === false) updated++
|
|
156
|
+
else inserted++
|
|
157
|
+
} else {
|
|
158
|
+
failed++
|
|
159
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
160
|
+
process.stderr.write(`cortex: ${relative(path, file)} failed — ${d.message}\n`)
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
failed++
|
|
164
|
+
process.stderr.write(`cortex: ${relative(path, file)} failed — ${e.message}\n`)
|
|
165
|
+
}
|
|
166
|
+
await sleep(60)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
process.stdout.write(`✓ ingested ${inserted}, updated ${updated}, failed ${failed}\n`)
|
|
170
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// `cortex install` — the cross-editor hub installer (P0-b). One command that wires the Agnoclast 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, wiredDistTag } 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
|
+
// Preserve an intentional @latest (dogfood) pin across re-runs; fresh installs + existing @stable
|
|
72
|
+
// get @stable. See wiredDistTag — forcing @stable here silently demoted a dogfooder (2026-07-24).
|
|
73
|
+
const spec = wiredDistTag() === 'latest' ? `${PKG}@latest` : `${PKG}@stable`
|
|
74
|
+
const home = homedir()
|
|
75
|
+
const log = (m) => process.stdout.write(m + '\n')
|
|
76
|
+
|
|
77
|
+
if (!token) {
|
|
78
|
+
process.stderr.write(
|
|
79
|
+
'Usage: npx @theronap/cortex-mcp install [<CORTEX_TOKEN>] [--editor auto|all|<id,...>]\n\n' +
|
|
80
|
+
'No token was given and none is already wired.\n' +
|
|
81
|
+
'Get your token from the Agnoclast console → Connect your AI.\n',
|
|
82
|
+
)
|
|
83
|
+
process.exit(1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let targets
|
|
87
|
+
try {
|
|
88
|
+
targets = resolveEditors(editor)
|
|
89
|
+
} catch (e) {
|
|
90
|
+
process.stderr.write(`\n✗ ${e.message}\n`)
|
|
91
|
+
process.exit(1)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
log('')
|
|
95
|
+
log('Agnoclast install — wiring your AI editors…')
|
|
96
|
+
if (editor === 'auto' && targets.length === 0) {
|
|
97
|
+
log(' (no supported editors detected — pass --editor all to force, or install an editor first)')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Carry forward prior manifest state for editors NOT targeted this run, so a single-editor
|
|
101
|
+
// `install --editor cursor` doesn't wipe the "wired" record of editors a previous run set up.
|
|
102
|
+
const priorById = new Map((readManifest().editors ?? []).map((e) => [e.id, e]))
|
|
103
|
+
const targetIds = new Set(targets.map((a) => a.id))
|
|
104
|
+
|
|
105
|
+
const entries = []
|
|
106
|
+
for (const adapter of ADAPTERS) {
|
|
107
|
+
const detected = safeDetect(adapter)
|
|
108
|
+
if (!targetIds.has(adapter.id)) {
|
|
109
|
+
const p = priorById.get(adapter.id)
|
|
110
|
+
entries.push({ adapter, detected, wired: p?.wired ?? false, wrote: p?.wrote ?? [], warnings: [] })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
log(`\n${adapter.displayName}:`)
|
|
114
|
+
let res = { wrote: [], skipped: [], warnings: [] }
|
|
115
|
+
try {
|
|
116
|
+
res = (await adapter.wire({ home, token, spec, log })) ?? res
|
|
117
|
+
} catch (e) {
|
|
118
|
+
// A single editor's failure must never abort the others (fail-soft, like setup's Codex block).
|
|
119
|
+
res.warnings = [...(res.warnings ?? []), `wire failed: ${e.message}`]
|
|
120
|
+
log(` ⚠ ${adapter.displayName} wiring failed: ${e.message}`)
|
|
121
|
+
}
|
|
122
|
+
for (const w of res.warnings ?? []) log(` ⚠ ${w}`)
|
|
123
|
+
entries.push({
|
|
124
|
+
adapter,
|
|
125
|
+
detected,
|
|
126
|
+
wired: (res.wrote ?? []).length > 0,
|
|
127
|
+
wrote: res.wrote ?? [],
|
|
128
|
+
warnings: res.warnings ?? [],
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const mpath = writeManifest(buildManifest(entries, { cortexMcp: version ?? null }))
|
|
133
|
+
log(`\n ✓ Capability manifest → ${mpath}`)
|
|
134
|
+
|
|
135
|
+
// Verify the token against the live API — writing config proves "files written", not "it works".
|
|
136
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
137
|
+
log('')
|
|
138
|
+
log('Verifying your token against Agnoclast…')
|
|
139
|
+
try {
|
|
140
|
+
const health = await checkToken(token, base)
|
|
141
|
+
if (health.ok) {
|
|
142
|
+
const n = health.projectCount
|
|
143
|
+
log(` ✓ Verified — your token works${typeof n === 'number' ? ` (you can see ${n} project${n === 1 ? '' : 's'})` : ''}.`)
|
|
144
|
+
} else {
|
|
145
|
+
log(' ⚠ Config written, but the live check did NOT pass:')
|
|
146
|
+
log(` ${health.diagnosis?.message ?? 'unknown error'}`)
|
|
147
|
+
log(' The files are in place; fix the above, then re-check with `doctor`.')
|
|
148
|
+
}
|
|
149
|
+
} catch (e) {
|
|
150
|
+
log(` ⚠ Could not reach Agnoclast to verify (${e.message}). Config is written; re-check with 'doctor'.`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const wired = entries.filter((e) => targetIds.has(e.adapter.id) && e.wired).map((e) => e.adapter.displayName)
|
|
154
|
+
log('')
|
|
155
|
+
log(`⟳ Wired ${wired.length} editor(s): ${wired.join(', ') || '(none)'}`)
|
|
156
|
+
log(' Fully quit and reopen each editor to load the Agnoclast server.')
|
|
157
|
+
log(` Console: ${base}`)
|
|
158
|
+
log('')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeDetect(adapter) {
|
|
162
|
+
try { return !!adapter.detect() } catch { return false }
|
|
163
|
+
}
|
package/lib/login.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { resolveBase, CANONICAL_BASE } from './diagnose.mjs'
|
|
4
|
+
|
|
5
|
+
// `cortex-mcp login` — get a token without the human copy-pasting one out of the web console.
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS EXISTS: the old path was log into the console, find /connect, copy a uuid, paste it into
|
|
8
|
+
// a terminal. That copy-paste is the step that failed on the first non-technical onboarding
|
|
9
|
+
// (2026-08-03), and it is the reason there could never be a real one-line install. Here the person
|
|
10
|
+
// clicks Approve in a browser and types nothing.
|
|
11
|
+
//
|
|
12
|
+
// THE FLOW (server side: /api/device/{start,approve,exchange}):
|
|
13
|
+
// 1. start — we generate nothing; the server issues a device_code (our secret) + a short
|
|
14
|
+
// user_code, and we open the pre-filled approval URL.
|
|
15
|
+
// 2. approve — happens in their browser, against their login. We never see their password and
|
|
16
|
+
// never handle a JWT.
|
|
17
|
+
// 3. exchange — we poll with the device_code; once a human has approved, the token is minted and
|
|
18
|
+
// returned exactly once. Then we hand straight off to the normal `setup` wiring.
|
|
19
|
+
//
|
|
20
|
+
// Deliberately a DEVICE flow rather than a loopback redirect. Loopback needs a local port and an
|
|
21
|
+
// open browser on the same machine, so it dies over SSH and on locked-down laptops. With the URL
|
|
22
|
+
// pre-filled, the device flow costs the user the same single click and works everywhere.
|
|
23
|
+
|
|
24
|
+
/** Open a URL in the user's default browser. Best-effort: never throws, never blocks the flow. */
|
|
25
|
+
function openBrowser(url) {
|
|
26
|
+
const cmd =
|
|
27
|
+
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
|
|
28
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url]
|
|
29
|
+
try {
|
|
30
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true })
|
|
31
|
+
// If the browser cannot be launched we still printed the URL, so this is genuinely non-fatal.
|
|
32
|
+
child.on('error', () => {})
|
|
33
|
+
child.unref()
|
|
34
|
+
return true
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
41
|
+
|
|
42
|
+
/** A label the person will recognise on the approval screen. Machine name, not a user name. */
|
|
43
|
+
function deviceLabel() {
|
|
44
|
+
const host = os.hostname().replace(/\.local$/i, '')
|
|
45
|
+
return host || `${os.platform()} device`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runLogin(argv = [], version = 'dev') {
|
|
49
|
+
const base = resolveBase(process.env.CORTEX_URL) || CANONICAL_BASE
|
|
50
|
+
|
|
51
|
+
// --label lets a person running several machines tell them apart later; account_tokens.label
|
|
52
|
+
// carries it, so it survives long after the grant row is swept.
|
|
53
|
+
const labelFlag = argv.indexOf('--label')
|
|
54
|
+
const label = labelFlag !== -1 && argv[labelFlag + 1] ? argv[labelFlag + 1] : deviceLabel()
|
|
55
|
+
|
|
56
|
+
let start
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(`${base}/api/device/start`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ label }),
|
|
62
|
+
})
|
|
63
|
+
if (!res.ok) throw new Error(`server said ${res.status}`)
|
|
64
|
+
start = await res.json()
|
|
65
|
+
} catch (err) {
|
|
66
|
+
process.stderr.write(
|
|
67
|
+
`cortex: could not reach ${base} to start login (${err.message}).\n` +
|
|
68
|
+
`Check your connection, then try again.\n`,
|
|
69
|
+
)
|
|
70
|
+
process.exitCode = 1
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const opened = openBrowser(start.verificationUriComplete)
|
|
75
|
+
|
|
76
|
+
process.stdout.write(
|
|
77
|
+
`\n Confirm this code in your browser: ${start.userCode}\n\n` +
|
|
78
|
+
(opened
|
|
79
|
+
? ` A browser should have opened. If not, go to:\n ${start.verificationUriComplete}\n\n`
|
|
80
|
+
: ` Open this in your browser:\n ${start.verificationUriComplete}\n\n`) +
|
|
81
|
+
` Waiting for you to approve it...\n`,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
// Poll until approved or the grant expires. The server sets the pace (and says slow_down if we
|
|
85
|
+
// are early), so the cadence stays server-controlled rather than hardcoded here.
|
|
86
|
+
let interval = (start.interval ?? 2) * 1000
|
|
87
|
+
const deadline = Date.now() + (start.expiresIn ?? 600) * 1000
|
|
88
|
+
let token = null
|
|
89
|
+
let activeOrgId = null
|
|
90
|
+
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
await sleep(interval)
|
|
93
|
+
let res
|
|
94
|
+
try {
|
|
95
|
+
res = await fetch(`${base}/api/device/exchange`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/json' },
|
|
98
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
99
|
+
})
|
|
100
|
+
} catch {
|
|
101
|
+
continue // transient network blip: keep waiting rather than failing a login mid-approval
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (res.status === 429) {
|
|
105
|
+
interval = Math.min(interval * 2, 10_000) // back off, do not give up
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (res.status === 410) {
|
|
109
|
+
process.stderr.write(`\ncortex: that took too long and the code expired. Run login again.\n`)
|
|
110
|
+
process.exitCode = 1
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (res.status === 409 || res.status === 404) {
|
|
114
|
+
const body = await res.json().catch(() => ({}))
|
|
115
|
+
process.stderr.write(`\ncortex: ${body.error ?? 'this login is no longer valid'}. Run login again.\n`)
|
|
116
|
+
process.exitCode = 1
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (!res.ok) continue
|
|
120
|
+
|
|
121
|
+
const body = await res.json().catch(() => ({}))
|
|
122
|
+
if (body.status === 'approved' && body.personalToken) {
|
|
123
|
+
token = body.personalToken
|
|
124
|
+
activeOrgId = body.activeOrgId ?? null
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
// 'pending' — the human has not clicked yet. Keep waiting quietly; a spinner that reprints
|
|
128
|
+
// every two seconds is noise on the one screen where the person is reading instructions.
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!token) {
|
|
132
|
+
process.stderr.write(`\ncortex: nobody approved that login before it expired. Run login again.\n`)
|
|
133
|
+
process.exitCode = 1
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
process.stdout.write(`\n Approved. Setting up...\n\n`)
|
|
138
|
+
|
|
139
|
+
// Hand the raw token straight to the existing installer. We never write it anywhere ourselves —
|
|
140
|
+
// runSetup owns every file that touches a credential, so there is exactly one code path that
|
|
141
|
+
// stores a token and one place to audit.
|
|
142
|
+
const { runSetup } = await import('./setup.mjs')
|
|
143
|
+
await runSetup([token], version)
|
|
144
|
+
|
|
145
|
+
if (activeOrgId) {
|
|
146
|
+
process.stdout.write(` New pages will be saved in the space you picked.\n`)
|
|
147
|
+
}
|
|
148
|
+
}
|
package/lib/managed.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Identity for the entries we write onto a machine — hooks in settings.json, crontab lines.
|
|
2
|
+
//
|
|
3
|
+
// The problem this exists to solve: uninstall used to find our entries by matching the product
|
|
4
|
+
// name (`CORTEX_RE = /cortex-mcp|.../`). That works only until the product is renamed, at which
|
|
5
|
+
// point uninstall removes the OLD entries, reports success, and leaves the NEW ones wired —
|
|
6
|
+
// firing on every session against a possibly revoked token. `67920bd` caught the same class of
|
|
7
|
+
// bug from the other side (renamed skills left uninstall unable to find the skills on disk).
|
|
8
|
+
// The installer had it too: its dedup filters matched `cortex-mcp.*capture`, so after a rename
|
|
9
|
+
// a `repair` run would fail to recognize its own previous hooks and APPEND duplicates rather
|
|
10
|
+
// than replace them.
|
|
11
|
+
//
|
|
12
|
+
// So identity stops depending on what the product is called. Every command we write carries an
|
|
13
|
+
// inert marker flag that is stable across renames. Name matching stays as a LEGACY fallback only, because machines wired
|
|
14
|
+
// before the marker existed can be found no other way — it can never be deleted, only demoted.
|
|
15
|
+
|
|
16
|
+
/** Inert flag appended to every command we write. Never change this string: it is the only thing
|
|
17
|
+
* that lets a future version recognize entries written by this one.
|
|
18
|
+
*
|
|
19
|
+
* Why a FLAG and not a trailing `# comment`: a comment is only inert if the hook command is run
|
|
20
|
+
* through a shell, and whether Claude Code does that is not something this package can verify.
|
|
21
|
+
* An argv token is inert either way — with a shell it is an argument, without one it is still an
|
|
22
|
+
* argument. The only requirement is that our own CLI ignore it, which holds because bin dispatches
|
|
23
|
+
* on argv[2] and every subcommand parses `rest` with `.includes()` (verified 2026-08-19:
|
|
24
|
+
* `uninstall --dry-run` and `uninstall --dry-run --agnoclast-managed` produce identical output).
|
|
25
|
+
* Do not move it before the subcommand — it must land in `rest`, not in argv[2]. */
|
|
26
|
+
export const MANAGED_MARKER = '--agnoclast-managed'
|
|
27
|
+
|
|
28
|
+
/** Names we shipped under, for machines predating MANAGED_MARKER. Additive only — a name that
|
|
29
|
+
* ever appeared in a written command must stay here forever. `capture-session-cloud` is an
|
|
30
|
+
* early Stop-hook command that predates the npx form. */
|
|
31
|
+
const LEGACY_NAME_RE = /cortex-mcp|agnoclast-mcp|capture-session-cloud|@theronap\/(cortex|agnoclast)/
|
|
32
|
+
|
|
33
|
+
/** Stamp a command as ours. Appended, never substituted, so the command still runs unchanged. */
|
|
34
|
+
export function markCommand(command) {
|
|
35
|
+
const c = String(command ?? '')
|
|
36
|
+
return c.includes(MANAGED_MARKER) ? c : `${c} ${MANAGED_MARKER}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True if we wrote this command — by marker (rename-proof) or by legacy name (pre-marker seats). */
|
|
40
|
+
export function isManagedCommand(command) {
|
|
41
|
+
const c = String(command ?? '')
|
|
42
|
+
return c.includes(MANAGED_MARKER) || LEGACY_NAME_RE.test(c)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True if this is one of ours AND runs the named subcommand. Used by the installer to replace
|
|
46
|
+
* its own prior entry for a given hook rather than appending a duplicate beside it. */
|
|
47
|
+
export function isManagedSubcommand(command, subcommand) {
|
|
48
|
+
return isManagedCommand(command) && new RegExp(`\\b${subcommand}\\b`).test(String(command ?? ''))
|
|
49
|
+
}
|