@elliotsystems/eai 0.1.1 → 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 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 | 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 | **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
- 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`.
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 and
92
- // are never re-interpreted by an intermediate shell.
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,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,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. Per-platform npm optionalDependency package — the shape a published,
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
- // 3. Bundled runtime — the shape a published/installed Elliot package will
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
- // 4. A configured fork location. Accepts either a checkout root or a dist
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 = { DEV_FALLBACK, SIBLING_FALLBACK, candidates, isExplicitlyConfigured, platformTriple, resolveOpencodeBin }
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.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
- "optionalDependencies": {
27
- "@elliotsystems/eai-darwin-arm64": "0.1.0",
28
- "@elliotsystems/eai-darwin-x64": "0.1.0",
29
- "@elliotsystems/eai-linux-x64": "0.1.0",
30
- "@elliotsystems/eai-linux-arm64": "0.1.0",
31
- "@elliotsystems/eai-windows-x64": "0.1.0"
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
+ }