@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.
Files changed (44) hide show
  1. package/README.md +47 -0
  2. package/bin/cortex-mcp.mjs +223 -0
  3. package/lib/capture.mjs +470 -0
  4. package/lib/code_graph_cli.mjs +59 -0
  5. package/lib/context_log.mjs +92 -0
  6. package/lib/diagnose.mjs +360 -0
  7. package/lib/docs_scan.mjs +171 -0
  8. package/lib/doctor.mjs +117 -0
  9. package/lib/edge_extract.mjs +156 -0
  10. package/lib/editors/_fsutil.mjs +31 -0
  11. package/lib/editors/antigravity.mjs +130 -0
  12. package/lib/editors/claude.mjs +202 -0
  13. package/lib/editors/codex.mjs +111 -0
  14. package/lib/editors/cursor.mjs +77 -0
  15. package/lib/editors/index.mjs +42 -0
  16. package/lib/extract_typed.mjs +68 -0
  17. package/lib/graphify_sync.mjs +134 -0
  18. package/lib/grep_cli.mjs +82 -0
  19. package/lib/hydrate.mjs +181 -0
  20. package/lib/imessage_send.mjs +88 -0
  21. package/lib/ingest_folder.mjs +170 -0
  22. package/lib/install.mjs +163 -0
  23. package/lib/login.mjs +148 -0
  24. package/lib/managed.mjs +49 -0
  25. package/lib/migrate_key.mjs +139 -0
  26. package/lib/presence.mjs +226 -0
  27. package/lib/publish_targets.mjs +51 -0
  28. package/lib/red_link_triage.mjs +37 -0
  29. package/lib/redact.mjs +40 -0
  30. package/lib/rename_notice.mjs +31 -0
  31. package/lib/resolve.mjs +153 -0
  32. package/lib/server.mjs +2986 -0
  33. package/lib/session_key.mjs +37 -0
  34. package/lib/setup.mjs +215 -0
  35. package/lib/skills.mjs +374 -0
  36. package/lib/statusline.mjs +67 -0
  37. package/lib/uninstall.mjs +237 -0
  38. package/lib/use_brain.mjs +82 -0
  39. package/lib/with_token.mjs +66 -0
  40. package/package.json +36 -0
  41. package/skills/author-docs/SKILL.md +74 -0
  42. package/skills/context/SKILL.md +25 -0
  43. package/skills/log/SKILL.md +114 -0
  44. 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
+ }
@@ -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
+ }
@@ -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
+ }