@100xprompt/brain 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +66 -3
  2. package/package.json +1 -1
  3. package/src/cli.ts +28 -3
  4. package/src/dashboard/cli.ts +148 -0
  5. package/src/dashboard/conversions.ts +170 -0
  6. package/src/dashboard/report.ts +275 -0
  7. package/src/dashboard/send.ts +213 -0
  8. package/src/dashboard/state.ts +95 -0
  9. package/src/gateway/common.ts +232 -0
  10. package/src/gateway/connect.ts +164 -162
  11. package/src/gateway/connectors/aider.ts +356 -0
  12. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  13. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  14. package/src/gateway/connectors/antigravity.ts +6 -0
  15. package/src/gateway/connectors/auggie.ts +6 -0
  16. package/src/gateway/connectors/cline-sdk.ts +85 -0
  17. package/src/gateway/connectors/cline.ts +34 -0
  18. package/src/gateway/connectors/codebuff.ts +6 -0
  19. package/src/gateway/connectors/continue.ts +579 -0
  20. package/src/gateway/connectors/copilot-cli.ts +138 -0
  21. package/src/gateway/connectors/crush.ts +313 -0
  22. package/src/gateway/connectors/cursor-cli.ts +6 -0
  23. package/src/gateway/connectors/cursor.ts +6 -0
  24. package/src/gateway/connectors/devin.ts +6 -0
  25. package/src/gateway/connectors/factory-droid.ts +308 -0
  26. package/src/gateway/connectors/goose.ts +79 -0
  27. package/src/gateway/connectors/grok-cli.ts +82 -0
  28. package/src/gateway/connectors/hermes.ts +230 -0
  29. package/src/gateway/connectors/iflow-cli.ts +50 -0
  30. package/src/gateway/connectors/index.ts +86 -0
  31. package/src/gateway/connectors/kilo-cli.ts +434 -0
  32. package/src/gateway/connectors/kilo-code.ts +32 -0
  33. package/src/gateway/connectors/kimi-code.ts +241 -0
  34. package/src/gateway/connectors/kiro-cli.ts +6 -0
  35. package/src/gateway/connectors/kiro-ide.ts +6 -0
  36. package/src/gateway/connectors/letta-code.ts +170 -0
  37. package/src/gateway/connectors/mastracode.ts +91 -0
  38. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  39. package/src/gateway/connectors/muse.ts +27 -0
  40. package/src/gateway/connectors/omp.ts +279 -0
  41. package/src/gateway/connectors/openhands.ts +296 -0
  42. package/src/gateway/connectors/pearai.ts +52 -0
  43. package/src/gateway/connectors/pi.ts +357 -0
  44. package/src/gateway/connectors/qoder-cli.ts +6 -0
  45. package/src/gateway/connectors/qwen-code.ts +411 -0
  46. package/src/gateway/connectors/roo.ts +43 -0
  47. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  48. package/src/gateway/connectors/void.ts +6 -0
  49. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  50. package/src/gateway/connectors/windsurf.ts +6 -0
  51. package/src/gateway/connectors/zed.ts +29 -0
@@ -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
- type Json = Record<string, any>
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 is still `old`: a changed one is left). */
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
- for (const x of rec.changes.filter((y) => SECRET_KEYS.has(y.key))) {
198
- const d = load(x.format, x.file)
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 (x.key === "GEMINI_API_KEY") {
202
- if (cur !== old) continue
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
- const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
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
- export const CONNECTORS: Connector[] = [
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
- /** Keys whose value contains the key: recorded and shown without it. */
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
- return existsSync(f) || existsSync(dirname(f)) || c.probe.some((p) => existsSync(join(home, p)))
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
- for (const { e, d } of docs) {
657
- for (const [key, after] of Object.entries(e.values)) {
658
- const current = getKey(d, key)
659
- // A reconnect keeps the value from before the FIRST connect, so disconnect still restores the person's own.
660
- const was = prior.find((x) => x.file === e.path && x.key === key)
661
- changes.push({ file: e.path, format: e.format, key, before: was ? was.before : current, after: SECRET_KEYS.has(key) ? "(contains the key)" : after })
662
- if (!same(current, after)) setKey(d, key, after)
663
- shown.push(` ${basename(e.path)}: ${key.replace(/\//g, ".")} = ${SECRET_KEYS.has(key) ? "(gateway settings and key)" : shownValue(after)}`)
664
- }
665
- // A key an earlier connect set that this one no longer wants (--no-context-edit after a connect with it): put it back now.
666
- for (const x of prior) {
667
- if (x.file !== e.path || x.key in e.values) continue
668
- const current = getKey(d, x.key)
669
- if (x.after === "(contains the key)" ? current !== undefined : same(current, x.after)) setKey(d, x.key, x.before)
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
- const d = load(rec.changes.find((x) => x.file === path)!.format ?? "json", path)
703
- if ("error" in d) {
704
- lines.push(`${path}: ${d.error}`)
705
- continue
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
- setKey(d, x.key, x.before)
716
- lines.push(` ${basename(path)}: ${x.key.replace(/\//g, ".")} ${x.before === undefined ? "removed" : "restored"}`)
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.