@elliotsystems/eai 0.1.2 → 0.1.3
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 +51 -5
- package/bin/eai +32 -3
- package/config/opencode.json +4 -0
- package/config/themes/elliot.json +105 -0
- package/config/tui.json +9 -0
- package/lib/resolve.js +42 -4
- package/lib/stage.js +116 -0
- package/package.json +9 -8
- 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/index.ts +199 -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,16 @@ 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 | **stock upstream runtime** | `opencode-ai/bin/opencode.exe`, resolved via `require.resolve` — **this is production** |
|
|
35
|
+
| 3 | Elliot platform package | `@elliotsystems/eai-<os>-<arch>/bin/…` — fork builds, legacy, no longer a dependency |
|
|
36
|
+
| 4 | bundled runtime | `vendor/opencode-<platform>-<arch>/bin/…` |
|
|
37
|
+
| 5 | `ELLIOT_OPENCODE_HOME` | `<home>/packages/opencode/dist/…` or `<home>/opencode-<platform>-<arch>/…` |
|
|
38
|
+
| 6 | in-repo fork checkout | `../opencode/…` — **development only** |
|
|
39
|
+
| 7 | sibling fork checkout | `../../opencode/…` — **development only**, last resort |
|
|
40
|
+
|
|
41
|
+
Stock sits ahead of the Elliot platform packages deliberately. Those are fork
|
|
42
|
+
builds, and preferring them would quietly reintroduce the patched OpenCode this
|
|
43
|
+
architecture exists to remove.
|
|
37
44
|
|
|
38
45
|
Two deliberate rules:
|
|
39
46
|
|
|
@@ -58,8 +65,7 @@ stays clean for piping).
|
|
|
58
65
|
(`@elliotsystems/elliot-ai`, currently on PATH). Both can coexist, so the legacy
|
|
59
66
|
CLI keeps working untouched and its retirement stays a separate, later decision.
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
are out of scope until approved. Run it locally with `node bin/eai`.
|
|
68
|
+
Run it locally, without installing, with `node bin/eai`.
|
|
63
69
|
|
|
64
70
|
## Install
|
|
65
71
|
|
|
@@ -99,3 +105,43 @@ To publish a new release to npm:
|
|
|
99
105
|
```
|
|
100
106
|
npm test # node --test, no dependencies
|
|
101
107
|
```
|
|
108
|
+
|
|
109
|
+
## Architecture
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
stock upstream OpenCode (opencode-ai, an npm dependency)
|
|
113
|
+
|
|
|
114
|
+
Elliot launcher (this package)
|
|
115
|
+
|-- config/ OPENCODE_CONFIG_DIR: theme + plugin registration
|
|
116
|
+
|-- config/themes/ the Elliot palette
|
|
117
|
+
|-- plugin/ the bundled Elliot plugin (wordmark, chrome)
|
|
118
|
+
+-- bin/eai OSC 0 terminal title
|
|
119
|
+
|
|
|
120
|
+
eai -> Elliot-branded TUI
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**OpenCode core modifications: 0.** Elliot ships no compiled OpenCode. An
|
|
124
|
+
upstream upgrade is a version bump of `opencode-ai` in `package.json`.
|
|
125
|
+
|
|
126
|
+
Three mechanisms carry the branding, none of which touch OpenCode source:
|
|
127
|
+
|
|
128
|
+
| Surface | Mechanism |
|
|
129
|
+
|---|---|
|
|
130
|
+
| Wordmark, palette, accent, chrome | the bundled plugin, via `home_logo` (`mode="replace"`) and theme discovery |
|
|
131
|
+
| Theme selection + plugin registration | `OPENCODE_CONFIG_DIR` -> this package's `config/` |
|
|
132
|
+
| Terminal title | `OPENCODE_DISABLE_TERMINAL_TITLE=1`, then the launcher writes OSC 0 |
|
|
133
|
+
|
|
134
|
+
### Why the plugin is bundled rather than depended on
|
|
135
|
+
|
|
136
|
+
`@elliotsystems/opencode-plugin` is not published to npm. A config that named it
|
|
137
|
+
by package name would install cleanly and then render as plain OpenCode, which
|
|
138
|
+
is the worst failure mode available -- it looks like it worked. `scripts/bundle.js`
|
|
139
|
+
copies it in from `opencode-plugin/` (the single source of truth) and runs from
|
|
140
|
+
`prepack`, so `npm pack` and `npm publish` cannot forget it.
|
|
141
|
+
|
|
142
|
+
### Why the theme file is shipped as well
|
|
143
|
+
|
|
144
|
+
The plugin can install the palette itself via `api.theme.install()`, but that
|
|
145
|
+
WRITES into the config directory, and a global npm install directory is not
|
|
146
|
+
reliably writable. Shipping `config/themes/elliot.json` means the theme resolves
|
|
147
|
+
read-only.
|
package/bin/eai
CHANGED
|
@@ -25,6 +25,7 @@ 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
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Resolve symlinks before deriving the package root.
|
|
@@ -50,6 +51,11 @@ function packageRoot() {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
const PACKAGE_ROOT = packageRoot()
|
|
54
|
+
const PACKAGE_VERSION = require("../package.json").version
|
|
55
|
+
// OSC 0 (set icon name and window title), BEL-terminated: every terminal that
|
|
56
|
+
// supports OSC accepts BEL, while some older ones reject ST.
|
|
57
|
+
const ELLIOT_TITLE = String.fromCharCode(27) + "]0;Elliot-AI" + String.fromCharCode(7)
|
|
58
|
+
|
|
53
59
|
const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"]
|
|
54
60
|
|
|
55
61
|
function fail(message) {
|
|
@@ -87,10 +93,33 @@ if (process.env.ELLIOT_LAUNCHER_DEBUG) {
|
|
|
87
93
|
// the child inherits process.env, so setting it here is enough.
|
|
88
94
|
injectToken(process.env)
|
|
89
95
|
|
|
96
|
+
// Elliot's identity travels WITH this package, not with the directory the user
|
|
97
|
+
// happens to be standing in. Before this, branding came from a `tui.json` in the
|
|
98
|
+
// Elliot repository root, so an installed `eai` outside that repo rendered as
|
|
99
|
+
// plain OpenCode. `config/` is packaged (see package.json `files`) and holds the
|
|
100
|
+
// theme, the palette and the plugin registration.
|
|
101
|
+
const env = { ...process.env }
|
|
102
|
+
if (!env.OPENCODE_CONFIG_DIR) {
|
|
103
|
+
// Staged OUTSIDE node_modules, which is not optional: a plugin under
|
|
104
|
+
// node_modules does not load (its tsconfig, and so its jsxImportSource, is
|
|
105
|
+
// not applied there), and the TUI comes up as plain OpenCode with nothing
|
|
106
|
+
// logged. See lib/stage.js.
|
|
107
|
+
const staged = stageOverlay({ packageRoot: PACKAGE_ROOT, version: PACKAGE_VERSION })
|
|
108
|
+
if (staged) env.OPENCODE_CONFIG_DIR = staged
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The one branding surface no plugin slot can reach. Upstream sets the terminal
|
|
112
|
+
// title itself from a `createEffect` with no slot and no config key, but it does
|
|
113
|
+
// honour this flag (`core/src/flag/flag.ts`), so we turn its title off and write
|
|
114
|
+
// our own below. This is what lets OpenCode stay stock.
|
|
115
|
+
env.OPENCODE_DISABLE_TERMINAL_TITLE = "1"
|
|
116
|
+
|
|
117
|
+
// Claim the title before the child paints anything.
|
|
118
|
+
if (process.stdout.isTTY) process.stdout.write(ELLIOT_TITLE)
|
|
119
|
+
|
|
90
120
|
// 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" })
|
|
121
|
+
// shell metacharacters in a prompt reach the runtime as the user typed them.
|
|
122
|
+
const child = childProcess.spawn(resolved.path, process.argv.slice(2), { stdio: "inherit", env })
|
|
94
123
|
|
|
95
124
|
child.on("error", (error) => fail(error.message))
|
|
96
125
|
|
|
@@ -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,7 +51,43 @@ function candidates(opts) {
|
|
|
50
51
|
out.push({ source: "ELLIOT_OPENCODE_BIN", path: env.ELLIOT_OPENCODE_BIN })
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
// 2.
|
|
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.
|
|
83
|
+
try {
|
|
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') })
|
|
86
|
+
} catch {
|
|
87
|
+
// opencode-ai not installed.
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 3. Per-platform npm optionalDependency package — the shape a published,
|
|
54
91
|
// `npm install`-ed `@elliotsystems/eai` gets its runtime from. Resolved via
|
|
55
92
|
// `require.resolve` so it works no matter where npm hoisted node_modules.
|
|
56
93
|
try {
|
|
@@ -60,11 +97,11 @@ function candidates(opts) {
|
|
|
60
97
|
// Platform package not installed (e.g. running from a dev checkout).
|
|
61
98
|
}
|
|
62
99
|
|
|
63
|
-
//
|
|
100
|
+
// 4. Bundled runtime — the shape a published/installed Elliot package will
|
|
64
101
|
// have. Nothing populates this yet; packaging is a later, approved phase.
|
|
65
102
|
out.push({ source: "bundled vendor runtime", path: path.join(root, "vendor", dir, "bin", binary) })
|
|
66
103
|
|
|
67
|
-
//
|
|
104
|
+
// 5. A configured fork location. Accepts either a checkout root or a dist
|
|
68
105
|
// root, because both are things a person reasonably points this at.
|
|
69
106
|
if (env.ELLIOT_OPENCODE_HOME) {
|
|
70
107
|
const home = env.ELLIOT_OPENCODE_HOME
|
|
@@ -129,4 +166,5 @@ function resolveOpencodeBin(opts) {
|
|
|
129
166
|
return null
|
|
130
167
|
}
|
|
131
168
|
|
|
132
|
-
module.exports = {
|
|
169
|
+
module.exports = {
|
|
170
|
+
STOCK_RUNTIME, DEV_FALLBACK, SIBLING_FALLBACK, candidates, isExplicitlyConfigured, platformTriple, resolveOpencodeBin }
|
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 }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elliotsystems/eai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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"
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"bin",
|
|
10
10
|
"lib",
|
|
11
|
+
"config",
|
|
12
|
+
"plugin",
|
|
11
13
|
"README.md"
|
|
12
14
|
],
|
|
13
15
|
"scripts": {
|
|
@@ -15,7 +17,10 @@
|
|
|
15
17
|
"vendor": "node scripts/vendor.js",
|
|
16
18
|
"assemble": "node scripts/assemble.js",
|
|
17
19
|
"publish:npm": "node scripts/publish.js",
|
|
18
|
-
"build:runtime": "bun run --cwd ../opencode/packages/opencode script/build.ts --skip-embed-web-ui"
|
|
20
|
+
"build:runtime": "bun run --cwd ../opencode/packages/opencode script/build.ts --skip-embed-web-ui",
|
|
21
|
+
"bundle": "node scripts/bundle.js",
|
|
22
|
+
"prepack": "node scripts/bundle.js",
|
|
23
|
+
"verify": "node scripts/verify-package.js"
|
|
19
24
|
},
|
|
20
25
|
"engines": {
|
|
21
26
|
"node": ">=18.0.0"
|
|
@@ -23,11 +28,7 @@
|
|
|
23
28
|
"publishConfig": {
|
|
24
29
|
"access": "public"
|
|
25
30
|
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"@elliotsystems/eai-darwin-x64": "0.1.2",
|
|
29
|
-
"@elliotsystems/eai-linux-x64": "0.1.2",
|
|
30
|
-
"@elliotsystems/eai-linux-arm64": "0.1.2",
|
|
31
|
-
"@elliotsystems/eai-windows-x64": "0.1.2"
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"opencode-ai": "1.18.30"
|
|
32
33
|
}
|
|
33
34
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @elliotsystems/opencode-plugin
|
|
2
|
+
|
|
3
|
+
Elliot AI's OpenCode integration. Phase C2 scope: repository-scope injection for
|
|
4
|
+
`elliot_knowledge_search`.
|
|
5
|
+
|
|
6
|
+
## What it does — and pointedly does not
|
|
7
|
+
|
|
8
|
+
The Elliot backend serves `knowledge_search` as an MCP tool on `/mcp/v1`; through the MCP
|
|
9
|
+
server name `elliot`, the agent sees **`elliot_knowledge_search`**. This plugin's single
|
|
10
|
+
job is to inject the session's repository (`owner/name`, derived from the worktree's git
|
|
11
|
+
remote) into that tool's arguments, so retrieval can be repo-focused **while the model
|
|
12
|
+
never names a repository**. The backend's `resolve_repo_scope` remains the only
|
|
13
|
+
authorizer — a wrong or forged hint can select nothing the tenant wasn't already granted.
|
|
14
|
+
|
|
15
|
+
No tokens (login/refresh/storage is OpenCode's native MCP OAuth — `opencode mcp auth
|
|
16
|
+
elliot`), no transport, no prompt injection.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
`opencode.json`:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"$schema": "https://opencode.ai/config.json",
|
|
25
|
+
"mcp": {
|
|
26
|
+
"elliot": {
|
|
27
|
+
"type": "remote",
|
|
28
|
+
"url": "https://backend.elliotsystemsonline.com/mcp/v1",
|
|
29
|
+
"oauth": { "clientId": "elliot-opencode" }
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"plugin": [["@elliotsystems/opencode-plugin@0.1.0", { "serverNames": ["elliot"] }]]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then `opencode mcp auth elliot` once. Pin the plugin version — never `@latest`.
|
|
37
|
+
|
|
38
|
+
`serverNames` must match your `mcp` key(s); the plugin recognises the composed tool key
|
|
39
|
+
`<server>_knowledge_search` exactly (OpenCode composes MCP tool keys as
|
|
40
|
+
`sanitize(client) + "_" + sanitize(tool)`).
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install
|
|
46
|
+
npm test # vitest — includes the security cases (scope overwrite/delete)
|
|
47
|
+
npm run typecheck
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Load the working copy into OpenCode with a path spec:
|
|
51
|
+
`"plugin": [["file:///abs/path/to/opencode-plugin/src/index.ts", {}]]`.
|
|
52
|
+
|
|
53
|
+
Windows note: if `bun`/OpenCode installs hit `EPERM (NtSetInformationFile())` on native
|
|
54
|
+
packages, that's AV interference during cache renames — use
|
|
55
|
+
`bun install --backend=copyfile` and/or add an AV exclusion (see Phase B findings).
|
|
56
|
+
|
|
57
|
+
## Security invariants (tested)
|
|
58
|
+
|
|
59
|
+
- Session truth **overwrites** any pre-existing `repo_scope_hint` in tool args.
|
|
60
|
+
- With no derivable scope, a smuggled hint is **deleted**; search degrades to
|
|
61
|
+
tenant-wide (authorized, just unfocused) — never an error, never a forged scope.
|
|
62
|
+
- Only the exact composed key on configured servers is touched; every other tool's
|
|
63
|
+
args pass through byte-identical.
|
|
64
|
+
- Options are zod-`strict()`: unknown keys fail the plugin at load, loudly.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elliotsystems/opencode-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Elliot AI integration for OpenCode: tenant-aware knowledge tools, repository scope, and policy.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./tui": "./src/tui/index.tsx"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"themes",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@opentui/core": ">=0.4.5 <0.5",
|
|
19
|
+
"@opentui/solid": ">=0.4.5 <0.5",
|
|
20
|
+
"solid-js": ">=1.9.10 <2",
|
|
21
|
+
"zod": ">=3.23"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"elliot": {
|
|
27
|
+
"opencodeRange": ">=1.18.19 <1.19.0"
|
|
28
|
+
},
|
|
29
|
+
"oc-themes": [
|
|
30
|
+
"themes/elliot.json"
|
|
31
|
+
]
|
|
32
|
+
}
|