@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/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 MODEL_ID = "elliot-governed"
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` on purpose: @ai-sdk/anthropic appends `/messages`,
73
- * and the route is `/llm-proxy/v1/messages`. Dropping the `/v1` yields a 404.
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. OpenCode
76
- * substitutes it at config-load time and the launcher sets ELLIOT_TOKEN from
77
- * ~/.elliot/config.json on every run, which keeps that file the single source
78
- * of truth for an hourly-expiring credential — a refresh there is picked up
79
- * without rewriting this one.
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 elliotProvider(backendUrl) {
82
- const base = String(backendUrl).replace(/\/+$/, "")
248
+ function elliotCommands() {
83
249
  return {
84
- npm: "@ai-sdk/anthropic",
85
- name: "Elliot (LightLLM proxy)",
86
- options: { baseURL: `${base}/llm-proxy/v1`, apiKey: "{env:ELLIOT_TOKEN}" },
87
- models: { [MODEL_ID]: { name: "Elliot Governed" } },
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
- * governed-only, so there must be no direct-to-vendor path. `model` is only set
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(backendUrl, env = process.env) {
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: { ...(existing.provider || {}), [PROVIDER_ID]: elliotProvider(backendUrl) },
385
+ provider: {
386
+ ...(existing.provider || {}),
387
+ [PROVIDER_ID]: provider,
388
+ },
112
389
  enabled_providers: [PROVIDER_ID],
113
- model: existing.model || `${PROVIDER_ID}/${MODEL_ID}`,
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
- async function fetchOnboardingConfig(backendUrl, tenantId, token) {
155
- const res = await fetch(`${backendUrl}/onboarding/config/${tenantId}`, {
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
- const opencodeFile = writeOpencodeConfig(elliotConfig.backend_url)
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
- MODEL_ID,
739
+ GENERAL_MODEL_ID,
740
+ CODER_MODEL_ID,
231
741
  }