@elliotsystems/eai 0.1.4 → 0.1.5

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/lib/status.js ADDED
@@ -0,0 +1,133 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai status` — is this machine configured, can it reach the backend, and
5
+ * which connectors are live.
6
+ *
7
+ * Migrated from `elliot-ai status` as that CLI is retired. It is the one
8
+ * question `eai` could not answer about itself: the launcher knows where its
9
+ * config is, but a user staring at a failing session had no way to tell a
10
+ * missing token from an unreachable backend from a connector that was never
11
+ * authorised.
12
+ *
13
+ * Three facts, in the order they can fail:
14
+ *
15
+ * 1. config present — nothing else is meaningful without it
16
+ * 2. backend reachable — /health, so a network or DNS fault is named as one
17
+ * 3. connectors — /connectors/<tenant>, the thing people actually ask
18
+ *
19
+ * Each step degrades rather than aborting: an unreachable backend still prints
20
+ * the workspace block, because "which workspace am I in" is answerable offline
21
+ * and is half of what the command is for.
22
+ */
23
+
24
+ const fs = require("fs")
25
+
26
+ const { elliotConfigPath } = require("./setup.js")
27
+
28
+ /** Read ~/.elliot/config.json, or null when absent/unreadable. */
29
+ function readElliotConfig() {
30
+ try {
31
+ const parsed = JSON.parse(fs.readFileSync(elliotConfigPath(), "utf8"))
32
+ return parsed && typeof parsed === "object" ? parsed : null
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ /**
39
+ * `/health`, with a timeout.
40
+ *
41
+ * Without one this hangs on a black-holed connection for the OS default, and a
42
+ * status command that hangs is worse than one that says "offline" — the user is
43
+ * running it precisely because something is already wrong.
44
+ */
45
+ async function checkBackendHealth(backendUrl, timeoutMs = 5000) {
46
+ const controller = new AbortController()
47
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
48
+ try {
49
+ const res = await fetch(`${String(backendUrl).replace(/\/+$/, "")}/health`, {
50
+ signal: controller.signal,
51
+ })
52
+ return res.ok
53
+ } catch {
54
+ return false
55
+ } finally {
56
+ clearTimeout(timer)
57
+ }
58
+ }
59
+
60
+ /** Connector rows for this tenant, or [] when they cannot be fetched. */
61
+ async function fetchConnectors(config, timeoutMs = 8000) {
62
+ const controller = new AbortController()
63
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
64
+ try {
65
+ const base = String(config.backend_url || "").replace(/\/+$/, "")
66
+ const res = await fetch(`${base}/connectors/${config.tenant_id}`, {
67
+ headers: { Authorization: `Bearer ${config.jwt_token}` },
68
+ signal: controller.signal,
69
+ })
70
+ if (!res.ok) return []
71
+ const body = await res.json()
72
+ if (Array.isArray(body)) return body
73
+ return Array.isArray(body && body.connectors) ? body.connectors : []
74
+ } catch {
75
+ return []
76
+ } finally {
77
+ clearTimeout(timer)
78
+ }
79
+ }
80
+
81
+ /** Title-case a provider id for display, without assuming it is non-empty. */
82
+ function displayName(provider) {
83
+ const name = String(provider || "unknown")
84
+ return name.charAt(0).toUpperCase() + name.slice(1)
85
+ }
86
+
87
+ /**
88
+ * @param {{log?: (s: string) => void, config?: object|null}} [opts]
89
+ * @returns {Promise<number>} the process exit code
90
+ */
91
+ async function runStatus(opts = {}) {
92
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
93
+ const config = opts.config !== undefined ? opts.config : readElliotConfig()
94
+
95
+ if (!config) {
96
+ log("Not configured. Copy the setup command from the Elliot portal and run it.")
97
+ return 1
98
+ }
99
+
100
+ log("")
101
+ log("Elliot-AI status")
102
+ log("─────────────────────────────")
103
+ log(`Workspace: ${config.org_name || "(unknown)"}`)
104
+ // The id, not just the name: it is the value the portal URLs and the setup
105
+ // command both carry, so it is what makes the CLI and the web app comparable
106
+ // at a glance.
107
+ log(`Workspace ID: ${config.tenant_id || "(unknown)"}`)
108
+ if (config.stack) log(`Stack: ${config.stack}`)
109
+ log(`Backend: ${config.backend_url || "(unset)"}`)
110
+
111
+ const healthy = config.backend_url ? await checkBackendHealth(config.backend_url) : false
112
+ log(`Reachable: ${healthy ? "yes" : "no"}`)
113
+
114
+ log("─────────────────────────────")
115
+ log("Connectors:")
116
+ const connectors = healthy ? await fetchConnectors(config) : []
117
+ if (!connectors.length) {
118
+ // Deliberately not "no connectors": an unreachable backend and an
119
+ // unconfigured workspace produce the same empty list, and saying which is
120
+ // which is the whole value of the line.
121
+ log(healthy ? " (none reported)" : " (backend unreachable — cannot tell)")
122
+ } else {
123
+ for (const c of connectors) {
124
+ const state = c.status === "connected" ? "connected" : c.status || "unknown"
125
+ log(` ${displayName(c.provider).padEnd(12)} ${state}`)
126
+ }
127
+ }
128
+ log("")
129
+
130
+ return healthy ? 0 : 1
131
+ }
132
+
133
+ module.exports = { runStatus, readElliotConfig, checkBackendHealth, fetchConnectors, displayName }
package/lib/token-env.js CHANGED
@@ -1,14 +1,22 @@
1
1
  "use strict"
2
2
 
3
3
  /**
4
- * Inject the Elliot access token into the environment the OpenCode runtime
5
- * inherits, so its `elliot` provider (configured by `elliot-ai setup` to use
6
- * `apiKey: "{env:ELLIOT_TOKEN}"`) authenticates against the LightLLM proxy.
4
+ * Inject LiteLLM's shared master key into the environment the OpenCode
5
+ * runtime inherits, so its `elliot` provider (configured by `eai setup` to
6
+ * use `apiKey: "{env:ELLIOT_TOKEN}"`) authenticates against LiteLLM directly.
7
7
  *
8
- * ~/.elliot/config.json is the single source of truth for the token; the CLI
9
- * (`elliot-ai`) refreshes it there. We only READ it here and copy the current
10
- * `jwt_token` into ELLIOT_TOKEN — no network, no writes, so this adds no latency
11
- * and cannot corrupt the config.
8
+ * The env var name (ELLIOT_TOKEN) is unchanged, but what it holds is not: it
9
+ * used to be the user's own per-instance JWT (`jwt_token`), revocable and
10
+ * scoped to them; it is now `litellm_master_key`, ONE secret shared by the
11
+ * whole gateway (LiteLLM is deliberately DB-less — no per-tenant credential
12
+ * is possible without one). `eai setup` writes it into
13
+ * ~/.elliot/config.json's `litellm_master_key` field verbatim, since the
14
+ * backend's `/onboarding/config` response includes it directly for exactly
15
+ * this purpose.
16
+ *
17
+ * ~/.elliot/config.json is the single source of truth; `eai setup` refreshes
18
+ * it there. We only READ it here — no network, no writes, so this adds no
19
+ * latency and cannot corrupt the config.
12
20
  *
13
21
  * Entirely best-effort: a missing/unreadable/corrupt config, or an env that
14
22
  * already sets ELLIOT_TOKEN, leaves things exactly as they were. The launcher's
@@ -20,27 +28,55 @@ const os = require("os")
20
28
  const path = require("path")
21
29
 
22
30
  /**
31
+ * TWO CREDENTIALS, TWO HOPS. They are not interchangeable and the difference
32
+ * is the whole reason both exist:
33
+ *
34
+ * ELLIOT_TOKEN the LiteLLM key. Authenticates this machine TO the
35
+ * gateway, and is consumed there — LiteLLM strips it.
36
+ * Used by the model path and as the MCP entry's
37
+ * `Authorization`.
38
+ * ELLIOT_MCP_TOKEN the Elliot MCP credential. LiteLLM forwards it as
39
+ * Elliot's own `Authorization`, and it is what carries
40
+ * tenant, user and the CLI surface. Minted by
41
+ * `/onboarding/config` with the MCP audience.
42
+ *
43
+ * Putting the MCP credential in `Authorization` would authenticate it to
44
+ * LiteLLM as an unknown key; putting the LiteLLM key in `x-mcp-auth` would
45
+ * reach Elliot as a credential it cannot decode. Each is useless in the
46
+ * other's place, which is why they are separate variables rather than one.
47
+ *
23
48
  * @param {NodeJS.ProcessEnv} env the environment to mutate (defaults to process.env)
24
49
  * @returns {boolean} true iff ELLIOT_TOKEN was set from the config file
25
50
  */
26
51
  function injectToken(env) {
27
52
  const target = env || process.env
28
53
 
29
- // An explicit ELLIOT_TOKEN in the environment wins — it lets a caller point
30
- // `eai` at a specific token without touching the config file.
31
- if (target.ELLIOT_TOKEN) return false
32
-
54
+ let config = null
33
55
  try {
34
56
  const configPath = path.join(os.homedir(), ".elliot", "config.json")
35
- const raw = fs.readFileSync(configPath, "utf-8")
36
- const config = JSON.parse(raw)
37
- if (config && typeof config.jwt_token === "string" && config.jwt_token) {
38
- target.ELLIOT_TOKEN = config.jwt_token
39
- return true
40
- }
57
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"))
41
58
  } catch {
42
59
  // No config yet (setup not run), unreadable, or malformed — the runtime
43
60
  // will simply have no elliot token, which is the pre-existing behaviour.
61
+ return false
62
+ }
63
+
64
+ // Set INDEPENDENTLY of ELLIOT_TOKEN below. An explicit gateway key in the
65
+ // environment is a deliberate override of the gateway hop and says nothing
66
+ // about Elliot identity, so it must not also suppress the MCP credential —
67
+ // doing so would leave MCP silently unauthenticated whenever someone pointed
68
+ // `eai` at a different LiteLLM key.
69
+ if (!target.ELLIOT_MCP_TOKEN && config && typeof config.mcp_token === "string" && config.mcp_token) {
70
+ target.ELLIOT_MCP_TOKEN = config.mcp_token
71
+ }
72
+
73
+ // An explicit ELLIOT_TOKEN in the environment wins — it lets a caller point
74
+ // `eai` at a specific token without touching the config file.
75
+ if (target.ELLIOT_TOKEN) return false
76
+
77
+ if (config && typeof config.litellm_master_key === "string" && config.litellm_master_key) {
78
+ target.ELLIOT_TOKEN = config.litellm_master_key
79
+ return true
44
80
  }
45
81
  return false
46
82
  }
@@ -0,0 +1,27 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * The detached half of the update notification.
5
+ *
6
+ * Spawned by `maybeNotify`, at most once a day, with stdio ignored and the
7
+ * handle unref'd — so it cannot print anything, cannot hold the parent open,
8
+ * and cannot fail in a way the user sees. Its only job is to leave the answer
9
+ * in the cache for the NEXT run to report.
10
+ *
11
+ * It is a separate file rather than an inline `-e` script so that the code is
12
+ * readable, testable and visible in a stack trace, and so a future change does
13
+ * not have to be escaped through a command line.
14
+ */
15
+
16
+ const { latestPublished, readState, writeState, notificationsDisabled } = require("./update.js")
17
+
18
+ if (notificationsDisabled(process.env)) process.exit(0)
19
+
20
+ latestPublished()
21
+ .then((latest) => {
22
+ if (latest) writeState({ ...readState(), latest, checkedAt: Date.now() })
23
+ })
24
+ .catch(() => {
25
+ // Silence is the contract. The parent already stamped checkedAt, so a
26
+ // failure here just means the answer is refreshed tomorrow instead.
27
+ })
package/lib/update.js ADDED
@@ -0,0 +1,303 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai update` — move this machine onto the current published CLI.
5
+ *
6
+ * ## Why this exists rather than "just run npm i -g"
7
+ *
8
+ * Because the user does not necessarily know how `eai` got onto their machine,
9
+ * and the right command differs: a global npm install, a pnpm or bun global, or
10
+ * a `node_modules` inside a project are four different updates, and running the
11
+ * wrong one either fails confusingly or installs a SECOND copy that shadows the
12
+ * first. Onboarding is one `npm i -g @elliotsystems/eai`, so the common case is
13
+ * easy; the point of this command is that the uncommon cases say what to do
14
+ * instead of failing.
15
+ *
16
+ * ## It updates the CLI, which brings the runtime with it
17
+ *
18
+ * `opencode-ai` is a dependency of this package, pinned to an exact version.
19
+ * So the OpenCode runtime a user has is decided by the EAI version they have,
20
+ * and npm fetches the matching runtime as part of the install. There is
21
+ * deliberately no separate "update OpenCode" path: the two moving
22
+ * independently on a user's machine is the drift this whole mechanism exists
23
+ * to prevent, and `check_opencode_versions.mjs` enforces the same pairing in
24
+ * CI.
25
+ *
26
+ * ## Nothing here hardcodes a version
27
+ *
28
+ * The published version is asked of the registry, and the installed one is read
29
+ * from the package. A literal in this file would be a third source of truth
30
+ * that goes stale on the next release and keeps looking authoritative.
31
+ */
32
+
33
+ const childProcess = require("child_process")
34
+ const fs = require("fs")
35
+ const os = require("os")
36
+ const path = require("path")
37
+
38
+ const PACKAGE_NAME = "@elliotsystems/eai"
39
+
40
+ /** The npm registry's own metadata endpoint for the published version. */
41
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
42
+
43
+ /**
44
+ * Run a package-manager command, portably and without a deprecation warning.
45
+ *
46
+ * Windows needs a shell: npm, pnpm and bun are all `.cmd` shims there, and
47
+ * execFile cannot run a `.cmd` directly — it throws ENOENT, which silently
48
+ * made every Windows user's global install look like "not a global install".
49
+ *
50
+ * But `shell: true` TOGETHER WITH an args array triggers Node 24's DEP0190
51
+ * ("arguments are not escaped, only concatenated"), which printed a security
52
+ * warning on every `eai update` run. Passing one pre-joined command string
53
+ * instead does the same work without the warning, because the concatenation
54
+ * is then explicitly ours rather than something Node did behind our back.
55
+ *
56
+ * That is only safe because every command and argument here is a literal from
57
+ * the table in detectInstall. Nothing user-supplied is ever interpolated, and
58
+ * nothing in this file should start doing so.
59
+ */
60
+ function runTool(bin, args, options) {
61
+ if (process.platform === "win32") {
62
+ return childProcess.execSync([bin, ...args].join(" "), { ...options, shell: undefined })
63
+ }
64
+ return childProcess.execFileSync(bin, args, options)
65
+ }
66
+
67
+ /**
68
+ * How was this copy installed?
69
+ *
70
+ * Decided from the path the package actually sits at, not from a guess: an
71
+ * `npm root -g` that matches means a global install, and anything else is
72
+ * reported rather than acted on. Updating the wrong tree is how a user ends up
73
+ * with two `eai` binaries and no idea which one runs.
74
+ */
75
+ function detectInstall(packageRoot) {
76
+ const managers = [
77
+ { name: "npm", probe: ["npm", ["root", "-g"]], update: "npm install -g " + PACKAGE_NAME + "@latest" },
78
+ { name: "pnpm", probe: ["pnpm", ["root", "-g"]], update: "pnpm add -g " + PACKAGE_NAME + "@latest" },
79
+ { name: "bun", probe: ["bun", ["pm", "bin", "-g"]], update: "bun add -g " + PACKAGE_NAME + "@latest" },
80
+ ]
81
+ const resolved = (() => {
82
+ try {
83
+ return fs.realpathSync(packageRoot)
84
+ } catch {
85
+ return packageRoot
86
+ }
87
+ })()
88
+
89
+ for (const m of managers) {
90
+ let root
91
+ try {
92
+ root = runTool(m.probe[0], m.probe[1], {
93
+ encoding: "utf8",
94
+ stdio: ["ignore", "pipe", "ignore"],
95
+ timeout: 15000,
96
+ // REQUIRED on Windows, and its absence is not a degraded probe — it is
97
+ // a total one. `npm`, `pnpm` and `bun` are all `.cmd` shims there, and
98
+ // execFileSync cannot execute a `.cmd` without a shell: it throws
99
+ // ENOENT, the catch below skips the manager, every manager is skipped,
100
+ // and `eai update` tells every Windows user their global install is
101
+ // "not a global install" and refuses. Windows is the platform
102
+ // publish-eai.yml validates on.
103
+ //
104
+ }).trim()
105
+ } catch {
106
+ continue // manager not installed; not an error, just not this one
107
+ }
108
+ if (!root) continue
109
+ let realRoot
110
+ try {
111
+ realRoot = fs.realpathSync(root)
112
+ } catch {
113
+ realRoot = root
114
+ }
115
+ if (resolved.startsWith(realRoot + path.sep) || resolved.startsWith(root + path.sep)) {
116
+ return { kind: "global", manager: m.name, command: m.update }
117
+ }
118
+ }
119
+ return { kind: "unknown", manager: null, command: null }
120
+ }
121
+
122
+ /**
123
+ * The published version, or null.
124
+ *
125
+ * Deliberately quiet on failure. An offline machine, a proxy or a registry
126
+ * blip must not turn `eai update` into an error — and must REALLY not turn a
127
+ * background check (see maybeNotify) into one.
128
+ */
129
+ async function latestPublished(timeoutMs = 5000) {
130
+ const controller = new AbortController()
131
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
132
+ try {
133
+ const response = await fetch(REGISTRY_URL, {
134
+ signal: controller.signal,
135
+ // `application/vnd.npm.install-v1+json` returns an abbreviated document.
136
+ // Smaller and, more to the point, it is the contract npm itself uses, so
137
+ // it is the least likely shape to change under us.
138
+ headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
139
+ })
140
+ if (!response.ok) return null
141
+ const body = await response.json()
142
+ return typeof body?.version === "string" ? body.version : null
143
+ } catch {
144
+ return null
145
+ } finally {
146
+ clearTimeout(timer)
147
+ }
148
+ }
149
+
150
+ function compareSemver(a, b) {
151
+ const parse = (v) => String(v).split("-")[0].split(".").map((n) => Number(n) || 0)
152
+ const [x, y] = [parse(a), parse(b)]
153
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]
154
+ }
155
+
156
+ /** `eai update`. */
157
+ async function runUpdate({ packageRoot, version, argv = [] } = {}) {
158
+ const check = argv.includes("--check")
159
+ const latest = await latestPublished()
160
+
161
+ if (!latest) {
162
+ process.stderr.write(
163
+ "eai update: could not reach the npm registry.\n" +
164
+ "Nothing was changed. Check your connection or proxy and try again.\n",
165
+ )
166
+ return 1
167
+ }
168
+
169
+ process.stdout.write(`installed: ${version}\npublished: ${latest}\n`)
170
+
171
+ if (compareSemver(latest, version) <= 0) {
172
+ process.stdout.write("\nAlready up to date.\n")
173
+ return 0
174
+ }
175
+
176
+ const install = detectInstall(packageRoot)
177
+ if (check) {
178
+ process.stdout.write(`\nUpdate available. Run: ${install.command || "eai update"}\n`)
179
+ return 0
180
+ }
181
+
182
+ if (install.kind !== "global") {
183
+ // Not a failure — a refusal to guess. Installing globally over a local
184
+ // checkout or a project dependency would leave two copies and no signal
185
+ // about which one the shell resolves.
186
+ process.stderr.write(
187
+ `\neai update: this copy of ${PACKAGE_NAME} is not a global install.\n` +
188
+ `It is at: ${packageRoot}\n` +
189
+ "Update it the way it was installed — or install globally with:\n" +
190
+ ` npm install -g ${PACKAGE_NAME}@latest\n`,
191
+ )
192
+ return 1
193
+ }
194
+
195
+ process.stdout.write(`\nUpdating via ${install.manager}: ${install.command}\n\n`)
196
+ // One command string, not (bin, args) — see runTool above for why the args
197
+ // form prints a DEP0190 security warning on every Windows run.
198
+ const result = process.platform === "win32"
199
+ ? childProcess.spawnSync(install.command, { stdio: "inherit", shell: true })
200
+ : childProcess.spawnSync(install.command.split(" ")[0], install.command.split(" ").slice(1), { stdio: "inherit" })
201
+ if (result.status !== 0) {
202
+ process.stderr.write(
203
+ `\neai update: ${install.manager} exited ${result.status}.\n` +
204
+ "On a permissions error, either re-run with the privileges that install needs,\n" +
205
+ "or point npm at a user-writable prefix (`npm config set prefix`).\n",
206
+ )
207
+ return result.status || 1
208
+ }
209
+ process.stdout.write(`\nUpdated to ${latest}. Run \`eai --version\` to confirm.\n`)
210
+ return 0
211
+ }
212
+
213
+ // ── update notification ────────────────────────────────────────────────────
214
+ //
215
+ // A CLI that silently goes stale is a CLI whose users report bugs that were
216
+ // fixed months ago. But a version check on the hot path is worse than the
217
+ // problem: it adds latency to every launch, it fails on an offline machine,
218
+ // and a nag on every single run gets ignored and then resented.
219
+ //
220
+ // So the notice is: throttled to once a day, written to STDERR so it can never
221
+ // corrupt piped output, never blocking (the check runs against a cached answer
222
+ // from a PREVIOUS run, never a live request on this one), and switchable off.
223
+
224
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
225
+
226
+ function stateFile() {
227
+ return path.join(os.homedir(), ".elliot", "update-check.json")
228
+ }
229
+
230
+ function readState() {
231
+ try {
232
+ return JSON.parse(fs.readFileSync(stateFile(), "utf8")) || {}
233
+ } catch {
234
+ return {}
235
+ }
236
+ }
237
+
238
+ function writeState(state) {
239
+ try {
240
+ fs.mkdirSync(path.dirname(stateFile()), { recursive: true })
241
+ fs.writeFileSync(stateFile(), JSON.stringify(state))
242
+ } catch {
243
+ // A read-only or full home directory must not break the CLI over a
244
+ // convenience feature. Losing the throttle means re-checking tomorrow.
245
+ }
246
+ }
247
+
248
+ function notificationsDisabled(env) {
249
+ // Two switches on purpose. ELLIOT_NO_UPDATE_CHECK is ours and explicit;
250
+ // CI is honoured because a version nag in build logs is pure noise and
251
+ // nobody sets a flag they do not know exists.
252
+ return Boolean(env.ELLIOT_NO_UPDATE_CHECK || env.CI || env.NO_UPDATE_NOTIFIER)
253
+ }
254
+
255
+ /**
256
+ * Print a notice if a newer version was seen EARLIER, then refresh the cache in
257
+ * the background.
258
+ *
259
+ * The ordering is the whole design. This run reports what the last run already
260
+ * learned, so the common path does no network I/O at all and costs a file read.
261
+ * The refresh is detached and unref'd, so the process exits without waiting on
262
+ * it, and a hung registry delays nothing.
263
+ */
264
+ function maybeNotify({ version, env = process.env, now = Date.now() } = {}) {
265
+ if (notificationsDisabled(env)) return
266
+ const state = readState()
267
+
268
+ if (state.latest && compareSemver(state.latest, version) > 0) {
269
+ process.stderr.write(
270
+ `\nA newer Elliot CLI is available: ${version} → ${state.latest}\n` +
271
+ "Run `eai update` to upgrade. (`ELLIOT_NO_UPDATE_CHECK=1` silences this.)\n\n",
272
+ )
273
+ }
274
+
275
+ if (now - (state.checkedAt || 0) < CHECK_INTERVAL_MS) return
276
+ // Stamp BEFORE spawning. If the check fails or the machine is offline, the
277
+ // throttle still holds and we do not spawn a doomed probe on every run.
278
+ writeState({ ...state, checkedAt: now })
279
+ try {
280
+ const child = childProcess.spawn(
281
+ process.execPath,
282
+ [path.join(__dirname, "update-check.js")],
283
+ { detached: true, stdio: "ignore", env },
284
+ )
285
+ child.unref()
286
+ } catch {
287
+ // Best effort, always.
288
+ }
289
+ }
290
+
291
+ module.exports = {
292
+ PACKAGE_NAME,
293
+ runUpdate,
294
+ latestPublished,
295
+ compareSemver,
296
+ detectInstall,
297
+ maybeNotify,
298
+ notificationsDisabled,
299
+ stateFile,
300
+ readState,
301
+ writeState,
302
+ CHECK_INTERVAL_MS,
303
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elliotsystems/eai",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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"
@@ -13,6 +13,7 @@
13
13
  "README.md"
14
14
  ],
15
15
  "scripts": {
16
+ "pretest": "node scripts/bundle.js",
16
17
  "test": "node --test test/*.test.js",
17
18
  "publish:npm": "node scripts/publish.js",
18
19
  "bundle": "node scripts/bundle.js",
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Shell-command classification — the policy intelligence glob patterns cannot express.
3
3
  *
4
- * Ported (adapted, not copied wholesale) from `elliot-cli/src/tools/bash-policy.ts`,
4
+ * Ported (adapted, not copied wholesale) from the retired elliot-ai CLI's
5
+ * `src/tools/bash-policy.ts`,
5
6
  * which is pure and dependency-free. Three things here are beyond what OpenCode's
6
7
  * native `permission.bash` globs can represent, which is exactly why this file exists:
7
8
  *