@elliotsystems/eai 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,9 +31,11 @@ In precedence order (`lib/resolve.js`):
31
31
  | # | Source | Path |
32
32
  |---|---|---|
33
33
  | 1 | `ELLIOT_OPENCODE_BIN` | the binary itself |
34
- | 2 | bundled runtime | `vendor/opencode-<platform>-<arch>/bin/opencode[.exe]` |
35
- | 3 | `ELLIOT_OPENCODE_HOME` | `<home>/packages/opencode/dist/…` or `<home>/opencode-<platform>-<arch>/…` |
36
- | 4 | sibling fork checkout | **development only**, last resort |
34
+ | 2 | platform npm package | `@elliotsystems/eai-<platform>-<arch>/bin/opencode[.exe]`, via `require.resolve` -- **this is how an installed `eai` finds its runtime** |
35
+ | 3 | bundled runtime | `vendor/opencode-<platform>-<arch>/bin/opencode[.exe]` |
36
+ | 4 | `ELLIOT_OPENCODE_HOME` | `<home>/packages/opencode/dist/…` or `<home>/opencode-<platform>-<arch>/…` |
37
+ | 5 | in-repo fork checkout | `../opencode/packages/opencode/dist/…` -- **development only** |
38
+ | 6 | sibling fork checkout | `../../opencode/packages/opencode/dist/…` -- **development only**, last resort |
37
39
 
38
40
  Two deliberate rules:
39
41
 
@@ -58,8 +60,11 @@ stays clean for piping).
58
60
  (`@elliotsystems/elliot-ai`, currently on PATH). Both can coexist, so the legacy
59
61
  CLI keeps working untouched and its retirement stays a separate, later decision.
60
62
 
61
- The package is still `private: true` — packaging, publishing and global install
62
- are out of scope until approved. Run it locally with `node bin/eai`.
63
+ Run it locally, without installing, with `node bin/eai`.
64
+
65
+ > This section previously said the package was `private: true` and that publishing
66
+ > was out of scope. It has been published as `@elliotsystems/eai` since 0.1.0; the
67
+ > note was left behind and is corrected here.
63
68
 
64
69
  ## Install
65
70
 
package/bin/eai CHANGED
@@ -25,6 +25,8 @@ 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")
29
+ const { runSetup, parseArgs } = require("../lib/setup.js")
28
30
 
