@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.
- package/README.md +101 -0
- package/bin/eai +113 -0
- package/lib/resolve.js +132 -0
- 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
|
+
}
|