@elliotsystems/eai 0.1.4 → 0.1.5
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 +84 -4
- package/bin/eai +133 -0
- package/lib/connect.js +205 -0
- package/lib/gdrive.js +238 -0
- package/lib/logout.js +73 -0
- package/lib/setup.js +392 -23
- package/lib/stage.js +45 -4
- package/lib/status.js +133 -0
- package/lib/token-env.js +53 -17
- package/lib/update-check.js +27 -0
- package/lib/update.js +303 -0
- package/package.json +2 -1
- package/plugin/opencode-plugin/src/classifier.ts +2 -1
- package/plugin/opencode-plugin/src/elliot-auth.ts +56 -104
- package/plugin/opencode-plugin/src/index.ts +1 -28
- package/plugin/opencode-plugin/src/mcp-keys.ts +128 -1
- package/plugin/opencode-plugin/src/policy.ts +36 -4
- package/plugin/opencode-plugin/src/proxy-provider.ts +124 -72
- package/plugin/opencode-plugin/src/scope.ts +2 -1
- package/plugin/opencode-plugin/src/tui/knowledge.ts +9 -3
package/README.md
CHANGED
|
@@ -52,13 +52,93 @@ runtime later is a file copy, not a translation.
|
|
|
52
52
|
|
|
53
53
|
Set `ELLIOT_LAUNCHER_DEBUG=1` to print the resolved path to **stderr** (stdout
|
|
54
54
|
stays clean for piping).
|
|
55
|
+
## Slash commands
|
|
56
|
+
|
|
57
|
+
`eai setup` writes these into `~/.config/opencode/opencode.json` under
|
|
58
|
+
`command`, which is OpenCode's own config-driven command mechanism — the same
|
|
59
|
+
one its built-in `/init` and `/review` use (`command/index.ts`). Nothing in the
|
|
60
|
+
vendored runtime is patched to add them, and a user's own custom commands in
|
|
61
|
+
that file are preserved.
|
|
62
|
+
|
|
63
|
+
Each one is a prompt that calls an Elliot MCP tool. There is no second
|
|
64
|
+
implementation behind any of them.
|
|
65
|
+
|
|
66
|
+
| Command | What it does |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `/usage` | Your own gateway spend and limits |
|
|
69
|
+
| `/eai init` | Writes `EAI.md`, this project's context file |
|
|
70
|
+
| `/eai-skills` | Writes `ESKILLS.md`, the project's skill catalogue index |
|
|
71
|
+
| `/eais` | Lists the EAI agents |
|
|
72
|
+
| `/eais <agent>` | Activates one, e.g. `/eais dev` |
|
|
73
|
+
|
|
74
|
+
### `EAI.md` and `AGENTS.md`
|
|
75
|
+
|
|
76
|
+
`EAI.md` is added to `instructions` in `opencode.json`, so OpenCode loads it as
|
|
77
|
+
a system instruction. It does **not** replace `AGENTS.md`: OpenCode resolves
|
|
78
|
+
`config.instructions` separately from its `AGENTS.md`/`CLAUDE.md`/`CONTEXT.md`
|
|
79
|
+
lookup, so a project can have both and a team's existing `AGENTS.md` keeps
|
|
80
|
+
working untouched. `eai setup` appends `EAI.md` only when it is absent, so
|
|
81
|
+
re-running it never duplicates the entry.
|
|
82
|
+
|
|
83
|
+
`/eai init` writes only between `<!-- EAI:GENERATED:START -->` and
|
|
84
|
+
`<!-- EAI:GENERATED:END -->`. Anything you write outside those markers survives
|
|
85
|
+
regeneration.
|
|
86
|
+
|
|
87
|
+
### Agents
|
|
88
|
+
|
|
89
|
+
An agent is a set of skill *disciplines*, not a stored list of skills. Its
|
|
90
|
+
members are resolved by joining the catalogue at read time, so a skill added by
|
|
91
|
+
the next sync belongs to its agent immediately and the two can never drift. The
|
|
92
|
+
agent set is seeded by migration `086` and derived from the catalogue's own
|
|
93
|
+
`discipline` values — a discipline with no agent is reported by `/eai-skills`
|
|
94
|
+
rather than hidden.
|
|
95
|
+
|
|
96
|
+
`/eais dev` does not start a second runtime or change your OpenCode
|
|
97
|
+
configuration. It establishes the agent for the current session: the model is
|
|
98
|
+
told which skills that agent can draw on, and fetches a skill's body with
|
|
99
|
+
`skills_content` only when a task needs it.
|
|
100
|
+
|
|
101
|
+
### Why `ESKILLS.md` is an index
|
|
102
|
+
|
|
103
|
+
The catalogue holds every skill's full body. Writing them all into a file that
|
|
104
|
+
OpenCode loads on every turn would spend the context window before any work
|
|
105
|
+
started, so `ESKILLS.md` carries metadata only — name, key, discipline,
|
|
106
|
+
description, version. Skill content is fetched one skill at a time, and that
|
|
107
|
+
fetch is audited; a body pasted into a file is read with neither.
|
|
108
|
+
|
|
109
|
+
### What `/eai-skills` does and does not do
|
|
110
|
+
|
|
111
|
+
It synchronizes **this project's view**: it reads the authoritative catalogue,
|
|
112
|
+
regenerates `ESKILLS.md`, and reports the upstream revision and sync time so a
|
|
113
|
+
stale catalogue is visible rather than assumed fresh.
|
|
114
|
+
|
|
115
|
+
It does **not** fetch from GitHub. That sync is tenant-wide and gated on an
|
|
116
|
+
admin role, and an MCP principal carries no role — so triggering it from a CLI
|
|
117
|
+
command would hand every user a privileged action the HTTP API refuses them.
|
|
118
|
+
Fetching from GitHub stays where its authorization lives: the dashboard's sync
|
|
119
|
+
button and the repository push webhook.
|
|
120
|
+
|
|
121
|
+
### Scoping
|
|
122
|
+
|
|
123
|
+
Agents resolve skills through the same authorization predicate as
|
|
124
|
+
`skills_search` and `skills_content`. An agent cannot surface a skill its
|
|
125
|
+
caller could not already read: only `ACTIVE`, MCP-visible skills resolve, and a
|
|
126
|
+
project-scoped skill stays invisible without its project. Newly synced skills
|
|
127
|
+
sit in `REVIEW` until they are governed through, so they are catalogued but not
|
|
128
|
+
yet invocable — `/eais` will show an agent with a count of `0` rather than
|
|
129
|
+
pretend it is empty.
|
|
130
|
+
|
|
131
|
+
|
|
55
132
|
|
|
56
133
|
## Command name
|
|
57
134
|
|
|
58
|
-
`eai` is
|
|
59
|
-
`elliot-ai`,
|
|
60
|
-
|
|
61
|
-
|
|
135
|
+
`eai` is the only command this project ships. It deliberately does **not** claim
|
|
136
|
+
`elliot-ai`, the bin name of the retired `@elliotsystems/elliot-ai`: that package
|
|
137
|
+
is no longer built or published, but it is still installed on machines that set
|
|
138
|
+
it up before the migration, and taking over its command name would overwrite a
|
|
139
|
+
working install rather than replace it on the user's terms. Everything it did now
|
|
140
|
+
lives here — `setup`, `status`, `logout`, the TUI, and MCP over the backend's
|
|
141
|
+
remote endpoint.
|
|
62
142
|
|
|
63
143
|
Run it locally, without installing, with `node bin/eai`.
|
|
64
144
|
|
package/bin/eai
CHANGED
|
@@ -27,6 +27,11 @@ const {
|
|
|
27
27
|
const { injectToken } = require("../lib/token-env.js")
|
|
28
28
|
const { stageOverlay } = require("../lib/stage.js")
|
|
29
29
|
const { runSetup, parseArgs } = require("../lib/setup.js")
|
|
30
|
+
const { runStatus } = require("../lib/status.js")
|
|
31
|
+
const { runLogout } = require("../lib/logout.js")
|
|
32
|
+
const { runConnect } = require("../lib/connect.js")
|
|
33
|
+
const { runUpdate, maybeNotify } = require("../lib/update.js")
|
|
34
|
+
const gdrive = require("../lib/gdrive.js")
|
|
30
35
|
|
|
31
36
|
/**
|
|
32
37
|
* Resolve symlinks before deriving the package root.
|
|
@@ -64,6 +69,36 @@ function fail(message) {
|
|
|
64
69
|
process.exit(127) // conventional "command not found"
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/**
|
|
73
|
+
* End an async command with an exit code, WITHOUT calling `process.exit`.
|
|
74
|
+
*
|
|
75
|
+
* Every command here that talks to the network used to end with
|
|
76
|
+
* `.then((code) => process.exit(code))`, and on Windows that crashes:
|
|
77
|
+
*
|
|
78
|
+
* Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c
|
|
79
|
+
*
|
|
80
|
+
* `process.exit` tears the loop down while undici (global `fetch`) still has
|
|
81
|
+
* handles closing, libuv asserts, and the process dies with **127** — after the
|
|
82
|
+
* command has already printed its correct output and done its work. So `eai
|
|
83
|
+
* status` reports the truth and then looks like "command not found" to any
|
|
84
|
+
* script that checks `$?`. Reproduced on Node 24.19.0 / Windows with a
|
|
85
|
+
* one-liner: `fetch(url).then(() => process.exit(0))`.
|
|
86
|
+
*
|
|
87
|
+
* Setting `process.exitCode` lets the loop drain on its own and exits with the
|
|
88
|
+
* same code. It is not a Windows-only patch applied conditionally, because the
|
|
89
|
+
* conditional version is the one nobody tests on the other platform.
|
|
90
|
+
*/
|
|
91
|
+
function finishWith(promise, label) {
|
|
92
|
+
promise
|
|
93
|
+
.then((code) => {
|
|
94
|
+
process.exitCode = typeof code === "number" ? code : 0
|
|
95
|
+
})
|
|
96
|
+
.catch((error) => {
|
|
97
|
+
process.stderr.write(`eai ${label}: ${error.message}\n`)
|
|
98
|
+
process.exitCode = 1
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
67
102
|
// `eai setup` is handled here and never reaches the runtime.
|
|
68
103
|
//
|
|
69
104
|
// It is intercepted BEFORE the runtime is resolved, because setup is what a
|
|
@@ -84,6 +119,78 @@ if (process.argv[2] === "setup") {
|
|
|
84
119
|
return
|
|
85
120
|
}
|
|
86
121
|
|
|
122
|
+
// `--version` answers for BOTH layers, because a bug report that says only
|
|
123
|
+
// "1.18.30" names the runtime and not the product — and the two move
|
|
124
|
+
// independently: one is this package, the other is its `opencode-ai`
|
|
125
|
+
// dependency. Forwarded verbatim like every other flag, it reported upstream's
|
|
126
|
+
// version alone and left the EAI version unknowable from a terminal.
|
|
127
|
+
//
|
|
128
|
+
// Both values are READ, never embedded. A literal here would be a second source
|
|
129
|
+
// of truth that goes stale the first time either is bumped.
|
|
130
|
+
if (process.argv[2] === "--version" || process.argv[2] === "-v") {
|
|
131
|
+
process.stdout.write(`EAI: ${PACKAGE_VERSION}\n`)
|
|
132
|
+
// Best-effort: an unresolvable dependency manifest is worth reporting as
|
|
133
|
+
// unknown, not worth failing the one command someone runs to find out what
|
|
134
|
+
// they have installed.
|
|
135
|
+
let runtime = "unknown"
|
|
136
|
+
try {
|
|
137
|
+
runtime = require("opencode-ai/package.json").version
|
|
138
|
+
} catch {
|
|
139
|
+
// Left as "unknown" rather than guessed.
|
|
140
|
+
}
|
|
141
|
+
process.stdout.write(`OpenCode: ${runtime}\n`)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// `eai update` is intercepted alongside setup/status/logout, and for the same
|
|
146
|
+
// reason: it is about this machine's installation rather than a coding session,
|
|
147
|
+
// and it has to work when the runtime cannot be located at all — a broken
|
|
148
|
+
// install is exactly when someone reaches for it. `update` is not an OpenCode
|
|
149
|
+
// command, so nothing is shadowed.
|
|
150
|
+
if (process.argv[2] === "update") {
|
|
151
|
+
finishWith(
|
|
152
|
+
runUpdate({ packageRoot: PACKAGE_ROOT, version: PACKAGE_VERSION, argv: process.argv.slice(3) }),
|
|
153
|
+
"update",
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// `connect|status|disconnect gdrive` is a GOOGLE connection, not an MCP one,
|
|
159
|
+
// and is intercepted here — before the runtime is even resolved — because it
|
|
160
|
+
// talks only to Elliot's API. `eai connect <mcp-server>` below still hands MCP
|
|
161
|
+
// authentication to the runtime; these two share a verb and nothing else.
|
|
162
|
+
//
|
|
163
|
+
// Placed above the `status` intercept so `eai status gdrive` reaches the Drive
|
|
164
|
+
// status rather than the general one, which knows nothing about personal
|
|
165
|
+
// connectors.
|
|
166
|
+
if (gdrive.isGdrive(process.argv[3])) {
|
|
167
|
+
const verb = process.argv[2]
|
|
168
|
+
const run =
|
|
169
|
+
verb === "connect" ? gdrive.connect
|
|
170
|
+
: verb === "disconnect" ? gdrive.disconnect
|
|
171
|
+
: verb === "status" ? gdrive.status
|
|
172
|
+
: null
|
|
173
|
+
if (run) {
|
|
174
|
+
finishWith(run(), `${verb} gdrive`)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// `status` and `logout` are intercepted for the same reason as `setup`: they
|
|
180
|
+
// are about this machine's Elliot configuration, not about the coding session,
|
|
181
|
+
// and both must work when the runtime cannot even be located -- "am I set up?"
|
|
182
|
+
// is the question you ask precisely when nothing else is working.
|
|
183
|
+
//
|
|
184
|
+
// Migrated here from the retired `elliot-ai` CLI, which is the only thing that
|
|
185
|
+
// ever answered them. Neither name is an OpenCode command, so nothing is
|
|
186
|
+
// shadowed.
|
|
187
|
+
if (process.argv[2] === "status" || process.argv[2] === "logout") {
|
|
188
|
+
const command = process.argv[2]
|
|
189
|
+
const run = command === "status" ? runStatus : () => runLogout({ force: process.argv.includes("--yes") })
|
|
190
|
+
finishWith(run(), command)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
87
194
|
const resolved = resolveOpencodeBin({ packageRoot: PACKAGE_ROOT })
|
|
88
195
|
|
|
89
196
|
if (!resolved) {
|
|
@@ -103,6 +210,15 @@ if (!resolved) {
|
|
|
103
210
|
)
|
|
104
211
|
}
|
|
105
212
|
|
|
213
|
+
// `connect` runs AFTER resolution, unlike setup/status/logout: it hands the
|
|
214
|
+
// actual OAuth to the runtime rather than implementing a second OAuth client.
|
|
215
|
+
// Tokens must be written by one implementation, or a refresh by one silently
|
|
216
|
+
// invalidates the other.
|
|
217
|
+
if (process.argv[2] === "connect") {
|
|
218
|
+
finishWith(runConnect({ bin: resolved.path, argv: process.argv.slice(3) }), "connect")
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
106
222
|
if (process.env.ELLIOT_LAUNCHER_DEBUG) {
|
|
107
223
|
// Opt-in only: the resolved path helps when a launch goes wrong, but it is
|
|
108
224
|
// noise on every normal run, and stdout must stay clean for piped output.
|
|
@@ -159,6 +275,23 @@ for (const signal of FORWARDED_SIGNALS) {
|
|
|
159
275
|
child.on("exit", (code, signal) => {
|
|
160
276
|
for (const s of FORWARDED_SIGNALS) process.removeListener(s, forwarders[s])
|
|
161
277
|
|
|
278
|
+
// The update notice goes here, on the way OUT, and that placement is the
|
|
279
|
+
// whole reason it is visible at all. Printed before the spawn it would be
|
|
280
|
+
// erased a moment later: the TUI takes the alternate screen buffer and the
|
|
281
|
+
// notice disappears with everything else that was on screen. On exit the
|
|
282
|
+
// buffer has been restored, so it lands in the scrollback the user is left
|
|
283
|
+
// looking at — and after `eai run`, after the output rather than before it.
|
|
284
|
+
//
|
|
285
|
+
// TTY-gated, so a piped or CI invocation stays clean, and cheap: it reports
|
|
286
|
+
// what a PREVIOUS run cached and never waits on the network.
|
|
287
|
+
if (process.stderr.isTTY) {
|
|
288
|
+
try {
|
|
289
|
+
maybeNotify({ version: PACKAGE_VERSION })
|
|
290
|
+
} catch {
|
|
291
|
+
// A version notice may never be the reason a session fails to exit.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
162
295
|
// Re-raise rather than translating to an exit code, so callers can still tell
|
|
163
296
|
// "killed by signal" from "exited non-zero" — scripts and CI depend on that.
|
|
164
297
|
if (signal) {
|
package/lib/connect.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `eai connect` — see every MCP tool and authenticate the one you pick.
|
|
5
|
+
*
|
|
6
|
+
* The runtime can already authenticate a server (`mcp auth <name>`), but only
|
|
7
|
+
* if you already know the server's exact name, that the command exists, and
|
|
8
|
+
* which of your servers still needs it. A user who just wants Slack working has
|
|
9
|
+
* none of those, and the failure they actually meet is a red `✗ failed` line
|
|
10
|
+
* with no next step in it.
|
|
11
|
+
*
|
|
12
|
+
* So this is discovery plus the action, in one place: list what is configured,
|
|
13
|
+
* say which are already connected, and run the login for the chosen one. The
|
|
14
|
+
* name is `connect` rather than `auth` because that is what the user is trying
|
|
15
|
+
* to do; authentication is how it happens, not what they came for.
|
|
16
|
+
*
|
|
17
|
+
* It reads the same files the runtime reads and shells out to the runtime to do
|
|
18
|
+
* the actual OAuth. It does NOT implement a second OAuth client: tokens must
|
|
19
|
+
* keep living in one place, written by one implementation, or a refresh done by
|
|
20
|
+
* one of them silently invalidates the other.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const childProcess = require("child_process")
|
|
24
|
+
const fs = require("fs")
|
|
25
|
+
const os = require("os")
|
|
26
|
+
const path = require("path")
|
|
27
|
+
const readline = require("readline")
|
|
28
|
+
|
|
29
|
+
/** Where the runtime keeps its config, matching `setup.js`'s resolution. */
|
|
30
|
+
function configDirs(env = process.env) {
|
|
31
|
+
const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
|
|
32
|
+
return [path.join(base, "opencode")]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the runtime stores MCP OAuth credentials.
|
|
37
|
+
*
|
|
38
|
+
* The DATA dir, not the config dir — they diverge, and looking in the wrong one
|
|
39
|
+
* reports every server as unauthenticated, which would send a user through a
|
|
40
|
+
* login they had already done.
|
|
41
|
+
*/
|
|
42
|
+
function mcpAuthFile(env = process.env) {
|
|
43
|
+
const base = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share")
|
|
44
|
+
return path.join(base, "opencode", "mcp-auth.json")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readJson(file) {
|
|
48
|
+
try {
|
|
49
|
+
const text = fs.readFileSync(file, "utf8")
|
|
50
|
+
// Strip // and /* */ comments: the runtime accepts .jsonc, and a user's
|
|
51
|
+
// hand-edited config very often has them.
|
|
52
|
+
const stripped = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1")
|
|
53
|
+
const parsed = JSON.parse(stripped)
|
|
54
|
+
return parsed && typeof parsed === "object" ? parsed : null
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every MCP server the runtime would load, with the state the user cares about.
|
|
62
|
+
*
|
|
63
|
+
* Later files win on a name collision, matching the runtime's own merge, so
|
|
64
|
+
* what is listed is what will actually run rather than what one file happens to
|
|
65
|
+
* say.
|
|
66
|
+
*/
|
|
67
|
+
function listServers(env = process.env) {
|
|
68
|
+
const servers = {}
|
|
69
|
+
for (const dir of configDirs(env)) {
|
|
70
|
+
for (const name of ["opencode.json", "opencode.jsonc"]) {
|
|
71
|
+
const cfg = readJson(path.join(dir, name))
|
|
72
|
+
if (cfg && cfg.mcp && typeof cfg.mcp === "object") Object.assign(servers, cfg.mcp)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const auth = readJson(mcpAuthFile(env)) || {}
|
|
77
|
+
return Object.entries(servers).map(([name, def]) => {
|
|
78
|
+
const remote = def && def.type === "remote"
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
type: (def && def.type) || "unknown",
|
|
82
|
+
url: (def && def.url) || null,
|
|
83
|
+
enabled: !def || def.enabled !== false,
|
|
84
|
+
authenticated: Boolean(auth[name]),
|
|
85
|
+
// A server carrying static `headers` authenticates with a token the user
|
|
86
|
+
// already supplied (GitHub's MCP takes a Bearer token this way). It has
|
|
87
|
+
// no OAuth step and stores nothing, so judging it by the credential file
|
|
88
|
+
// reports a working server as "not connected" and sends the user through
|
|
89
|
+
// a login that does not exist. Only OAuth-capable remotes are offerable.
|
|
90
|
+
tokenAuth: remote && Boolean(def && def.headers),
|
|
91
|
+
connectable: remote && !(def && def.headers),
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stateLabel(server) {
|
|
97
|
+
if (!server.enabled) return "disabled"
|
|
98
|
+
if (server.tokenAuth) return "token"
|
|
99
|
+
if (!server.connectable) return "local"
|
|
100
|
+
return server.authenticated ? "connected" : "not connected"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Render the table. Returned as lines so tests read them without a TTY. */
|
|
104
|
+
function render(servers) {
|
|
105
|
+
if (!servers.length) {
|
|
106
|
+
return [
|
|
107
|
+
"No MCP servers are configured.",
|
|
108
|
+
"Run `eai setup` with the command from the Elliot portal — it configures them for you.",
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
const width = Math.max(...servers.map((s) => s.name.length), 4)
|
|
112
|
+
const lines = ["", "MCP tools", "─────────────────────────────"]
|
|
113
|
+
for (const s of servers) {
|
|
114
|
+
lines.push(` ${s.name.padEnd(width)} ${stateLabel(s)}`)
|
|
115
|
+
}
|
|
116
|
+
lines.push("")
|
|
117
|
+
const pending = servers.filter((s) => s.connectable && s.enabled && !s.authenticated)
|
|
118
|
+
if (pending.length) {
|
|
119
|
+
lines.push(`${pending.length} tool${pending.length === 1 ? "" : "s"} can be connected.`)
|
|
120
|
+
} else {
|
|
121
|
+
lines.push("Everything that can be connected is connected.")
|
|
122
|
+
}
|
|
123
|
+
return lines
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ask(question) {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
129
|
+
rl.question(question, (answer) => {
|
|
130
|
+
rl.close()
|
|
131
|
+
resolve(String(answer || "").trim())
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Hand off to the runtime's own OAuth. stdio is inherited so its browser
|
|
138
|
+
* prompt and its "paste the code" step reach the user unchanged.
|
|
139
|
+
*/
|
|
140
|
+
function authenticate(bin, name, env, spawn = childProcess.spawnSync) {
|
|
141
|
+
const result = spawn(bin, ["mcp", "auth", name], { stdio: "inherit", env })
|
|
142
|
+
if (result.error) throw result.error
|
|
143
|
+
return typeof result.status === "number" ? result.status : 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {{bin: string, argv?: string[], env?: object, log?: Function, ask?: Function, spawn?: Function}} opts
|
|
148
|
+
* @returns {Promise<number>} the process exit code
|
|
149
|
+
*/
|
|
150
|
+
async function runConnect(opts) {
|
|
151
|
+
const env = opts.env || process.env
|
|
152
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
153
|
+
const prompt = opts.ask || ask
|
|
154
|
+
const servers = listServers(env)
|
|
155
|
+
const requested = (opts.argv || []).find((a) => !a.startsWith("-"))
|
|
156
|
+
|
|
157
|
+
if (requested) {
|
|
158
|
+
const hit = servers.find((s) => s.name === requested)
|
|
159
|
+
if (!hit) {
|
|
160
|
+
log(`No MCP server named "${requested}".`)
|
|
161
|
+
for (const line of render(servers)) log(line)
|
|
162
|
+
return 1
|
|
163
|
+
}
|
|
164
|
+
if (!hit.connectable) {
|
|
165
|
+
// Said plainly rather than attempted: `mcp auth` on a local server fails
|
|
166
|
+
// with an error about the server, which reads as "it is broken".
|
|
167
|
+
log(
|
|
168
|
+
hit.tokenAuth
|
|
169
|
+
? `"${requested}" authenticates with a token from your config — there is no login to run.`
|
|
170
|
+
: `"${requested}" is a local server — it runs as a process and needs no login.`,
|
|
171
|
+
)
|
|
172
|
+
return 0
|
|
173
|
+
}
|
|
174
|
+
return authenticate(opts.bin, requested, env, opts.spawn)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const line of render(servers)) log(line)
|
|
178
|
+
|
|
179
|
+
const pending = servers.filter((s) => s.connectable && s.enabled && !s.authenticated)
|
|
180
|
+
if (!pending.length) return 0
|
|
181
|
+
|
|
182
|
+
// Non-interactive callers (CI, a pipe) get the listing and the command to run
|
|
183
|
+
// rather than a prompt into a stream nobody is reading.
|
|
184
|
+
if (!process.stdin.isTTY) {
|
|
185
|
+
log("")
|
|
186
|
+
for (const s of pending) log(` eai connect ${s.name}`)
|
|
187
|
+
return 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
log("")
|
|
191
|
+
pending.forEach((s, i) => log(` ${i + 1}) ${s.name}`))
|
|
192
|
+
const answer = await prompt("Connect which? [number, or Enter to skip] ")
|
|
193
|
+
if (!answer) {
|
|
194
|
+
log("Nothing connected.")
|
|
195
|
+
return 0
|
|
196
|
+
}
|
|
197
|
+
const index = Number.parseInt(answer, 10)
|
|
198
|
+
if (!Number.isInteger(index) || index < 1 || index > pending.length) {
|
|
199
|
+
log(`"${answer}" is not one of the listed numbers.`)
|
|
200
|
+
return 1
|
|
201
|
+
}
|
|
202
|
+
return authenticate(opts.bin, pending[index - 1].name, env, opts.spawn)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { runConnect, listServers, render, stateLabel, mcpAuthFile, configDirs }
|