@elliotsystems/eai 0.1.3 → 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/README.md +93 -54
- package/bin/eai +154 -0
- package/lib/connect.js +205 -0
- package/lib/gdrive.js +238 -0
- package/lib/logout.js +73 -0
- package/lib/resolve.js +9 -15
- package/lib/setup.js +600 -0
- package/lib/stage.js +45 -4
- package/lib/status.js +133 -0
- package/lib/token-env.js +53 -17
- package/lib/update-check.js +27 -0
- package/lib/update.js +303 -0
- package/package.json +2 -4
- package/plugin/opencode-plugin/src/classifier.ts +2 -1
- package/plugin/opencode-plugin/src/elliot-auth.ts +56 -104
- package/plugin/opencode-plugin/src/identity.ts +69 -0
- package/plugin/opencode-plugin/src/index.ts +13 -28
- package/plugin/opencode-plugin/src/mcp-keys.ts +128 -1
- package/plugin/opencode-plugin/src/policy.ts +36 -4
- package/plugin/opencode-plugin/src/proxy-provider.ts +124 -72
- package/plugin/opencode-plugin/src/scope.ts +2 -1
- package/plugin/opencode-plugin/src/tui/knowledge.ts +9 -3
package/lib/gdrive.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `eai connect|status|disconnect gdrive` — this person's own Google Drive.
|
|
5
|
+
*
|
|
6
|
+
* PERSONAL, not the team's. The Elliot CLI is an individual's tool: whichever
|
|
7
|
+
* Elliot teams you belong to — none, one, ten — the Drive reached here is the
|
|
8
|
+
* Google account YOU connect. Team Drive is a WebChat capability with its own
|
|
9
|
+
* grants, and the two never resolve to each other.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this polls instead of running a loopback server
|
|
12
|
+
*
|
|
13
|
+
* The obvious CLI OAuth shape is a `127.0.0.1` listener that Google redirects
|
|
14
|
+
* to. It does not fit here, and the reason is not preference: the Google OAuth
|
|
15
|
+
* client is registered against ELLIOT's callback URL, so redirecting to a
|
|
16
|
+
* loopback port would need every deployment's Google client re-registered with
|
|
17
|
+
* a new URI, and `http://127.0.0.1:<random>` cannot be pre-registered because
|
|
18
|
+
* the port is not known in advance.
|
|
19
|
+
*
|
|
20
|
+
* So the browser half completes entirely server-side — Google redirects to the
|
|
21
|
+
* Elliot callback that already exists and already handles state, nonce and
|
|
22
|
+
* encryption — and this polls `GET /connectors/me/gdrive` to learn when it
|
|
23
|
+
* landed. The CLI therefore never touches the authorization code or the token,
|
|
24
|
+
* which is a stronger position than the loopback design would have given: there
|
|
25
|
+
* is no moment where a credential passes through this process at all.
|
|
26
|
+
*
|
|
27
|
+
* ## What this file is careful about
|
|
28
|
+
*
|
|
29
|
+
* No dependencies, matching the rest of the launcher. No secret is ever printed
|
|
30
|
+
* — not the token, not the state, not the JWT. The authorization URL IS shown,
|
|
31
|
+
* because a user whose browser failed to open needs it and it carries no
|
|
32
|
+
* credential of ours: it is a Google consent link bound to a signed, expiring,
|
|
33
|
+
* single-use state.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require("fs")
|
|
37
|
+
const os = require("os")
|
|
38
|
+
const path = require("path")
|
|
39
|
+
const childProcess = require("child_process")
|
|
40
|
+
|
|
41
|
+
/** How long to wait for the browser half before giving up. */
|
|
42
|
+
const POLL_TIMEOUT_MS = 5 * 60 * 1000
|
|
43
|
+
/** Gap between polls. Two seconds is invisible to a person and gentle on the
|
|
44
|
+
* backend for a flow that takes tens of seconds of human time. */
|
|
45
|
+
const POLL_INTERVAL_MS = 2000
|
|
46
|
+
|
|
47
|
+
/** The provider this file speaks. */
|
|
48
|
+
const PROVIDER = "gdrive"
|
|
49
|
+
|
|
50
|
+
/** Accepted spellings, because `google-drive` is what people type. */
|
|
51
|
+
const ALIASES = new Set(["gdrive", "google-drive", "googledrive", "drive"])
|
|
52
|
+
|
|
53
|
+
function elliotConfigPath() {
|
|
54
|
+
return path.join(os.homedir(), ".elliot", "config.json")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readConfig() {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(fs.readFileSync(elliotConfigPath(), "utf8"))
|
|
60
|
+
return parsed && typeof parsed === "object" ? parsed : null
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open a URL in the user's browser. Best-effort by design.
|
|
68
|
+
*
|
|
69
|
+
* A failure here is not a failure of the command: the URL is printed either
|
|
70
|
+
* way, and a user on a headless box pasting it into another machine's browser
|
|
71
|
+
* is a supported path rather than a workaround.
|
|
72
|
+
*/
|
|
73
|
+
function openBrowser(url, spawn = childProcess.spawn) {
|
|
74
|
+
const [cmd, args] =
|
|
75
|
+
process.platform === "win32"
|
|
76
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
77
|
+
: process.platform === "darwin"
|
|
78
|
+
? ["open", [url]]
|
|
79
|
+
: ["xdg-open", [url]]
|
|
80
|
+
try {
|
|
81
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
82
|
+
child.on("error", () => {})
|
|
83
|
+
child.unref()
|
|
84
|
+
return true
|
|
85
|
+
} catch {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An authenticated call to the Elliot API, or a clear error. */
|
|
91
|
+
async function api(config, method, endpoint, fetchImpl = fetch) {
|
|
92
|
+
const base = String(config.backend_url || "").replace(/\/+$/, "")
|
|
93
|
+
if (!base) throw new Error("no backend_url in ~/.elliot/config.json")
|
|
94
|
+
if (!config.jwt_token) {
|
|
95
|
+
throw new Error("not signed in — run the setup command from the portal")
|
|
96
|
+
}
|
|
97
|
+
const res = await fetchImpl(`${base}${endpoint}`, {
|
|
98
|
+
method,
|
|
99
|
+
headers: { Authorization: `Bearer ${config.jwt_token}` },
|
|
100
|
+
})
|
|
101
|
+
if (res.status === 401) {
|
|
102
|
+
throw new Error("your Elliot session has expired — run `eai setup` again")
|
|
103
|
+
}
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
// The body is deliberately NOT interpolated: a connector error can name a
|
|
106
|
+
// provider URL, and this string is printed to a terminal and pasted into
|
|
107
|
+
// issues. The status is enough to act on.
|
|
108
|
+
throw new Error(`Elliot returned ${res.status} for ${method} ${endpoint}`)
|
|
109
|
+
}
|
|
110
|
+
return res.json()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function status(opts = {}) {
|
|
114
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
115
|
+
const config = opts.config !== undefined ? opts.config : readConfig()
|
|
116
|
+
if (!config) {
|
|
117
|
+
log("Not configured. Copy the setup command from the Elliot portal first.")
|
|
118
|
+
return 1
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let info
|
|
122
|
+
try {
|
|
123
|
+
info = await api(config, "GET", `/connectors/me/${PROVIDER}`, opts.fetch)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
log(`Google Drive: could not check (${error.message})`)
|
|
126
|
+
return 1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
log("")
|
|
130
|
+
log("Google Drive")
|
|
131
|
+
if (!info.connected) {
|
|
132
|
+
log(" Status: Not connected")
|
|
133
|
+
log(" Connect: eai connect gdrive")
|
|
134
|
+
return 0
|
|
135
|
+
}
|
|
136
|
+
log(" Status: Connected")
|
|
137
|
+
// The account address, never a credential. It is what makes "connected" a
|
|
138
|
+
// useful answer — a person with two Google accounts needs to know which one.
|
|
139
|
+
if (info.account) log(` Account: ${info.account}`)
|
|
140
|
+
log(" Scope: your own My Drive and any Shared Drives Google gives you")
|
|
141
|
+
return 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function connect(opts = {}) {
|
|
145
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
146
|
+
const config = opts.config !== undefined ? opts.config : readConfig()
|
|
147
|
+
if (!config) {
|
|
148
|
+
log("Not configured. Copy the setup command from the Elliot portal first.")
|
|
149
|
+
return 1
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let start
|
|
153
|
+
try {
|
|
154
|
+
start = await api(
|
|
155
|
+
config, "POST", `/connectors/me/${PROVIDER}/authorize`, opts.fetch
|
|
156
|
+
)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
log(`Could not start Google authorization: ${error.message}`)
|
|
159
|
+
return 1
|
|
160
|
+
}
|
|
161
|
+
const url = start && start.authorization_url
|
|
162
|
+
if (!url) {
|
|
163
|
+
log("Elliot did not return an authorization URL.")
|
|
164
|
+
return 1
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
log("")
|
|
168
|
+
log("Opening Google authorization in your browser...")
|
|
169
|
+
const opened = openBrowser(url, opts.spawn)
|
|
170
|
+
if (!opened) log("Could not open a browser automatically.")
|
|
171
|
+
log("")
|
|
172
|
+
log("If the browser does not open, visit:")
|
|
173
|
+
log(` ${url}`)
|
|
174
|
+
log("")
|
|
175
|
+
log("Waiting for Google authorization...")
|
|
176
|
+
|
|
177
|
+
const deadline = Date.now() + (opts.timeoutMs || POLL_TIMEOUT_MS)
|
|
178
|
+
const wait = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
179
|
+
while (Date.now() < deadline) {
|
|
180
|
+
await wait(opts.intervalMs || POLL_INTERVAL_MS)
|
|
181
|
+
let info
|
|
182
|
+
try {
|
|
183
|
+
info = await api(config, "GET", `/connectors/me/${PROVIDER}`, opts.fetch)
|
|
184
|
+
} catch {
|
|
185
|
+
// A transient failure mid-flow must not abandon a connection the user is
|
|
186
|
+
// in the middle of authorizing. Keep polling until the deadline.
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
if (info.connected) {
|
|
190
|
+
log("")
|
|
191
|
+
log("✓ Google Drive connected.")
|
|
192
|
+
if (info.account) log(` Account: ${info.account}`)
|
|
193
|
+
log("")
|
|
194
|
+
log("Run `eai` and ask for a file — for example:")
|
|
195
|
+
log(" \"search my Google Drive for the architecture document\"")
|
|
196
|
+
return 0
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
log("")
|
|
201
|
+
log("Timed out waiting for Google authorization.")
|
|
202
|
+
log("If you completed it in the browser, check with: eai status gdrive")
|
|
203
|
+
return 1
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function disconnect(opts = {}) {
|
|
207
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
208
|
+
const config = opts.config !== undefined ? opts.config : readConfig()
|
|
209
|
+
if (!config) {
|
|
210
|
+
log("Not configured. Copy the setup command from the Elliot portal first.")
|
|
211
|
+
return 1
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await api(config, "DELETE", `/connectors/me/${PROVIDER}`, opts.fetch)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
log(`Could not disconnect Google Drive: ${error.message}`)
|
|
217
|
+
return 1
|
|
218
|
+
}
|
|
219
|
+
log("Google Drive disconnected. Your team's Drive is unaffected.")
|
|
220
|
+
return 0
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** True when `name` is how someone spells Google Drive. */
|
|
224
|
+
function isGdrive(name) {
|
|
225
|
+
return ALIASES.has(String(name || "").toLowerCase())
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
connect,
|
|
230
|
+
disconnect,
|
|
231
|
+
status,
|
|
232
|
+
isGdrive,
|
|
233
|
+
openBrowser,
|
|
234
|
+
readConfig,
|
|
235
|
+
elliotConfigPath,
|
|
236
|
+
PROVIDER,
|
|
237
|
+
ALIASES,
|
|
238
|
+
}
|
package/lib/logout.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `eai logout` — disconnect this machine from its Elliot workspace.
|
|
5
|
+
*
|
|
6
|
+
* Migrated from `elliot-ai logout` as that CLI is retired. Without it the only
|
|
7
|
+
* way to leave a workspace was to know that `~/.elliot/config.json` exists and
|
|
8
|
+
* delete it by hand — which is also how people end up deleting the wrong thing.
|
|
9
|
+
*
|
|
10
|
+
* It removes ONLY that file. `~/.config/opencode/opencode.json` is left alone
|
|
11
|
+
* on purpose: it is the user's own config, carrying their MCP servers, theme
|
|
12
|
+
* and anything else they put there, and a logout that silently discarded it
|
|
13
|
+
* would be destroying work the user never offered up. The provider block left
|
|
14
|
+
* behind is inert without a token.
|
|
15
|
+
*
|
|
16
|
+
* Confirmation is required and defaults to NO, because this is irreversible
|
|
17
|
+
* from the CLI's side: recovering means fetching a fresh token from the portal.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require("fs")
|
|
21
|
+
const readline = require("readline")
|
|
22
|
+
|
|
23
|
+
const { elliotConfigPath } = require("./setup.js")
|
|
24
|
+
const { readElliotConfig } = require("./status.js")
|
|
25
|
+
|
|
26
|
+
/** Ask once on the TTY. Any answer but y/yes is a no, including empty. */
|
|
27
|
+
function confirm(question) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
30
|
+
rl.question(question, (answer) => {
|
|
31
|
+
rl.close()
|
|
32
|
+
const normalised = String(answer || "").trim().toLowerCase()
|
|
33
|
+
resolve(normalised === "y" || normalised === "yes")
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{log?: (s: string) => void, confirm?: (q: string) => Promise<boolean>, force?: boolean}} [opts]
|
|
40
|
+
* @returns {Promise<number>} the process exit code
|
|
41
|
+
*/
|
|
42
|
+
async function runLogout(opts = {}) {
|
|
43
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
44
|
+
const ask = opts.confirm || confirm
|
|
45
|
+
const file = elliotConfigPath()
|
|
46
|
+
|
|
47
|
+
const config = readElliotConfig()
|
|
48
|
+
if (!config) {
|
|
49
|
+
// Not an error: "make this machine logged out" is already true, and exiting
|
|
50
|
+
// non-zero would fail a script that is just ensuring a clean state.
|
|
51
|
+
log("Already logged out — no Elliot configuration on this machine.")
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const where = config.org_name || config.tenant_id || "this workspace"
|
|
56
|
+
if (!opts.force && !(await ask(`Log out of ${where}? [y/N] `))) {
|
|
57
|
+
log("Cancelled.")
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
fs.rmSync(file, { force: true })
|
|
63
|
+
} catch (error) {
|
|
64
|
+
log(`Could not remove ${file}: ${error.message}`)
|
|
65
|
+
return 1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
log(`Removed ${file}`)
|
|
69
|
+
log("Logged out. Copy the setup command from the Elliot portal to reconnect.")
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { runLogout, confirm }
|
package/lib/resolve.js
CHANGED
|
@@ -87,20 +87,14 @@ function candidates(opts) {
|
|
|
87
87
|
// opencode-ai not installed.
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 4. Bundled runtime — the shape a published/installed Elliot package will
|
|
101
|
-
// have. Nothing populates this yet; packaging is a later, approved phase.
|
|
102
|
-
out.push({ source: "bundled vendor runtime", path: path.join(root, "vendor", dir, "bin", binary) })
|
|
103
|
-
|
|
90
|
+
// The fork's per-platform packages (@elliotsystems/eai-<os>-<arch>) and the
|
|
91
|
+
// `vendor/` directory used to sit here. Both shipped a fork-built OpenCode;
|
|
92
|
+
// the runtime now comes from upstream's own opencode-ai above, the packages
|
|
93
|
+
// are being retired on npm, and nothing populates `vendor/` since the script
|
|
94
|
+
// that did was removed. Kept as a note rather than dead branches so nobody
|
|
95
|
+
// re-adds them wondering why stock is the only source.
|
|
96
|
+
|
|
97
|
+
// 3. A configured fork location. Accepts either a checkout root or a dist
|
|
104
98
|
// 5. A configured fork location. Accepts either a checkout root or a dist
|
|
105
99
|
// root, because both are things a person reasonably points this at.
|
|
106
100
|
if (env.ELLIOT_OPENCODE_HOME) {
|
|
@@ -112,7 +106,7 @@ function candidates(opts) {
|
|
|
112
106
|
out.push({ source: "ELLIOT_OPENCODE_HOME (dist)", path: path.join(home, dir, "bin", binary) })
|
|
113
107
|
}
|
|
114
108
|
|
|
115
|
-
//
|
|
109
|
+
// 4. Development convenience ONLY: the fork lives in this repo at `opencode/`
|
|
116
110
|
// (subtree), so from the launcher package root that is one level up. Kept
|
|
117
111
|
// explicitly last before PATH so a dev build can never silently win over an
|
|
118
112
|
// override or a packaged runtime.
|