@elliotsystems/eai 0.1.5 → 0.1.9

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/bin/eai CHANGED
@@ -25,12 +25,15 @@ const {
25
25
  resolveOpencodeBin,
26
26
  } = require("../lib/resolve.js")
27
27
  const { injectToken } = require("../lib/token-env.js")
28
- const { stageOverlay } = require("../lib/stage.js")
28
+ const { stageOverlay, readGatewayAlias } = require("../lib/stage.js")
29
29
  const { runSetup, parseArgs } = require("../lib/setup.js")
30
30
  const { runStatus } = require("../lib/status.js")
31
31
  const { runLogout } = require("../lib/logout.js")
32
32
  const { runConnect } = require("../lib/connect.js")
33
33
  const { runUpdate, maybeNotify } = require("../lib/update.js")
34
+ const { runLogin } = require("../lib/login.js")
35
+ const { runTeamsList, runTeamsUse, runTeamsCurrent, runTeamsClear } = require("../lib/teams.js")
36
+ const { startupSummary } = require("../lib/context.js")
34
37
  const gdrive = require("../lib/gdrive.js")
35
38
 
36
39
  /**
@@ -119,6 +122,37 @@ if (process.argv[2] === "setup") {
119
122
  return
120
123
  }
121
124
 
125
+ // `eai login` and `eai teams` are intercepted here for the same reason as
126
+ // `setup`/`status`/`logout`: they configure this machine's Elliot session
127
+ // and must work when nothing is configured yet (or before the runtime can
128
+ // even be located). Neither is an OpenCode command, so nothing is shadowed.
129
+ //
130
+ // `login` reuses the paste-token provisioning path (`lib/login.js`), and
131
+ // `teams` the same session service (`lib/auth.js`) + the exact setup
132
+ // rematerialisation path (`lib/setup.js::runSetup`) — one auth implementation
133
+ // for every entry point.
134
+ if (process.argv[2] === "login") {
135
+ const argv = process.argv.slice(3)
136
+ const backendIdx = argv.indexOf("--backend-url")
137
+ const backendUrl = backendIdx >= 0 && argv[backendIdx + 1] ? argv[backendIdx + 1] : undefined
138
+ finishWith(runLogin({ backendUrl }), "login")
139
+ return
140
+ }
141
+
142
+ if (process.argv[2] === "teams") {
143
+ const verb = process.argv[3]
144
+ const run =
145
+ verb === "use"
146
+ ? () => runTeamsUse({ team: process.argv[4] })
147
+ : verb === "current"
148
+ ? runTeamsCurrent
149
+ : verb === "clear"
150
+ ? runTeamsClear
151
+ : runTeamsList
152
+ finishWith(run(), "teams")
153
+ return
154
+ }
155
+
122
156
  // `--version` answers for BOTH layers, because a bug report that says only
123
157
  // "1.18.30" names the runtime and not the product — and the two move
124
158
  // independently: one is this package, the other is its `opencode-ai`
@@ -230,6 +264,20 @@ if (process.env.ELLIOT_LAUNCHER_DEBUG) {
230
264
  // the child inherits process.env, so setting it here is enough.
231
265
  injectToken(process.env)
232
266
 
267
+ // A concise startup context summary (TTY only, read-only, network-free — see
268
+ // lib/context.js). The fail-closed gate for an invalid context lives at the
269
+ // CREDENTIAL level (empty ELLIOT_TOKEN => LiteLLM rejects, no master-key
270
+ // fallback), so this never blocks or fails startup; it just tells the user
271
+ // what context the session resolves to.
272
+ if (process.stdout.isTTY) {
273
+ try {
274
+ for (const line of startupSummary()) process.stdout.write(`${line}\n`)
275
+ process.stdout.write("\n")
276
+ } catch {
277
+ // A context summary may never be the reason a session fails to start.
278
+ }
279
+ }
280
+
233
281
  // Elliot's identity travels WITH this package, not with the directory the user
234
282
  // happens to be standing in. Before this, branding came from a `tui.json` in the
235
283
  // Elliot repository root, so an installed `eai` outside that repo rendered as
@@ -241,7 +289,11 @@ if (!env.OPENCODE_CONFIG_DIR) {
241
289
  // node_modules does not load (its tsconfig, and so its jsxImportSource, is
242
290
  // not applied there), and the TUI comes up as plain OpenCode with nothing
243
291
  // logged. See lib/stage.js.
244
- const staged = stageOverlay({ packageRoot: PACKAGE_ROOT, version: PACKAGE_VERSION })
292
+ const staged = stageOverlay({
293
+ packageRoot: PACKAGE_ROOT,
294
+ version: PACKAGE_VERSION,
295
+ gatewayAlias: readGatewayAlias(),
296
+ })
245
297
  if (staged) env.OPENCODE_CONFIG_DIR = staged
246
298
  }
247
299
 
package/lib/auth.js ADDED
@@ -0,0 +1,173 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * Shared authentication/session service for the EAI CLI.
5
+ *
6
+ * `eai login`, `eai teams`, `eai status` and the startup path all resolve the
7
+ * same facts from the same file and the same backend endpoints, so this
8
+ * module is where those facts live once:
9
+ *
10
+ * ~/.elliot/config.json the secure session store (0600, written by
11
+ * `eai login`/`eai setup`, single source of truth)
12
+ * POST /auth/cli-token exchange a portal token for a CLI access/refresh
13
+ * pair (backend/app/auth/router.py:238)
14
+ * POST /auth/refresh refresh a session (backend/app/auth/router.py:175)
15
+ * GET /usage/me verify the session and read the user (usage.py:86)
16
+ *
17
+ * Security rules from docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §5/§7:
18
+ * - the backend is the only authority for identity and membership;
19
+ * - a missing/expired/revoked session fails closed with an actionable
20
+ * message — never a silent fallback to a privileged credential;
21
+ * - credentials are never printed or written to project files.
22
+ */
23
+
24
+ const fs = require("fs")
25
+ const os = require("os")
26
+ const path = require("path")
27
+
28
+ const { elliotConfigPath, writeElliotConfig } = require("./setup.js")
29
+
30
+ // The default backend a pasted login token targets. The token itself carries
31
+ // no backend URL (it is minted by whichever portal produced it), so an
32
+ // existing config's `backend_url` wins, `--backend-url` overrides, and this
33
+ // production default is the fallback — see runLogin.
34
+ const DEFAULT_BACKEND_URL = "https://backend.elliotsystemsonline.com"
35
+
36
+ /** Read ~/.elliot/config.json, or null when absent/unreadable. */
37
+ function readConfig() {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(elliotConfigPath(), "utf8"))
40
+ return parsed && typeof parsed === "object" ? parsed : null
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ /** The backend URL to use, from config or the default. */
47
+ function backendUrl(config) {
48
+ return String((config && config.backend_url) || "").replace(/\/+$/, "") || DEFAULT_BACKEND_URL
49
+ }
50
+
51
+ /**
52
+ * Resolve a fresh access token, refreshing from `~/.elliot/config.json` when
53
+ * the stored one is expired or the backend rejects it. Returns the token, or
54
+ * null when no session is usable (and explains why).
55
+ *
56
+ * `POST /auth/refresh` takes the refresh token in the body (auth/router.py:
57
+ * 175-235); it is the credential there, and is refused for audience-bound
58
+ * tokens. Non-rotating: the same refresh token keeps working until revoked.
59
+ *
60
+ * @param {{log?: (s: string) => void}} [opts]
61
+ * @returns {Promise<{token: string, config: object} | null>}
62
+ */
63
+ async function ensureFreshAccessToken(opts = {}) {
64
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
65
+ const config = readConfig()
66
+ if (!config) {
67
+ log("Not logged in. Run `eai login` first.")
68
+ return null
69
+ }
70
+ if (!config.jwt_token) {
71
+ log("Your login is incomplete (no session token). Run `eai login` again.")
72
+ return null
73
+ }
74
+
75
+ // Refresh proactively when we know the access token has expired. Parsed
76
+ // leniently: a missing/odd `expires_at` simply means "don't refresh early".
77
+ const expiresAt = Date.parse(String(config.access_token_expires_at || ""))
78
+ const needsRefresh = Number.isFinite(expiresAt) && expiresAt <= Date.now()
79
+
80
+ if (needsRefresh && config.refresh_token) {
81
+ const fresh = await refresh(config)
82
+ if (fresh) return { token: fresh.token, config: fresh.config }
83
+ log(
84
+ "Your session has expired and could not be refreshed. Run `eai login` again.",
85
+ )
86
+ return null
87
+ }
88
+
89
+ return { token: config.jwt_token, config }
90
+ }
91
+
92
+ /**
93
+ * Call `POST /auth/refresh` and persist the new access token. Returns
94
+ * `{token, config}` on success, null on any failure (never throws).
95
+ */
96
+ async function refresh(config) {
97
+ try {
98
+ const base = backendUrl(config)
99
+ const res = await fetch(`${base}/auth/refresh`, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ refresh_token: config.refresh_token }),
103
+ })
104
+ if (!res.ok) return null
105
+ const body = await res.json()
106
+ if (!body || typeof body.access_token !== "string") return null
107
+ const next = {
108
+ ...config,
109
+ jwt_token: body.access_token,
110
+ ...(typeof body.access_token_expires_at === "string"
111
+ ? { access_token_expires_at: body.access_token_expires_at }
112
+ : {}),
113
+ }
114
+ writeElliotConfig(next)
115
+ return { token: next.jwt_token, config: next }
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Authenticated GET. Uses `ensureFreshAccessToken` for a fresh bearer, and
123
+ * retries once after a refresh when the backend answers 401 (a token that
124
+ * was still unexpired locally but revoked server-side).
125
+ *
126
+ * @returns {Promise<{ok: boolean, status: number, body: any} | null>}
127
+ * null when no usable session exists.
128
+ */
129
+ async function authorizedGet(url, opts = {}) {
130
+ const session = await ensureFreshAccessToken(opts)
131
+ if (!session) return null
132
+ const { token, config } = session
133
+ const base = backendUrl(config)
134
+
135
+ let res
136
+ try {
137
+ res = await fetch(`${base}${url}`, {
138
+ headers: { Authorization: `Bearer ${token}` },
139
+ signal: opts.signal,
140
+ })
141
+ } catch (error) {
142
+ opts.log && opts.log(`Could not reach the backend at ${base}: ${error.message}`)
143
+ return { ok: false, status: 0, body: null }
144
+ }
145
+
146
+ if (res.status === 401 && config.refresh_token) {
147
+ const fresh = await refresh(config)
148
+ if (fresh) {
149
+ res = await fetch(`${base}${url}`, {
150
+ headers: { Authorization: `Bearer ${fresh.token}` },
151
+ signal: opts.signal,
152
+ })
153
+ }
154
+ }
155
+
156
+ let body = null
157
+ try {
158
+ body = await res.json()
159
+ } catch {
160
+ // Non-JSON body; callers fall back to the status.
161
+ }
162
+ return { ok: res.ok, status: res.status, body }
163
+ }
164
+
165
+ module.exports = {
166
+ DEFAULT_BACKEND_URL,
167
+ readConfig,
168
+ backendUrl,
169
+ ensureFreshAccessToken,
170
+ refresh,
171
+ authorizedGet,
172
+ elliotConfigPath,
173
+ }
package/lib/context.js ADDED
@@ -0,0 +1,52 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * The EAI CLI's effective billing context, resolved from the local session.
5
+ *
6
+ * The BACKEND remains the authority for identity, membership and the
7
+ * budgeted credential — this module only renders what a session already
8
+ * resolved (see `docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md` §6/§7).
9
+ * It is read-only and network-free, so it can run on the startup path and in
10
+ * `eai status` without adding latency or a hard dependency on the backend.
11
+ *
12
+ * The fail-closed gate for an invalid/unresolved context is at the CREDENTIAL
13
+ * level, not here: if `~/.elliot/config.json` carries no budgeted LiteLLM key
14
+ * (`litellm_master_key`), `lib/token-env.js` injects no `ELLIOT_TOKEN`, and
15
+ * the runtime's model calls are rejected by LiteLLM — no unbudgeted spend, no
16
+ * shared-master-key fallback.
17
+ */
18
+
19
+ const { readConfig } = require("./auth.js")
20
+
21
+ /** `{type: "team"|"individual", label, teamId}` from the local session. */
22
+ function billingContext(config) {
23
+ if (!config) return { type: "none", label: "Not signed in", teamId: null }
24
+ if (config.team_id) {
25
+ return {
26
+ type: "team",
27
+ label: `Team — ${config.team_name || config.team_id}`,
28
+ teamId: String(config.team_id),
29
+ }
30
+ }
31
+ return { type: "individual", label: "Individual", teamId: null }
32
+ }
33
+
34
+ /**
35
+ * The lines printed when starting `eai` (TTY only) and in `eai status`.
36
+ * Never prints credentials; identifiers are shown only where they are the
37
+ * user-facing value (e.g. the team id when no name is known).
38
+ */
39
+ function startupSummary(config = readConfig()) {
40
+ const billing = billingContext(config)
41
+ const signedIn = config && (config.email || config.user_id) ? config.email || config.user_id : "(none)"
42
+ const gateway = config && config.litellm_base_url ? "LiteLLM" : "(unconfigured)"
43
+ return [
44
+ "Elliot AI",
45
+ `Signed in: ${signedIn}`,
46
+ `Billing: ${billing.label}`,
47
+ `Gateway: ${gateway}`,
48
+ "Project: (none)",
49
+ ]
50
+ }
51
+
52
+ module.exports = { billingContext, startupSummary }
package/lib/login.js ADDED
@@ -0,0 +1,114 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai login` — establish an authenticated CLI session with a pasted token.
5
+ *
6
+ * Approved flow (docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §5/§15):
7
+ * reuse the existing paste-token mechanism. The token is obtained from the
8
+ * portal (or the existing `eai setup` handoff), prompted for SECURELY (never
9
+ * via argv, so it cannot leak into shell history or `ps`), exchanged for a
10
+ * CLI access/refresh pair at `POST /auth/cli-token`, then provisioned through
11
+ * the exact same path `eai setup` uses (`lib/setup.js::runSetup`), so there
12
+ * is ONE authentication implementation, not two.
13
+ *
14
+ * Fail-closed: an unreadable/invalid token, an expired/revoked session or a
15
+ * backend that cannot resolve a budgeted credential all abort with an
16
+ * actionable message. No credential is ever printed.
17
+ */
18
+
19
+ const readline = require("readline")
20
+
21
+ const auth = require("./auth.js")
22
+ const { decodeJwtPayload, runSetup } = require("./setup.js")
23
+
24
+ /**
25
+ * Prompt for a token without echoing it to the terminal (no shell history, no
26
+ * process-argument exposure). On a non-TTY stdin (piped/CI), reads a plain
27
+ * line — callers can also pass `opts.token` to skip the prompt entirely.
28
+ */
29
+ function promptHidden(question) {
30
+ return new Promise((resolve) => {
31
+ if (!process.stdin.isTTY) {
32
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
33
+ rl.question(`${question} `, (answer) => {
34
+ rl.close()
35
+ resolve(String(answer || "").trim())
36
+ })
37
+ return
38
+ }
39
+ process.stdout.write(question + " ")
40
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true })
41
+ rl._writeToOutput = (str) => {
42
+ // Mask the echoed token as asterisks; keep newlines/control output.
43
+ if (/[\r\n]/.test(str)) rl.output.write(str)
44
+ else rl.output.write("*".repeat(str.length))
45
+ }
46
+ rl.question("", (answer) => {
47
+ process.stdout.write("\n")
48
+ rl.close()
49
+ resolve(String(answer || "").trim())
50
+ })
51
+ })
52
+ }
53
+
54
+ /**
55
+ * @param {{token?: string, backendUrl?: string, log?: (s: string) => void}} opts
56
+ * @returns {Promise<number>} exit code
57
+ */
58
+ async function runLogin(opts = {}) {
59
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
60
+ const existing = auth.readConfig()
61
+ const backendUrl = String(
62
+ opts.backendUrl || auth.backendUrl(existing) || auth.DEFAULT_BACKEND_URL,
63
+ ).replace(/\/+$/, "")
64
+
65
+ log("Elliot AI login")
66
+ log("─────────────────────────────")
67
+ log(
68
+ "Get a login token from the Elliot portal (or from `eai setup`'s own " +
69
+ "handoff), then paste it below. It is not echoed and never appears in " +
70
+ "your shell history.",
71
+ )
72
+ if (!existing) {
73
+ log(`Backend: ${backendUrl} (use --backend-url to target another environment)`)
74
+ }
75
+ log("")
76
+
77
+ const token = opts.token || (await promptHidden("Paste your login token:"))
78
+ if (!token) {
79
+ log("Login cancelled — no token entered.")
80
+ return 1
81
+ }
82
+
83
+ // Validate before any network call: the token must look like an Elliot
84
+ // access token carrying a tenant claim.
85
+ const claims = decodeJwtPayload(token) || {}
86
+ if (!claims.tenant_id) {
87
+ log(
88
+ "That does not look like a valid Elliot login token (no tenant). " +
89
+ "Copy the full token from the portal and try again.",
90
+ )
91
+ return 1
92
+ }
93
+
94
+ log("")
95
+ try {
96
+ // The exact provisioning path `eai setup` uses: cli-token exchange,
97
+ // /onboarding/config fetch, secure config writes, opencode.json
98
+ // materialisation, agent files. One implementation, both entry points.
99
+ const result = await runSetup({ token, backendUrl, log })
100
+ log("")
101
+ log("Logged in.")
102
+ log("Next: run `eai teams` to view your teams, `eai teams use \"<name>\"`")
103
+ log(" to pick a billing team, or `eai` to start with individual billing.")
104
+ return result ? 0 : 1
105
+ } catch (error) {
106
+ // Includes the fail-closed 503 from /onboarding/config when no budgeted
107
+ // credential resolves — the message from the backend is actionable and
108
+ // confirms no model request was configured.
109
+ log(String(error && error.message ? error.message : error))
110
+ return 1
111
+ }
112
+ }
113
+
114
+ module.exports = { runLogin, promptHidden }