29
31
  /**
30
32
  * Resolve symlinks before deriving the package root.
@@ -50,6 +52,11 @@ function packageRoot() {
50
52
  }
51
53
 
52
54
  const PACKAGE_ROOT = packageRoot()
55
+ const PACKAGE_VERSION = require("../package.json").version
56
+ // OSC 0 (set icon name and window title), BEL-terminated: every terminal that
57
+ // supports OSC accepts BEL, while some older ones reject ST.
58
+ const ELLIOT_TITLE = String.fromCharCode(27) + "]0;Elliot-AI" + String.fromCharCode(7)
59
+
53
60
  const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"]
54
61
 
55
62
  function fail(message) {
@@ -57,6 +64,26 @@ function fail(message) {
57
64
  process.exit(127) // conventional "command not found"
58
65
  }
59
66
 
67
+ // `eai setup` is handled here and never reaches the runtime.
68
+ //
69
+ // It is intercepted BEFORE the runtime is resolved, because setup is what a
70
+ // machine runs when nothing is configured yet -- failing with "could not locate
71
+ // the OpenCode runtime" would be a confusing way to refuse to configure it.
72
+ // `setup` is not an OpenCode command, so nothing is shadowed.
73
+ //
74
+ // This exists so `eai` is the only CLI to install. Onboarding used to hand out
75
+ // a second global package whose whole job was writing two config files, and a
76
+ // user who ran only the first install got a CLI with no backend and no token.
77
+ if (process.argv[2] === "setup") {
78
+ const options = parseArgs(process.argv.slice(3))
79
+ runSetup(options).catch((error) => {
80
+ process.stderr.write(`eai setup: ${error.message}
81
+ `)
82
+ process.exit(1)
83
+ })
84
+ return
85
+ }
86
+
60
87
  const resolved = resolveOpencodeBin({ packageRoot: PACKAGE_ROOT })
61
88
 
62
89
  if (!resolved) {
@@ -87,10 +114,33 @@ if (process.env.ELLIOT_LAUNCHER_DEBUG) {
87
114
  // the child inherits process.env, so setting it here is enough.
88
115
  injectToken(process.env)
89
116
 
117
+ // Elliot's identity travels WITH this package, not with the directory the user
118
+ // happens to be standing in. Before this, branding came from a `tui.json` in the
119
+ // Elliot repository root, so an installed `eai` outside that repo rendered as
120
+ // plain OpenCode. `config/` is packaged (see package.json `files`) and holds the
121
+ // theme, the palette and the plugin registration.
122
+ const env = { ...process.env }
123
+ if (!env.OPENCODE_CONFIG_DIR) {
124
+ // Staged OUTSIDE node_modules, which is not optional: a plugin under
125
+ // node_modules does not load (its tsconfig, and so its jsxImportSource, is
126
+ // not applied there), and the TUI comes up as plain OpenCode with nothing
127
+ // logged. See lib/stage.js.
128
+ const staged = stageOverlay({ packageRoot: PACKAGE_ROOT, version: PACKAGE_VERSION })
129
+ if (staged) env.OPENCODE_CONFIG_DIR = staged
130
+ }
131
+
132
+ // The one branding surface no plugin slot can reach. Upstream sets the terminal
133
+ // title itself from a `createEffect` with no slot and no config key, but it does
134
+ // honour this flag (`core/src/flag/flag.ts`), so we turn its title off and write
135
+ // our own below. This is what lets OpenCode stay stock.
136
+ env.OPENCODE_DISABLE_TERMINAL_TITLE = "1"
137
+
138
+ // Claim the title before the child paints anything.
139
+ if (process.stdout.isTTY) process.stdout.write(ELLIOT_TITLE)
140
+
90
141
  // argv passes through untouched and WITHOUT a shell, so quoting, spaces and
91
- // shell metacharacters in a prompt reach the runtime as the user typed them and
92
- // are never re-interpreted by an intermediate shell.
93
- const child = childProcess.spawn(resolved.path, process.argv.slice(2), { stdio: "inherit" })
142
+ // shell metacharacters in a prompt reach the runtime as the user typed them.
143
+ const child = childProcess.spawn(resolved.path, process.argv.slice(2), { stdio: "inherit", env })
94
144
 
95
145
  child.on("error", (error) => fail(error.message))
96
146
 
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "plugin": ["../plugin/opencode-plugin"]
4
+ }
@@ -0,0 +1,105 @@
1
+ {
2
+ "$schema": "https://opencode.ai/theme.json",
3
+ "defs": {
4
+ "brandOrange": "#FF6600",
5
+ "accentOrange": "#FF7A2E",
6
+ "accentOrangeLit": "#FF9354",
7
+ "accentOrangeSoft": "#FFB07A",
8
+ "accentOrangeDeep": "#C2410C",
9
+ "accentOrangeInk": "#7C2D12",
10
+
11
+ "ground": "#0B0C0E",
12
+ "panel": "#121315",
13
+ "elevated": "#191B1D",
14
+ "elevated2": "#212325",
15
+ "border": "#262829",
16
+ "borderSoft": "#1C1E20",
17
+
18
+ "text": "#F5F6F8",
19
+ "textFaint": "#A0A3A8",
20
+ "textDim": "#8E9297",
21
+ "textGhost": "#3A3D3F",
22
+
23
+ "success": "#5DD89B",
24
+ "warning": "#E8C33A",
25
+ "error": "#F7768E",
26
+ "teal": "#5EEAD4",
27
+
28
+ "lightGround": "#FFFFFF",
29
+ "lightPanel": "#FAFAFA",
30
+ "lightElevated": "#F2F2F4",
31
+ "lightElevated2": "#E9E9EC",
32
+ "lightBorder": "#D9D9DE",
33
+ "lightBorderSoft": "#EDEDF0",
34
+ "lightText": "#0B0C0E",
35
+ "lightTextDim": "#6B6E73",
36
+ "lightTextFaint": "#8E9297",
37
+ "lightTextGhost": "#C4C6CA",
38
+ "lightSuccess": "#1B7F4F",
39
+ "lightWarning": "#8A6D0B",
40
+ "lightError": "#C2334C",
41
+ "lightTeal": "#0F766E"
42
+ },
43
+ "theme": {
44
+ "primary": { "dark": "brandOrange", "light": "brandOrange" },
45
+ "accent": { "dark": "accentOrange", "light": "accentOrangeDeep" },
46
+ "secondary": { "dark": "teal", "light": "lightTeal" },
47
+ "info": { "dark": "accentOrangeSoft", "light": "accentOrangeDeep" },
48
+
49
+ "error": { "dark": "error", "light": "lightError" },
50
+ "warning": { "dark": "warning", "light": "lightWarning" },
51
+ "success": { "dark": "success", "light": "lightSuccess" },
52
+
53
+ "text": { "dark": "text", "light": "lightText" },
54
+ "textMuted": { "dark": "textDim", "light": "lightTextDim" },
55
+
56
+ "selectedListItemText": { "dark": "ground", "light": "lightGround" },
57
+
58
+ "background": { "dark": "ground", "light": "lightGround" },
59
+ "backgroundPanel": { "dark": "panel", "light": "lightPanel" },
60
+ "backgroundElement": { "dark": "elevated", "light": "lightElevated" },
61
+ "backgroundMenu": { "dark": "elevated2", "light": "lightElevated2" },
62
+
63
+ "border": { "dark": "border", "light": "lightBorder" },
64
+ "borderActive": { "dark": "accentOrange", "light": "accentOrangeDeep" },
65
+ "borderSubtle": { "dark": "borderSoft", "light": "lightBorderSoft" },
66
+
67
+ "diffAdded": { "dark": "success", "light": "lightSuccess" },
68
+ "diffRemoved": { "dark": "error", "light": "lightError" },
69
+ "diffContext": { "dark": "textDim", "light": "lightTextDim" },
70
+ "diffHunkHeader": { "dark": "textFaint", "light": "lightTextFaint" },
71
+ "diffHighlightAdded": { "dark": "#8DE8B8", "light": "#12603B" },
72
+ "diffHighlightRemoved": { "dark": "#FF9BAC", "light": "#A62740" },
73
+ "diffAddedBg": { "dark": "#0F2018", "light": "#E4F5EB" },
74
+ "diffRemovedBg": { "dark": "#241318", "light": "#FBE4E9" },
75
+ "diffContextBg": { "dark": "panel", "light": "lightPanel" },
76
+ "diffLineNumber": { "dark": "textGhost", "light": "lightTextGhost" },
77
+ "diffAddedLineNumberBg": { "dark": "#0C1913", "light": "#D6EDDF" },
78
+ "diffRemovedLineNumberBg": { "dark": "#1D0F13", "light": "#F6D8DF" },
79
+
80
+ "markdownText": { "dark": "text", "light": "lightText" },
81
+ "markdownHeading": { "dark": "brandOrange", "light": "accentOrangeDeep" },
82
+ "markdownLink": { "dark": "accentOrange", "light": "accentOrangeDeep" },
83
+ "markdownLinkText": { "dark": "accentOrangeSoft", "light": "accentOrangeDeep" },
84
+ "markdownCode": { "dark": "accentOrangeSoft", "light": "accentOrangeDeep" },
85
+ "markdownBlockQuote": { "dark": "textFaint", "light": "lightTextFaint" },
86
+ "markdownEmph": { "dark": "accentOrangeLit", "light": "accentOrangeDeep" },
87
+ "markdownStrong": { "dark": "accentOrange", "light": "accentOrangeDeep" },
88
+ "markdownHorizontalRule": { "dark": "borderSoft", "light": "lightBorderSoft" },
89
+ "markdownListItem": { "dark": "accentOrange", "light": "accentOrangeDeep" },
90
+ "markdownListEnumeration": { "dark": "accentOrangeLit", "light": "accentOrangeDeep" },
91
+ "markdownImage": { "dark": "accentOrange", "light": "accentOrangeDeep" },
92
+ "markdownImageText": { "dark": "textFaint", "light": "lightTextFaint" },
93
+ "markdownCodeBlock": { "dark": "text", "light": "lightText" },
94
+
95
+ "syntaxComment": { "dark": "textGhost", "light": "lightTextGhost" },
96
+ "syntaxKeyword": { "dark": "accentOrange", "light": "accentOrangeDeep" },
97
+ "syntaxFunction": { "dark": "accentOrangeLit", "light": "accentOrangeInk" },
98
+ "syntaxVariable": { "dark": "text", "light": "lightText" },
99
+ "syntaxString": { "dark": "success", "light": "lightSuccess" },
100
+ "syntaxNumber": { "dark": "accentOrangeSoft", "light": "accentOrangeDeep" },
101
+ "syntaxType": { "dark": "teal", "light": "lightTeal" },
102
+ "syntaxOperator": { "dark": "textFaint", "light": "lightTextFaint" },
103
+ "syntaxPunctuation": { "dark": "textDim", "light": "lightTextDim" }
104
+ }
105
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://opencode.ai/tui.json",
3
+ "theme": "elliot",
4
+ "mouse": true,
5
+ "plugin": ["../plugin/opencode-plugin"],
6
+ "plugin_enabled": {
7
+ "internal:home-tips": false
8
+ }
9
+ }
package/lib/resolve.js CHANGED
@@ -15,6 +15,7 @@ const path = require("path")
15
15
  const os = require("os")
16
16
 
17
17
  const DEV_FALLBACK = "in-repo fork checkout (dev)"
18
+ const STOCK_RUNTIME = "stock upstream runtime"
18
19
  const SIBLING_FALLBACK = "sibling fork checkout (dev)"
19
20
 
20
21
  const PLATFORM_MAP = { darwin: "darwin", linux: "linux", win32: "windows" }
@@ -50,21 +51,51 @@ function candidates(opts) {
50
51
  out.push({ source: "ELLIOT_OPENCODE_BIN", path: env.ELLIOT_OPENCODE_BIN })
51
52
  }
52
53
 
53
- // 2. Per-platform npm optionalDependency package — the shape a published,
54
- // `npm install`-ed `@elliotsystems/eai` gets its runtime from. Resolved via
55
- // `require.resolve` so it works no matter where npm hoisted node_modules.
54
+ // 2. Stock upstream OpenCode, from upstream's OWN per-platform npm package.
55
+ //
56
+ // Resolved DIRECTLY rather than through `opencode-ai/bin/opencode.exe`,
57
+ // and that distinction is the difference between working and not. The path
58
+ // inside `opencode-ai` is a 479-byte placeholder shell script until its
59
+ // postinstall replaces it with the real binary -- and postinstall does not
60
+ // always run: `--ignore-scripts`, pnpm's defaults, and npm's newer
61
+ // script-gating all skip it. Observed on a clean install here: the platform
62
+ // package unpacked its 146MB binary fine while the stub was still in place.
63
+ // The platform package needs no script at all, so this path is the reliable
64
+ // one.
65
+ //
66
+ // Baseline variant second: upstream ships `-baseline` for x64 CPUs without
67
+ // AVX2, so it is the correct fallback rather than an error.
68
+ for (const suffix of ['', '-baseline']) {
69
+ try {
70
+ const spec = `opencode-${platform}-${arch}${suffix}/package.json`
71
+ const p = requireResolve(spec)
72
+ out.push({
73
+ source: suffix ? STOCK_RUNTIME + ' (baseline)' : STOCK_RUNTIME,
74
+ path: path.join(path.dirname(p), 'bin', binary),
75
+ })
76
+ } catch {
77
+ // Not installed for this platform/arch; try the next shape.
78
+ }
79
+ }
80
+
81
+ // Last stock shape: `opencode-ai`'s own bin, valid once its postinstall HAS
82
+ // run. Kept so an install that did run scripts still resolves.
56
83
  try {
57
- const p = requireResolve(`@elliotsystems/eai-${platform}-${arch}/package.json`)
58
- out.push({ source: "platform package (optionalDependency)", path: path.join(path.dirname(p), "bin", binary) })
84
+ const p = requireResolve('opencode-ai/package.json')
85
+ out.push({ source: STOCK_RUNTIME + ' (via opencode-ai postinstall)', path: path.join(path.dirname(p), 'bin', 'opencode.exe') })
59
86
  } catch {
60
- // Platform package not installed (e.g. running from a dev checkout).
87
+ // opencode-ai not installed.
61
88
  }
62
89
 
63
- // 3. Bundled runtime — the shape a published/installed Elliot package will
64
- // have. Nothing populates this yet; packaging is a later, approved phase.
65
- out.push({ source: "bundled vendor runtime", path: path.join(root, "vendor", dir, "bin", binary) })
90
+ // The fork's per-platform packages (@elliotsystems/eai-<os>-<arch>) and the
91
+ // `vendor/` directory used to sit here. Both shipped a fork-built OpenCode;
92
+ // the runtime now comes from upstream's own opencode-ai above, the packages
93
+ // are being retired on npm, and nothing populates `vendor/` since the script
94
+ // that did was removed. Kept as a note rather than dead branches so nobody
95
+ // re-adds them wondering why stock is the only source.
66
96
 
67
- // 4. A configured fork location. Accepts either a checkout root or a dist
97
+ // 3. A configured fork location. Accepts either a checkout root or a dist
98
+ // 5. A configured fork location. Accepts either a checkout root or a dist
68
99
  // root, because both are things a person reasonably points this at.
69
100
  if (env.ELLIOT_OPENCODE_HOME) {
70
101
  const home = env.ELLIOT_OPENCODE_HOME
@@ -75,7 +106,7 @@ function candidates(opts) {
75
106
  out.push({ source: "ELLIOT_OPENCODE_HOME (dist)", path: path.join(home, dir, "bin", binary) })
76
107
  }
77
108
 
78
- // 5. Development convenience ONLY: the fork lives in this repo at `opencode/`
109
+ // 4. Development convenience ONLY: the fork lives in this repo at `opencode/`
79
110
  // (subtree), so from the launcher package root that is one level up. Kept
80
111
  // explicitly last before PATH so a dev build can never silently win over an
81
112
  // override or a packaged runtime.
@@ -129,4 +160,5 @@ function resolveOpencodeBin(opts) {
129
160
  return null
130
161
  }
131
162
 
132
- module.exports = { DEV_FALLBACK, SIBLING_FALLBACK, candidates, isExplicitlyConfigured, platformTriple, resolveOpencodeBin }
163
+ module.exports = {
164
+ STOCK_RUNTIME, DEV_FALLBACK, SIBLING_FALLBACK, candidates, isExplicitlyConfigured, platformTriple, resolveOpencodeBin }
package/lib/setup.js ADDED
@@ -0,0 +1,231 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai setup` — configure this machine against an Elliot backend.
5
+ *
6
+ * This is a port of `elliot-ai setup`, so that `eai` is the ONLY CLI a user has
7
+ * to install. Onboarding previously handed out two global installs plus a
8
+ * second binary whose entire job was writing two files; a user who ran only the
9
+ * first got a CLI with no backend and no token, and nothing said why.
10
+ *
11
+ * It writes exactly what the old command wrote, because the launcher and the
12
+ * TUI already read those files and neither should have to care which CLI
13
+ * produced them:
14
+ *
15
+ * ~/.elliot/config.json the org config plus the token the launcher
16
+ * copies into ELLIOT_TOKEN on every run.
17
+ * Mode 0600 in a 0700 directory: it holds a
18
+ * bearer token and a refresh token.
19
+ * ~/.config/opencode/opencode.json the `elliot` provider, merged into
20
+ * whatever else is already in that file.
21
+ *
22
+ * Two behaviours are deliberately preserved rather than simplified:
23
+ *
24
+ * - The pasted browser token is EXCHANGED at `/auth/cli-token` for a
25
+ * longer-lived credential with a refresh token. Storing the pasted token
26
+ * directly would work for an hour and then start failing silently.
27
+ * - The exchange is best-effort. If the endpoint is missing or errors, setup
28
+ * continues with the pasted token and says so; a backend that predates the
29
+ * endpoint should still be configurable.
30
+ */
31
+
32
+ const fs = require("fs")
33
+ const os = require("os")
34
+ const path = require("path")
35
+
36
+ const PROVIDER_ID = "elliot"
37
+ const MODEL_ID = "elliot-governed"
38
+
39
+ /** Matches OpenCode's own resolution (xdg-basedir, whose fallback is ~/.config everywhere). */
40
+ function opencodeConfigDir(env = process.env) {
41
+ const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
42
+ return path.join(base, "opencode")
43
+ }
44
+
45
+ function elliotConfigPath() {
46
+ return path.join(os.homedir(), ".elliot", "config.json")
47
+ }
48
+
49
+ /**
50
+ * Decode a JWT payload without verifying it.
51
+ *
52
+ * Verification is the backend's job — every token here is sent straight back to
53
+ * it and rejected if bad. This only reads `tenant_id` so setup can address the
54
+ * right config, so a malformed token must return undefined rather than throw.
55
+ */
56
+ function decodeJwtPayload(token) {
57
+ try {
58
+ const part = String(token).split(".")[1]
59
+ if (!part) return undefined
60
+ const padded = part.replace(/-/g, "+").replace(/_/g, "/")
61
+ const json = Buffer.from(padded + "=".repeat((4 - (padded.length % 4)) % 4), "base64").toString("utf8")
62
+ const parsed = JSON.parse(json)
63
+ return parsed && typeof parsed === "object" ? parsed : undefined
64
+ } catch {
65
+ return undefined
66
+ }
67
+ }
68
+
69
+ /**
70
+ * The `provider.elliot` block.
71
+ *
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.
74
+ *
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.
80
+ */
81
+ function elliotProvider(backendUrl) {
82
+ const base = String(backendUrl).replace(/\/+$/, "")
83
+ 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" } },
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Merge the provider into the user's opencode.json, preserving every other key.
93
+ *
94
+ * `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
96
+ * when absent, so a user who picked a model keeps it.
97
+ */
98
+ function writeOpencodeConfig(backendUrl, env = process.env) {
99
+ const dir = opencodeConfigDir(env)
100
+ const file = path.join(dir, "opencode.json")
101
+ let existing = {}
102
+ try {
103
+ existing = JSON.parse(fs.readFileSync(file, "utf8")) || {}
104
+ } catch {
105
+ // Absent or unparseable. Overwriting unparseable JSON is the lesser evil:
106
+ // OpenCode would refuse to start on it anyway.
107
+ }
108
+ const next = {
109
+ ...existing,
110
+ $schema: existing.$schema || "https://opencode.ai/config.json",
111
+ provider: { ...(existing.provider || {}), [PROVIDER_ID]: elliotProvider(backendUrl) },
112
+ enabled_providers: [PROVIDER_ID],
113
+ model: existing.model || `${PROVIDER_ID}/${MODEL_ID}`,
114
+ }
115
+ fs.mkdirSync(dir, { recursive: true })
116
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n")
117
+ return file
118
+ }
119
+
120
+ /** Owner-only: this file holds a bearer token and a refresh token. */
121
+ function writeElliotConfig(config) {
122
+ const file = elliotConfigPath()
123
+ const dir = path.dirname(file)
124
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
125
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 })
126
+ try {
127
+ fs.chmodSync(dir, 0o700)
128
+ } catch {
129
+ // Windows and some mounts do not honour POSIX modes; not worth failing over.
130
+ }
131
+ return file
132
+ }
133
+
134
+ async function exchangeForCliToken(backendUrl, token) {
135
+ try {
136
+ const res = await fetch(`${backendUrl}/auth/cli-token`, {
137
+ method: "POST",
138
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
139
+ })
140
+ if (!res.ok) return undefined
141
+ const body = await res.json()
142
+ if (!body || typeof body.access_token !== "string") return undefined
143
+ return {
144
+ access_token: body.access_token,
145
+ refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : undefined,
146
+ expires_at: typeof body.access_token_expires_at === "string" ? body.access_token_expires_at : undefined,
147
+ }
148
+ } catch {
149
+ // Offline, or a backend that predates the endpoint.
150
+ return undefined
151
+ }
152
+ }
153
+
154
+ async function fetchOnboardingConfig(backendUrl, tenantId, token) {
155
+ const res = await fetch(`${backendUrl}/onboarding/config/${tenantId}`, {
156
+ headers: { Authorization: `Bearer ${token}` },
157
+ })
158
+ if (!res.ok) {
159
+ throw new Error(`backend returned ${res.status} fetching the organisation config`)
160
+ }
161
+ return (await res.json()) || {}
162
+ }
163
+
164
+ /**
165
+ * @param {{token: string, backendUrl: string, tenantId?: string, log?: (s: string) => void}} opts
166
+ */
167
+ async function runSetup(opts) {
168
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
169
+ const backendUrl = String(opts.backendUrl || "").replace(/\/+$/, "")
170
+ if (!opts.token) throw new Error("a --token is required; copy the command from the Elliot portal")
171
+ if (!backendUrl) throw new Error("a --backend-url is required")
172
+
173
+ const claims = decodeJwtPayload(opts.token) || {}
174
+ const tenantId = opts.tenantId || claims.tenant_id
175
+ if (!tenantId) {
176
+ throw new Error("no tenant_id in the token; pass --tenant-id, or copy a fresh command from the portal")
177
+ }
178
+
179
+ const exchanged = await exchangeForCliToken(backendUrl, opts.token)
180
+ if (!exchanged) {
181
+ log("Note: could not exchange for a CLI token — continuing with the pasted token (silent refresh unavailable).")
182
+ }
183
+ const bearer = exchanged ? exchanged.access_token : opts.token
184
+
185
+ log("Fetching configuration from backend...")
186
+ const orgConfig = await fetchOnboardingConfig(backendUrl, tenantId, bearer)
187
+
188
+ const elliotConfig = {
189
+ ...orgConfig,
190
+ backend_url: orgConfig.backend_url || backendUrl,
191
+ tenant_id: tenantId,
192
+ jwt_token: bearer,
193
+ ...(exchanged && exchanged.refresh_token ? { refresh_token: exchanged.refresh_token } : {}),
194
+ ...(exchanged && exchanged.expires_at ? { access_token_expires_at: exchanged.expires_at } : {}),
195
+ configured_at: new Date().toISOString(),
196
+ }
197
+
198
+ const elliotFile = writeElliotConfig(elliotConfig)
199
+ const opencodeFile = writeOpencodeConfig(elliotConfig.backend_url)
200
+
201
+ log(`Wrote ${elliotFile}`)
202
+ log(`Wrote ${opencodeFile}`)
203
+ log("")
204
+ log("Setup complete. Run `eai` from any directory.")
205
+ return { elliotFile, opencodeFile, tenantId, exchanged: Boolean(exchanged) }
206
+ }
207
+
208
+ /** Minimal `--flag value` parsing; the launcher has no dependencies by design. */
209
+ function parseArgs(argv) {
210
+ const out = {}
211
+ for (let i = 0; i < argv.length; i++) {
212
+ const arg = argv[i]
213
+ if (arg === "--token") out.token = argv[++i]
214
+ else if (arg === "--backend-url") out.backendUrl = argv[++i]
215
+ else if (arg === "--tenant-id") out.tenantId = argv[++i]
216
+ }
217
+ return out
218
+ }
219
+
220
+ module.exports = {
221
+ runSetup,
222
+ parseArgs,
223
+ decodeJwtPayload,
224
+ elliotProvider,
225
+ writeOpencodeConfig,
226
+ writeElliotConfig,
227
+ opencodeConfigDir,
228
+ elliotConfigPath,
229
+ PROVIDER_ID,
230
+ MODEL_ID,
231
+ }
package/lib/stage.js ADDED
@@ -0,0 +1,116 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * Stage the Elliot overlay outside `node_modules` before launching.
5
+ *
6
+ * This exists because of a hard runtime constraint, found by installing the
7
+ * tarball and watching it fail: a plugin that lives under `node_modules` does
8
+ * not load. The same files, byte for byte, load fine one directory outside it.
9
+ * The runtime does not apply the plugin's own `tsconfig.json` — and therefore
10
+ * its `jsxImportSource` — to anything under `node_modules`, so the plugin's JSX
11
+ * never compiles. Nothing is logged: the TUI simply comes up as plain OpenCode,
12
+ * which is the failure mode that looks like success.
13
+ *
14
+ * So the tarball ships the overlay (it must be self-contained, since
15
+ * `@elliotsystems/opencode-plugin` is not published), and this copies it to a
16
+ * writable location outside `node_modules` on first run. That location doubles
17
+ * as `OPENCODE_CONFIG_DIR`, which also solves a second problem: the plugin's
18
+ * `api.theme.install()` writes into the config dir, and a global npm install
19
+ * directory is not reliably writable.
20
+ *
21
+ * The generated `tui.json` names the plugin by ABSOLUTE path. A relative one
22
+ * would resolve against the staged config file and work, but an absolute path
23
+ * is what makes a broken stage obvious instead of silently falling back to
24
+ * upstream's own chrome.
25
+ */
26
+
27
+ const fs = require("fs")
28
+ const os = require("os")
29
+ const path = require("path")
30
+
31
+ /**
32
+ * Where the staged overlay lives.
33
+ *
34
+ * Keyed by version so an upgrade stages fresh rather than reusing a stale tree,
35
+ * and so two installed versions cannot fight over one directory.
36
+ */
37
+ function stageRoot(version, env = process.env) {
38
+ const base =
39
+ env.ELLIOT_STAGE_DIR ||
40
+ (process.platform === "win32" && env.LOCALAPPDATA
41
+ ? path.join(env.LOCALAPPDATA, "elliot", "eai")
42
+ : path.join(os.homedir(), ".elliot", "eai"))
43
+ return path.join(base, version)
44
+ }
45
+
46
+ function copyDir(from, to) {
47
+ fs.mkdirSync(to, { recursive: true })
48
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
49
+ // node_modules is deliberately not copied: the host supplies solid-js and
50
+ // @opentui/* to runtime-loaded plugins, and a second copy of solid-js in the
51
+ // TUI process would break reactivity across the slot boundary.
52
+ if (entry.name === "node_modules") continue
53
+ const src = path.join(from, entry.name)
54
+ const dst = path.join(to, entry.name)
55
+ if (entry.isDirectory()) copyDir(src, dst)
56
+ else fs.copyFileSync(src, dst)
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Ensure the overlay is staged, and return the directory to use as
62
+ * OPENCODE_CONFIG_DIR. Returns undefined if staging is not possible, so the
63
+ * caller can still launch — an unbranded TUI beats no TUI.
64
+ */
65
+ function stageOverlay(opts) {
66
+ const packageRoot = opts.packageRoot
67
+ const version = opts.version
68
+ const env = opts.env || process.env
69
+
70
+ const pluginSource = path.join(packageRoot, "plugin", "opencode-plugin")
71
+ const themeSource = path.join(packageRoot, "config", "themes")
72
+ if (!fs.existsSync(pluginSource)) return undefined
73
+
74
+ const root = stageRoot(version, env)
75
+ const marker = path.join(root, ".staged")
76
+ const pluginDir = path.join(root, "plugin", "opencode-plugin")
77
+
78
+ try {
79
+ // `.staged` records the version this tree was built from. Re-staging on
80
+ // every launch would add hundreds of file copies to startup.
81
+ const done = fs.existsSync(marker) && fs.readFileSync(marker, "utf8").trim() === version
82
+ if (!done) {
83
+ fs.rmSync(root, { recursive: true, force: true })
84
+ copyDir(pluginSource, pluginDir)
85
+ if (fs.existsSync(themeSource)) copyDir(themeSource, path.join(root, "themes"))
86
+
87
+ const plugin = [pluginDir]
88
+ fs.writeFileSync(
89
+ path.join(root, "tui.json"),
90
+ JSON.stringify(
91
+ {
92
+ $schema: "https://opencode.ai/tui.json",
93
+ theme: "elliot",
94
+ mouse: true,
95
+ plugin,
96
+ plugin_enabled: { "internal:home-tips": false },
97
+ },
98
+ null,
99
+ 2,
100
+ ),
101
+ )
102
+ fs.writeFileSync(
103
+ path.join(root, "opencode.json"),
104
+ JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin }, null, 2),
105
+ )
106
+ fs.writeFileSync(marker, version)
107
+ }
108
+ return root
109
+ } catch {
110
+ // Read-only home, exotic permissions, a racing install. Branding is worth
111
+ // less than starting up, so this is not fatal.
112
+ return undefined
113
+ }
114
+ }
115
+
116
+ module.exports = { stageOverlay, stageRoot }