@elliotsystems/eai 0.1.0

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.
Files changed (4) hide show
  1. package/README.md +101 -0
  2. package/bin/eai +113 -0
  3. package/lib/resolve.js +132 -0
  4. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # `eai` — Elliot AI CLI
2
+
3
+ The Elliot AI CLI entry point. It resolves the OpenCode runtime binary and hands
4
+ over to it, forwarding every argument verbatim.
5
+
6
+ ```
7
+ eai -> <runtime> (TUI)
8
+ eai --version -> <runtime> --version
9
+ eai --help -> <runtime> --help
10
+ eai models -> <runtime> models
11
+ eai mcp list -> <runtime> mcp list
12
+ eai run "..." -> <runtime> run "..."
13
+ ```
14
+
15
+ ## Why it parses nothing
16
+
17
+ Any flag this launcher understands is a flag it can disagree with the runtime
18
+ about. By forwarding `process.argv.slice(2)` untouched, every current and future
19
+ runtime command works on day one and cannot drift. There is no argument schema
20
+ here on purpose.
21
+
22
+ Arguments are passed **without a shell**, so quoting, spaces and shell
23
+ metacharacters in a prompt arrive exactly as typed. Exit codes are propagated,
24
+ signal deaths are re-raised rather than flattened, and stdio is inherited (which
25
+ is what makes the TUI work).
26
+
27
+ ## How the runtime is located
28
+
29
+ In precedence order (`lib/resolve.js`):
30
+
31
+ | # | Source | Path |
32
+ |---|---|---|
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 |
37
+
38
+ Two deliberate rules:
39
+
40
+ - **Explicit configuration is strict.** If `ELLIOT_OPENCODE_BIN` or
41
+ `ELLIOT_OPENCODE_HOME` is set, development fallbacks are skipped entirely.
42
+ Silently running a different build than the operator configured is worse than
43
+ not running: the results look legitimate and nobody suspects the path.
44
+ - **No PATH lookup.** Falling back to whatever `opencode` happens to be on PATH
45
+ would let an unrelated or upstream build answer as the Elliot runtime, with no
46
+ version guarantee.
47
+
48
+ Platform/arch directory names mirror upstream's own dist naming, so bundling a
49
+ runtime later is a file copy, not a translation.
50
+
51
+ Set `ELLIOT_LAUNCHER_DEBUG=1` to print the resolved path to **stderr** (stdout
52
+ stays clean for piping).
53
+
54
+ ## Command name
55
+
56
+ `eai` is a new, unused command name. It deliberately does **not** collide with
57
+ `elliot-ai`, which is the bin name of the shipped legacy CLI
58
+ (`@elliotsystems/elliot-ai`, currently on PATH). Both can coexist, so the legacy
59
+ CLI keeps working untouched and its retirement stays a separate, later decision.
60
+
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
+
64
+ ## Install
65
+
66
+ ```
67
+ npm i -g @elliotsystems/eai
68
+ ```
69
+
70
+ The platform-specific OpenCode binary is delivered automatically via the package's `optionalDependencies` (`@elliotsystems/eai-<os>-<arch>`). There is no build or toolchain step required on the user's machine.
71
+
72
+ ## Supported platforms (v1)
73
+
74
+ - `darwin-arm64` (macOS, Apple Silicon)
75
+ - `darwin-x64` (macOS, Intel)
76
+ - `linux-x64` (Linux, x86-64)
77
+ - `linux-arm64` (Linux, ARM64)
78
+ - `windows-x64` (Windows, x86-64)
79
+
80
+ ## Release process (maintainers)
81
+
82
+ To publish a new release to npm:
83
+
84
+ 1. Ensure the GitHub Actions secret `NPM_TOKEN` is configured (an npm automation/granular token with read+write permissions to the `@elliotsystems` scope) and you are a member of the `@elliotsystems` npm organization.
85
+
86
+ 2. Run the **`publish-eai`** GitHub Actions workflow via `workflow_dispatch`:
87
+ - Set `dryRun: true` **first** to verify the build and package assembly on CI.
88
+ - After verifying the dry-run succeeds, trigger again with `dryRun: false` to publish to npm.
89
+ - Alternatively, push a tag matching `eai-v*` (e.g., `eai-v1.0.0`) to publish directly; the tag triggers a live publish (dry-run is skipped).
90
+
91
+ 3. The workflow cross-compiles all platforms on a single Ubuntu runner (using Bun), assembles per-platform npm packages (`@elliotsystems/eai-<os>-<arch>`), and publishes them along with the meta package. The publish is idempotent—versions already on npm are skipped.
92
+
93
+ 4. Bump `elliot-launcher/package.json` `version` for a new release; all per-platform packages inherit that version.
94
+
95
+ **Note:** The terminal project-screen `eai` install command currently uses the local vendor + `npm i -g <checkout>` flow. Repointing it to `npm i -g @elliotsystems/eai` is a follow-up task once the first publish succeeds.
96
+
97
+ ## Tests
98
+
99
+ ```
100
+ npm test # node --test, no dependencies
101
+ ```
package/bin/eai ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `eai` — Elliot AI's CLI entry point.
4
+ *
5
+ * This is a LAUNCHER, not a CLI. It deliberately parses nothing: every argument
6
+ * is forwarded verbatim to the OpenCode runtime, so `--version`, `--help`,
7
+ * `models`, `run "..."` and the bare TUI all behave exactly as that runtime
8
+ * defines them, and stay correct as it gains commands. The moment this file
9
+ * starts interpreting flags, it starts drifting from them.
10
+ *
11
+ * The spawn/signal/exit-code handling mirrors upstream's own `bin/opencode`
12
+ * (v1.18.19) — that shape is already proven against this binary, so copying it
13
+ * is cheaper and safer than rediscovering it.
14
+ */
15
+ "use strict"
16
+
17
+ const childProcess = require("child_process")
18
+ const fs = require("fs")
19
+ const path = require("path")
20
+
21
+ const {
22
+ DEV_FALLBACK,
23
+ candidates,
24
+ isExplicitlyConfigured,
25
+ resolveOpencodeBin,
26
+ } = require("../lib/resolve.js")
27
+
28
+ /**
29
+ * Resolve symlinks before deriving the package root.
30
+ *
31
+ * `npm link` (and `npm i -g` from a path on some setups) puts a symlink — a
32
+ * junction on Windows — in the global node_modules. Without realpath, __dirname
33
+ * stays inside that junction, so every path derived from it points at the global
34
+ * install tree instead of the checkout: the bundled-runtime candidate is empty
35
+ * there and the dev-checkout candidate resolves to a directory that does not
36
+ * exist, and `eai` dies with "could not locate the OpenCode runtime" the moment
37
+ * it is run from outside the repo.
38
+ *
39
+ * Upstream's own `bin/opencode` does exactly this (`fs.realpathSync(__filename)`)
40
+ * for the same reason. Falls back to the unresolved path if realpath fails, so a
41
+ * broken link degrades to the old behaviour rather than crashing here.
42
+ */
43
+ function packageRoot() {
44
+ try {
45
+ return path.resolve(path.dirname(fs.realpathSync(__filename)), "..")
46
+ } catch {
47
+ return path.resolve(__dirname, "..")
48
+ }
49
+ }
50
+
51
+ const PACKAGE_ROOT = packageRoot()
52
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"]
53
+
54
+ function fail(message) {
55
+ process.stderr.write(`eai: ${message}\n`)
56
+ process.exit(127) // conventional "command not found"
57
+ }
58
+
59
+ const resolved = resolveOpencodeBin({ packageRoot: PACKAGE_ROOT })
60
+
61
+ if (!resolved) {
62
+ // Report exactly what was consulted. Under explicit configuration the dev
63
+ // fallback is skipped, so listing it would send a person debugging this down
64
+ // a path the launcher never actually tried.
65
+ const strict = isExplicitlyConfigured(process.env)
66
+ const tried = candidates({ packageRoot: PACKAGE_ROOT })
67
+ .filter((c) => !(strict && c.source === DEV_FALLBACK))
68
+ .map((c) => ` - ${c.source}: ${c.path}`)
69
+ .join("\n")
70
+ fail(
71
+ "could not locate the OpenCode runtime binary.\n" +
72
+ `Tried, in order:\n${tried}\n` +
73
+ (strict ? "Explicit configuration is set; development fallbacks were skipped.\n" : "") +
74
+ "Set ELLIOT_OPENCODE_BIN to the binary, or ELLIOT_OPENCODE_HOME to the fork checkout.",
75
+ )
76
+ }
77
+
78
+ if (process.env.ELLIOT_LAUNCHER_DEBUG) {
79
+ // Opt-in only: the resolved path helps when a launch goes wrong, but it is
80
+ // noise on every normal run, and stdout must stay clean for piped output.
81
+ process.stderr.write(`eai: launching ${resolved.path} (via ${resolved.source})\n`)
82
+ }
83
+
84
+ // argv passes through untouched and WITHOUT a shell, so quoting, spaces and
85
+ // shell metacharacters in a prompt reach the runtime as the user typed them and
86
+ // are never re-interpreted by an intermediate shell.
87
+ const child = childProcess.spawn(resolved.path, process.argv.slice(2), { stdio: "inherit" })
88
+
89
+ child.on("error", (error) => fail(error.message))
90
+
91
+ const forwarders = {}
92
+ for (const signal of FORWARDED_SIGNALS) {
93
+ forwarders[signal] = () => {
94
+ try {
95
+ child.kill(signal)
96
+ } catch {
97
+ // Already exited; nothing to signal.
98
+ }
99
+ }
100
+ process.on(signal, forwarders[signal])
101
+ }
102
+
103
+ child.on("exit", (code, signal) => {
104
+ for (const s of FORWARDED_SIGNALS) process.removeListener(s, forwarders[s])
105
+
106
+ // Re-raise rather than translating to an exit code, so callers can still tell
107
+ // "killed by signal" from "exited non-zero" — scripts and CI depend on that.
108
+ if (signal) {
109
+ process.kill(process.pid, signal)
110
+ return
111
+ }
112
+ process.exit(typeof code === "number" ? code : 0)
113
+ })
package/lib/resolve.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Locate the OpenCode runtime binary that `eai` should launch.
3
+ *
4
+ * Kept as a pure, dependency-free module so the resolution ORDER — the only real
5
+ * logic in this launcher — is testable without spawning anything.
6
+ *
7
+ * Platform/arch names deliberately mirror upstream's own dist naming
8
+ * (`opencode-<platform>-<arch>`, see packages/opencode/bin/opencode @ v1.18.19).
9
+ * That is what makes future bundling a file COPY rather than a translation.
10
+ */
11
+ "use strict"
12
+
13
+ const fs = require("fs")
14
+ const path = require("path")
15
+ const os = require("os")
16
+
17
+ const DEV_FALLBACK = "in-repo fork checkout (dev)"
18
+ const SIBLING_FALLBACK = "sibling fork checkout (dev)"
19
+
20
+ const PLATFORM_MAP = { darwin: "darwin", linux: "linux", win32: "windows" }
21
+ const ARCH_MAP = { x64: "x64", arm64: "arm64", arm: "arm" }
22
+
23
+ function platformTriple(platform = os.platform(), arch = os.arch()) {
24
+ const p = PLATFORM_MAP[platform] || platform
25
+ const a = ARCH_MAP[arch] || arch
26
+ return {
27
+ dir: `opencode-${p}-${a}`,
28
+ binary: p === "windows" ? "opencode.exe" : "opencode",
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Candidate paths in precedence order, each with a human-readable source so a
34
+ * failure can explain what was tried instead of just "not found".
35
+ *
36
+ * @param {{packageRoot: string, env?: NodeJS.ProcessEnv, platform?: string, arch?: string}} opts
37
+ */
38
+ function candidates(opts) {
39
+ const env = opts.env || process.env
40
+ const { dir, binary } = platformTriple(opts.platform, opts.arch)
41
+ const platform = PLATFORM_MAP[opts.platform || os.platform()] || opts.platform || os.platform()
42
+ const arch = ARCH_MAP[opts.arch || os.arch()] || opts.arch || os.arch()
43
+ const requireResolve = opts.requireResolve || require.resolve
44
+ const root = opts.packageRoot
45
+ const out = []
46
+
47
+ // 1. Explicit override. Highest precedence, always: this is how CI, tests and
48
+ // a developer pointing at a locally built fork get a deterministic answer.
49
+ if (env.ELLIOT_OPENCODE_BIN) {
50
+ out.push({ source: "ELLIOT_OPENCODE_BIN", path: env.ELLIOT_OPENCODE_BIN })
51
+ }
52
+
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.
56
+ 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) })
59
+ } catch {
60
+ // Platform package not installed (e.g. running from a dev checkout).
61
+ }
62
+
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) })
66
+
67
+ // 4. A configured fork location. Accepts either a checkout root or a dist
68
+ // root, because both are things a person reasonably points this at.
69
+ if (env.ELLIOT_OPENCODE_HOME) {
70
+ const home = env.ELLIOT_OPENCODE_HOME
71
+ out.push({
72
+ source: "ELLIOT_OPENCODE_HOME (checkout)",
73
+ path: path.join(home, "packages", "opencode", "dist", dir, "bin", binary),
74
+ })
75
+ out.push({ source: "ELLIOT_OPENCODE_HOME (dist)", path: path.join(home, dir, "bin", binary) })
76
+ }
77
+
78
+ // 5. Development convenience ONLY: the fork lives in this repo at `opencode/`
79
+ // (subtree), so from the launcher package root that is one level up. Kept
80
+ // explicitly last before PATH so a dev build can never silently win over an
81
+ // override or a packaged runtime.
82
+ out.push({
83
+ source: DEV_FALLBACK,
84
+ path: path.join(root, "..", "opencode", "packages", "opencode", "dist", dir, "bin", binary),
85
+ })
86
+
87
+ // The same checkout one level higher: the fork cloned BESIDE Elliot-AI rather
88
+ // than inside it. Both layouts exist in practice, and looking only in-repo
89
+ // fails confusingly -- an empty `Elliot-AI/opencode` directory matches the path
90
+ // above, so resolution reports "no built runtime" while a freshly built binary
91
+ // sits in the sibling checkout. Cheap to try both; the first that has a real
92
+ // file wins.
93
+ out.push({
94
+ source: SIBLING_FALLBACK,
95
+ path: path.join(root, "..", "..", "opencode", "packages", "opencode", "dist", dir, "bin", binary),
96
+ })
97
+
98
+ return out
99
+ }
100
+
101
+ /** True when the operator explicitly told us where the runtime is. */
102
+ function isExplicitlyConfigured(env) {
103
+ return Boolean(env.ELLIOT_OPENCODE_BIN || env.ELLIOT_OPENCODE_HOME)
104
+ }
105
+
106
+ /**
107
+ * First candidate that exists on disk, or null.
108
+ *
109
+ * Explicit configuration is STRICT: if ELLIOT_OPENCODE_BIN or
110
+ * ELLIOT_OPENCODE_HOME is set, the dev-only fallbacks are not consulted at all.
111
+ * Silently launching a different runtime than the operator configured is worse
112
+ * than not launching — it produces results attributed to the wrong build, and
113
+ * the person debugging has no reason to suspect the path.
114
+ */
115
+ function resolveOpencodeBin(opts) {
116
+ const env = opts.env || process.env
117
+ const strict = isExplicitlyConfigured(env)
118
+
119
+ for (const candidate of candidates({ ...opts, env })) {
120
+ if (strict && (candidate.source === DEV_FALLBACK || candidate.source === SIBLING_FALLBACK)) continue
121
+ try {
122
+ if (fs.statSync(candidate.path).isFile()) {
123
+ return { ...candidate, path: path.resolve(candidate.path) }
124
+ }
125
+ } catch {
126
+ // Missing or unreadable — try the next candidate.
127
+ }
128
+ }
129
+ return null
130
+ }
131
+
132
+ module.exports = { DEV_FALLBACK, SIBLING_FALLBACK, candidates, isExplicitlyConfigured, platformTriple, resolveOpencodeBin }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@elliotsystems/eai",
3
+ "version": "0.1.0",
4
+ "description": "Elliot AI CLI entry point (eai) - launches the Elliot runtime with arguments forwarded verbatim.",
5
+ "bin": {
6
+ "eai": "./bin/eai"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "lib",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test test/*.test.js",
15
+ "vendor": "node scripts/vendor.js",
16
+ "assemble": "node scripts/assemble.js",
17
+ "publish:npm": "node scripts/publish.js",
18
+ "build:runtime": "bun run --cwd ../opencode/packages/opencode script/build.ts --skip-embed-web-ui"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
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"
32
+ }
33
+ }