@elliotsystems/eai 0.1.4 → 0.1.7

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
@@ -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 a new, unused command name. It deliberately does **not** collide with
59
- `elliot-ai`, which is the bin name of the shipped legacy CLI
60
- (`@elliotsystems/elliot-ai`, currently on PATH). Both can coexist, so the legacy
61
- 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.
62
142
 
63
143
  Run it locally, without installing, with `node bin/eai`.
64
144
 
package/bin/eai CHANGED
@@ -25,8 +25,13 @@ const {
25
25
  resolveOpencodeBin,
26
26
  } = require("../lib/resolve.js")
27
27
  const { injectToken } = require("../lib/token-env.js")
28
- const { stageOverlay } = require("../lib/stage.js")
28
+ const { stageOverlay, readGatewayAlias } = 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.
@@ -125,7 +241,11 @@ if (!env.OPENCODE_CONFIG_DIR) {
125
241
  // node_modules does not load (its tsconfig, and so its jsxImportSource, is
126
242
  // not applied there), and the TUI comes up as plain OpenCode with nothing
127
243
  // logged. See lib/stage.js.
128
- const staged = stageOverlay({ packageRoot: PACKAGE_ROOT, version: PACKAGE_VERSION })
244
+ const staged = stageOverlay({
245
+ packageRoot: PACKAGE_ROOT,
246
+ version: PACKAGE_VERSION,
247
+ gatewayAlias: readGatewayAlias(),
248
+ })
129
249
  if (staged) env.OPENCODE_CONFIG_DIR = staged
130
250
  }
131
251
 
@@ -159,6 +279,23 @@ for (const signal of FORWARDED_SIGNALS) {
159
279
  child.on("exit", (code, signal) => {
160
280
  for (const s of FORWARDED_SIGNALS) process.removeListener(s, forwarders[s])
161
281
 
282
+ // The update notice goes here, on the way OUT, and that placement is the
283
+ // whole reason it is visible at all. Printed before the spawn it would be
284
+ // erased a moment later: the TUI takes the alternate screen buffer and the
285
+ // notice disappears with everything else that was on screen. On exit the
286
+ // buffer has been restored, so it lands in the scrollback the user is left
287
+ // looking at — and after `eai run`, after the output rather than before it.
288
+ //
289
+ // TTY-gated, so a piped or CI invocation stays clean, and cheap: it reports
290
+ // what a PREVIOUS run cached and never waits on the network.
291
+ if (process.stderr.isTTY) {
292
+ try {
293
+ maybeNotify({ version: PACKAGE_VERSION })
294
+ } catch {
295
+ // A version notice may never be the reason a session fails to exit.
296
+ }
297
+ }
298
+
162
299
  // Re-raise rather than translating to an exit code, so callers can still tell
163
300
  // "killed by signal" from "exited non-zero" — scripts and CI depend on that.
164
301
  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 }