@elliotsystems/eai 0.1.7 → 0.1.14

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
@@ -36,6 +36,14 @@ const path = require("path")
36
36
  const PROVIDER_ID = "elliot"
37
37
  const GENERAL_MODEL_ID = "elliot-general"
38
38
  const CODER_MODEL_ID = "elliot-coder"
39
+ // The guardrail name a team's own request-level `metadata.guardrails`
40
+ // override names — must match the real `guardrail_name` LiteLLM's
41
+ // `deploy/litellm/config.yaml` configures (`jev-policy`), and the exact
42
+ // value `ProviderManager._guardrail_names_for_team` already injects for Web
43
+ // Chat's own RAG/orchestration path. Restated here (mirrors `teamconfig.py`'s
44
+ // own `JEV_GUARDRAIL_NAME`) rather than imported, since this file has no
45
+ // dependency on that Python module — a test pins both equal.
46
+ const JEV_GUARDRAIL_NAME = "jev-policy"
39
47
 
40
48
  /** Matches OpenCode's own resolution (xdg-basedir, whose fallback is ~/.config everywhere). */
41
49
  function opencodeConfigDir(env = process.env) {
@@ -107,8 +115,22 @@ function decodeJwtPayload(token) {
107
115
  * from OFFERING a model that will be rejected. Falsy (undefined/null/[]) —
108
116
  * every team until an admin restricts it — offers both, unchanged from
109
117
  * before this parameter existed.
118
+ *
119
+ * `jevPolicyEnabled` closes the cross-client enforcement gap the staging
120
+ * integration audit found: the backend's `ProviderManager.
121
+ * _guardrail_names_for_team` already injects the request-level `metadata.
122
+ * guardrails` override for Web Chat's own native RAG/orchestration
123
+ * completions when a team's `jev_policy_enabled` is set, but this CLI (like
124
+ * OpenCode) calls LiteLLM directly via `@ai-sdk/openai-compatible`, outside
125
+ * that Python code path — so the override never reached it. Straight from
126
+ * the same onboarding response's `jev_policy_enabled` field
127
+ * (`app/routers/onboarding.py`). `false`/undefined (every team until
128
+ * explicitly opted in) produces a config identical to one built before this
129
+ * parameter existed. Mirrors the identical fix in `app/services/opencode/
130
+ * teamconfig.py::build_opencode_config` for OpenCode's own web runtimes —
131
+ * same flag, same mechanism, second client.
110
132
  */
111
- function elliotProvider(litellmBaseUrl, identity = undefined, allowedModels = undefined) {
133
+ function elliotProvider(litellmBaseUrl, identity = undefined, allowedModels = undefined, jevPolicyEnabled = false) {
112
134
  const base = String(litellmBaseUrl).replace(/\/+$/, "")
113
135
  const options = { baseURL: `${base}/v1`, apiKey: "{env:ELLIOT_TOKEN}" }
114
136
  const headers = litellmIdentityHeaders(identity)
@@ -119,7 +141,7 @@ function elliotProvider(litellmBaseUrl, identity = undefined, allowedModels = un
119
141
  // — EAI branding only, never the gateway's own vendor name.
120
142
  name: "EAI Gateway",
121
143
  options,
122
- models: offeredModels(allowedModels),
144
+ models: offeredModels(allowedModels, jevPolicyEnabled),
123
145
  }
124
146
  }
125
147
 
@@ -131,18 +153,33 @@ function elliotProvider(litellmBaseUrl, identity = undefined, allowedModels = un
131
153
  * degrades to offering both for that listing only, exactly like the
132
154
  * backend's own fallback — LiteLLM still rejects any actual request the
133
155
  * team isn't allowed to make either way.
156
+ *
157
+ * `jevPolicyEnabled` adds `options.metadata.guardrails` to EACH offered
158
+ * model — see `elliotProvider`'s own docstring for why. A model-level
159
+ * `options` object is the one static, declarative opencode.json field
160
+ * confirmed (offline, against the real `@ai-sdk/openai-compatible`
161
+ * package) to reach the request body this CLI's runtime actually sends.
134
162
  */
135
- function offeredModels(allowedModels) {
163
+ function offeredModels(allowedModels, jevPolicyEnabled = false) {
136
164
  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
165
+ let result = all
166
+ if (allowedModels && allowedModels.length > 0) {
167
+ const allowed = new Set(allowedModels)
168
+ const offered = Object.keys(all).filter((id) => allowed.has(id))
169
+ if (offered.length > 0) {
170
+ const filtered = {}
171
+ for (const id of offered) filtered[id] = all[id]
172
+ result = filtered
173
+ }
174
+ }
175
+ if (jevPolicyEnabled) {
176
+ const withGuardrails = {}
177
+ for (const [id, entry] of Object.entries(result)) {
178
+ withGuardrails[id] = { ...entry, options: { metadata: { guardrails: [JEV_GUARDRAIL_NAME] } } }
179
+ }
180
+ result = withGuardrails
181
+ }
182
+ return result
146
183
  }
147
184
 
148
185
  /** The default `model` field, given whatever `models` ended up offered —
@@ -298,6 +335,15 @@ function elliotCommands() {
298
335
  "intentionally omitted\".\n\n" +
299
336
  "Do not list individual skills here; SKILLS.md is the skill index and " +
300
337
  "`/eai-skills` generates it.\n\n" +
338
+ "Append the standard operating guidance: call the " +
339
+ "`elliot_eai_agents_standard` tool (the `elliot` MCP server exposes it), " +
340
+ "and append its `markdown` value verbatim to the END of AGENTS.md under " +
341
+ "a `## Standard guidance` heading. That tool returns the same canonical " +
342
+ "sections every runtime-generated AGENTS.md carries, from the backend's " +
343
+ "single source of truth — do not paraphrase or rewrite them. If the tool " +
344
+ "is not available yet, try once more after `sleep 3` before skipping; a " +
345
+ "project initialised here should carry the same standard sections a " +
346
+ "team's runtime does.\n\n" +
301
347
  "When done, report the file written and the key facts you detected.",
302
348
  },
303
349
  "eai-skills": {
@@ -339,6 +385,7 @@ function writeOpencodeConfig(
339
385
  servers = undefined,
340
386
  identity = undefined,
341
387
  allowedModels = undefined,
388
+ jevPolicyEnabled = false,
342
389
  ) {
343
390
  const dir = opencodeConfigDir(env)
344
391
  const file = path.join(dir, "opencode.json")
@@ -362,7 +409,7 @@ function writeOpencodeConfig(
362
409
  // replacement. Every other key is the user's own and survives untouched.
363
410
  const { [PROVIDER_ID]: _ours, ...theirs } = existing.mcp || {}
364
411
  const mcp = { ...theirs, ...(servers || elliotMcpServers()) }
365
- const provider = elliotProvider(litellmBaseUrl, identity, allowedModels)
412
+ const provider = elliotProvider(litellmBaseUrl, identity, allowedModels, jevPolicyEnabled)
366
413
  // Org commands are spread LAST, same rule as `mcp` above: this repairs a
367
414
  // stale `/usage` from an older package rather than leaving it, while a
368
415
  // user's own unrelated custom commands are untouched.
@@ -390,6 +437,16 @@ function writeOpencodeConfig(
390
437
  // Follows the restriction, not always General — a team restricted to
391
438
  // Coder-only must not default to a model its own menu just excluded.
392
439
  model: existing.model || `${PROVIDER_ID}/${defaultModelId(provider.models)}`,
440
+ // OpenCode's own self-updater can never succeed here: it detects its
441
+ // install method by grepping `npm list -g --depth=0` for `opencode-ai`,
442
+ // but opencode-ai is a pinned dependency of this package, not its own
443
+ // top-level global install, so that check never matches and `/update`
444
+ // (and the unprompted "Update available" notice) always dead-ends on
445
+ // "Installation method not found". `eai update` is the one supported
446
+ // update path (see lib/update.js) and is unaffected by this setting.
447
+ // Only defaulted, same as `model` above, so a user who already set their
448
+ // own `autoupdate` keeps it.
449
+ autoupdate: existing.autoupdate ?? false,
393
450
  mcp,
394
451
  command,
395
452
  }
@@ -539,23 +596,80 @@ function writeAgentFile(agent, env = process.env) {
539
596
  }
540
597
 
541
598
  /**
542
- * Every agent the backend's `eai_agents` response named, in one pass.
599
+ * Path to the manifest recording which files in the agents directory THIS
600
+ * launcher itself wrote on its last run.
601
+ *
602
+ * It is the one safe way to tell "ours, now stale" apart from "a user's own
603
+ * file that happens to live here" without guessing from the filename -- which
604
+ * is exactly the guess `elliotCommands()`'s "OURS TO REPLACE, THEIRS TO KEEP"
605
+ * rule exists to avoid making. A file never named in any manifest this
606
+ * launcher wrote is never touched, no matter what it's called.
607
+ */
608
+ function agentManifestPath(dir) {
609
+ return path.join(dir, ".eai-manifest.json")
610
+ }
611
+
612
+ /** The manifest's own filenames, or `[]` for "none yet" -- missing, corrupt,
613
+ * or never written are the same case: nothing to clean up against. */
614
+ function readAgentManifest(manifestFile) {
615
+ try {
616
+ const parsed = JSON.parse(fs.readFileSync(manifestFile, "utf-8"))
617
+ return Array.isArray(parsed) ? parsed : []
618
+ } catch {
619
+ return []
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Every agent the backend's `eai_agents` response named, in one pass --
625
+ * and, via the manifest above, nothing it no longer names.
543
626
  *
544
627
  * `agents` is exactly the `eai_agents` array `fetchOnboardingConfig`'s
545
628
  * response carries (or `[]`/undefined for a backend that predates it, which
546
629
  * this treats as "nothing to write" rather than an error -- a CLI update
547
630
  * must not need a backend update released in lockstep, the same tolerance
548
631
  * `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.
632
+ * `mcp_servers`). `undefined` specifically (backend doesn't send the field at
633
+ * all) skips the manifest reconcile too -- it carries no information about
634
+ * what the agent set NOW is, so nothing is safe to remove.
635
+ *
636
+ * A RENAME is a REMOVAL from this function's point of view: migration 090
637
+ * is exactly this -- `dev` became `eai-dev` as the SAME backend row, but
638
+ * `writeAgentFile` only ever knows the key it's given today, so the file
639
+ * named after the OLD key is, from here, indistinguishable from an agent
640
+ * that was deleted outright. Either way, this launcher wrote it and it is
641
+ * no longer in the current set, which is the one condition that makes
642
+ * deleting it safe.
556
643
  */
557
644
  function writeAllAgentFiles(agents, env = process.env) {
558
- return (agents || []).map((agent) => writeAgentFile(agent, env))
645
+ if (!Array.isArray(agents)) return []
646
+
647
+ const dir = path.join(opencodeConfigDir(env), "agents")
648
+ const manifestFile = agentManifestPath(dir)
649
+ const previousFiles = readAgentManifest(manifestFile)
650
+
651
+ const written = agents.map((agent) => writeAgentFile(agent, env))
652
+ const writtenNames = new Set(written.map((f) => path.basename(f)))
653
+
654
+ for (const name of previousFiles) {
655
+ if (writtenNames.has(name)) continue
656
+ try {
657
+ fs.unlinkSync(path.join(dir, name))
658
+ } catch {
659
+ // Already gone (hand-deleted, or a second setup run) -- not an error.
660
+ }
661
+ }
662
+
663
+ // Only touch the manifest -- and so only ever create the directory -- when
664
+ // there is something to record or clean up. An empty/undefined response
665
+ // with no prior manifest must leave the directory exactly as this function
666
+ // always has: never created at all.
667
+ if (written.length > 0 || previousFiles.length > 0) {
668
+ fs.mkdirSync(dir, { recursive: true })
669
+ fs.writeFileSync(manifestFile, JSON.stringify([...writtenNames].sort(), null, 2))
670
+ }
671
+
672
+ return written
559
673
  }
560
674
 
561
675
  /** Owner-only: this file holds a bearer token and a refresh token. */
@@ -611,7 +725,20 @@ async function fetchOnboardingConfig(backendUrl, tenantId, token, teamId) {
611
725
  headers: { Authorization: `Bearer ${token}` },
612
726
  })
613
727
  if (!res.ok) {
614
- throw new Error(`backend returned ${res.status} fetching the organisation config`)
728
+ // Surface the backend's own detail when it sent one (e.g. the fail-closed
729
+ // "no budgeted credential" 503 — EAI_ONBOARDING_BILLING_AUDIT.md §7), so
730
+ // `eai login`/`eai setup`/`eai teams use` show an actionable message
731
+ // rather than a bare status code.
732
+ let detail = ""
733
+ try {
734
+ const body = await res.json()
735
+ if (body && typeof body.detail === "string") detail = body.detail
736
+ } catch {
737
+ // Non-JSON error body; fall back to the status below.
738
+ }
739
+ throw new Error(
740
+ detail || `backend returned ${res.status} fetching the organisation config`,
741
+ )
615
742
  }
616
743
  return (await res.json()) || {}
617
744
  }
@@ -694,6 +821,7 @@ async function runSetup(opts) {
694
821
  servers,
695
822
  identity,
696
823
  orgConfig.allowed_models,
824
+ orgConfig.jev_policy_enabled,
697
825
  )
698
826
 
699
827
  const agentFiles = writeAllAgentFiles(orgConfig.eai_agents, process.env)
@@ -738,4 +866,5 @@ module.exports = {
738
866
  PROVIDER_ID,
739
867
  GENERAL_MODEL_ID,
740
868
  CODER_MODEL_ID,
869
+ JEV_GUARDRAIL_NAME,
741
870
  }
package/lib/stage.js CHANGED
@@ -29,6 +29,62 @@ const fs = require("fs")
29
29
  const os = require("os")
30
30
  const path = require("path")
31
31
 
32
+ /**
33
+ * The JEV (fast-jev-compaction-opencode) plugin spec. NOT staged by default:
34
+ * pass it as `jevPlugin` to stage it alongside Elliot's own branding plugin.
35
+ * It is off because the plugin writes its own log lines onto the TUI (and its
36
+ * `github:` spec also triggers a Node `url.parse()` deprecation warning at
37
+ * load), with no option to silence either. Turn it on again once it can log to
38
+ * a file only.
39
+ *
40
+ * Registering it here does NOT mean compaction works: as of this writing its
41
+ * default backend ("typesafe") has no configured credential on a stock
42
+ * install, so its own compaction hook fails closed and OpenCode's native
43
+ * compaction runs instead -- confirmed by reading its own log output, not
44
+ * claimed from its source alone. A plugin that fails to load or fails inside
45
+ * its own hook does not block startup or touch Elliot's own plugin entry;
46
+ * OpenCode loads each plugin independently and logs a per-plugin failure
47
+ * rather than refusing to start (see opencode.log's own "failed to load
48
+ * plugin" entries for Elliot's branding plugin on a missing gatewayAlias,
49
+ * which never affected the rest of the session).
50
+ *
51
+ * A bare string, not a [spec, options] tuple: unlike Elliot's own plugin, it
52
+ * takes no required option here, so there is nothing to pass.
53
+ */
54
+ const DEFAULT_JEV_PLUGIN_SPEC = "github:overbit/fast-jev-compaction-opencode"
55
+
56
+ /**
57
+ * Credential-shaped keys this function refuses to ever write into a staged
58
+ * config file. Checked by NAME, not by guessing at a value's shape -- a
59
+ * deliberately conservative denylist, since the cost of missing a future
60
+ * secret-shaped option name is much higher than the cost of a false
61
+ * positive dropping a harmless key that happened to share a name.
62
+ */
63
+ const JEV_PLUGIN_SECRET_OPTION_KEYS = new Set(["apiKey", "localApiKey"])
64
+
65
+ /**
66
+ * `opts.jevPluginOptions`, minus any credential-shaped key -- see
67
+ * DEFAULT_JEV_PLUGIN_SPEC's own docstring above for why a secret should
68
+ * never reach here in the first place. `undefined`/non-object input
69
+ * returns `undefined` (no options tuple at all, same as omitting the
70
+ * field), never throws -- this must never be the reason staging fails.
71
+ */
72
+ function jevPluginOptionsExcludingSecrets(raw) {
73
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined
74
+ const clean = {}
75
+ for (const [key, value] of Object.entries(raw)) {
76
+ if (JEV_PLUGIN_SECRET_OPTION_KEYS.has(key)) {
77
+ process.stderr.write(
78
+ `eai: refusing to write "${key}" into the staged JEV plugin config -- ` +
79
+ `credentials must be set as an environment variable (e.g. TYPESAFE_API_KEY), never in a config file.\n`,
80
+ )
81
+ continue
82
+ }
83
+ clean[key] = value
84
+ }
85
+ return Object.keys(clean).length > 0 ? clean : undefined
86
+ }
87
+
32
88
  /**
33
89
  * The LiteLLM MCP gateway's alias, from `~/.elliot/config.json`'s
34
90
  * `gateway_alias` field -- `eai setup` writes it there straight out of
@@ -139,6 +195,27 @@ function stageOverlay(opts) {
139
195
  // from. See readGatewayAlias's own docstring for why `undefined` must not
140
196
  // become a guessed `"elliot"`.
141
197
  const gatewayAlias = opts.gatewayAlias
198
+ // OFF unless a caller passes a spec (the tests do; pass DEFAULT_JEV_PLUGIN_SPEC
199
+ // to turn it on). It was staged for every install, but the plugin copies each
200
+ // of its log lines to the console and has no option to stop it, so on every
201
+ // launch `[fast-jev-compaction-opencode] initialized {...}` was drawn into the
202
+ // TUI prompt box -- for a compaction hook that fails closed on a stock install
203
+ // anyway. See DEFAULT_JEV_PLUGIN_SPEC's docstring.
204
+ const jevPlugin = opts.jevPlugin || null
205
+ // Non-secret backend selection ONLY -- e.g. { backend: "systemone",
206
+ // localBaseUrl: "...", localModel: "..." } to point the plugin at a local
207
+ // decision model instead of its own TypeSafe-hosted default. NEVER a
208
+ // credential: the plugin already reads TYPESAFE_API_KEY straight from
209
+ // process.env on every call (jev-compaction-opencode's own client.ts),
210
+ // and the OpenCode child process already inherits this launcher's full
211
+ // environment (bin/eai's `childProcess.spawn(..., { env })`) -- there is
212
+ // no path by which a secret needs to pass through this function, through
213
+ // a JSON file this function writes, or through anything this launcher
214
+ // logs. `jevPluginOptionsExcludingSecrets` enforces that as a real
215
+ // invariant, not just an absence of code: an `apiKey`/`localApiKey` key
216
+ // here is dropped and a warning is written to stderr rather than ever
217
+ // reaching a file on disk.
218
+ const jevPluginOptions = jevPluginOptionsExcludingSecrets(opts.jevPluginOptions)
142
219
 
143
220
  const pluginSource = path.join(packageRoot, "plugin", "opencode-plugin")
144
221
  const themeSource = path.join(packageRoot, "config", "themes")
@@ -150,13 +227,13 @@ function stageOverlay(opts) {
150
227
 
151
228
  try {
152
229
  // `.staged` records the version, a content fingerprint of the source this
153
- // tree was built from, AND the gateway alias baked into the written
154
- // config -- re-staging on every launch would add hundreds of file copies
155
- // to startup, but skipping it on an alias change would leave a machine
156
- // running `eai setup` against a renamed gateway alias (or configuring it
157
- // for the first time) stuck on the stale tui.json/opencode.json until
158
- // something else invalidated the stamp.
159
- const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""}`.trim()
230
+ // tree was built from, the gateway alias baked into the written config,
231
+ // AND the JEV plugin spec -- re-staging on every launch would add
232
+ // hundreds of file copies to startup, but skipping it on any of these
233
+ // changing would leave a machine stuck on a stale tui.json/opencode.json
234
+ // (the alias case is the incident this already protects against; the JEV
235
+ // spec is the same risk the moment that spec is ever overridden).
236
+ const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""} ${jevPlugin || ""} ${JSON.stringify(jevPluginOptions || {})}`.trim()
160
237
  const done = fs.existsSync(marker) && fs.readFileSync(marker, "utf8").trim() === stamp
161
238
  if (!done) {
162
239
  fs.rmSync(root, { recursive: true, force: true })
@@ -176,9 +253,13 @@ function stageOverlay(opts) {
176
253
  // OUT of the array entirely rather than included in a form guaranteed
177
254
  // to fail — "plain OpenCode chrome" is the same visible outcome either
178
255
  // way, but without logging a load error on every single launch.
179
- const plugin = gatewayAlias
180
- ? [[pluginDir, { serverNames: ["elliot"], gatewayAlias }]]
181
- : []
256
+ // JEV is appended independently of gatewayAlias: it has no such
257
+ // required option (see DEFAULT_JEV_PLUGIN_SPEC), so there is nothing
258
+ // here that can fail validation the way a bare Elliot plugin path did.
259
+ const plugin = [
260
+ ...(gatewayAlias ? [[pluginDir, { serverNames: ["elliot"], gatewayAlias }]] : []),
261
+ ...(jevPlugin ? [jevPluginOptions ? [jevPlugin, jevPluginOptions] : jevPlugin] : []),
262
+ ]
182
263
  fs.writeFileSync(
183
264
  path.join(root, "tui.json"),
184
265
  JSON.stringify(
@@ -207,4 +288,4 @@ function stageOverlay(opts) {
207
288
  }
208
289
  }
209
290
 
210
- module.exports = { stageOverlay, stageRoot, readGatewayAlias }
291
+ module.exports = { stageOverlay, stageRoot, readGatewayAlias, DEFAULT_JEV_PLUGIN_SPEC }
package/lib/status.js CHANGED
@@ -24,6 +24,7 @@
24
24
  const fs = require("fs")
25
25
 
26
26
  const { elliotConfigPath } = require("./setup.js")
27
+ const { billingContext } = require("./context.js")
27
28
 
28
29
  /** Read ~/.elliot/config.json, or null when absent/unreadable. */
29
30
  function readElliotConfig() {
@@ -107,6 +108,7 @@ async function runStatus(opts = {}) {
107
108
  log(`Workspace ID: ${config.tenant_id || "(unknown)"}`)
108
109
  if (config.stack) log(`Stack: ${config.stack}`)
109
110
  log(`Backend: ${config.backend_url || "(unset)"}`)
111
+ log(`Billing: ${billingContext(config).label}`)
110
112
 
111
113
  const healthy = config.backend_url ? await checkBackendHealth(config.backend_url) : false
112
114
  log(`Reachable: ${healthy ? "yes" : "no"}`)
package/lib/teams.js ADDED
@@ -0,0 +1,233 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai teams` — list, select, verify and clear the team billing context.
5
+ *
6
+ * Approved model (docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §4/§6):
7
+ * the BACKEND is the only authority for membership and for the budgeted
8
+ * credential. These commands never trust a client-supplied team id as proof
9
+ * of membership — `GET /teams` returns only the caller's authorized teams,
10
+ * and `teams use`/`teams clear` re-fetch `/onboarding/config` through the
11
+ * exact provisioning path `eai setup` uses, so the materialised `opencode.json`
12
+ * carries the correct team (or personal) budgeted LiteLLM key. Fail-closed:
13
+ * a revoked membership, unknown/ambiguous team, or missing budgeted
14
+ * credential aborts with an actionable message and no config change.
15
+ *
16
+ * Switching teams rematerialises configuration and requires a FRESH `eai`
17
+ * session — OpenCode cannot swap the embedded key mid-session, so in-flight
18
+ * requests keep their original billing context (audit §3, decision 3).
19
+ */
20
+
21
+ const { ensureFreshAccessToken, authorizedGet } = require("./auth.js")
22
+ const { runSetup } = require("./setup.js")
23
+
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
25
+
26
+ /** Unwrap a /teams response whether the backend returns a bare array or an
27
+ * object with a `teams` key. */
28
+ function teamsFromBody(body) {
29
+ if (Array.isArray(body)) return body
30
+ if (body && Array.isArray(body.teams)) return body.teams
31
+ return []
32
+ }
33
+
34
+ /** Fetch the caller's authorized teams (empty on failure). */
35
+ async function fetchTeams(opts = {}) {
36
+ const session = await ensureFreshAccessToken(opts)
37
+ if (!session) return null
38
+ const { config } = session
39
+ if (!config.tenant_id) {
40
+ opts.log && opts.log("Your login has no tenant. Run `eai login` again.")
41
+ return null
42
+ }
43
+ const result = await authorizedGet(
44
+ `/teams?tenant_id=${encodeURIComponent(config.tenant_id)}`,
45
+ opts,
46
+ )
47
+ if (!result || !result.ok) return []
48
+ return teamsFromBody(result.body)
49
+ }
50
+
51
+ async function runTeamsList(opts = {}) {
52
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
53
+ const teams = await fetchTeams(opts)
54
+ if (teams === null) return 1
55
+ if (!teams.length) {
56
+ log("No teams are available to you yet.")
57
+ log("Ask an administrator to add you to a team, or run `eai` to use individual billing.")
58
+ return 0
59
+ }
60
+ log("Your teams:")
61
+ for (const team of teams) {
62
+ const name = team.name || "(unnamed)"
63
+ const budget =
64
+ typeof team.max_budget_usd === "number" && team.max_budget_usd > 0
65
+ ? ` — budget $${team.max_budget_usd}/day`
66
+ : ""
67
+ log(` ${name}${budget} (${team.id})`)
68
+ }
69
+ log("")
70
+ log('Select one with: eai teams use "<team name>"')
71
+ log("Show the active context with: eai teams current")
72
+ return 0
73
+ }
74
+
75
+ /** Resolve a `teams use` argument to a single team id, or null (with a
76
+ * message) when it is unknown or ambiguous. */
77
+ function resolveTeamId(teams, arg) {
78
+ if (UUID_RE.test(arg)) {
79
+ const match = teams.find((t) => String(t.id) === arg)
80
+ if (match) return { id: String(match.id), name: match.name }
81
+ return null
82
+ }
83
+ const matches = teams.filter((t) => String(t.name) === arg)
84
+ if (matches.length === 1) {
85
+ return { id: String(matches[0].id), name: matches[0].name }
86
+ }
87
+ if (matches.length > 1) {
88
+ const list = matches.map((t) => ` ${t.name} (${t.id})`).join("\n")
89
+ return {
90
+ ambiguous: true,
91
+ message:
92
+ `Multiple teams are named "${arg}". Use the id instead:\n${list}\n` +
93
+ ' eai teams use "<id>"',
94
+ }
95
+ }
96
+ return null
97
+ }
98
+
99
+ async function runTeamsUse(opts = {}) {
100
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
101
+ const arg = String(opts.team || "").trim()
102
+ if (!arg) {
103
+ log('Usage: eai teams use "<team name or id>"')
104
+ return 1
105
+ }
106
+ const session = await ensureFreshAccessToken(opts)
107
+ if (!session) return 1
108
+ const { token, config } = session
109
+ const backendUrl = String(opts.backendUrl || config.backend_url || "").replace(/\/+$/, "")
110
+ if (!backendUrl) {
111
+ log("No backend configured. Run `eai login` first.")
112
+ return 1
113
+ }
114
+
115
+ const teams = await fetchTeams(opts)
116
+ if (teams === null) return 1
117
+ const resolved = resolveTeamId(teams, arg)
118
+ if (!resolved) {
119
+ log(`You are not a member of a team named or id'd "${arg}".`)
120
+ log("Run `eai teams` to see the teams available to you.")
121
+ return 1
122
+ }
123
+ if (resolved.ambiguous) {
124
+ log(resolved.message)
125
+ return 1
126
+ }
127
+
128
+ // Rematerialise through the exact setup path: the backend validates
129
+ // membership, resolves the TEAM's budgeted LiteLLM key (fail-closed if
130
+ // none resolves), and writes the config + opencode.json. A failure here
131
+ // leaves the previous config untouched.
132
+ log(`Selecting team "${resolved.name}"...`)
133
+ try {
134
+ await runSetup({ token, backendUrl, teamId: resolved.id, log })
135
+ } catch (error) {
136
+ log(String(error && error.message ? error.message : error))
137
+ log("Team selection did not change. Your previous billing context is unchanged.")
138
+ return 1
139
+ }
140
+ log("")
141
+ log(`Billing team set to "${resolved.name}".`)
142
+ log("Restart EAI (`eai`) to apply the new context. In-flight work is unaffected.")
143
+ return 0
144
+ }
145
+
146
+ async function runTeamsCurrent(opts = {}) {
147
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
148
+ const session = await ensureFreshAccessToken(opts)
149
+ if (!session) return 1
150
+ const { config } = session
151
+
152
+ const displayUser = config.email || config.user_id || "(unknown)"
153
+ const teamId = config.team_id
154
+ const isTeam = Boolean(teamId)
155
+
156
+ // Verify server-side, not from local config alone: resolve the team from
157
+ // the authorized list (revoked membership => 404/absent => unverified).
158
+ let teamName = null
159
+ let verified = false
160
+ const teams = await fetchTeams(opts)
161
+ if (teams) {
162
+ if (isTeam) {
163
+ const team = teams.find((t) => String(t.id) === String(teamId))
164
+ if (team) {
165
+ teamName = team.name
166
+ verified = true
167
+ }
168
+ } else {
169
+ // Individual billing: verified as long as we can fetch the authorized
170
+ // list (i.e. the session is live and the caller is an identified user).
171
+ verified = true
172
+ }
173
+ }
174
+
175
+ log("Elliot AI context")
176
+ log("─────────────────────────────")
177
+ log(`Signed in: ${displayUser}`)
178
+ if (isTeam && teamName) {
179
+ log(`Billing: Team — ${teamName}`)
180
+ } else if (isTeam && !teamName) {
181
+ log("Billing: Team (name unavailable)")
182
+ } else {
183
+ log("Billing: Individual")
184
+ }
185
+ log(`Status: ${verified ? "Verified" : "Unverified — cannot confirm with the backend"}`)
186
+ if (isTeam) {
187
+ log("Action: Restart EAI to apply the current context")
188
+ } else {
189
+ log("Action: none — requests are charged to your individual budget")
190
+ }
191
+ log("")
192
+ return verified ? 0 : 1
193
+ }
194
+
195
+ async function runTeamsClear(opts = {}) {
196
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
197
+ const session = await ensureFreshAccessToken(opts)
198
+ if (!session) return 1
199
+ const { token, config } = session
200
+ const backendUrl = String(opts.backendUrl || config.backend_url || "").replace(/\/+$/, "")
201
+ if (!backendUrl) {
202
+ log("No backend configured. Run `eai login` first.")
203
+ return 1
204
+ }
205
+
206
+ log("Clearing team selection — reverting to individual billing...")
207
+ try {
208
+ // No team_id => /onboarding/config resolves the caller's PERSONAL key
209
+ // (fail-closed if none resolves — never the shared master key).
210
+ await runSetup({ token, backendUrl, log })
211
+ } catch (error) {
212
+ log(String(error && error.message ? error.message : error))
213
+ log(
214
+ "Individual billing could not be established. Model execution stays " +
215
+ "blocked until a personal budgeted credential is available. Contact " +
216
+ "your administrator.",
217
+ )
218
+ return 1
219
+ }
220
+ log("")
221
+ log("Team selection cleared — billing is now Individual.")
222
+ log("Restart EAI (`eai`) to apply the new context.")
223
+ return 0
224
+ }
225
+
226
+ module.exports = {
227
+ fetchTeams,
228
+ resolveTeamId,
229
+ runTeamsList,
230
+ runTeamsUse,
231
+ runTeamsCurrent,
232
+ runTeamsClear,
233
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elliotsystems/eai",
3
- "version": "0.1.7",
3
+ "version": "0.1.14",
4
4
  "description": "Elliot AI CLI entry point (eai) - launches the Elliot runtime with arguments forwarded verbatim.",
5
5
  "bin": {
6
6
  "eai": "./bin/eai"
@@ -27,6 +27,6 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "opencode-ai": "1.19.0"
30
+ "opencode-ai": "1.19.5"
31
31
  }
32
32
  }