@100xprompt/brain 0.1.0 → 0.1.1
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 +66 -3
- package/package.json +1 -1
- package/src/cli.ts +28 -3
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +164 -162
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
package/src/gateway/connect.ts
CHANGED
|
@@ -15,36 +15,52 @@
|
|
|
15
15
|
// stores the key that comes back in <home>/.brain/gateway.key (0600). For scripts and CI, --key-env VAR takes it from an
|
|
16
16
|
// environment variable instead; --from-cli reuses the 100xprompt CLI's sign-in. It is never printed. Tools read it at run time where they can (Claude Code's apiKeyHelper,
|
|
17
17
|
// OpenCode's {file:...}); Codex and Gemini CLI can only take it in their own config, which is then made private (0600).
|
|
18
|
-
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
18
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
19
19
|
import { basename, dirname, join, resolve } from "node:path"
|
|
20
|
+
import {
|
|
21
|
+
brainDir,
|
|
22
|
+
type Codec,
|
|
23
|
+
type Connector,
|
|
24
|
+
DEFAULT_MODEL,
|
|
25
|
+
DEFAULT_ROUTE,
|
|
26
|
+
DEFAULT_URL,
|
|
27
|
+
deleteAt,
|
|
28
|
+
EDIT_HEADER,
|
|
29
|
+
type Env,
|
|
30
|
+
type FileEdit,
|
|
31
|
+
type Format,
|
|
32
|
+
type GatewaySettings,
|
|
33
|
+
getAt,
|
|
34
|
+
type Json,
|
|
35
|
+
keyPath,
|
|
36
|
+
MODEL_NAMES,
|
|
37
|
+
modelId,
|
|
38
|
+
modelName,
|
|
39
|
+
readKey,
|
|
40
|
+
routeUrl,
|
|
41
|
+
setAt,
|
|
42
|
+
shellQuote,
|
|
43
|
+
statePath,
|
|
44
|
+
} from "./common.ts"
|
|
45
|
+
import { EXTRA_CONNECTORS } from "./connectors/index.ts"
|
|
20
46
|
import { askHidden, browserSignIn, type SignInDeps } from "./signin.ts"
|
|
21
47
|
|
|
22
|
-
|
|
48
|
+
export {
|
|
49
|
+
DEFAULT_MODEL,
|
|
50
|
+
DEFAULT_ROUTE,
|
|
51
|
+
DEFAULT_URL,
|
|
52
|
+
EDIT_HEADER,
|
|
53
|
+
keyPath,
|
|
54
|
+
modelId,
|
|
55
|
+
modelName,
|
|
56
|
+
statePath,
|
|
57
|
+
type Connector,
|
|
58
|
+
type FileEdit,
|
|
59
|
+
type GatewaySettings,
|
|
60
|
+
} from "./common.ts"
|
|
23
61
|
|
|
24
|
-
export const DEFAULT_URL = "https://proxy.100xprompt.com"
|
|
25
|
-
export const DEFAULT_ROUTE = "flash"
|
|
26
|
-
export const DEFAULT_MODEL = "gemini-3.8-flash"
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* What 100xprompt calls the models brain may connect a tool to, as the 100xprompt CLI shows them: tools are given the
|
|
30
|
-
* gateway's model id (that is what the gateway serves), and everything brain prints uses the product name.
|
|
31
|
-
*/
|
|
32
|
-
const MODEL_NAMES: Record<string, string> = { "gemini-3.8-flash": "100xprompt Pro", "gemini-3.7-flash": "100xprompt Flash", "gemini-3.6-flash": "100xprompt Flash" }
|
|
33
|
-
/** `--model pro` / `--model flash`: the product names, turned into the gateway's model ids. */
|
|
34
|
-
const MODEL_ALIASES: Record<string, string> = { pro: "gemini-3.8-flash", flash: "gemini-3.7-flash" }
|
|
35
|
-
export const modelName = (id: string) => MODEL_NAMES[id] ?? id
|
|
36
|
-
export const modelId = (given: string) => MODEL_ALIASES[given.trim().toLowerCase()] ?? given.trim()
|
|
37
62
|
/** A value as connect and its dry run show it: the model ids in it by their product names. */
|
|
38
63
|
const shownValue = (v: unknown) => Object.entries(MODEL_NAMES).reduce((t, [id, name]) => t.replaceAll(id, name), JSON.stringify(v))
|
|
39
|
-
export const EDIT_HEADER = "x-100x-context-edit"
|
|
40
|
-
|
|
41
|
-
export interface GatewaySettings {
|
|
42
|
-
url: string
|
|
43
|
-
route: string
|
|
44
|
-
model: string
|
|
45
|
-
/** Send `x-100x-context-edit: on` (the gateway clears old tool results when that saves money). */
|
|
46
|
-
contextEdit: boolean
|
|
47
|
-
}
|
|
48
64
|
|
|
49
65
|
/** What one connect changed in one file: the key (JSON path with "/", TOML or .env key) and the value it had before (undefined: absent). */
|
|
50
66
|
interface Change {
|
|
@@ -55,29 +71,14 @@ interface Change {
|
|
|
55
71
|
after: unknown
|
|
56
72
|
}
|
|
57
73
|
|
|
58
|
-
type Format = "json" | "toml" | "dotenv"
|
|
59
|
-
|
|
60
|
-
/** One file a connector changes, and the values it sets there. */
|
|
61
|
-
export interface FileEdit {
|
|
62
|
-
path: string
|
|
63
|
-
format: Format
|
|
64
|
-
values: Record<string, unknown>
|
|
65
|
-
/** The file holds the key: make it private (0600). */
|
|
66
|
-
holdsKey?: boolean
|
|
67
|
-
}
|
|
68
|
-
|
|
69
74
|
interface State {
|
|
70
75
|
settings?: GatewaySettings
|
|
71
76
|
keySource?: string
|
|
72
77
|
/** Who signed in (from the sign-in page), shown by status. */
|
|
73
78
|
email?: string
|
|
74
|
-
tools: Record<string, { at: string; changes: Change[]; created?: string[] }>
|
|
79
|
+
tools: Record<string, { at: string; changes: Change[]; created?: string[]; createdDirs?: string[]; modes?: Record<string, number> }>
|
|
75
80
|
}
|
|
76
81
|
|
|
77
|
-
const brainDir = (home: string) => join(home, ".brain")
|
|
78
|
-
export const statePath = (home: string) => join(brainDir(home), "gateway.json")
|
|
79
|
-
export const keyPath = (home: string) => join(brainDir(home), "gateway.key")
|
|
80
|
-
|
|
81
82
|
function loadState(home: string): State {
|
|
82
83
|
try {
|
|
83
84
|
const s = JSON.parse(readFileSync(statePath(home), "utf8"))
|
|
@@ -160,21 +161,12 @@ export function hasKey(home: string): boolean {
|
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
/** The stored key ("" when there is none: a dry run shows what would change without one). */
|
|
164
|
-
const readKey = (home: string) => {
|
|
165
|
-
try {
|
|
166
|
-
return readFileSync(keyPath(home), "utf8").trim()
|
|
167
|
-
} catch {
|
|
168
|
-
return ""
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
164
|
/**
|
|
173
165
|
* Writes the key (0600, never printed) and where it came from. Tools that keep their own copy of the key (Codex, Gemini
|
|
174
166
|
* CLI) get the new one too, where their copy is still the old key: otherwise a sign-in to another account would leave them
|
|
175
167
|
* on the old one while status names the new. Returns the tools whose copy was replaced.
|
|
176
168
|
*/
|
|
177
|
-
export function storeKey(home: string, key: string, source: string, email?: string): string[] {
|
|
169
|
+
export function storeKey(home: string, key: string, source: string, email?: string, env: Env = process.env): string[] {
|
|
178
170
|
const old = readKey(home)
|
|
179
171
|
mkdirSync(brainDir(home), { recursive: true, mode: 0o700 })
|
|
180
172
|
writePrivate(keyPath(home), `${key}\n`)
|
|
@@ -183,28 +175,40 @@ export function storeKey(home: string, key: string, source: string, email?: stri
|
|
|
183
175
|
if (email) s.email = email
|
|
184
176
|
else delete s.email
|
|
185
177
|
saveState(home, s)
|
|
186
|
-
return refreshKeyCopies(home, old, key)
|
|
178
|
+
return refreshKeyCopies(home, old, key, env)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Whether `v` (a string, or anything holding strings) contains `k`; and `v` with every `a` in it replaced by `b`. */
|
|
182
|
+
const holds = (v: unknown, k: string): boolean =>
|
|
183
|
+
typeof v === "string" ? v.includes(k) : Array.isArray(v) ? v.some((x) => holds(x, k)) : !!v && typeof v === "object" && Object.values(v).some((x) => holds(x, k))
|
|
184
|
+
const swap = (v: unknown, a: string, b: string): unknown =>
|
|
185
|
+
typeof v === "string" ? v.replaceAll(a, b) : Array.isArray(v) ? v.map((x) => swap(x, a, b)) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, swap(x, a, b)])) : v
|
|
186
|
+
|
|
187
|
+
/** The codec of each custom-format file a connector edits, by path (disconnect and the key refresh need it to read the file). */
|
|
188
|
+
function codecsOf(c: Connector, home: string, st: State, env: Env): Map<string, Codec> {
|
|
189
|
+
const s = st.settings ?? { url: DEFAULT_URL, route: DEFAULT_ROUTE, model: DEFAULT_MODEL, contextEdit: true }
|
|
190
|
+
try {
|
|
191
|
+
return new Map(c.edits(home, s, env).flatMap((e) => (e.codec ? [[e.path, e.codec] as const] : [])))
|
|
192
|
+
} catch {
|
|
193
|
+
return new Map()
|
|
194
|
+
}
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
/** Replaces `old` with `key` in the key copies connect wrote (only where the copy
|
|
190
|
-
function refreshKeyCopies(home: string, old: string, key: string): string[] {
|
|
197
|
+
/** Replaces `old` with `key` in the key copies connect wrote (only where the copy still holds `old`: a changed one is left). */
|
|
198
|
+
function refreshKeyCopies(home: string, old: string, key: string, env: Env): string[] {
|
|
191
199
|
if (!old || old === key) return []
|
|
192
200
|
const st = loadState(home)
|
|
193
201
|
const done = new Set<string>()
|
|
194
202
|
for (const c of CONNECTORS) {
|
|
195
203
|
const rec = st.tools[c.id]
|
|
196
204
|
if (!rec) continue
|
|
197
|
-
|
|
198
|
-
|
|
205
|
+
const codecs = codecsOf(c, home, st, env)
|
|
206
|
+
for (const x of rec.changes.filter((y) => y.after === "(contains the key)")) {
|
|
207
|
+
const d = load(x.format, x.file, codecs.get(x.file))
|
|
199
208
|
if ("error" in d) continue
|
|
200
209
|
const cur = getKey(d, x.key)
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
setKey(d, x.key, key)
|
|
204
|
-
} else {
|
|
205
|
-
if (!cur || typeof cur !== "object" || (cur as Json).experimental_bearer_token !== old) continue
|
|
206
|
-
setKey(d, x.key, { ...(cur as Json), experimental_bearer_token: key })
|
|
207
|
-
}
|
|
210
|
+
if (!holds(cur, old)) continue
|
|
211
|
+
setKey(d, x.key, swap(cur, old, key))
|
|
208
212
|
writeToolFile(home, c.id, x.file, render(d), 0o600, false)
|
|
209
213
|
done.add(c.label)
|
|
210
214
|
}
|
|
@@ -233,15 +237,15 @@ export function login(home: string, opts: { keyEnv?: string; fromCli?: boolean;
|
|
|
233
237
|
if (!key) return { ok: false, why: "the 100xprompt CLI is not signed in here; run `brain gateway login` to sign in in the browser" }
|
|
234
238
|
} else return { ok: false, why: "say where the key comes from (--key-env VAR or --from-cli), or run `brain gateway login` to sign in in the browser" }
|
|
235
239
|
if (/\s/.test(key) || key.length < 8) return { ok: false, why: "that does not look like a gateway key" }
|
|
236
|
-
return { ok: true, source, refreshed: storeKey(home, key, source) }
|
|
240
|
+
return { ok: true, source, refreshed: storeKey(home, key, source, undefined, env) }
|
|
237
241
|
}
|
|
238
242
|
|
|
239
243
|
/** Signs in in the browser (signin.ts) and stores the key that comes back. */
|
|
240
|
-
export async function signIn(home: string, d: SignInDeps): Promise<{ ok: true; source: string; email?: string; refreshed: string[] } | { ok: false; why: string }> {
|
|
244
|
+
export async function signIn(home: string, d: SignInDeps, env: Env = process.env): Promise<{ ok: true; source: string; email?: string; refreshed: string[] } | { ok: false; why: string }> {
|
|
241
245
|
const r = await browserSignIn(d)
|
|
242
246
|
if (!r.ok) return r
|
|
243
247
|
const source = r.via === "browser" ? "signing in in the browser" : "a pasted key"
|
|
244
|
-
const refreshed = storeKey(home, r.key, source, r.email)
|
|
248
|
+
const refreshed = storeKey(home, r.key, source, r.email, env)
|
|
245
249
|
return { ok: true, source, ...(r.email ? { email: r.email } : {}), refreshed }
|
|
246
250
|
}
|
|
247
251
|
|
|
@@ -260,7 +264,7 @@ export function logout(home: string, env: Record<string, string | undefined> = p
|
|
|
260
264
|
const readers = connected.filter((c) => !copies.includes(c))
|
|
261
265
|
const names = (cs: Connector[]) => cs.map((c) => c.label).join(" and ")
|
|
262
266
|
if (readers.length)
|
|
263
|
-
out.push(`${names(readers)} ${readers.length === 1 ? "reads" : "read"} the key from that file, so ${readers.length === 1 ? "it" : "they"} cannot reach the gateway until you sign in again (brain gateway login); brain gateway disconnect --tool <id> puts ${readers.length === 1 ? "it" : "them"} back as before.`)
|
|
267
|
+
out.push(`${names(readers)} ${readers.length === 1 ? "reads" : "read"} the key from that file, so ${readers.length === 1 ? "it" : "they"} cannot reach the gateway until you sign in again (brain gateway login), and a tool that reads it at start may refuse to start; brain gateway disconnect --tool <id> puts ${readers.length === 1 ? "it" : "them"} back as before.`)
|
|
264
268
|
if (copies.length)
|
|
265
269
|
out.push(`${names(copies)} ${copies.length === 1 ? "keeps its" : "keep their"} own copy of the key and still ${copies.length === 1 ? "reaches" : "reach"} the gateway with it (signing out here does not revoke a key): brain gateway disconnect --tool <id> removes the copy.`)
|
|
266
270
|
return out
|
|
@@ -269,65 +273,13 @@ export function logout(home: string, env: Record<string, string | undefined> = p
|
|
|
269
273
|
// ---- connectors -------------------------------------------------------------------------------------------------------
|
|
270
274
|
|
|
271
275
|
/** Dotted-path get/set/delete on JSON objects (keys never contain dots here except where quoted with a slash). */
|
|
272
|
-
const split = (k: string) => k.split("/")
|
|
273
|
-
function getAt(o: Json, key: string): unknown {
|
|
274
|
-
let cur: any = o
|
|
275
|
-
for (const p of split(key)) {
|
|
276
|
-
if (!cur || typeof cur !== "object" || !(p in cur)) return undefined
|
|
277
|
-
cur = cur[p]
|
|
278
|
-
}
|
|
279
|
-
return cur
|
|
280
|
-
}
|
|
281
|
-
function setAt(o: Json, key: string, v: unknown) {
|
|
282
|
-
const ps = split(key)
|
|
283
|
-
let cur: any = o
|
|
284
|
-
for (const p of ps.slice(0, -1)) {
|
|
285
|
-
if (!cur[p] || typeof cur[p] !== "object" || Array.isArray(cur[p])) cur[p] = {}
|
|
286
|
-
cur = cur[p]
|
|
287
|
-
}
|
|
288
|
-
cur[ps[ps.length - 1]!] = v
|
|
289
|
-
}
|
|
290
|
-
function deleteAt(o: Json, key: string) {
|
|
291
|
-
const ps = split(key)
|
|
292
|
-
const parents: any[] = [o]
|
|
293
|
-
let cur: any = o
|
|
294
|
-
for (const p of ps.slice(0, -1)) {
|
|
295
|
-
if (!cur || typeof cur !== "object") return
|
|
296
|
-
cur = cur[p]
|
|
297
|
-
parents.push(cur)
|
|
298
|
-
}
|
|
299
|
-
if (cur && typeof cur === "object") delete cur[ps[ps.length - 1]!]
|
|
300
|
-
// Drop objects this left empty (env: {} after the last key went), never the root.
|
|
301
|
-
for (let i = ps.length - 1; i >= 1; i--) {
|
|
302
|
-
const parent = parents[i - 1]
|
|
303
|
-
const child = parent?.[ps[i - 1]!]
|
|
304
|
-
if (child && typeof child === "object" && !Array.isArray(child) && !Object.keys(child).length) delete parent[ps[i - 1]!]
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
276
|
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
export interface Connector {
|
|
311
|
-
id: string
|
|
312
|
-
label: string
|
|
313
|
-
/** The main file it edits (shown in status), absolute. */
|
|
314
|
-
file(home: string, env: Record<string, string | undefined>): string
|
|
315
|
-
/** Folder or file that shows the tool has run here (relative to home), besides the main file or its folder. */
|
|
316
|
-
probe: string[]
|
|
317
|
-
/** Every file it changes, with the values to set. */
|
|
318
|
-
edits(home: string, s: GatewaySettings, env: Record<string, string | undefined>): FileEdit[]
|
|
319
|
-
/** How the key reaches the gateway from this tool, for the key check: header name. */
|
|
320
|
-
authHeader: "x-api-key" | "authorization" | "x-goog-api-key"
|
|
321
|
-
/** What the person should know (shown on connect). */
|
|
322
|
-
notes: string[]
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const routeUrl = (s: GatewaySettings) => `${s.url.replace(/\/+$/, "")}/${s.route}`
|
|
277
|
+
|
|
326
278
|
const claudeDir = (home: string, env: Record<string, string | undefined>) => (env.CLAUDE_CONFIG_DIR && existsSync(env.CLAUDE_CONFIG_DIR) ? env.CLAUDE_CONFIG_DIR : join(home, ".claude"))
|
|
327
279
|
const codexDir = (home: string, env: Record<string, string | undefined>) => (env.CODEX_HOME && existsSync(env.CODEX_HOME) ? env.CODEX_HOME : join(home, ".codex"))
|
|
328
280
|
const geminiDir = (home: string, env: Record<string, string | undefined>) => join(env.GEMINI_CLI_HOME && existsSync(env.GEMINI_CLI_HOME) ? env.GEMINI_CLI_HOME : home, ".gemini")
|
|
329
281
|
|
|
330
|
-
|
|
282
|
+
const BUILT_IN: Connector[] = [
|
|
331
283
|
{
|
|
332
284
|
id: "claude-code",
|
|
333
285
|
label: "Claude Code",
|
|
@@ -414,20 +366,25 @@ export const CONNECTORS: Connector[] = [
|
|
|
414
366
|
notes: [
|
|
415
367
|
"Gemini CLI reads ~/.gemini/.env at start; the key is kept there, so the file is now readable by you only (0600). Its sign-in is set to API key.",
|
|
416
368
|
"Gemini CLI cannot send extra headers: context editing applies only when the gateway turns it on for everyone.",
|
|
369
|
+
"Gemini CLI loads only the first .env it finds from the project folder up, and ~/.gemini/.env only when there is none: in a project with its own .env (or .gemini/.env), add GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY and GEMINI_MODEL there, or export them.",
|
|
417
370
|
],
|
|
418
371
|
},
|
|
419
372
|
]
|
|
420
373
|
|
|
421
|
-
/**
|
|
374
|
+
/** The four connectors written first; every other tool's is in connectors/<tool>.ts. */
|
|
375
|
+
export const CONNECTORS: Connector[] = [...BUILT_IN, ...EXTRA_CONNECTORS]
|
|
376
|
+
|
|
377
|
+
/** Keys of the built-in connectors whose value contains the key (the others say so with FileEdit.secretKeys). */
|
|
422
378
|
const SECRET_KEYS = new Set(["GEMINI_API_KEY", "model_providers/brain-gateway"])
|
|
379
|
+
const isSecret = (e: FileEdit, key: string) => SECRET_KEYS.has(key) || !!e.secretKeys?.includes(key)
|
|
423
380
|
|
|
424
381
|
export const connectorFor = (id: string) => CONNECTORS.find((c) => c.id === id || c.label.toLowerCase() === id.toLowerCase())
|
|
425
382
|
|
|
426
383
|
// ---- reading and writing each format ---------------------------------------------------------------------------------
|
|
427
384
|
|
|
428
|
-
type Doc = { kind: "json"; obj: Json; indent: string } | { kind: "toml"; text: string } | { kind: "dotenv"; lines: string[] }
|
|
385
|
+
type Doc = { kind: "json"; obj: Json; indent: string } | { kind: "toml"; text: string } | { kind: "dotenv"; lines: string[] } | { kind: "custom"; text: string; codec: Codec }
|
|
429
386
|
|
|
430
|
-
function load(format: Format, path: string): Doc | { error: string } {
|
|
387
|
+
function load(format: Format, path: string, codec?: Codec): Doc | { error: string } {
|
|
431
388
|
let text = ""
|
|
432
389
|
if (existsSync(path)) {
|
|
433
390
|
try {
|
|
@@ -436,6 +393,11 @@ function load(format: Format, path: string): Doc | { error: string } {
|
|
|
436
393
|
return { error: `cannot read it (${(e as Error).message})` }
|
|
437
394
|
}
|
|
438
395
|
}
|
|
396
|
+
if (format === "custom") {
|
|
397
|
+
if (!codec) return { error: "no codec for this file; left as it is" }
|
|
398
|
+
const why = codec.check?.(text)
|
|
399
|
+
return why ? { error: why } : { kind: "custom", text, codec }
|
|
400
|
+
}
|
|
439
401
|
if (format === "json") {
|
|
440
402
|
if (!text.trim()) return { kind: "json", obj: {}, indent: " " }
|
|
441
403
|
try {
|
|
@@ -460,6 +422,7 @@ function load(format: Format, path: string): Doc | { error: string } {
|
|
|
460
422
|
}
|
|
461
423
|
|
|
462
424
|
function render(d: Doc): string {
|
|
425
|
+
if (d.kind === "custom") return d.text
|
|
463
426
|
if (d.kind === "json") return `${JSON.stringify(d.obj, null, d.indent)}\n`
|
|
464
427
|
if (d.kind === "toml") return d.text.endsWith("\n") || !d.text ? d.text : `${d.text}\n`
|
|
465
428
|
return d.lines.length ? `${d.lines.join("\n")}\n` : ""
|
|
@@ -545,11 +508,16 @@ function dotenvSet(lines: string[], key: string, v: string | undefined): string[
|
|
|
545
508
|
}
|
|
546
509
|
|
|
547
510
|
function getKey(d: Doc, key: string): unknown {
|
|
511
|
+
if (d.kind === "custom") return d.codec.get(d.text, key)
|
|
548
512
|
if (d.kind === "json") return getAt(d.obj, key)
|
|
549
513
|
if (d.kind === "toml") return tomlGet(d.text, key)
|
|
550
514
|
return dotenvGet(d.lines, key)
|
|
551
515
|
}
|
|
552
516
|
function setKey(d: Doc, key: string, v: unknown) {
|
|
517
|
+
if (d.kind === "custom") {
|
|
518
|
+
d.text = d.codec.set(d.text, key, v)
|
|
519
|
+
return
|
|
520
|
+
}
|
|
553
521
|
if (d.kind === "json") return v === undefined ? deleteAt(d.obj, key) : setAt(d.obj, key, v)
|
|
554
522
|
if (d.kind === "toml") d.text = tomlSet(d.text, key, v)
|
|
555
523
|
else d.lines = dotenvSet(d.lines, key, v === undefined ? undefined : String(v))
|
|
@@ -611,7 +579,8 @@ export interface Result {
|
|
|
611
579
|
|
|
612
580
|
export function installedHere(c: Connector, home: string, env: Record<string, string | undefined>): boolean {
|
|
613
581
|
const f = c.file(home, env)
|
|
614
|
-
|
|
582
|
+
// The main file's folder counts, unless it is the home itself (~/.aider.conf.yml: every home has a home).
|
|
583
|
+
return existsSync(f) || (resolve(dirname(f)) !== resolve(home) && existsSync(dirname(f))) || c.probe.some((p) => existsSync(join(home, p)))
|
|
615
584
|
}
|
|
616
585
|
|
|
617
586
|
export async function connect(
|
|
@@ -646,37 +615,54 @@ export async function connect(
|
|
|
646
615
|
const edits = c.edits(home, s, env)
|
|
647
616
|
const docs: Array<{ e: FileEdit; d: Doc }> = []
|
|
648
617
|
for (const e of edits) {
|
|
649
|
-
const d = load(e.format, e.path)
|
|
618
|
+
const d = load(e.format, e.path, e.codec)
|
|
650
619
|
if ("error" in d) return { ok: false, lines: [`${e.path}: ${d.error}; nothing was changed`] }
|
|
651
620
|
docs.push({ e, d })
|
|
652
621
|
}
|
|
653
622
|
const prior = st.tools[c.id]?.changes ?? []
|
|
654
623
|
const changes: Change[] = []
|
|
655
624
|
const shown: string[] = []
|
|
656
|
-
|
|
657
|
-
for (const
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const
|
|
669
|
-
|
|
625
|
+
try {
|
|
626
|
+
for (const { e, d } of docs) {
|
|
627
|
+
for (const [key, after] of Object.entries(e.values)) {
|
|
628
|
+
const current = getKey(d, key)
|
|
629
|
+
// A reconnect keeps the value from before the FIRST connect, so disconnect still restores the person's own.
|
|
630
|
+
const was = prior.find((x) => x.file === e.path && x.key === key)
|
|
631
|
+
const secret = isSecret(e, key)
|
|
632
|
+
changes.push({ file: e.path, format: e.format, key, before: was ? was.before : current, after: secret ? "(contains the key)" : after })
|
|
633
|
+
if (!same(current, after)) setKey(d, key, after)
|
|
634
|
+
shown.push(` ${basename(e.path)}: ${key.replace(/\//g, ".")} = ${secret ? "(gateway settings and key)" : shownValue(after)}`)
|
|
635
|
+
}
|
|
636
|
+
// A key an earlier connect set that this one no longer wants (--no-context-edit after a connect with it): put it back now.
|
|
637
|
+
for (const x of prior) {
|
|
638
|
+
if (x.file !== e.path || x.key in e.values) continue
|
|
639
|
+
const current = getKey(d, x.key)
|
|
640
|
+
if (x.after === "(contains the key)" ? current !== undefined : same(current, x.after)) setKey(d, x.key, x.before)
|
|
641
|
+
}
|
|
670
642
|
}
|
|
643
|
+
} catch (err) {
|
|
644
|
+
// A file format the connector's codec could not change after all: nothing has been written yet.
|
|
645
|
+
return { ok: false, lines: [`could not change ${c.label}'s settings (${(err as Error).message.slice(0, 200)}); nothing was changed`] }
|
|
671
646
|
}
|
|
672
647
|
const named = MODEL_NAMES[s.model] ? [`note: models are shown by their 100xprompt names; the files get the gateway's id for ${modelName(s.model)}`] : []
|
|
673
648
|
if (o.dryRun) return { ok: true, lines: [`would change:`, ...shown, ...named, ...c.notes.map((n) => `note: ${n}`)] }
|
|
674
649
|
const created = new Set(st.tools[c.id]?.created ?? [])
|
|
675
650
|
for (const { e } of docs) if (!existsSync(e.path)) created.add(e.path)
|
|
651
|
+
// Folders connect has to create (Goose's custom_providers/), so disconnect can take them away again when they are empty.
|
|
652
|
+
const createdDirs = new Set(st.tools[c.id]?.createdDirs ?? [])
|
|
653
|
+
for (const { e } of docs) for (let dir = dirname(e.path); !existsSync(dir) && dir.startsWith(`${resolve(home)}/`); dir = dirname(dir)) createdDirs.add(dir)
|
|
654
|
+
// A file that is made private (it gets the key) goes back to the mode it had when disconnect gives it back.
|
|
655
|
+
const modes: Record<string, number> = { ...(st.tools[c.id]?.modes ?? {}) }
|
|
656
|
+
for (const { e } of docs) {
|
|
657
|
+
if (!e.holdsKey || !existsSync(e.path) || modes[e.path] !== undefined) continue
|
|
658
|
+
try {
|
|
659
|
+
modes[e.path] = statSync(e.path).mode & 0o777
|
|
660
|
+
} catch {}
|
|
661
|
+
}
|
|
676
662
|
const first = !st.tools[c.id]
|
|
677
663
|
for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
|
|
678
664
|
st.settings = s
|
|
679
|
-
st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}) }
|
|
665
|
+
st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}) }
|
|
680
666
|
saveState(home, st)
|
|
681
667
|
return {
|
|
682
668
|
ok: true,
|
|
@@ -691,36 +677,48 @@ export async function connect(
|
|
|
691
677
|
}
|
|
692
678
|
}
|
|
693
679
|
|
|
694
|
-
export function disconnect(home: string, toolId: string, o: { dryRun?: boolean } = {}): Result {
|
|
680
|
+
export function disconnect(home: string, toolId: string, o: { dryRun?: boolean; env?: Env } = {}): Result {
|
|
695
681
|
const c = connectorFor(toolId)
|
|
696
682
|
if (!c) return { ok: false, lines: [`unknown tool "${toolId}"`] }
|
|
697
683
|
const st = loadState(home)
|
|
698
684
|
const rec = st.tools[c.id]
|
|
699
685
|
if (!rec) return { ok: true, lines: [`${c.label} is not connected by brain; nothing to undo`] }
|
|
686
|
+
const codecs = codecsOf(c, home, st, o.env ?? process.env)
|
|
700
687
|
const lines: string[] = []
|
|
701
688
|
for (const path of [...new Set(rec.changes.map((x) => x.file))]) {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
}
|
|
707
|
-
for (const x of rec.changes.filter((y) => y.file === path)) {
|
|
708
|
-
const current = getKey(d, x.key)
|
|
709
|
-
// Our own value (or, for entries holding the key, still our provider/key entry): put back what was there before.
|
|
710
|
-
const ours = x.after === "(contains the key)" ? current !== undefined : same(current, x.after)
|
|
711
|
-
if (!ours) {
|
|
712
|
-
lines.push(` ${basename(path)}: ${x.key.replace(/\//g, ".")} changed since connect; left as it is`)
|
|
689
|
+
try {
|
|
690
|
+
const d = load(rec.changes.find((x) => x.file === path)!.format ?? "json", path, codecs.get(path))
|
|
691
|
+
if ("error" in d) {
|
|
692
|
+
lines.push(`${path}: ${d.error}`)
|
|
713
693
|
continue
|
|
714
694
|
}
|
|
715
|
-
|
|
716
|
-
|
|
695
|
+
for (const x of rec.changes.filter((y) => y.file === path)) {
|
|
696
|
+
const current = getKey(d, x.key)
|
|
697
|
+
// Our own value (or, for entries holding the key, still our provider/key entry): put back what was there before.
|
|
698
|
+
const ours = x.after === "(contains the key)" ? current !== undefined : same(current, x.after)
|
|
699
|
+
if (!ours) {
|
|
700
|
+
lines.push(` ${basename(path)}: ${x.key.replace(/\//g, ".")} changed since connect; left as it is`)
|
|
701
|
+
continue
|
|
702
|
+
}
|
|
703
|
+
setKey(d, x.key, x.before)
|
|
704
|
+
lines.push(` ${basename(path)}: ${x.key.replace(/\//g, ".")} ${x.before === undefined ? "removed" : "restored"}`)
|
|
705
|
+
}
|
|
706
|
+
if (o.dryRun) continue
|
|
707
|
+
const text = render(d)
|
|
708
|
+
// A file connect created and that holds nothing else now is removed, not left behind empty.
|
|
709
|
+
if (rec.created?.includes(path) && /^(\{\}\n?|\s*)$/.test(text)) rmSync(path, { force: true })
|
|
710
|
+
else writeToolFile(home, c.id, path, text, rec.modes?.[path], false)
|
|
711
|
+
} catch (err) {
|
|
712
|
+
lines.push(`${path}: could not be changed back (${(err as Error).message.slice(0, 200)}); left as it is`)
|
|
717
713
|
}
|
|
718
|
-
if (o.dryRun) continue
|
|
719
|
-
const text = render(d)
|
|
720
|
-
// A file connect created and that holds nothing else now is removed, not left behind empty.
|
|
721
|
-
if (rec.created?.includes(path) && /^(\{\}\n?|\s*)$/.test(text)) rmSync(path, { force: true })
|
|
722
|
-
else writeToolFile(home, c.id, path, text, undefined, false)
|
|
723
714
|
}
|
|
715
|
+
// Folders connect created, deepest first, if nothing else has been put in them.
|
|
716
|
+
if (!o.dryRun)
|
|
717
|
+
for (const dir of [...(rec.createdDirs ?? [])].sort((a, b) => b.length - a.length)) {
|
|
718
|
+
try {
|
|
719
|
+
if (readdirSync(dir).length === 0) rmdirSync(dir)
|
|
720
|
+
} catch {}
|
|
721
|
+
}
|
|
724
722
|
if (!o.dryRun) {
|
|
725
723
|
delete st.tools[c.id]
|
|
726
724
|
saveState(home, st)
|
|
@@ -787,7 +785,7 @@ export async function gatewayCommand(
|
|
|
787
785
|
}
|
|
788
786
|
// The browser sign-in, with a hidden paste prompt when there is a terminal to ask in.
|
|
789
787
|
const browserLogin = async () => {
|
|
790
|
-
const r = await signIn(home, { say: io.out, ask: process.stdin.isTTY ? askHidden : undefined, browser: !has("no-browser"), ...deps.signIn })
|
|
788
|
+
const r = await signIn(home, { say: io.out, ask: process.stdin.isTTY ? askHidden : undefined, browser: !has("no-browser"), ...deps.signIn }, env)
|
|
791
789
|
io.out(r.ok ? `signed in${r.email ? ` as ${r.email}` : ""}; gateway key stored in ${keyPath(home)} (mode 0600)` : `brain gateway login: ${r.why}`)
|
|
792
790
|
if (r.ok) refreshedNote(r.refreshed)
|
|
793
791
|
return r.ok
|
|
@@ -802,9 +800,13 @@ export async function gatewayCommand(
|
|
|
802
800
|
}
|
|
803
801
|
return (await browserLogin()) ? 0 : 1
|
|
804
802
|
}
|
|
805
|
-
case "logout":
|
|
803
|
+
case "logout": {
|
|
804
|
+
// The dashboard's last word from this machine: signed out (after logout there is no key to send it with).
|
|
805
|
+
const { reportSignedOut } = await import("../dashboard/send.ts")
|
|
806
|
+
await reportSignedOut(home, { env, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
|
|
806
807
|
logout(home, env).forEach((l) => io.out(l))
|
|
807
808
|
return 0
|
|
809
|
+
}
|
|
808
810
|
case "connect":
|
|
809
811
|
case "disconnect": {
|
|
810
812
|
const tool = flag("tool")
|
|
@@ -812,7 +814,7 @@ export async function gatewayCommand(
|
|
|
812
814
|
io.out(`usage: ${USAGE[sub === "connect" ? 3 : 4]}`)
|
|
813
815
|
return 2
|
|
814
816
|
}
|
|
815
|
-
if (sub === "disconnect") return print(disconnect(home, tool, { dryRun: has("dry-run") }))
|
|
817
|
+
if (sub === "disconnect") return print(disconnect(home, tool, { dryRun: has("dry-run"), env }))
|
|
816
818
|
const settings = { ...(flag("url") ? { url: flag("url")! } : {}), ...(flag("route") ? { route: flag("route")! } : {}), ...(flag("model") ? { model: flag("model")! } : {}), ...(has("no-context-edit") ? { contextEdit: false } : has("context-edit") ? { contextEdit: true } : {}) }
|
|
817
819
|
// Not signed in yet: sign in first, as the 100xprompt CLI would, then connect. Everything that can be checked here
|
|
818
820
|
// (the tool, its files, the flags) is checked first, so nobody signs in for a connect that was bound to fail.
|