@theronap/cortex-mcp 0.9.60 → 0.9.61
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 +14 -5
- package/lib/login.mjs +148 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -36,11 +36,13 @@ if (cmd === '--version' || cmd === '-v') {
|
|
|
36
36
|
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
37
37
|
process.stdout.write(
|
|
38
38
|
`cortex-mcp ${VERSION} — connect your AI assistant to Cortex\n\n` +
|
|
39
|
-
`Onboard (one command):\n` +
|
|
40
|
-
` npx -y @theronap/cortex-mcp
|
|
41
|
-
`This
|
|
42
|
-
`
|
|
39
|
+
`Onboard (one command, no token to copy):\n` +
|
|
40
|
+
` npx -y @theronap/cortex-mcp login\n\n` +
|
|
41
|
+
`This opens your browser, you click Approve, and it wires everything up. Restart\n` +
|
|
42
|
+
`Claude Code after, and your AI sees your Cortex context while your sessions flow\n` +
|
|
43
|
+
`into the org automatically.\n\n` +
|
|
43
44
|
`Subcommands:\n` +
|
|
45
|
+
` login [--label <name>] browser-approved sign-in — gets a token for you, then runs setup\n` +
|
|
44
46
|
` setup <token> wire MCP server + capture hook into ~/.claude config (single editor)\n` +
|
|
45
47
|
` install [<token>] [--editor auto|all|<id,...>] wire Cortex into EVERY detected editor + write the capability manifest\n` +
|
|
46
48
|
` repair re-run setup at the latest version using your existing token (no token needed)\n` +
|
|
@@ -68,7 +70,14 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
68
70
|
// A graceful drain lets libuv finish those handles first, so the assertion never fires. The MCP
|
|
69
71
|
// server (default branch) runs forever and never reaches an exit path. if/else so the CLI
|
|
70
72
|
// commands don't fall through into the server.
|
|
71
|
-
if (cmd === '
|
|
73
|
+
if (cmd === 'login') {
|
|
74
|
+
// Browser-approved sign-in: no token to copy. Ends by calling runSetup with the token it
|
|
75
|
+
// collected, so there is still exactly ONE code path that writes a credential to disk.
|
|
76
|
+
const { runLogin } = await import('../lib/login.mjs')
|
|
77
|
+
await runLogin(rest, VERSION)
|
|
78
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
79
|
+
await closeFetch()
|
|
80
|
+
} else if (cmd === 'setup') {
|
|
72
81
|
const { runSetup } = await import('../lib/setup.mjs')
|
|
73
82
|
await runSetup(rest, VERSION)
|
|
74
83
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
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
|
+
}
|