@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 +84 -4
- package/bin/eai +139 -2
- package/lib/connect.js +205 -0
- package/lib/gdrive.js +238 -0
- package/lib/logout.js +73 -0
- package/lib/setup.js +534 -24
- package/lib/stage.js +100 -6
- 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 +3 -2
- package/plugin/opencode-plugin/package.json +1 -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/identity-headers.ts +81 -0
- package/plugin/opencode-plugin/src/index.ts +22 -31
- package/plugin/opencode-plugin/src/mcp-keys.ts +132 -1
- package/plugin/opencode-plugin/src/policy.ts +36 -4
- package/plugin/opencode-plugin/src/proxy-provider.ts +153 -72
- package/plugin/opencode-plugin/src/scope.ts +2 -1
- package/plugin/opencode-plugin/src/tui/format.ts +37 -0
- package/plugin/opencode-plugin/src/tui/knowledge.ts +9 -3
- package/plugin/opencode-plugin/src/tui/sidebar.tsx +90 -0
- package/plugin/opencode-plugin/src/tui/usage.ts +132 -0
package/lib/setup.js
CHANGED
|
@@ -34,7 +34,8 @@ const os = require("os")
|
|
|
34
34
|
const path = require("path")
|
|
35
35
|
|
|
36
36
|
const PROVIDER_ID = "elliot"
|
|
37
|
-
const
|
|
37
|
+
const GENERAL_MODEL_ID = "elliot-general"
|
|
38
|
+
const CODER_MODEL_ID = "elliot-coder"
|
|
38
39
|
|
|
39
40
|
/** Matches OpenCode's own resolution (xdg-basedir, whose fallback is ~/.config everywhere). */
|
|
40
41
|
function opencodeConfigDir(env = process.env) {
|
|
@@ -69,22 +70,254 @@ function decodeJwtPayload(token) {
|
|
|
69
70
|
/**
|
|
70
71
|
* The `provider.elliot` block.
|
|
71
72
|
*
|
|
72
|
-
* `baseURL` ends in `/v1`
|
|
73
|
-
*
|
|
73
|
+
* `baseURL` ends in `/v1` because LiteLLM's own OpenAI-compatible endpoint is
|
|
74
|
+
* at `/v1/chat/completions`. `litellmBaseUrl` is the PUBLIC url the backend's
|
|
75
|
+
* onboarding config returns (`{backend_url}/litellm`, proxied by nginx) — not
|
|
76
|
+
* an internal Docker address, since this runs on the developer's own machine.
|
|
74
77
|
*
|
|
75
|
-
* `apiKey` is the literal string `{env:ELLIOT_TOKEN}`, not the token.
|
|
76
|
-
* substitutes it at config-load time and the launcher
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
78
|
+
* `apiKey` is the literal string `{env:ELLIOT_TOKEN}`, not the token itself.
|
|
79
|
+
* OpenCode substitutes it at config-load time, and the launcher
|
|
80
|
+
* (`token-env.js`) sets ELLIOT_TOKEN from `~/.elliot/config.json`'s
|
|
81
|
+
* `litellm_master_key` on every run — ONE shared secret for the whole
|
|
82
|
+
* gateway now, not a per-user credential (LiteLLM is deliberately DB-less;
|
|
83
|
+
* there is no per-tenant key without one). Two models are offered
|
|
84
|
+
* (`elliot-general`/`elliot-coder` — LiteLLM's own configured aliases,
|
|
85
|
+
* never a literal vendor id); OpenCode's own model-switcher picks between
|
|
86
|
+
* them.
|
|
87
|
+
*
|
|
88
|
+
* `identity` (Phase 1 governance) bakes static LiteLLM spend-log attribution
|
|
89
|
+
* headers into `options.headers` — `eai setup` runs once per DEVELOPER, so
|
|
90
|
+
* unlike OpenCode's shared team runtime, this can carry full identity:
|
|
91
|
+
* `x-litellm-end-user-id` = the real user, `x-litellm-spend-logs-metadata` =
|
|
92
|
+
* `{tenant_id, team_id, source: "eai"}`. All three come straight out of the
|
|
93
|
+
* `/onboarding/config/{tenant_id}` response `runSetup` already fetches
|
|
94
|
+
* (`user_id`/`team_id`/`tenant_id` — see that endpoint in
|
|
95
|
+
* `app/routers/onboarding.py`), never invented here. Omitted entirely (no
|
|
96
|
+
* `headers` key) when no identity was resolved, matching the backend's own
|
|
97
|
+
* `_identity_headers`/`_litellm_identity_headers` "don't invent identity"
|
|
98
|
+
* rule.
|
|
99
|
+
*
|
|
100
|
+
* `allowedModels` (Phase 4 governance, docs/LITELLM_GATEWAY.md §14) — the
|
|
101
|
+
* opened team's own model restriction, straight from the same onboarding
|
|
102
|
+
* response's `allowed_models` field. LiteLLM was always the real
|
|
103
|
+
* enforcement point regardless of what this function offers (LiteLLM
|
|
104
|
+
* gateway audit finding #5, mirrors the identical fix already made to
|
|
105
|
+
* `app/services/opencode/teamconfig.py::build_opencode_config` on the
|
|
106
|
+
* backend side for OpenCode's own web runtimes) — this only stops the menu
|
|
107
|
+
* from OFFERING a model that will be rejected. Falsy (undefined/null/[]) —
|
|
108
|
+
* every team until an admin restricts it — offers both, unchanged from
|
|
109
|
+
* before this parameter existed.
|
|
110
|
+
*/
|
|
111
|
+
function elliotProvider(litellmBaseUrl, identity = undefined, allowedModels = undefined) {
|
|
112
|
+
const base = String(litellmBaseUrl).replace(/\/+$/, "")
|
|
113
|
+
const options = { baseURL: `${base}/v1`, apiKey: "{env:ELLIOT_TOKEN}" }
|
|
114
|
+
const headers = litellmIdentityHeaders(identity)
|
|
115
|
+
if (headers) options.headers = headers
|
|
116
|
+
return {
|
|
117
|
+
npm: "@ai-sdk/openai-compatible",
|
|
118
|
+
// User-facing (the TUI's own provider/model picker shows this verbatim)
|
|
119
|
+
// — EAI branding only, never the gateway's own vendor name.
|
|
120
|
+
name: "EAI Gateway",
|
|
121
|
+
options,
|
|
122
|
+
models: offeredModels(allowedModels),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The `models` block for `elliotProvider`, filtered to `allowedModels` —
|
|
128
|
+
* see that function's own docstring. A team restricted to ONLY
|
|
129
|
+
* elliot-classifier (never offered here — internal-only, deploy/litellm/
|
|
130
|
+
* config.yaml §5) would otherwise get an empty, unusable menu; this
|
|
131
|
+
* degrades to offering both for that listing only, exactly like the
|
|
132
|
+
* backend's own fallback — LiteLLM still rejects any actual request the
|
|
133
|
+
* team isn't allowed to make either way.
|
|
134
|
+
*/
|
|
135
|
+
function offeredModels(allowedModels) {
|
|
136
|
+
const all = { [GENERAL_MODEL_ID]: { name: "EAI General" }, [CODER_MODEL_ID]: { name: "EAI Coder" } }
|
|
137
|
+
if (!allowedModels || allowedModels.length === 0) return all
|
|
138
|
+
|
|
139
|
+
const allowed = new Set(allowedModels)
|
|
140
|
+
const offered = Object.keys(all).filter((id) => allowed.has(id))
|
|
141
|
+
if (offered.length === 0) return all
|
|
142
|
+
|
|
143
|
+
const filtered = {}
|
|
144
|
+
for (const id of offered) filtered[id] = all[id]
|
|
145
|
+
return filtered
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The default `model` field, given whatever `models` ended up offered —
|
|
149
|
+
* always General when it's offered (unchanged default), otherwise
|
|
150
|
+
* whichever one alias remains. */
|
|
151
|
+
function defaultModelId(models) {
|
|
152
|
+
return GENERAL_MODEL_ID in models ? GENERAL_MODEL_ID : Object.keys(models)[0]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `{ "x-litellm-end-user-id": ..., "x-litellm-spend-logs-metadata": ... }`,
|
|
157
|
+
* or undefined when `identity` carries nothing usable — see `elliotProvider`.
|
|
158
|
+
*/
|
|
159
|
+
function litellmIdentityHeaders(identity) {
|
|
160
|
+
if (!identity) return undefined
|
|
161
|
+
const headers = {}
|
|
162
|
+
if (identity.userId) headers["x-litellm-end-user-id"] = String(identity.userId)
|
|
163
|
+
const metadata = {}
|
|
164
|
+
if (identity.tenantId) metadata.tenant_id = String(identity.tenantId)
|
|
165
|
+
if (identity.teamId) metadata.team_id = String(identity.teamId)
|
|
166
|
+
if (Object.keys(metadata).length > 0) {
|
|
167
|
+
metadata.source = "eai"
|
|
168
|
+
headers["x-litellm-spend-logs-metadata"] = JSON.stringify(metadata)
|
|
169
|
+
}
|
|
170
|
+
return Object.keys(headers).length > 0 ? headers : undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The `mcp` block: the MCP servers this org's CLI should talk to.
|
|
175
|
+
*
|
|
176
|
+
* Comes from the backend (`mcp_servers`) so the list can grow without an npm
|
|
177
|
+
* release — adding a connector is a backend change, and the next `eai setup`
|
|
178
|
+
* picks it up. There is no longer a literal fallback below; see why.
|
|
179
|
+
*
|
|
180
|
+
* ONE server carries every connector. Elliot's own MCP endpoint exposes
|
|
181
|
+
* `knowledge_search`, `search_codebase`, `search_symbols`, `search_jira`,
|
|
182
|
+
* `search_slack`, `search_clickup`, `graph_query` and the rest, all governed
|
|
183
|
+
* and all authenticated as the user against this backend. Wiring Slack, Jira
|
|
184
|
+
* and ClickUp as separate third-party MCP servers instead would mean an OAuth
|
|
185
|
+
* app registered per workspace and a client secret distributed to every
|
|
186
|
+
* machine, for tools this backend already serves.
|
|
187
|
+
*
|
|
188
|
+
* THIS FALLBACK NO LONGER GUESSES AN ENDPOINT.
|
|
189
|
+
*
|
|
190
|
+
* It used to compose `{backendUrl}/mcp/v1` — Elliot MCP, dialled directly.
|
|
191
|
+
* Runtimes now reach Elliot through the LiteLLM MCP gateway, and the address
|
|
192
|
+
* of that gateway is the backend's to decide (`services/mcp_gateway.py`,
|
|
193
|
+
* derived from `litellm_base_url`), not something this launcher can infer from
|
|
194
|
+
* a backend URL. The two credentials the gateway needs are equally not
|
|
195
|
+
* inferable here.
|
|
196
|
+
*
|
|
197
|
+
* So a backend that does not serve `mcp_servers` gets NO MCP configuration
|
|
198
|
+
* rather than a direct one. That is the honest degradation: the CLI works
|
|
199
|
+
* without tools and says nothing false, where the old fallback would have
|
|
200
|
+
* quietly configured the bypass this cutover exists to remove. A backend old
|
|
201
|
+
* enough to lack the key is also old enough to lack the gateway.
|
|
202
|
+
*
|
|
203
|
+
* Returning nothing is NOT the same as leaving whatever is there. See
|
|
204
|
+
* `writeOpencodeConfig`: the `elliot` key is ours, so it is removed before the
|
|
205
|
+
* merge whether or not we have a replacement. Without that, a machine set up
|
|
206
|
+
* before this change keeps its stale entry forever — which is the one failure
|
|
207
|
+
* the old fallback was quietly preventing.
|
|
208
|
+
*/
|
|
209
|
+
function elliotMcpServers() {
|
|
210
|
+
return {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The `/usage` command: a compact "Personal Usage & Limits" view inside the
|
|
215
|
+
* CLI — today's budget (spend, limit, percentage, reset time), rate limits,
|
|
216
|
+
* and this week's spend/requests/tokens with a daily breakdown — via the
|
|
217
|
+
* SAME `get_eai_usage` MCP tool the `elliot` server exposes as
|
|
218
|
+
* `elliot-get_eai_usage` — never a second usage implementation, and never
|
|
219
|
+
* fake/static data: the tool wraps `GET /usage/litellm/{tenant_id}/me`
|
|
220
|
+
* directly, the SAME endpoint and the SAME numbers the Member/User Web
|
|
221
|
+
* Chat Settings -> Usage screen shows — always THIS person's own usage,
|
|
222
|
+
* since that endpoint takes no scope parameter at all.
|
|
223
|
+
*
|
|
224
|
+
* That server is no longer defined in this file — it is SERVED, from the
|
|
225
|
+
* backend's own catalogue (`services/mcp_catalog.py`), so the command and
|
|
226
|
+
* the server it calls are configured from different places. The name is the
|
|
227
|
+
* join between them, and the join character is a HYPHEN, not an underscore:
|
|
228
|
+
* LiteLLM's MCP gateway namespaces every tool it proxies as `<alias>-<tool>`
|
|
229
|
+
* (live-verified against the real gateway, and pinned on the backend side by
|
|
230
|
+
* `tests/test_litellm_mcp_alias.py` — see that file's own docstring for the
|
|
231
|
+
* silent-failure mode an underscore here would reproduce). So
|
|
232
|
+
* `elliot-get_eai_usage` resolves only while the served entry is still
|
|
233
|
+
* called `elliot`, which `mcp_catalog.ELLIOT_SERVER` fixes.
|
|
234
|
+
*
|
|
235
|
+
* OpenCode's own config-driven custom-command mechanism (`cfg.command` in
|
|
236
|
+
* `command/index.ts`) is what makes this possible with no vendored-source
|
|
237
|
+
* change: any `command.<name>` entry here becomes a real, registered slash
|
|
238
|
+
* command, the identical mechanism the built-in `/init`/`/review` use.
|
|
239
|
+
*
|
|
240
|
+
* The template tells the agent to relay the tool's OWN pre-formatted text
|
|
241
|
+
* verbatim, inside a fenced code block, rather than re-summarizing it — the
|
|
242
|
+
* formatting (the progress bar, compact spend, DD Mon YYYY dates, EAI
|
|
243
|
+
* branding, never "LiteLLM") is computed once, on the backend, so it reads
|
|
244
|
+
* identically to Web Chat regardless of which model is answering; the code
|
|
245
|
+
* block is what keeps the progress bar's characters and spacing intact
|
|
246
|
+
* rather than a model reflowing them as prose.
|
|
80
247
|
*/
|
|
81
|
-
function
|
|
82
|
-
const base = String(backendUrl).replace(/\/+$/, "")
|
|
248
|
+
function elliotCommands() {
|
|
83
249
|
return {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
250
|
+
usage: {
|
|
251
|
+
description: "Show your EAI Personal Usage & Limits — spend, limits, reset time",
|
|
252
|
+
template:
|
|
253
|
+
"Call the elliot-get_eai_usage tool with no arguments. If it is not " +
|
|
254
|
+
"available yet (the elliot MCP server can still be connecting in a " +
|
|
255
|
+
"brand-new session), run `sleep 3` and try the tool call once more " +
|
|
256
|
+
"before concluding it is unavailable — do not report a failure after " +
|
|
257
|
+
"only one attempt. Once it succeeds, reply with ONLY its `summary` " +
|
|
258
|
+
"text, inside a fenced code block, exactly as returned — do not " +
|
|
259
|
+
"re-summarize, re-total, reformat, or add commentary beyond what it " +
|
|
260
|
+
"says.",
|
|
261
|
+
},
|
|
262
|
+
eai: {
|
|
263
|
+
description: "EAI project commands. /eai init writes AGENTS.md",
|
|
264
|
+
template:
|
|
265
|
+
"The subcommand is: $ARGUMENTS\n\n" +
|
|
266
|
+
"If it is not \"init\", reply with only:\n" +
|
|
267
|
+
"\"Usage: /eai init\n\n" +
|
|
268
|
+
"To update the Elliot CLI itself, exit this chat and run `eai update` " +
|
|
269
|
+
"in your terminal. That is a shell command, not a chat command — this " +
|
|
270
|
+
"slash command only writes AGENTS.md.\" — then stop.\n\n" +
|
|
271
|
+
"For \"init\", create or update `AGENTS.md` in the project root. " +
|
|
272
|
+
"AGENTS.md is the instruction file OpenCode already loads for this " +
|
|
273
|
+
"project; this command writes that file rather than introducing a " +
|
|
274
|
+
"second one.\n\n" +
|
|
275
|
+
"The goal is a compact instruction file that helps future sessions " +
|
|
276
|
+
"avoid mistakes and ramp up quickly. Every line should answer: " +
|
|
277
|
+
"\"Would an agent likely miss this without help?\" If not, leave it " +
|
|
278
|
+
"out.\n\n" +
|
|
279
|
+
"Investigate before writing: README and root manifests, lockfiles, " +
|
|
280
|
+
"build/test/lint/typecheck config, CI workflows, existing instruction " +
|
|
281
|
+
"files, and the real entrypoints. Prefer executable sources over " +
|
|
282
|
+
"prose; where they disagree, trust the executable one.\n\n" +
|
|
283
|
+
"Extract the highest-signal facts: exact developer commands including " +
|
|
284
|
+
"non-obvious ones, how to run a single test, required command order " +
|
|
285
|
+
"where it matters, package boundaries and real entrypoints, toolchain " +
|
|
286
|
+
"quirks (codegen, migrations, build artifacts, env loading), and " +
|
|
287
|
+
"conventions that differ from language or framework defaults.\n\n" +
|
|
288
|
+
"Exclude generic software advice, tutorials, exhaustive file trees, " +
|
|
289
|
+
"obvious language conventions, and anything you could not verify. " +
|
|
290
|
+
"When in doubt, omit.\n\n" +
|
|
291
|
+
"If AGENTS.md already exists, improve it IN PLACE rather than " +
|
|
292
|
+
"rewriting blindly: preserve verified useful guidance, delete stale " +
|
|
293
|
+
"claims, and reconcile it with the current codebase.\n\n" +
|
|
294
|
+
"NEVER copy a secret into AGENTS.md: no API keys, tokens, passwords, " +
|
|
295
|
+
"connection strings or any value from a .env or credentials file. " +
|
|
296
|
+
"Where such a file matters, name it and say values are omitted — for " +
|
|
297
|
+
"example \"`.env` holds runtime configuration and credentials; values " +
|
|
298
|
+
"intentionally omitted\".\n\n" +
|
|
299
|
+
"Do not list individual skills here; SKILLS.md is the skill index and " +
|
|
300
|
+
"`/eai-skills` generates it.\n\n" +
|
|
301
|
+
"When done, report the file written and the key facts you detected.",
|
|
302
|
+
},
|
|
303
|
+
"eai-skills": {
|
|
304
|
+
description: "Synchronize this project's EAI skill catalogue and write SKILLS.md",
|
|
305
|
+
template:
|
|
306
|
+
"Call the elliot_eai_skills_document tool with no arguments.\n\n" +
|
|
307
|
+
"Write its `markdown` value VERBATIM to the file named by `filename` " +
|
|
308
|
+
"in the project root, replacing that file entirely — it is a generated " +
|
|
309
|
+
"catalogue index and carries no hand-written content.\n\n" +
|
|
310
|
+
"Then report, as a short plain-text summary and nothing else:\n" +
|
|
311
|
+
"- the total number of skills in the document\n" +
|
|
312
|
+
"- each agent and its skill count, from the document's Agents " +
|
|
313
|
+
"section\n" +
|
|
314
|
+
"- the catalogue revision and last-synchronized time if present\n" +
|
|
315
|
+
"- any `unassigned_disciplines` entries, as a warning that those " +
|
|
316
|
+
"skills are not reachable through any agent\n\n" +
|
|
317
|
+
"Do NOT read, summarize or load the body of any individual skill. The " +
|
|
318
|
+
"document is an index; skill content is fetched per task with " +
|
|
319
|
+
"elliot_skills_content.",
|
|
320
|
+
},
|
|
88
321
|
}
|
|
89
322
|
}
|
|
90
323
|
|
|
@@ -92,10 +325,21 @@ function elliotProvider(backendUrl) {
|
|
|
92
325
|
* Merge the provider into the user's opencode.json, preserving every other key.
|
|
93
326
|
*
|
|
94
327
|
* `enabled_providers` pins OpenCode to this one provider: `eai` is
|
|
95
|
-
*
|
|
328
|
+
* gateway-only, so there must be no direct-to-vendor path. `model` is only set
|
|
96
329
|
* when absent, so a user who picked a model keeps it.
|
|
330
|
+
*
|
|
331
|
+
* `backendUrl` and `litellmBaseUrl` are deliberately separate: the MCP server
|
|
332
|
+
* still talks to Elliot's own backend, while the LLM provider now talks to
|
|
333
|
+
* LiteLLM directly (no more `/llm-proxy` hop in between).
|
|
97
334
|
*/
|
|
98
|
-
function writeOpencodeConfig(
|
|
335
|
+
function writeOpencodeConfig(
|
|
336
|
+
backendUrl,
|
|
337
|
+
litellmBaseUrl,
|
|
338
|
+
env = process.env,
|
|
339
|
+
servers = undefined,
|
|
340
|
+
identity = undefined,
|
|
341
|
+
allowedModels = undefined,
|
|
342
|
+
) {
|
|
99
343
|
const dir = opencodeConfigDir(env)
|
|
100
344
|
const file = path.join(dir, "opencode.json")
|
|
101
345
|
let existing = {}
|
|
@@ -105,18 +349,215 @@ function writeOpencodeConfig(backendUrl, env = process.env) {
|
|
|
105
349
|
// Absent or unparseable. Overwriting unparseable JSON is the lesser evil:
|
|
106
350
|
// OpenCode would refuse to start on it anyway.
|
|
107
351
|
}
|
|
352
|
+
// Org servers are spread LAST so they win on a name collision. That is what
|
|
353
|
+
// repairs a stale entry rather than preserving it: a machine set up before
|
|
354
|
+
// this carries an `elliot` server pointing at a local process from an
|
|
355
|
+
// older, now-retired CLI package, which serves a subset of the tools and
|
|
356
|
+
// requires that package to be installed. The user's own unrelated servers
|
|
357
|
+
// are untouched.
|
|
358
|
+
// OURS TO REPLACE, THEIRS TO KEEP. The `elliot` key is this launcher's, so
|
|
359
|
+
// any existing one is dropped before the merge: a machine configured before
|
|
360
|
+
// the gateway carries either a local `elliot-cli` server or a direct
|
|
361
|
+
// `/mcp/v1` one, and both must go even when the backend supplies no
|
|
362
|
+
// replacement. Every other key is the user's own and survives untouched.
|
|
363
|
+
const { [PROVIDER_ID]: _ours, ...theirs } = existing.mcp || {}
|
|
364
|
+
const mcp = { ...theirs, ...(servers || elliotMcpServers()) }
|
|
365
|
+
const provider = elliotProvider(litellmBaseUrl, identity, allowedModels)
|
|
366
|
+
// Org commands are spread LAST, same rule as `mcp` above: this repairs a
|
|
367
|
+
// stale `/usage` from an older package rather than leaving it, while a
|
|
368
|
+
// user's own unrelated custom commands are untouched.
|
|
369
|
+
const command = { ...(existing.command || {}), ...elliotCommands() }
|
|
370
|
+
// `/eai init` writes AGENTS.md, which OpenCode already discovers through its
|
|
371
|
+
// own AGENTS.md/CLAUDE.md/CONTEXT.md lookup (`session/instruction.ts`). So
|
|
372
|
+
// there is nothing for this launcher to register: adding it to
|
|
373
|
+
// `config.instructions` as well would load the same file twice.
|
|
374
|
+
//
|
|
375
|
+
// A machine set up by an older package carries `EAI.md` here, pointing at a
|
|
376
|
+
// file nothing writes any more. Dropped on the next setup — OURS TO REPLACE,
|
|
377
|
+
// same rule the `mcp` and `command` merges above follow. Every other entry is
|
|
378
|
+
// the user's own and survives untouched.
|
|
379
|
+
const instructions = (
|
|
380
|
+
Array.isArray(existing.instructions) ? existing.instructions : []
|
|
381
|
+
).filter((entry) => entry !== "EAI.md")
|
|
108
382
|
const next = {
|
|
109
383
|
...existing,
|
|
110
384
|
$schema: existing.$schema || "https://opencode.ai/config.json",
|
|
111
|
-
provider: {
|
|
385
|
+
provider: {
|
|
386
|
+
...(existing.provider || {}),
|
|
387
|
+
[PROVIDER_ID]: provider,
|
|
388
|
+
},
|
|
112
389
|
enabled_providers: [PROVIDER_ID],
|
|
113
|
-
|
|
390
|
+
// Follows the restriction, not always General — a team restricted to
|
|
391
|
+
// Coder-only must not default to a model its own menu just excluded.
|
|
392
|
+
model: existing.model || `${PROVIDER_ID}/${defaultModelId(provider.models)}`,
|
|
393
|
+
mcp,
|
|
394
|
+
command,
|
|
114
395
|
}
|
|
396
|
+
if (instructions.length) next.instructions = instructions
|
|
397
|
+
else delete next.instructions
|
|
115
398
|
fs.mkdirSync(dir, { recursive: true })
|
|
116
399
|
fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n")
|
|
117
400
|
return file
|
|
118
401
|
}
|
|
119
402
|
|
|
403
|
+
/**
|
|
404
|
+
* One entry per row in `eai_agents` (migration 086) -- `key` is the DB's own
|
|
405
|
+
* identity (case-folded by `resolve()`), and `description`/`responsibilities`
|
|
406
|
+
* are this launcher's, written for what OpenCode shows as a real agent.
|
|
407
|
+
*
|
|
408
|
+
* These files ARE the agent mechanism now. The `/eais <key>` command that
|
|
409
|
+
* used to activate an agent for a session is gone: OpenCode already selects
|
|
410
|
+
* a primary agent with Tab and reaches a subagent by `@mention`, and a second
|
|
411
|
+
* Elliot-specific activation path could only disagree with it.
|
|
412
|
+
*
|
|
413
|
+
* `mode` is a deliberate per-agent product choice, not derived from anything
|
|
414
|
+
* -- `dev`/`qa`/`devops`/`security` are `primary` (selectable with Tab, like
|
|
415
|
+
* `build`/`plan`); `governance`/`architecture`/`data`/`ai`/`product` are
|
|
416
|
+
* `subagent` (reachable by `@mention` or the `task` tool, like the built-in
|
|
417
|
+
* `general`/`explore`). Both are equally native; `agentMarkdown` just writes
|
|
418
|
+
* whichever this entry names.
|
|
419
|
+
*
|
|
420
|
+
* NOT fetched from the backend at setup time. The agent CATALOGUE (these nine
|
|
421
|
+
* rows) changes on the order of a migration, not a sync -- unlike skill
|
|
422
|
+
* MEMBERSHIP, which `eai_agent_disciplines` resolves fresh on every read
|
|
423
|
+
* specifically because it changes on every sync. Hardcoding the skill LIST
|
|
424
|
+
* each agent would reach is exactly what this file must never do (see
|
|
425
|
+
* agent_skills()'s own docstring); hardcoding which NINE agent roles exist is
|
|
426
|
+
* not that -- it is the same kind of static authorship as `dev`'s own prompt
|
|
427
|
+
* body, which this list simply extends to the other eight.
|
|
428
|
+
*
|
|
429
|
+
* `key` here must keep matching `eai_agents.key`, or `opencode agent
|
|
430
|
+
* list`/Tab-cycle and SKILLS.md would name the same agent differently --
|
|
431
|
+
* confusing rather than merely wrong.
|
|
432
|
+
*
|
|
433
|
+
* NOTHING ABOUT THE CATALOGUE IS HARDCODED HERE. The first cut of this
|
|
434
|
+
* feature put the nine keys, their descriptions AND their primary/subagent
|
|
435
|
+
* split in a JS list in this file -- repeating, in the launcher, the exact
|
|
436
|
+
* mistake migration 086 exists to stop for skill MEMBERSHIP: a second, stored
|
|
437
|
+
* copy of a fact that drifts from its own source of truth the moment either
|
|
438
|
+
* side changes without the other. `eai_agents` (`mode` added by migration
|
|
439
|
+
* 087) already owns all of it. `/onboarding/config/{tenant_id}` now returns
|
|
440
|
+
* it as `eai_agents` -- the exact rows `services/skills_catalogue/agents.py
|
|
441
|
+
* ::list_agents` resolves, the same call `eai_agents_list` makes -- and this
|
|
442
|
+
* file only turns whatever that response says into Markdown. An admin
|
|
443
|
+
* disabling an agent, moving one between primary and subagent, or a future
|
|
444
|
+
* tenth agent all take effect on the next `eai setup`, no launcher release.
|
|
445
|
+
*/
|
|
446
|
+
|
|
447
|
+
//: Verbatim shared constraints, identical across every EAI agent regardless
|
|
448
|
+
//: of mode or catalogue state -- these are not discipline-specific, and a
|
|
449
|
+
//: copy that drifted per agent (one devops agent allowed to push commits,
|
|
450
|
+
//: say, because someone forgot this block) would be a worse failure than a
|
|
451
|
+
//: single shared block repeated into every file.
|
|
452
|
+
const EAI_AGENT_CONSTRAINTS = [
|
|
453
|
+
"Inspect the existing configuration before changing it; preserve the project's current provider, model, MCP, permission, instruction and command settings.",
|
|
454
|
+
"Never write a secret or a credential into a file you create or edit.",
|
|
455
|
+
"Do not bypass this project's own authorization, approval or review workflow for a change -- including one already in flight for a skill, a PR, or a config value.",
|
|
456
|
+
"Do not push a commit or open a pull request unless asked to.",
|
|
457
|
+
"Do not build a second, parallel execution path for something this project already does one way.",
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* One EAI agent's Markdown definition, from one row of the backend's live
|
|
462
|
+
* `eai_agents` response (`{key, name, description, mode, skill_count}`).
|
|
463
|
+
*
|
|
464
|
+
* OpenCode discovers a primary agent three ways: a built-in (`build`, `plan`,
|
|
465
|
+
* `general`), an `agent.<name>` entry in opencode.json, or a Markdown file
|
|
466
|
+
* under `agent/` or `agents/` in any directory it scans for config — which
|
|
467
|
+
* INCLUDES the global config directory (`~/.config/opencode`, the same one
|
|
468
|
+
* `writeOpencodeConfig` already writes into) alongside any per-project
|
|
469
|
+
* `.opencode/`. `opencode agent create --global` writes exactly here, so this
|
|
470
|
+
* is the sanctioned location, not a guess: `~/.config/opencode/agents/<key>.md`.
|
|
471
|
+
*
|
|
472
|
+
* `agent.mode` decides WHICH of two native mechanisms an agent uses, not
|
|
473
|
+
* whether it is native: `primary` is what makes it show up next to
|
|
474
|
+
* `build`/`plan` in the TUI's own agent list and Tab-cycle (`agent.cycle` in
|
|
475
|
+
* opencode/packages/tui/src/config/keybind.ts); `subagent` is what makes it
|
|
476
|
+
* reachable by `@mention` and the `task` tool instead, the same way the
|
|
477
|
+
* built-in `general`/`explore` subagents are -- still a real, natively
|
|
478
|
+
* discovered agent, just invoked rather than switched to. This file writes
|
|
479
|
+
* whichever the backend says for this agent; it does not decide.
|
|
480
|
+
*
|
|
481
|
+
* No `model` field, regardless of mode. The schema (`ConfigAgentV1.Info`)
|
|
482
|
+
* makes it optional for exactly this case: an agent with no model inherits
|
|
483
|
+
* whatever the session's own default is, so this file never pins a
|
|
484
|
+
* vendor-specific model id that would drift from `provider.elliot`'s own
|
|
485
|
+
* configured models.
|
|
486
|
+
*
|
|
487
|
+
* The body is ONE generic template, parameterised by `agent.name`/
|
|
488
|
+
* `agent.description` -- not nine hand-written prompts. Authoring nine
|
|
489
|
+
* separate bodies would mean the one EAI-specific thing about them (which
|
|
490
|
+
* role each performs) living only in this file, invisible to and unverified
|
|
491
|
+
* against the backend's own `description` for that same agent; the generic
|
|
492
|
+
* template keeps that single fact in one place.
|
|
493
|
+
*/
|
|
494
|
+
function agentMarkdown(agent) {
|
|
495
|
+
const lines = (items) => items.map((s) => `- ${s}\n`).join("")
|
|
496
|
+
return (
|
|
497
|
+
"---\n" +
|
|
498
|
+
`description: Elliot's ${agent.name} agent -- ${agent.description}.\n` +
|
|
499
|
+
`mode: ${agent.mode}\n` +
|
|
500
|
+
"---\n" +
|
|
501
|
+
`You are Elliot's ${agent.name} agent: ${agent.description}.\n` +
|
|
502
|
+
"\n" +
|
|
503
|
+
"## Responsibilities\n" +
|
|
504
|
+
"\n" +
|
|
505
|
+
lines([
|
|
506
|
+
"Understand the repository's architecture and existing conventions in this area before changing anything.",
|
|
507
|
+
"Do the work within that architecture -- do not introduce a parallel one.",
|
|
508
|
+
"For a defect, find the root cause before writing a fix; a fix for a symptom that leaves the cause in place is not done.",
|
|
509
|
+
"Verify a claim against a real run, a real test, or a real read of the code -- never report success without having checked.",
|
|
510
|
+
"Explain what changed, the decisions that mattered, the real outcome of any check you ran, and any risk left outstanding.",
|
|
511
|
+
"Avoid unrelated changes, unnecessary new dependencies, and a second implementation of something that already exists in this codebase.",
|
|
512
|
+
]) +
|
|
513
|
+
"\n" +
|
|
514
|
+
"## Constraints\n" +
|
|
515
|
+
"\n" +
|
|
516
|
+
lines(EAI_AGENT_CONSTRAINTS) +
|
|
517
|
+
"\n" +
|
|
518
|
+
"If this project has an `AGENTS.md`, it is read automatically and is the project's context reference; `SKILLS.md`, if present, indexes the skill catalogue by agent and is regenerated by `/eai-skills` -- use them when a task calls for a convention or a skill this file does not already cover. Load a skill body with the skills_content tool, one at a time, only when the task needs it.\n"
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Write one EAI agent's file into the global OpenCode config directory.
|
|
524
|
+
*
|
|
525
|
+
* Unconditional overwrite, same rule as `elliotCommands()` above ("OURS TO
|
|
526
|
+
* REPLACE"): this file is entirely EAI-generated, so re-running setup must
|
|
527
|
+
* repair a stale copy from an older package rather than leave it, exactly
|
|
528
|
+
* like the `elliot` MCP server and the `/eai` commands already do.
|
|
529
|
+
* There is nothing here for a user to have hand-edited -- unlike
|
|
530
|
+
* opencode.json, which merges around a user's own unrelated keys, this file
|
|
531
|
+
* has exactly one owner.
|
|
532
|
+
*/
|
|
533
|
+
function writeAgentFile(agent, env = process.env) {
|
|
534
|
+
const dir = path.join(opencodeConfigDir(env), "agents")
|
|
535
|
+
const file = path.join(dir, `${agent.key}.md`)
|
|
536
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
537
|
+
fs.writeFileSync(file, agentMarkdown(agent))
|
|
538
|
+
return file
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Every agent the backend's `eai_agents` response named, in one pass.
|
|
543
|
+
*
|
|
544
|
+
* `agents` is exactly the `eai_agents` array `fetchOnboardingConfig`'s
|
|
545
|
+
* response carries (or `[]`/undefined for a backend that predates it, which
|
|
546
|
+
* this treats as "nothing to write" rather than an error -- a CLI update
|
|
547
|
+
* must not need a backend update released in lockstep, the same tolerance
|
|
548
|
+
* `writeOpencodeConfig`'s own `servers` fallback already extends to
|
|
549
|
+
* `mcp_servers`).
|
|
550
|
+
*
|
|
551
|
+
* Stale-file cleanup is deliberately NOT done here: deleting a file this
|
|
552
|
+
* launcher did not just enumerate would mean trusting a naming convention to
|
|
553
|
+
* tell an EAI-generated agent apart from one the user wrote themselves under
|
|
554
|
+
* the same directory, which is exactly the kind of guess `elliotCommands()`'s
|
|
555
|
+
* "OURS TO REPLACE, THEIRS TO KEEP" rule exists to avoid making.
|
|
556
|
+
*/
|
|
557
|
+
function writeAllAgentFiles(agents, env = process.env) {
|
|
558
|
+
return (agents || []).map((agent) => writeAgentFile(agent, env))
|
|
559
|
+
}
|
|
560
|
+
|
|
120
561
|
/** Owner-only: this file holds a bearer token and a refresh token. */
|
|
121
562
|
function writeElliotConfig(config) {
|
|
122
563
|
const file = elliotConfigPath()
|
|
@@ -151,8 +592,22 @@ async function exchangeForCliToken(backendUrl, token) {
|
|
|
151
592
|
}
|
|
152
593
|
}
|
|
153
594
|
|
|
154
|
-
|
|
155
|
-
|
|
595
|
+
/**
|
|
596
|
+
* `team_id` is OPTIONAL and, when given, is appended as a query param — the
|
|
597
|
+
* same way `terminal/src/CliSetup.tsx` already scopes its own call to
|
|
598
|
+
* `/onboarding/config/{tenantId}?team_id=...`. Without it, `get_onboarding_
|
|
599
|
+
* config` resolves no team at all and its LiteLLM key falls back to a
|
|
600
|
+
* shared/personal credential rather than this team's own — the team-CLI
|
|
601
|
+
* half of the LiteLLM credential-routing audit: the browser resolves team_id
|
|
602
|
+
* correctly when it mints the setup command, but this re-fetch used to drop
|
|
603
|
+
* it, so `eai setup --token <jwt minted with team context>` still came back
|
|
604
|
+
* scoped to no team at all.
|
|
605
|
+
*/
|
|
606
|
+
async function fetchOnboardingConfig(backendUrl, tenantId, token, teamId) {
|
|
607
|
+
const url = teamId
|
|
608
|
+
? `${backendUrl}/onboarding/config/${tenantId}?team_id=${encodeURIComponent(teamId)}`
|
|
609
|
+
: `${backendUrl}/onboarding/config/${tenantId}`
|
|
610
|
+
const res = await fetch(url, {
|
|
156
611
|
headers: { Authorization: `Bearer ${token}` },
|
|
157
612
|
})
|
|
158
613
|
if (!res.ok) {
|
|
@@ -175,6 +630,13 @@ async function runSetup(opts) {
|
|
|
175
630
|
if (!tenantId) {
|
|
176
631
|
throw new Error("no tenant_id in the token; pass --tenant-id, or copy a fresh command from the portal")
|
|
177
632
|
}
|
|
633
|
+
// The pasted token is minted by `terminal/src/CliSetup.tsx` from the
|
|
634
|
+
// project card the user opened, and already carries that team in its own
|
|
635
|
+
// `team_id` claim (withheld only for an owner/admin, whose setup is
|
|
636
|
+
// deliberately tenant-wide — see `get_onboarding_config`'s own comment on
|
|
637
|
+
// `tenant_wide`). `--team-id` is an explicit override for a caller who
|
|
638
|
+
// wants to force a different scope than the token names.
|
|
639
|
+
const teamId = opts.teamId || claims.team_id
|
|
178
640
|
|
|
179
641
|
const exchanged = await exchangeForCliToken(backendUrl, opts.token)
|
|
180
642
|
if (!exchanged) {
|
|
@@ -183,26 +645,65 @@ async function runSetup(opts) {
|
|
|
183
645
|
const bearer = exchanged ? exchanged.access_token : opts.token
|
|
184
646
|
|
|
185
647
|
log("Fetching configuration from backend...")
|
|
186
|
-
const orgConfig = await fetchOnboardingConfig(backendUrl, tenantId, bearer)
|
|
648
|
+
const orgConfig = await fetchOnboardingConfig(backendUrl, tenantId, bearer, teamId)
|
|
187
649
|
|
|
188
650
|
const elliotConfig = {
|
|
189
651
|
...orgConfig,
|
|
190
652
|
backend_url: orgConfig.backend_url || backendUrl,
|
|
191
653
|
tenant_id: tenantId,
|
|
192
654
|
jwt_token: bearer,
|
|
655
|
+
// The CLI's ELLIOT MCP credential, minted by the same onboarding request
|
|
656
|
+
// that minted `jwt_token` and carrying the same identity with the MCP
|
|
657
|
+
// audience. `token-env.js` exports it as ELLIOT_MCP_TOKEN, which is what
|
|
658
|
+
// the `{env:ELLIOT_MCP_TOKEN}` placeholder in the generated opencode.json
|
|
659
|
+
// resolves to.
|
|
660
|
+
//
|
|
661
|
+
// Spread from the response rather than assigned unconditionally: a backend
|
|
662
|
+
// that predates this does not send it, and writing `undefined` would
|
|
663
|
+
// overwrite a working value from a previous setup with nothing.
|
|
664
|
+
...(typeof orgConfig.mcp_token === "string" && orgConfig.mcp_token
|
|
665
|
+
? { mcp_token: orgConfig.mcp_token }
|
|
666
|
+
: {}),
|
|
193
667
|
...(exchanged && exchanged.refresh_token ? { refresh_token: exchanged.refresh_token } : {}),
|
|
194
668
|
...(exchanged && exchanged.expires_at ? { access_token_expires_at: exchanged.expires_at } : {}),
|
|
195
669
|
configured_at: new Date().toISOString(),
|
|
196
670
|
}
|
|
197
671
|
|
|
198
672
|
const elliotFile = writeElliotConfig(elliotConfig)
|
|
199
|
-
|
|
673
|
+
// `mcp_servers` arrives in OpenCode's own config shape, so it is written
|
|
674
|
+
// through unchanged. Validated only for shape — a backend that sends garbage
|
|
675
|
+
// must not leave the user with an unparseable config, and an older backend
|
|
676
|
+
// that sends nothing falls back to the built-in server list.
|
|
677
|
+
const servers =
|
|
678
|
+
orgConfig.mcp_servers && typeof orgConfig.mcp_servers === "object" && !Array.isArray(orgConfig.mcp_servers)
|
|
679
|
+
? orgConfig.mcp_servers
|
|
680
|
+
: undefined
|
|
681
|
+
// litellm_base_url is a PUBLIC url (backend's onboarding response derives
|
|
682
|
+
// it as `{backend_url}/litellm`) — fall back to the backend url itself only
|
|
683
|
+
// for a backend that predates this field, which would then 404 rather than
|
|
684
|
+
// silently pointing nowhere.
|
|
685
|
+
const litellmBaseUrl = orgConfig.litellm_base_url || elliotConfig.backend_url
|
|
686
|
+
// Phase 1 governance: full identity is available here (eai setup runs once
|
|
687
|
+
// per developer) — straight from the onboarding response, never invented.
|
|
688
|
+
// See elliotProvider's docstring above.
|
|
689
|
+
const identity = { tenantId: tenantId, userId: orgConfig.user_id, teamId: orgConfig.team_id }
|
|
690
|
+
const opencodeFile = writeOpencodeConfig(
|
|
691
|
+
elliotConfig.backend_url,
|
|
692
|
+
litellmBaseUrl,
|
|
693
|
+
process.env,
|
|
694
|
+
servers,
|
|
695
|
+
identity,
|
|
696
|
+
orgConfig.allowed_models,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
const agentFiles = writeAllAgentFiles(orgConfig.eai_agents, process.env)
|
|
200
700
|
|
|
201
701
|
log(`Wrote ${elliotFile}`)
|
|
202
702
|
log(`Wrote ${opencodeFile}`)
|
|
703
|
+
for (const file of agentFiles) log(`Wrote ${file}`)
|
|
203
704
|
log("")
|
|
204
705
|
log("Setup complete. Run `eai` from any directory.")
|
|
205
|
-
return { elliotFile, opencodeFile, tenantId, exchanged: Boolean(exchanged) }
|
|
706
|
+
return { elliotFile, opencodeFile, agentFiles, tenantId, exchanged: Boolean(exchanged) }
|
|
206
707
|
}
|
|
207
708
|
|
|
208
709
|
/** Minimal `--flag value` parsing; the launcher has no dependencies by design. */
|
|
@@ -213,6 +714,7 @@ function parseArgs(argv) {
|
|
|
213
714
|
if (arg === "--token") out.token = argv[++i]
|
|
214
715
|
else if (arg === "--backend-url") out.backendUrl = argv[++i]
|
|
215
716
|
else if (arg === "--tenant-id") out.tenantId = argv[++i]
|
|
717
|
+
else if (arg === "--team-id") out.teamId = argv[++i]
|
|
216
718
|
}
|
|
217
719
|
return out
|
|
218
720
|
}
|
|
@@ -221,11 +723,19 @@ module.exports = {
|
|
|
221
723
|
runSetup,
|
|
222
724
|
parseArgs,
|
|
223
725
|
decodeJwtPayload,
|
|
726
|
+
fetchOnboardingConfig,
|
|
224
727
|
elliotProvider,
|
|
728
|
+
litellmIdentityHeaders,
|
|
729
|
+
elliotMcpServers,
|
|
730
|
+
elliotCommands,
|
|
225
731
|
writeOpencodeConfig,
|
|
226
732
|
writeElliotConfig,
|
|
733
|
+
agentMarkdown,
|
|
734
|
+
writeAgentFile,
|
|
735
|
+
writeAllAgentFiles,
|
|
227
736
|
opencodeConfigDir,
|
|
228
737
|
elliotConfigPath,
|
|
229
738
|
PROVIDER_ID,
|
|
230
|
-
|
|
739
|
+
GENERAL_MODEL_ID,
|
|
740
|
+
CODER_MODEL_ID,
|
|
231
741
|
}
|