@elliotsystems/eai 0.1.3 → 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 CHANGED
@@ -31,16 +31,11 @@ In precedence order (`lib/resolve.js`):
31
31
  | # | Source | Path |
32
32
  |---|---|---|
33
33
  | 1 | `ELLIOT_OPENCODE_BIN` | the binary itself |
34
- | 2 | **stock upstream runtime** | `opencode-ai/bin/opencode.exe`, resolved via `require.resolve` — **this is production** |
35
- | 3 | Elliot platform package | `@elliotsystems/eai-<os>-<arch>/bin/…` — fork builds, legacy, no longer a dependency |
36
- | 4 | bundled runtime | `vendor/opencode-<platform>-<arch>/bin/…` |
37
- | 5 | `ELLIOT_OPENCODE_HOME` | `<home>/packages/opencode/dist/…` or `<home>/opencode-<platform>-<arch>/…` |
38
- | 6 | in-repo fork checkout | `../opencode/…` — **development only** |
39
- | 7 | sibling fork checkout | `../../opencode/…` — **development only**, last resort |
40
-
41
- Stock sits ahead of the Elliot platform packages deliberately. Those are fork
42
- builds, and preferring them would quietly reintroduce the patched OpenCode this
43
- architecture exists to remove.
34
+ | 2 | platform npm package | `@elliotsystems/eai-<platform>-<arch>/bin/opencode[.exe]`, via `require.resolve` -- **this is how an installed `eai` finds its runtime** |
35
+ | 3 | bundled runtime | `vendor/opencode-<platform>-<arch>/bin/opencode[.exe]` |
36
+ | 4 | `ELLIOT_OPENCODE_HOME` | `<home>/packages/opencode/dist/…` or `<home>/opencode-<platform>-<arch>/…` |
37
+ | 5 | in-repo fork checkout | `../opencode/packages/opencode/dist/…` -- **development only** |
38
+ | 6 | sibling fork checkout | `../../opencode/packages/opencode/dist/…` -- **development only**, last resort |
44
39
 
45
40
  Two deliberate rules:
46
41
 
@@ -57,16 +52,100 @@ runtime later is a file copy, not a translation.
57
52
 
58
53
  Set `ELLIOT_LAUNCHER_DEBUG=1` to print the resolved path to **stderr** (stdout
59
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
+
60
132
 
61
133
  ## Command name
62
134
 
63
- `eai` is a new, unused command name. It deliberately does **not** collide with
64
- `elliot-ai`, which is the bin name of the shipped legacy CLI
65
- (`@elliotsystems/elliot-ai`, currently on PATH). Both can coexist, so the legacy
66
- CLI keeps working untouched and its retirement stays a separate, later decision.
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.
67
142
 
68
143
  Run it locally, without installing, with `node bin/eai`.
69
144
 
145
+ > This section previously said the package was `private: true` and that publishing
146
+ > was out of scope. It has been published as `@elliotsystems/eai` since 0.1.0; the
147
+ > note was left behind and is corrected here.
148
+
70
149
  ## Install
71
150
 
72
151
  ```
@@ -105,43 +184,3 @@ To publish a new release to npm:
105
184
  ```
106
185
  npm test # node --test, no dependencies
107
186
  ```
108
-
109
- ## Architecture
110
-
111
- ```
112
- stock upstream OpenCode (opencode-ai, an npm dependency)
113
- |
114
- Elliot launcher (this package)
115
- |-- config/ OPENCODE_CONFIG_DIR: theme + plugin registration
116
- |-- config/themes/ the Elliot palette
117
- |-- plugin/ the bundled Elliot plugin (wordmark, chrome)
118
- +-- bin/eai OSC 0 terminal title
119
- |
120
- eai -> Elliot-branded TUI
121
- ```
122
-
123
- **OpenCode core modifications: 0.** Elliot ships no compiled OpenCode. An
124
- upstream upgrade is a version bump of `opencode-ai` in `package.json`.
125
-
126
- Three mechanisms carry the branding, none of which touch OpenCode source:
127
-
128
- | Surface | Mechanism |
129
- |---|---|
130
- | Wordmark, palette, accent, chrome | the bundled plugin, via `home_logo` (`mode="replace"`) and theme discovery |
131
- | Theme selection + plugin registration | `OPENCODE_CONFIG_DIR` -> this package's `config/` |
132
- | Terminal title | `OPENCODE_DISABLE_TERMINAL_TITLE=1`, then the launcher writes OSC 0 |
133
-
134
- ### Why the plugin is bundled rather than depended on
135
-
136
- `@elliotsystems/opencode-plugin` is not published to npm. A config that named it
137
- by package name would install cleanly and then render as plain OpenCode, which
138
- is the worst failure mode available -- it looks like it worked. `scripts/bundle.js`
139
- copies it in from `opencode-plugin/` (the single source of truth) and runs from
140
- `prepack`, so `npm pack` and `npm publish` cannot forget it.
141
-
142
- ### Why the theme file is shipped as well
143
-
144
- The plugin can install the palette itself via `api.theme.install()`, but that
145
- WRITES into the config directory, and a global npm install directory is not
146
- reliably writable. Shipping `config/themes/elliot.json` means the theme resolves
147
- read-only.
package/bin/eai CHANGED
@@ -26,6 +26,12 @@ const {
26
26
  } = require("../lib/resolve.js")
27
27
  const { injectToken } = require("../lib/token-env.js")
28
28
  const { stageOverlay } = require("../lib/stage.js")
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")
29
35
 
30
36
  /**
31
37
  * Resolve symlinks before deriving the package root.
@@ -63,6 +69,128 @@ function fail(message) {
63
69
  process.exit(127) // conventional "command not found"
64
70
  }
65
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
+
102
+ // `eai setup` is handled here and never reaches the runtime.
103
+ //
104
+ // It is intercepted BEFORE the runtime is resolved, because setup is what a
105
+ // machine runs when nothing is configured yet -- failing with "could not locate
106
+ // the OpenCode runtime" would be a confusing way to refuse to configure it.
107
+ // `setup` is not an OpenCode command, so nothing is shadowed.
108
+ //
109
+ // This exists so `eai` is the only CLI to install. Onboarding used to hand out
110
+ // a second global package whose whole job was writing two config files, and a
111
+ // user who ran only the first install got a CLI with no backend and no token.
112
+ if (process.argv[2] === "setup") {
113
+ const options = parseArgs(process.argv.slice(3))
114
+ runSetup(options).catch((error) => {
115
+ process.stderr.write(`eai setup: ${error.message}
116
+ `)
117
+ process.exit(1)
118
+ })
119
+ return
120
+ }
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
+
66
194
  const resolved = resolveOpencodeBin({ packageRoot: PACKAGE_ROOT })
67
195
 
68
196
  if (!resolved) {
@@ -82,6 +210,15 @@ if (!resolved) {
82
210
  )
83
211
  }
84
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
+
85
222
  if (process.env.ELLIOT_LAUNCHER_DEBUG) {
86
223
  // Opt-in only: the resolved path helps when a launch goes wrong, but it is
87
224
  // noise on every normal run, and stdout must stay clean for piped output.
@@ -138,6 +275,23 @@ for (const signal of FORWARDED_SIGNALS) {
138
275
  child.on("exit", (code, signal) => {
139
276
  for (const s of FORWARDED_SIGNALS) process.removeListener(s, forwarders[s])
140
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
+
141
295
  // Re-raise rather than translating to an exit code, so callers can still tell
142
296
  // "killed by signal" from "exited non-zero" — scripts and CI depend on that.
143
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 }