@100xprompt/brain 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,845 @@
1
+ // `brain gateway login | connect | disconnect | status | tools`: point an AI coding tool at the 100xprompt gateway, and back.
2
+ //
3
+ // One command per tool, run by the person: `brain gateway connect --tool codex`. Nothing happens on install. Connecting
4
+ // a tool changes which model it uses (100xprompt's models, billed to a 100xprompt account),
5
+ // so it is always an explicit act, shown before it is done with --dry-run, and undone exactly by `disconnect`.
6
+ //
7
+ // How each change is made safe, as `brain mcp install` does it:
8
+ // - only the keys this command owns are written; everything else in the tool's file is left as it was;
9
+ // - the value each key had before is recorded (in <home>/.brain/gateway.json), and `disconnect` puts it back, or removes
10
+ // the key when there was none; a value the person changed since is left alone and reported;
11
+ // - the whole file is also copied once to <home>/.brain/backups/ before the first change;
12
+ // - writes are atomic and keep the file's mode; a file that cannot be parsed is left alone and reported.
13
+ //
14
+ // The key: `brain gateway login` signs in to 100xprompt in the browser, as `100xprompt auth login` does (signin.ts), and
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
+ // 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
+ // 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"
19
+ import { basename, dirname, join, resolve } from "node:path"
20
+ import { askHidden, browserSignIn, type SignInDeps } from "./signin.ts"
21
+
22
+ type Json = Record<string, any>
23
+
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
+ /** A value as connect and its dry run show it: the model ids in it by their product names. */
38
+ 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
+
49
+ /** 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
+ interface Change {
51
+ file: string
52
+ format: Format
53
+ key: string
54
+ before: unknown
55
+ after: unknown
56
+ }
57
+
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
+ interface State {
70
+ settings?: GatewaySettings
71
+ keySource?: string
72
+ /** Who signed in (from the sign-in page), shown by status. */
73
+ email?: string
74
+ tools: Record<string, { at: string; changes: Change[]; created?: string[] }>
75
+ }
76
+
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
+ function loadState(home: string): State {
82
+ try {
83
+ const s = JSON.parse(readFileSync(statePath(home), "utf8"))
84
+ return { tools: {}, ...s }
85
+ } catch {
86
+ return { tools: {} }
87
+ }
88
+ }
89
+
90
+ function saveState(home: string, s: State) {
91
+ mkdirSync(brainDir(home), { recursive: true, mode: 0o700 })
92
+ writePrivate(statePath(home), `${JSON.stringify(s, null, 2)}\n`)
93
+ }
94
+
95
+ /** Atomic write with mode 0600 (the key and the record of what was changed). */
96
+ function writePrivate(path: string, text: string) {
97
+ const tmp = `${path}.brain-tmp`
98
+ rmSync(tmp, { force: true })
99
+ writeFileSync(tmp, text, { mode: 0o600 })
100
+ chmodSync(tmp, 0o600)
101
+ renameSync(tmp, path)
102
+ }
103
+
104
+ /**
105
+ * Atomic write through a symlink, keeping the file's mode (or `mode` when given). With `backup`, the file as it was is
106
+ * first copied once to backups/: only on a tool's first connect, so a backup never holds the gateway key (a file that
107
+ * already has the key in it, at disconnect or a key refresh, is never copied).
108
+ */
109
+ function writeToolFile(home: string, tool: string, path: string, text: string, mode: number | undefined, backup: boolean) {
110
+ const real = existsSync(path) ? realpathSync(path) : path
111
+ mkdirSync(dirname(real), { recursive: true })
112
+ const copy = join(brainDir(home), "backups", `gateway-${tool}-${basename(path)}`)
113
+ if (backup && existsSync(real) && !existsSync(copy)) {
114
+ mkdirSync(dirname(copy), { recursive: true, mode: 0o700 })
115
+ copyFileSync(real, copy)
116
+ chmodSync(copy, 0o600)
117
+ }
118
+ let keep: number | undefined = mode
119
+ if (keep === undefined) {
120
+ try {
121
+ keep = statSync(real).mode & 0o777
122
+ } catch {}
123
+ }
124
+ const tmp = `${real}.brain-tmp`
125
+ rmSync(tmp, { force: true })
126
+ writeFileSync(tmp, text, keep === undefined ? {} : { mode: keep })
127
+ if (keep !== undefined) chmodSync(tmp, keep)
128
+ renameSync(tmp, real)
129
+ }
130
+
131
+ // ---- the key ----------------------------------------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Where the 100xprompt CLI keeps its sign-in, resolved as the CLI does: under $HUNDREDXPROMPT_HOME (its isolated home,
135
+ * XDG ignored) when set, else $XDG_DATA_HOME, else ~/.local/share. XDG_DATA_HOME describes the person's real home, so it is
136
+ * followed only when brain works in that home (a throwaway BRAIN_HOME never reaches the real sign-in).
137
+ */
138
+ export function cliAuthPath(home: string, env: Record<string, string | undefined> = process.env): string {
139
+ const iso = env.HUNDREDXPROMPT_HOME?.trim()
140
+ const xdg = env.XDG_DATA_HOME
141
+ const data = iso ? join(resolve(iso), ".local", "share") : xdg?.startsWith("/") && env.HOME && resolve(home) === resolve(env.HOME) ? xdg : join(home, ".local", "share")
142
+ return join(data, "100xprompt", "auth.json")
143
+ }
144
+
145
+ function keyFromCli(home: string, env: Record<string, string | undefined>): string | undefined {
146
+ try {
147
+ const a = JSON.parse(readFileSync(cliAuthPath(home, env), "utf8"))
148
+ const k = a?.["100xprompt"]?.key
149
+ return typeof k === "string" && k.trim() ? k.trim() : undefined
150
+ } catch {
151
+ return undefined
152
+ }
153
+ }
154
+
155
+ export function hasKey(home: string): boolean {
156
+ try {
157
+ return readFileSync(keyPath(home), "utf8").trim().length > 0
158
+ } catch {
159
+ return false
160
+ }
161
+ }
162
+
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
+ /**
173
+ * Writes the key (0600, never printed) and where it came from. Tools that keep their own copy of the key (Codex, Gemini
174
+ * 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
+ * on the old one while status names the new. Returns the tools whose copy was replaced.
176
+ */
177
+ export function storeKey(home: string, key: string, source: string, email?: string): string[] {
178
+ const old = readKey(home)
179
+ mkdirSync(brainDir(home), { recursive: true, mode: 0o700 })
180
+ writePrivate(keyPath(home), `${key}\n`)
181
+ const s = loadState(home)
182
+ s.keySource = source
183
+ if (email) s.email = email
184
+ else delete s.email
185
+ saveState(home, s)
186
+ return refreshKeyCopies(home, old, key)
187
+ }
188
+
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[] {
191
+ if (!old || old === key) return []
192
+ const st = loadState(home)
193
+ const done = new Set<string>()
194
+ for (const c of CONNECTORS) {
195
+ const rec = st.tools[c.id]
196
+ 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)
199
+ if ("error" in d) continue
200
+ 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
+ }
208
+ writeToolFile(home, c.id, x.file, render(d), 0o600, false)
209
+ done.add(c.label)
210
+ }
211
+ }
212
+ return [...done]
213
+ }
214
+
215
+ /**
216
+ * Stores a key that is already on this machine: from `env[keyEnv]` (scripts, CI), or with `fromCli` from the 100xprompt
217
+ * CLI's sign-in. Signing in in the browser is `signIn` below.
218
+ */
219
+ export function login(home: string, opts: { keyEnv?: string; fromCli?: boolean; env?: Record<string, string | undefined> } = {}): { ok: true; source: string; refreshed: string[] } | { ok: false; why: string } {
220
+ const env = opts.env ?? process.env
221
+ let key: string | undefined
222
+ let source: string
223
+ if (opts.keyEnv !== undefined && opts.fromCli) return { ok: false, why: "use --key-env or --from-cli, not both" }
224
+ if (opts.keyEnv !== undefined) {
225
+ // A key passed where its variable's name belongs (`--key-env "$KEY"`) must not be echoed back in the error.
226
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(opts.keyEnv)) return { ok: false, why: "--key-env takes the NAME of an environment variable (for example --key-env GATEWAY_KEY), not the key itself" }
227
+ key = env[opts.keyEnv]?.trim()
228
+ source = `the ${opts.keyEnv} environment variable`
229
+ if (!key) return { ok: false, why: `${opts.keyEnv} is empty or not set` }
230
+ } else if (opts.fromCli) {
231
+ key = keyFromCli(home, env)
232
+ source = "the 100xprompt CLI's sign-in"
233
+ 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
+ } 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
+ 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) }
237
+ }
238
+
239
+ /** 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 }> {
241
+ const r = await browserSignIn(d)
242
+ if (!r.ok) return r
243
+ const source = r.via === "browser" ? "signing in in the browser" : "a pasted key"
244
+ const refreshed = storeKey(home, r.key, source, r.email)
245
+ return { ok: true, source, ...(r.email ? { email: r.email } : {}), refreshed }
246
+ }
247
+
248
+ /** Forgets the key. Tools still connected stop working until the next login, which is said. */
249
+ export function logout(home: string, env: Record<string, string | undefined> = process.env): string[] {
250
+ const had = hasKey(home)
251
+ rmSync(keyPath(home), { force: true })
252
+ const s = loadState(home)
253
+ delete s.keySource
254
+ delete s.email
255
+ if (existsSync(statePath(home))) saveState(home, s)
256
+ const out = [had ? `signed out: removed ${keyPath(home)}` : "not signed in; nothing to remove"]
257
+ const settings = s.settings ?? { url: DEFAULT_URL, route: DEFAULT_ROUTE, model: DEFAULT_MODEL, contextEdit: true }
258
+ const connected = CONNECTORS.filter((c) => s.tools[c.id])
259
+ const copies = connected.filter((c) => c.edits(home, settings, env).some((e) => e.holdsKey))
260
+ const readers = connected.filter((c) => !copies.includes(c))
261
+ const names = (cs: Connector[]) => cs.map((c) => c.label).join(" and ")
262
+ 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.`)
264
+ if (copies.length)
265
+ 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
+ return out
267
+ }
268
+
269
+ // ---- connectors -------------------------------------------------------------------------------------------------------
270
+
271
+ /** 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
+ 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}`
326
+ 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
+ const codexDir = (home: string, env: Record<string, string | undefined>) => (env.CODEX_HOME && existsSync(env.CODEX_HOME) ? env.CODEX_HOME : join(home, ".codex"))
328
+ 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
+
330
+ export const CONNECTORS: Connector[] = [
331
+ {
332
+ id: "claude-code",
333
+ label: "Claude Code",
334
+ file: (home, env) => join(claudeDir(home, env), "settings.json"),
335
+ probe: [".claude", ".claude.json"],
336
+ authHeader: "x-api-key",
337
+ edits: (home, s, env) => [
338
+ {
339
+ path: join(claudeDir(home, env), "settings.json"),
340
+ format: "json",
341
+ values: {
342
+ "env/ANTHROPIC_BASE_URL": routeUrl(s),
343
+ "env/ANTHROPIC_MODEL": s.model,
344
+ "env/ANTHROPIC_DEFAULT_HAIKU_MODEL": s.model,
345
+ ...(s.contextEdit ? { "env/ANTHROPIC_CUSTOM_HEADERS": `${EDIT_HEADER}: on` } : {}),
346
+ apiKeyHelper: `cat ${shellQuote(keyPath(home))}`,
347
+ },
348
+ },
349
+ ],
350
+ notes: [
351
+ "Claude Code now runs on the gateway's model, not on Claude; its key is read from the brain's key file at each start (apiKeyHelper).",
352
+ "An ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN exported in your shell takes precedence over the helper: unset it for Claude Code.",
353
+ ],
354
+ },
355
+ {
356
+ id: "codex",
357
+ label: "Codex",
358
+ file: (home, env) => join(codexDir(home, env), "config.toml"),
359
+ probe: [".codex"],
360
+ authHeader: "authorization",
361
+ edits: (home, s, env) => [
362
+ {
363
+ path: join(codexDir(home, env), "config.toml"),
364
+ format: "toml",
365
+ holdsKey: true,
366
+ values: {
367
+ model_provider: "brain-gateway",
368
+ model: s.model,
369
+ "model_providers/brain-gateway": {
370
+ name: "100xprompt gateway",
371
+ base_url: routeUrl(s),
372
+ wire_api: "responses",
373
+ experimental_bearer_token: readKey(home),
374
+ ...(s.contextEdit ? { http_headers: { [EDIT_HEADER]: "on" } } : {}),
375
+ },
376
+ },
377
+ },
378
+ ],
379
+ notes: ["Codex keeps the key in its config.toml (Codex has no key-file option), so the file is now readable by you only (0600)."],
380
+ },
381
+ {
382
+ id: "opencode",
383
+ label: "OpenCode",
384
+ file: (home) => join(home, ".config", "opencode", "opencode.json"),
385
+ probe: [".config/opencode"],
386
+ authHeader: "authorization",
387
+ edits: (home, s) => [
388
+ {
389
+ path: join(home, ".config", "opencode", "opencode.json"),
390
+ format: "json",
391
+ values: {
392
+ "provider/brain-gateway": {
393
+ npm: "@ai-sdk/openai-compatible",
394
+ name: "100xprompt gateway",
395
+ options: { baseURL: `${routeUrl(s)}/v1`, apiKey: `{file:${keyPath(home)}}`, ...(s.contextEdit ? { headers: { [EDIT_HEADER]: "on" } } : {}) },
396
+ models: { [s.model]: { name: modelName(s.model) } },
397
+ },
398
+ model: `brain-gateway/${s.model}`,
399
+ },
400
+ },
401
+ ],
402
+ notes: ["OpenCode reads the key from the brain's key file each time it starts."],
403
+ },
404
+ {
405
+ id: "gemini-cli",
406
+ label: "Gemini CLI",
407
+ file: (home, env) => join(geminiDir(home, env), ".env"),
408
+ probe: [".gemini"],
409
+ authHeader: "x-goog-api-key",
410
+ edits: (home, s, env) => [
411
+ { path: join(geminiDir(home, env), ".env"), format: "dotenv", holdsKey: true, values: { GOOGLE_GEMINI_BASE_URL: routeUrl(s), GEMINI_API_KEY: readKey(home), GEMINI_MODEL: s.model } },
412
+ { path: join(geminiDir(home, env), "settings.json"), format: "json", values: { "security/auth/selectedType": "gemini-api-key" } },
413
+ ],
414
+ notes: [
415
+ "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
+ "Gemini CLI cannot send extra headers: context editing applies only when the gateway turns it on for everyone.",
417
+ ],
418
+ },
419
+ ]
420
+
421
+ /** Keys whose value contains the key: recorded and shown without it. */
422
+ const SECRET_KEYS = new Set(["GEMINI_API_KEY", "model_providers/brain-gateway"])
423
+
424
+ export const connectorFor = (id: string) => CONNECTORS.find((c) => c.id === id || c.label.toLowerCase() === id.toLowerCase())
425
+
426
+ // ---- reading and writing each format ---------------------------------------------------------------------------------
427
+
428
+ type Doc = { kind: "json"; obj: Json; indent: string } | { kind: "toml"; text: string } | { kind: "dotenv"; lines: string[] }
429
+
430
+ function load(format: Format, path: string): Doc | { error: string } {
431
+ let text = ""
432
+ if (existsSync(path)) {
433
+ try {
434
+ text = readFileSync(path, "utf8")
435
+ } catch (e) {
436
+ return { error: `cannot read it (${(e as Error).message})` }
437
+ }
438
+ }
439
+ if (format === "json") {
440
+ if (!text.trim()) return { kind: "json", obj: {}, indent: " " }
441
+ try {
442
+ const v = JSON.parse(text)
443
+ if (!v || typeof v !== "object" || Array.isArray(v)) return { error: "it is not a JSON object; left as it is" }
444
+ return { kind: "json", obj: v, indent: text.match(/\n([ \t]+)"/)?.[1] ?? " " }
445
+ } catch {
446
+ return { error: "it is not plain JSON (comments?); left as it is" }
447
+ }
448
+ }
449
+ if (format === "toml") {
450
+ if (text.trim()) {
451
+ try {
452
+ Bun.TOML.parse(text)
453
+ } catch {
454
+ return { error: "it is not valid TOML; left as it is" }
455
+ }
456
+ }
457
+ return { kind: "toml", text }
458
+ }
459
+ return { kind: "dotenv", lines: text ? text.replace(/\n$/, "").split("\n") : [] }
460
+ }
461
+
462
+ function render(d: Doc): string {
463
+ if (d.kind === "json") return `${JSON.stringify(d.obj, null, d.indent)}\n`
464
+ if (d.kind === "toml") return d.text.endsWith("\n") || !d.text ? d.text : `${d.text}\n`
465
+ return d.lines.length ? `${d.lines.join("\n")}\n` : ""
466
+ }
467
+
468
+ // TOML: top-level keys live before the first [table]; our table is [model_providers.brain-gateway].
469
+ const tomlValue = (v: unknown): string =>
470
+ typeof v === "string" ? JSON.stringify(v) : typeof v === "number" || typeof v === "boolean" ? String(v) : `{ ${Object.entries(v as Json).map(([k, x]) => `${JSON.stringify(k)} = ${tomlValue(x)}`).join(", ")} }`
471
+ const TABLE = "model_providers.brain-gateway"
472
+ function tomlGet(text: string, key: string): unknown {
473
+ const parsed = text.trim() ? (Bun.TOML.parse(text) as Json) : {}
474
+ if (key === `model_providers/brain-gateway`) return parsed.model_providers?.["brain-gateway"]
475
+ return parsed[key]
476
+ }
477
+ function tomlTableRange(text: string): { start: number; end: number } | undefined {
478
+ const m = /^[ \t]*\[model_providers\.brain-gateway\][ \t]*(#.*)?$/m.exec(text)
479
+ if (!m) return undefined
480
+ const after = m.index + m[0].length
481
+ const n = /^[ \t]*\[/m.exec(text.slice(after))
482
+ return { start: m.index, end: n ? after + n.index : text.length }
483
+ }
484
+ function tomlSet(text: string, key: string, v: unknown): string {
485
+ if (key === "model_providers/brain-gateway") {
486
+ const r = tomlTableRange(text)
487
+ const cleaned = r ? text.slice(0, r.start) + text.slice(r.end) : text
488
+ if (v === undefined) return cleaned.replace(/\n{2,}$/, "\n")
489
+ const body = Object.entries(v as Json).map(([k, x]) => `${k} = ${tomlValue(x)}`).join("\n")
490
+ return `${cleaned.replace(/\n*$/, "")}${cleaned.trim() ? "\n\n" : ""}[${TABLE}]\n${body}\n`
491
+ }
492
+ // A top-level key: replace its line in the preamble (before the first table), or add it at the top.
493
+ const firstTable = /^[ \t]*\[/m.exec(text)
494
+ const pre = firstTable ? text.slice(0, firstTable.index) : text
495
+ const rest = firstTable ? text.slice(firstTable.index) : ""
496
+ const line = new RegExp(`^[ \\t]*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[ \\t]*=.*(?:\\n|$)`, "m")
497
+ let out: string
498
+ // A line that is replaced keeps its trailing comment (`model = "x" # mine`), so disconnect gives the line back as it was.
499
+ if (line.test(pre)) out = v === undefined ? pre.replace(line, "") : pre.replace(line, (m) => `${key} = ${tomlValue(v)}${trailingComment(m.replace(/\n$/, ""))}\n`)
500
+ else out = v === undefined ? pre : `${key} = ${tomlValue(v)}\n${pre}`
501
+ return out + rest
502
+ }
503
+
504
+ /** The ` # comment` at the end of a TOML `key = value` line (a # outside any quoted string), with the spaces before it. */
505
+ export function trailingComment(line: string): string {
506
+ let quote: string | undefined
507
+ for (let i = line.indexOf("=") + 1; i > 0 && i < line.length; i++) {
508
+ const c = line[i]
509
+ if (quote) {
510
+ if (c === "\\" && quote === '"') i++
511
+ else if (c === quote) quote = undefined
512
+ } else if (c === '"' || c === "'") quote = c
513
+ else if (c === "#") {
514
+ let j = i
515
+ while (j > 0 && /[ \t]/.test(line[j - 1]!)) j--
516
+ return line.slice(j).trimEnd()
517
+ }
518
+ }
519
+ return ""
520
+ }
521
+
522
+ // dotenv: KEY=value lines; ours are plain assignments (values quoted when needed).
523
+ const dotenvLine = (k: string, v: string) => `${k}=${/[\s#"'$]/.test(v) ? JSON.stringify(v) : v}`
524
+ function dotenvGet(lines: string[], key: string): string | undefined {
525
+ for (const l of lines) {
526
+ const m = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(.*)$`).exec(l)
527
+ if (m) {
528
+ const raw = m[1]!.trim()
529
+ return /^".*"$/.test(raw) ? JSON.parse(raw) : /^'.*'$/.test(raw) ? raw.slice(1, -1) : raw
530
+ }
531
+ }
532
+ return undefined
533
+ }
534
+ function dotenvSet(lines: string[], key: string, v: string | undefined): string[] {
535
+ const re = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`)
536
+ const i = lines.findIndex((l) => re.test(l))
537
+ const out = lines.slice()
538
+ if (v === undefined) {
539
+ if (i >= 0) out.splice(i, 1)
540
+ return out
541
+ }
542
+ if (i >= 0) out[i] = dotenvLine(key, v)
543
+ else out.push(dotenvLine(key, v))
544
+ return out
545
+ }
546
+
547
+ function getKey(d: Doc, key: string): unknown {
548
+ if (d.kind === "json") return getAt(d.obj, key)
549
+ if (d.kind === "toml") return tomlGet(d.text, key)
550
+ return dotenvGet(d.lines, key)
551
+ }
552
+ function setKey(d: Doc, key: string, v: unknown) {
553
+ if (d.kind === "json") return v === undefined ? deleteAt(d.obj, key) : setAt(d.obj, key, v)
554
+ if (d.kind === "toml") d.text = tomlSet(d.text, key, v)
555
+ else d.lines = dotenvSet(d.lines, key, v === undefined ? undefined : String(v))
556
+ }
557
+
558
+ // ---- the free key check ---------------------------------------------------------------------------------------------
559
+
560
+ export interface Probe {
561
+ verdict: "accepted" | "rejected" | "unreachable"
562
+ /** The key is accepted but the account cannot use it now (quota or allowance used up): the gateway's own words. */
563
+ limit?: string
564
+ }
565
+
566
+ /**
567
+ * Asks the gateway whether it accepts this key the way the tool will send it (a model-list call: no model, no tokens).
568
+ * A key that is accepted on an account whose quota is used up is still accepted, with `limit` saying so: connecting is
569
+ * fine, but every request will fail until it resets, which the person should hear now and not from the tool later.
570
+ */
571
+ export async function probeKey(home: string, c: Connector, s: GatewaySettings, fetchImpl: typeof fetch = fetch): Promise<Probe> {
572
+ const key = readKey(home)
573
+ const headers: Record<string, string> = c.authHeader === "authorization" ? { authorization: `Bearer ${key}` } : { [c.authHeader]: key }
574
+ const ctl = new AbortController()
575
+ const t = setTimeout(() => ctl.abort(), 8_000)
576
+ try {
577
+ const r = await fetchImpl(`${routeUrl(s)}/v1/models`, { headers, signal: ctl.signal, redirect: "error" })
578
+ // 401 is the gateway refusing the key. Anything else means the key got past authentication: the model list itself is
579
+ // not served (403 "models.list is not served"), which is fine, since nothing is generated.
580
+ if (r.status === 401) {
581
+ void r.body?.cancel().catch(() => {})
582
+ return { verdict: "rejected" }
583
+ }
584
+ // 402: monthly quota or daily free allowance used up; 429: weekly or 5-hour quota, or the per-minute rate limit
585
+ // (gone within a minute, so not worth a warning).
586
+ if (r.status === 402 || r.status === 429) {
587
+ let err: { type?: unknown; message?: unknown } | undefined
588
+ try {
589
+ err = JSON.parse((await r.text()).slice(0, 8_000))?.error
590
+ } catch {}
591
+ if (err?.type === "rate_limit_exceeded") return { verdict: "accepted" }
592
+ const said = typeof err?.message === "string" && err.message.trim() ? err.message : `the gateway answered HTTP ${r.status}`
593
+ const clean = (key ? said.replaceAll(key, "<key>") : said).replace(/[\u0000-\u001f\u007f]+/g, " ").trim()
594
+ return { verdict: "accepted", limit: clean.length > 300 ? `${clean.slice(0, 299)}…` : clean }
595
+ }
596
+ void r.body?.cancel().catch(() => {})
597
+ return { verdict: "accepted" }
598
+ } catch {
599
+ return { verdict: "unreachable" }
600
+ } finally {
601
+ clearTimeout(t)
602
+ }
603
+ }
604
+
605
+ // ---- connect / disconnect / status ------------------------------------------------------------------------------------
606
+
607
+ export interface Result {
608
+ ok: boolean
609
+ lines: string[]
610
+ }
611
+
612
+ export function installedHere(c: Connector, home: string, env: Record<string, string | undefined>): boolean {
613
+ const f = c.file(home, env)
614
+ return existsSync(f) || existsSync(dirname(f)) || c.probe.some((p) => existsSync(join(home, p)))
615
+ }
616
+
617
+ export async function connect(
618
+ home: string,
619
+ toolId: string,
620
+ o: { settings?: Partial<GatewaySettings>; dryRun?: boolean; probe?: boolean; env?: Record<string, string | undefined>; fetchImpl?: typeof fetch } = {},
621
+ ): Promise<Result> {
622
+ const env = o.env ?? process.env
623
+ const c = connectorFor(toolId)
624
+ if (!c) return { ok: false, lines: [`unknown tool "${toolId}"; \`brain gateway tools\` lists the ones that can be connected`] }
625
+ if (!hasKey(home) && !o.dryRun) return { ok: false, lines: ["not signed in to the gateway yet: run `brain gateway login`"] }
626
+ const st = loadState(home)
627
+ const s: GatewaySettings = {
628
+ url: o.settings?.url ?? st.settings?.url ?? DEFAULT_URL,
629
+ route: o.settings?.route ?? st.settings?.route ?? DEFAULT_ROUTE,
630
+ model: o.settings?.model !== undefined ? modelId(o.settings.model) : (st.settings?.model ?? DEFAULT_MODEL),
631
+ contextEdit: o.settings?.contextEdit ?? st.settings?.contextEdit ?? true,
632
+ }
633
+ if (!/^https?:\/\/[^\s]+$/.test(s.url) || (/^http:/.test(s.url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/.test(s.url))) return { ok: false, lines: ["--url must be https:// (http:// only for a gateway on this machine)"] }
634
+ if (!/^[a-z0-9-]{1,40}$/.test(s.route)) return { ok: false, lines: ["--route must be a gateway route name such as flash or pro"] }
635
+ if (!/^[\w.:/-]{1,80}$/.test(s.model)) return { ok: false, lines: ["--model must be pro, flash or a plain model name"] }
636
+ const main = c.file(home, env)
637
+ if (!installedHere(c, home, env)) return { ok: false, lines: [`${c.label} has not been run on this machine (nothing at ${main}); run it once, then connect it`] }
638
+ let limit: string | undefined
639
+ if (o.probe !== false && !o.dryRun) {
640
+ const p = await probeKey(home, c, s, o.fetchImpl)
641
+ limit = p.limit
642
+ if (p.verdict === "rejected") return { ok: false, lines: [`the gateway at ${routeUrl(s)} refused the key the way ${c.label} sends it (${c.authHeader}); nothing was changed`] }
643
+ if (p.verdict === "unreachable") return { ok: false, lines: [`could not reach ${routeUrl(s)}; nothing was changed (pass --no-probe to connect anyway)`] }
644
+ }
645
+ // Read and check every file first: nothing is written unless all of them can be changed.
646
+ const edits = c.edits(home, s, env)
647
+ const docs: Array<{ e: FileEdit; d: Doc }> = []
648
+ for (const e of edits) {
649
+ const d = load(e.format, e.path)
650
+ if ("error" in d) return { ok: false, lines: [`${e.path}: ${d.error}; nothing was changed`] }
651
+ docs.push({ e, d })
652
+ }
653
+ const prior = st.tools[c.id]?.changes ?? []
654
+ const changes: Change[] = []
655
+ 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)
670
+ }
671
+ }
672
+ 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
+ if (o.dryRun) return { ok: true, lines: [`would change:`, ...shown, ...named, ...c.notes.map((n) => `note: ${n}`)] }
674
+ const created = new Set(st.tools[c.id]?.created ?? [])
675
+ for (const { e } of docs) if (!existsSync(e.path)) created.add(e.path)
676
+ const first = !st.tools[c.id]
677
+ for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
678
+ st.settings = s
679
+ st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}) }
680
+ saveState(home, st)
681
+ return {
682
+ ok: true,
683
+ lines: [
684
+ `connected ${c.label} to ${routeUrl(s)} (${modelName(s.model)}${s.contextEdit ? ", context editing on" : ""})`,
685
+ ...(limit ? [`warning: the gateway accepts the key, but this account cannot use it right now: ${limit}`, `warning: ${c.label} will get that error on every request until the limit resets (or brain gateway disconnect --tool ${c.id} puts it back).`] : []),
686
+ "changed:",
687
+ ...shown,
688
+ ...c.notes.map((n) => `note: ${n}`),
689
+ `undo: brain gateway disconnect --tool ${c.id}`,
690
+ ],
691
+ }
692
+ }
693
+
694
+ export function disconnect(home: string, toolId: string, o: { dryRun?: boolean } = {}): Result {
695
+ const c = connectorFor(toolId)
696
+ if (!c) return { ok: false, lines: [`unknown tool "${toolId}"`] }
697
+ const st = loadState(home)
698
+ const rec = st.tools[c.id]
699
+ if (!rec) return { ok: true, lines: [`${c.label} is not connected by brain; nothing to undo`] }
700
+ const lines: string[] = []
701
+ 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`)
713
+ continue
714
+ }
715
+ setKey(d, x.key, x.before)
716
+ lines.push(` ${basename(path)}: ${x.key.replace(/\//g, ".")} ${x.before === undefined ? "removed" : "restored"}`)
717
+ }
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
+ }
724
+ if (!o.dryRun) {
725
+ delete st.tools[c.id]
726
+ saveState(home, st)
727
+ }
728
+ return { ok: true, lines: [`${o.dryRun ? "would disconnect" : "disconnected"} ${c.label}:`, ...lines] }
729
+ }
730
+
731
+ export function status(home: string, env: Record<string, string | undefined> = process.env): string[] {
732
+ const st = loadState(home)
733
+ const s = st.settings
734
+ const out = [
735
+ `gateway: ${s ? `${routeUrl(s)}, ${modelName(s.model)}, context editing ${s.contextEdit ? "on" : "off"}` : `${DEFAULT_URL}/${DEFAULT_ROUTE}, ${modelName(DEFAULT_MODEL)} (defaults; nothing connected yet)`}`,
736
+ `key: ${hasKey(home) ? `${st.email ? `signed in as ${st.email}; ` : ""}stored (from ${st.keySource ?? "an earlier login"}) in ${keyPath(home)}` : "not signed in (brain gateway login)"}`,
737
+ "",
738
+ ]
739
+ for (const c of CONNECTORS) {
740
+ const rec = st.tools[c.id]
741
+ const here = installedHere(c, home, env)
742
+ out.push(`${c.label.padEnd(12)} ${rec ? `connected since ${rec.at.slice(0, 10)}` : here ? "not connected" : "not installed here"} (${c.file(home, env)})`)
743
+ }
744
+ return out
745
+ }
746
+
747
+ // ---- the command --------------------------------------------------------------------------------------------------------
748
+
749
+ const USAGE = [
750
+ "brain gateway login [--no-browser] sign in to 100xprompt in your browser, as `100xprompt auth login` does",
751
+ "brain gateway login --key-env VAR | --from-cli take the key from VAR (scripts, CI) or from the 100xprompt CLI's sign-in",
752
+ "brain gateway logout forget the key",
753
+ "brain gateway connect --tool <id> [--model pro|flash] [--route flash] [--url https://...] [--no-context-edit | --context-edit] [--dry-run] [--no-probe]",
754
+ "brain gateway disconnect --tool <id> [--dry-run]",
755
+ "brain gateway status | tools",
756
+ ]
757
+
758
+ export async function gatewayCommand(
759
+ args: string[],
760
+ home: string,
761
+ io: { out(l: string): void } = { out: (l) => console.log(l) },
762
+ deps: { env?: Record<string, string | undefined>; fetchImpl?: typeof fetch; signIn?: Partial<SignInDeps> } = {},
763
+ ): Promise<number> {
764
+ const [sub, ...rest] = args
765
+ // A value flag takes the word after it; a flag there instead (`--key-env --from-cli`) is a missing value, not the value.
766
+ const VALUE_FLAGS = ["key-env", "tool", "route", "model", "url"]
767
+ const flag = (n: string) => {
768
+ const i = rest.indexOf(`--${n}`)
769
+ const v = i >= 0 ? rest[i + 1] : undefined
770
+ return v === undefined || v.startsWith("--") ? undefined : v
771
+ }
772
+ const has = (n: string) => rest.includes(`--${n}`)
773
+ const env = deps.env ?? process.env
774
+ const print = (r: Result) => {
775
+ r.lines.forEach((l) => io.out(l))
776
+ return r.ok ? 0 : 1
777
+ }
778
+ const usageFor: Record<string, number[]> = { login: [0, 1], logout: [2], connect: [3], disconnect: [4] }
779
+ const missing = VALUE_FLAGS.find((n) => has(n) && flag(n) === undefined)
780
+ if (missing) {
781
+ io.out(`--${missing} needs a value`)
782
+ for (const i of usageFor[sub ?? ""] ?? []) io.out(`usage: ${USAGE[i]}`)
783
+ return 2
784
+ }
785
+ const refreshedNote = (labels: string[]) => {
786
+ if (labels.length) io.out(`the new key also replaced the old one in ${labels.join(" and ")}'s own config`)
787
+ }
788
+ // The browser sign-in, with a hidden paste prompt when there is a terminal to ask in.
789
+ const browserLogin = async () => {
790
+ const r = await signIn(home, { say: io.out, ask: process.stdin.isTTY ? askHidden : undefined, browser: !has("no-browser"), ...deps.signIn })
791
+ 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
+ if (r.ok) refreshedNote(r.refreshed)
793
+ return r.ok
794
+ }
795
+ switch (sub) {
796
+ case "login": {
797
+ if (has("key-env") || has("from-cli")) {
798
+ const r = login(home, { ...(has("key-env") ? { keyEnv: flag("key-env") } : {}), fromCli: has("from-cli"), env })
799
+ io.out(r.ok ? `gateway key stored in ${keyPath(home)} (mode 0600), from ${r.source}` : `brain gateway login: ${r.why}`)
800
+ if (r.ok) refreshedNote(r.refreshed)
801
+ return r.ok ? 0 : 1
802
+ }
803
+ return (await browserLogin()) ? 0 : 1
804
+ }
805
+ case "logout":
806
+ logout(home, env).forEach((l) => io.out(l))
807
+ return 0
808
+ case "connect":
809
+ case "disconnect": {
810
+ const tool = flag("tool")
811
+ if (!tool) {
812
+ io.out(`usage: ${USAGE[sub === "connect" ? 3 : 4]}`)
813
+ return 2
814
+ }
815
+ if (sub === "disconnect") return print(disconnect(home, tool, { dryRun: has("dry-run") }))
816
+ 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
+ // Not signed in yet: sign in first, as the 100xprompt CLI would, then connect. Everything that can be checked here
818
+ // (the tool, its files, the flags) is checked first, so nobody signs in for a connect that was bound to fail.
819
+ if (!hasKey(home) && !has("dry-run")) {
820
+ const pre = await connect(home, tool, { settings, dryRun: true, env })
821
+ if (!pre.ok) return print(pre)
822
+ io.out("not signed in to the gateway yet; signing in first")
823
+ if (!(await browserLogin())) return 1
824
+ }
825
+ return print(
826
+ await connect(home, tool, {
827
+ settings,
828
+ dryRun: has("dry-run"),
829
+ probe: !has("no-probe"),
830
+ env,
831
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
832
+ }),
833
+ )
834
+ }
835
+ case "status":
836
+ status(home, env).forEach((l) => io.out(l))
837
+ return 0
838
+ case "tools":
839
+ for (const c of CONNECTORS) io.out(`${c.id.padEnd(12)} ${c.label}: ${c.file(home, env)}`)
840
+ return 0
841
+ default:
842
+ USAGE.forEach((l) => io.out(l))
843
+ return sub ? 2 : 0
844
+ }
845
+ }