@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 +10 -5
- package/bin/eai +53 -3
- package/config/opencode.json +4 -0
- package/config/themes/elliot.json +105 -0
- package/config/tui.json +9 -0
- package/lib/resolve.js +44 -12
- package/lib/setup.js +231 -0
- package/lib/stage.js +116 -0
- package/package.json +8 -10
- package/plugin/opencode-plugin/README.md +64 -0
- package/plugin/opencode-plugin/package.json +32 -0
- package/plugin/opencode-plugin/src/classifier.ts +238 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +133 -0
- package/plugin/opencode-plugin/src/identity.ts +69 -0
- package/plugin/opencode-plugin/src/index.ts +211 -0
- package/plugin/opencode-plugin/src/mcp-keys.ts +30 -0
- package/plugin/opencode-plugin/src/policy.ts +122 -0
- package/plugin/opencode-plugin/src/proxy-provider.ts +96 -0
- package/plugin/opencode-plugin/src/scope.ts +78 -0
- package/plugin/opencode-plugin/src/tui/api.ts +117 -0
- package/plugin/opencode-plugin/src/tui/format.ts +73 -0
- package/plugin/opencode-plugin/src/tui/home.tsx +88 -0
- package/plugin/opencode-plugin/src/tui/host.d.ts +95 -0
- package/plugin/opencode-plugin/src/tui/index.tsx +99 -0
- package/plugin/opencode-plugin/src/tui/knowledge.ts +243 -0
- package/plugin/opencode-plugin/src/tui/prompt.tsx +62 -0
- package/plugin/opencode-plugin/src/tui/sidebar.tsx +184 -0
- package/plugin/opencode-plugin/src/tui/wordmark.tsx +109 -0
- package/plugin/opencode-plugin/themes/elliot.json +105 -0
- package/plugin/opencode-plugin/tsconfig.json +19 -0
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 |
|
|
35
|
-
| 3 |
|
|
36
|
-
| 4 |
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
92
|
-
|
|
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,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
|
+
}
|
package/config/tui.json
ADDED
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.
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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(
|
|
58
|
-
out.push({ source:
|
|
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
|
-
//
|
|
87
|
+
// opencode-ai not installed.
|
|
61
88
|
}
|
|
62
89
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 = {
|
|
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 }
|