@100xprompt/brain 0.0.0-stage → 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.
Files changed (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -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 +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
@@ -0,0 +1,286 @@
1
+ // Gateway connector for GitHub Copilot Chat in VS Code (checked 2026-11-10 against VS Code 1.144.0 and the Copilot Chat
2
+ // extension 0.72.0 built into it: /Applications/Visual Studio Code.app/Contents/Resources/app/extensions/copilot).
3
+ //
4
+ // Bring-your-own-key models live in the VS Code user folder, in chatLanguageModels.json (out/vs/workbench/
5
+ // workbench.desktop.main.js: profile `languageModelsResource` = <User>/chatLanguageModels.json, a JSON array of provider
6
+ // groups `{ name, vendor, ...settings }` that VS Code watches and parses as JSON with comments). The Copilot extension's
7
+ // "Custom Endpoint" provider (vendor `customendpoint`, package.json contributes.languageModelChatProviders; in Stable since
8
+ // VS Code 1.122, https://code.visualstudio.com/updates/v1_122) takes per model an `id`, `name`, `url`, `apiType`
9
+ // (chat-completions | responses | messages) and `requestHeaders`.
10
+ //
11
+ // The key. A group's `apiKey` is a secret: VS Code keeps only `${input:chat.lm.secret.<n>}` in the file and the value in
12
+ // its secret storage (workbench: _resolveConfiguration / _resolveLanguageModelProviderGroup), which brain cannot write.
13
+ // But the Custom Endpoint provider lets a model's `requestHeaders` carry the authorization itself: dist/extension.js class
14
+ // for `CustomEndpoint` endpoints overrides _isReservedHeader so `authorization` and `api-key` are allowed
15
+ // (_overridableReservedAuthHeaders), and skips its own `Authorization: Bearer <apiKey>` when the model sets one
16
+ // (_userAuthHeaderSuppressionSet). So brain adds one group, "100xprompt gateway", with no apiKey and a model whose
17
+ // requestHeaders hold `Authorization: Bearer <key>` (and `x-100x-context-edit: on`): the key is copied into the file, which
18
+ // is made private (0600). chatLanguageModels.json is not part of Settings Sync (sync resources: settings, keybindings,
19
+ // snippets, prompts, tasks, extensions, globalState, profiles, mcp), so the copy stays on this machine.
20
+ //
21
+ // What brain does not set: the model picked in Copilot Chat. VS Code remembers that in its own storage; the
22
+ // `chat.defaultModel` setting matches models by their id alone (workbench: function resolving it compares metadata.id),
23
+ // and the gateway's model id (gemini-3.8-flash) could equally name a model Copilot serves itself, so the person picks
24
+ // "100xprompt Pro" in the model picker once (the notes say so). The model list is given in the file, so the gateway's
25
+ // /v1/models (403) is never asked (CustomEndpoint.getAllModels reads `models` unless the group has a `url`).
26
+ //
27
+ // Only the default VS Code profile and VS Code Stable ("Code") are handled; Insiders, VSCodium and other profiles keep
28
+ // their own User folders.
29
+ import { join, resolve } from "node:path"
30
+ import { type Codec, type Connector, EDIT_HEADER, type Env, type Json, modelName, readKey, routeUrl } from "../common.ts"
31
+
32
+ const GROUP = "100xprompt gateway"
33
+ const VENDOR = "customendpoint"
34
+ /** Gemini 3.x Flash, which the gateway's models are: a 1M-token window, 64K out. */
35
+ const MAX_OUTPUT = 65_536
36
+ const MAX_INPUT = 1_048_576 - MAX_OUTPUT
37
+
38
+ /** VS Code's user folder: ~/Library/Application Support/Code/User on macOS, $XDG_CONFIG_HOME/Code/User or ~/.config/Code/User elsewhere. */
39
+ export function vscodeUserDir(home: string, env: Env, platform: string = process.platform): string {
40
+ if (platform === "darwin") return join(home, "Library", "Application Support", "Code", "User")
41
+ if (platform === "win32") return join(env.APPDATA ?? join(home, "AppData", "Roaming"), "Code", "User")
42
+ // XDG_CONFIG_HOME describes the real home: followed only when brain works in that home.
43
+ const xdg = env.XDG_CONFIG_HOME?.startsWith("/") && env.HOME && resolve(home) === resolve(env.HOME) ? env.XDG_CONFIG_HOME : join(home, ".config")
44
+ return join(xdg, "Code", "User")
45
+ }
46
+
47
+ // ---- JSON with comments, with the position of every value -------------------------------------------------------------
48
+
49
+ type Node =
50
+ | { kind: "object"; start: number; end: number; members: Array<{ key: string; value: Node }> }
51
+ | { kind: "array"; start: number; end: number; items: Node[] }
52
+ | { kind: "scalar"; start: number; end: number; value: unknown }
53
+
54
+ /** Parses JSON with // and /* comments and trailing commas (what VS Code accepts in its JSON files); throws otherwise. */
55
+ export function parseJsonc(text: string): Node | undefined {
56
+ let i = 0
57
+ const fail = (what: string): never => {
58
+ throw new Error(`${what} at offset ${i}`)
59
+ }
60
+ const skip = () => {
61
+ for (;;) {
62
+ while (i < text.length && /\s/.test(text[i]!)) i++
63
+ if (text.startsWith("//", i)) {
64
+ const nl = text.indexOf("\n", i)
65
+ i = nl < 0 ? text.length : nl + 1
66
+ } else if (text.startsWith("/*", i)) {
67
+ const end = text.indexOf("*/", i + 2)
68
+ if (end < 0) fail("unclosed comment")
69
+ i = end + 2
70
+ } else return
71
+ }
72
+ }
73
+ const string = (): string => {
74
+ const start = i
75
+ i++
76
+ while (i < text.length && text[i] !== '"') i += text[i] === "\\" ? 2 : 1
77
+ if (i >= text.length) fail("unclosed string")
78
+ i++
79
+ return JSON.parse(text.slice(start, i)) as string
80
+ }
81
+ const value = (): Node => {
82
+ skip()
83
+ const start = i
84
+ const c = text[i]
85
+ if (c === "{") {
86
+ i++
87
+ const members: Array<{ key: string; value: Node }> = []
88
+ for (;;) {
89
+ skip()
90
+ if (text[i] === "}") break
91
+ if (text[i] !== '"') fail("expected a key")
92
+ const key = string()
93
+ skip()
94
+ if (text[i] !== ":") fail("expected :")
95
+ i++
96
+ members.push({ key, value: value() })
97
+ skip()
98
+ if (text[i] === ",") i++
99
+ else if (text[i] !== "}") fail("expected , or }")
100
+ }
101
+ i++
102
+ return { kind: "object", start, end: i, members }
103
+ }
104
+ if (c === "[") {
105
+ i++
106
+ const items: Node[] = []
107
+ for (;;) {
108
+ skip()
109
+ if (text[i] === "]") break
110
+ items.push(value())
111
+ skip()
112
+ if (text[i] === ",") i++
113
+ else if (text[i] !== "]") fail("expected , or ]")
114
+ }
115
+ i++
116
+ return { kind: "array", start, end: i, items }
117
+ }
118
+ if (c === '"') {
119
+ const v = string()
120
+ return { kind: "scalar", start, end: i, value: v }
121
+ }
122
+ const m = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(i, i + 400))
123
+ if (!m) fail("unexpected character")
124
+ i += m![0].length
125
+ return { kind: "scalar", start, end: i, value: JSON.parse(m![0]) }
126
+ }
127
+ skip()
128
+ if (i >= text.length) return undefined
129
+ const root = value()
130
+ skip()
131
+ if (i < text.length) fail("text after the end")
132
+ return root
133
+ }
134
+
135
+ export function plain(n: Node): unknown {
136
+ if (n.kind === "scalar") return n.value
137
+ if (n.kind === "array") return n.items.map(plain)
138
+ const o: Json = {}
139
+ for (const m of n.members) o[m.key] = plain(m.value)
140
+ return o
141
+ }
142
+
143
+ /** The first `,` between from and to that is not inside a comment, or -1. */
144
+ function commaBetween(text: string, from: number, to: number): number {
145
+ for (let i = from; i < to; i++) {
146
+ if (text.startsWith("//", i)) {
147
+ const nl = text.indexOf("\n", i)
148
+ i = nl < 0 ? to : nl
149
+ } else if (text.startsWith("/*", i)) i = text.indexOf("*/", i + 2) + 1
150
+ else if (text[i] === ",") return i
151
+ }
152
+ return -1
153
+ }
154
+
155
+ /** The whitespace a value at `at` is indented by, when it starts its line; undefined when something precedes it there. */
156
+ function lineIndent(text: string, at: number): string | undefined {
157
+ const lineStart = text.lastIndexOf("\n", at - 1) + 1
158
+ const before = text.slice(lineStart, at)
159
+ return /^[ \t]*$/.test(before) ? before : undefined
160
+ }
161
+
162
+ const indentUnit = (text: string) => text.match(/\n([ \t]+)[^\s]/)?.[1]?.replace(/^(\t| {2,4}).*$/, "$1") ?? "\t"
163
+ const render = (v: unknown, indent: string, unit: string) => JSON.stringify(v, null, unit).replace(/\n/g, `\n${indent}`)
164
+
165
+ /**
166
+ * chatLanguageModels.json: a JSON-with-comments array of model groups. A key names one Custom Endpoint group (its `name`);
167
+ * set adds, replaces or removes that one array element and leaves every other byte as it was, so removing a group this
168
+ * codec added gives the file back exactly. A file that does not parse as such an array is refused.
169
+ */
170
+ export const languageModelGroups: Codec = {
171
+ check(text) {
172
+ try {
173
+ const root = parseJsonc(text)
174
+ if (root && root.kind !== "array") return "it is not a list of model groups; left as it is"
175
+ } catch {
176
+ return "it is not valid JSON (with comments); left as it is"
177
+ }
178
+ return undefined
179
+ },
180
+ get(text, key) {
181
+ const root = parseJsonc(text)
182
+ if (root?.kind !== "array") return undefined
183
+ const el = findGroup(root, key)
184
+ return el ? plain(el) : undefined
185
+ },
186
+ set(text, key, value) {
187
+ const root = parseJsonc(text)
188
+ const unit = indentUnit(text)
189
+ if (!root) return value === undefined ? text : `[\n${unit}${render(value, unit, unit)}\n]\n`
190
+ if (root.kind !== "array") throw new Error("not a list of model groups")
191
+ const el = findGroup(root, key)
192
+ if (el && value !== undefined) return text.slice(0, el.start) + render(value, lineIndent(text, el.start) ?? unit, unit) + text.slice(el.end)
193
+ if (!el && value === undefined) return text
194
+ if (!el) {
195
+ const last = root.items[root.items.length - 1]
196
+ if (!last) {
197
+ // An empty list: `[]` (VS Code's own empty file) becomes one element on its own lines, closing at the list's indent.
198
+ const close = lineIndent(text, root.end - 1) ?? ""
199
+ const inner = text.slice(root.start + 1, root.end - 1)
200
+ const body = inner.trim() ? `${inner.replace(/\s*$/, "")}\n${close}${unit}` : `\n${close}${unit}`
201
+ return `${text.slice(0, root.start + 1)}${body}${render(value, close + unit, unit)}\n${close}${text.slice(root.end - 1)}`
202
+ }
203
+ const indent = lineIndent(text, last.start) ?? unit
204
+ return `${text.slice(0, last.end)},\n${indent}${render(value, indent, unit)}${text.slice(last.end)}`
205
+ }
206
+ // Remove the element with the comma that joins it to its neighbour.
207
+ const at = root.items.indexOf(el)
208
+ const prev = root.items[at - 1]
209
+ const next = root.items[at + 1]
210
+ let out: string
211
+ if (prev) {
212
+ const gap = text.slice(prev.end, el.start)
213
+ if (/^\s*,\s*$/.test(gap)) out = text.slice(0, prev.end) + text.slice(el.end)
214
+ else {
215
+ // Comments between the two: keep them, drop the comma before this element and the element's own line.
216
+ const comma = commaBetween(text, prev.end, el.start)
217
+ const from = lineIndent(text, el.start) !== undefined ? text.lastIndexOf("\n", el.start - 1) : el.start
218
+ out = text.slice(0, comma) + text.slice(comma + 1, from) + text.slice(el.end)
219
+ }
220
+ } else if (next) {
221
+ const comma = commaBetween(text, el.end, next.start)
222
+ out = text.slice(0, el.start) + (/^\s*,\s*$/.test(text.slice(el.end, next.start)) ? text.slice(next.start) : text.slice(comma + 1).replace(/^[ \t]*\n?[ \t]*/, ""))
223
+ } else {
224
+ const inner = text.slice(root.start + 1, el.start) + text.slice(el.end, root.end - 1)
225
+ out = text.slice(0, root.start + 1) + (inner.trim() ? inner : "") + text.slice(root.end - 1)
226
+ }
227
+ // A file this codec created (one group, "[...]\n") ends empty, so disconnect removes it; VS Code's own "[]" stays.
228
+ return out === "[]\n" ? "" : out
229
+ },
230
+ }
231
+
232
+ function findGroup(root: Extract<Node, { kind: "array" }>, name: string) {
233
+ let found: Node | undefined
234
+ for (const it of root.items) {
235
+ if (it.kind !== "object") continue
236
+ const v = plain(it) as Json
237
+ if (v.name === name && v.vendor === VENDOR) found = it
238
+ }
239
+ return found
240
+ }
241
+
242
+ // ---- the connector ------------------------------------------------------------------------------------------------------
243
+
244
+ export const connectors: Connector[] = [
245
+ {
246
+ id: "vscode-copilot",
247
+ label: "Copilot Chat (VS Code)",
248
+ file: (home, env) => join(vscodeUserDir(home, env), "chatLanguageModels.json"),
249
+ probe: [],
250
+ authHeader: "authorization",
251
+ edits: (home, s, env) => [
252
+ {
253
+ path: join(vscodeUserDir(home, env), "chatLanguageModels.json"),
254
+ format: "custom",
255
+ codec: languageModelGroups,
256
+ holdsKey: true,
257
+ secretKeys: [GROUP],
258
+ values: {
259
+ [GROUP]: {
260
+ name: GROUP,
261
+ vendor: VENDOR,
262
+ apiType: "chat-completions",
263
+ models: [
264
+ {
265
+ id: s.model,
266
+ name: modelName(s.model),
267
+ url: `${routeUrl(s)}/v1/chat/completions`,
268
+ apiType: "chat-completions",
269
+ toolCalling: true,
270
+ vision: true,
271
+ maxInputTokens: MAX_INPUT,
272
+ maxOutputTokens: MAX_OUTPUT,
273
+ requestHeaders: { Authorization: `Bearer ${readKey(home)}`, ...(s.contextEdit ? { [EDIT_HEADER]: "on" } : {}) },
274
+ },
275
+ ],
276
+ },
277
+ },
278
+ },
279
+ ],
280
+ notes: [
281
+ 'In Copilot Chat, open the model picker and choose the model listed under "100xprompt gateway" once; VS Code remembers the choice (brain does not change your default model).',
282
+ "VS Code keeps a custom model's key in its secret storage, which brain cannot write, so the gateway key is in the model's request headers in chatLanguageModels.json instead; that file is now readable by you only (0600) and is not synced by Settings Sync.",
283
+ "Needs VS Code 1.122 or later (the Custom Endpoint provider). On Copilot Business or Enterprise, your organization must allow bring-your-own-key models.",
284
+ ],
285
+ },
286
+ ]
@@ -0,0 +1,6 @@
1
+ // Gateway connector for Windsurf (now Devin Desktop): none. Its models are served by Cognition's backend only; the one
2
+ // network setting is an HTTP proxy, and "You cannot point Devin Desktop at your own LLM" (checked 2026-11-10:
3
+ // https://agentgateway.dev/docs/standalone/main/integrations/llm-clients/devin/; docs.windsurf.com now redirects to docs.devin.ai).
4
+ import type { Connector } from "../common.ts"
5
+
6
+ export const connectors: Connector[] = []
@@ -0,0 +1,29 @@
1
+ // Gateway connector for Zed: NOT BUILT. Zed cannot be given the gateway key through a file brain can write and undo.
2
+ //
3
+ // Checked 2026-11-10 (Zed is not installed on this machine) against the Zed docs and the zed-industries/zed main source:
4
+ // - https://zed.dev/docs/ai/use-api-access ("OpenAI-Compatible Endpoints", "Anthropic-Compatible Endpoints"): providers
5
+ // go in settings.json (JSON with comments, ~/.config/zed/settings.json) under `language_models.openai_compatible.<id>`
6
+ // (`api_url`, `available_models: [{ name, display_name, max_tokens, max_output_tokens, capabilities }]`,
7
+ // `custom_headers`) or `language_models.anthropic_compatible.<name>`, and the agent's model under `agent.default_model`.
8
+ // All of that is writable. The key is not: "Enter the API key in the provider settings UI or set the generated
9
+ // environment variable" (<PROVIDER_ID>_API_KEY, upper snake case), keys saved in Zed "are stored in the system
10
+ // keychain, not in settings.json", and `custom_headers` cannot carry it ("Headers Zed manages, such as provider
11
+ // authentication headers, are ignored with a warning").
12
+ // - crates/language_model/src/api_key.rs (ApiKeyState::load_if_needed): the key comes from the environment variable if it
13
+ // is set and non-empty, otherwise from the credentials provider (the system keychain) keyed by the API URL
14
+ // (read_credentials(url)); there is no file or command source.
15
+ // - crates/language_models/src/provider/open_ai_compatible.rs: OpenAiCompatibleSettings { api_url, available_models,
16
+ // custom_headers }; requests fail with NoApiKey when neither source has a key.
17
+ //
18
+ // So the key could only reach Zed through (a) the keychain, which brain must not write (and could neither refresh on a new
19
+ // sign-in nor remove on disconnect), or (b) an environment variable in Zed's own launch environment, which means the
20
+ // person's shell profile, not Zed's config, and would expose the key to every process started from that shell.
21
+ //
22
+ // A semi-manual connector was considered and not built: brain could add the provider and default model to settings.json
23
+ // and ask the person to paste the key into Zed's Agent settings. It is not automatic, `connect` would report Zed as
24
+ // connected while it has no key, `disconnect` would leave the key in the keychain, and a new `brain gateway login` would
25
+ // leave Zed on the old key without anyone noticing. Not worth it until the core can mark a connector as needing a manual
26
+ // key step (and status can say whether that step was done).
27
+ import type { Connector } from "../common.ts"
28
+
29
+ export const connectors: Connector[] = []
@@ -0,0 +1,323 @@
1
+ // `brain gateway login`: sign in to 100xprompt in the browser, the same way the 100xprompt CLI does (`100xprompt auth login`).
2
+ //
3
+ // 1. A one-use server listens on 127.0.0.1 on a free port, for /auth/callback only.
4
+ // 2. The browser opens https://100xprompt.com/auth/cli?port=<port>&state=<random>&challenge=<PKCE S256>. The person signs in
5
+ // there; the site sends the browser back to http://127.0.0.1:<port>/auth/callback?token=...&state=...&email=...
6
+ // 3. The state must be the one sent (so no other page can hand this machine a key); the token is the gateway key.
7
+ // 4. No browser here (SSH), or the callback cannot get through: the person pastes the one-time code or API key the
8
+ // site shows instead (input hidden). It is checked the way the CLI checks it.
9
+ //
10
+ // HUNDREDXPROMPT_AUTH_URL points the sign-in at another site (the CLI reads the same variable). The key is never printed.
11
+ //
12
+ // The state is the only thing that tells the real sign-in apart from a key planted by someone else on this machine, so
13
+ // it is kept out of other processes' view where the system allows: on macOS the address goes to the browser through
14
+ // AppleScript's `open location` on stdin, not on a command line that `ps` shows every user. (Linux and Windows browsers
15
+ // take the address on their command line; there, another user who watches `ps` during the sign-in could plant a key. The
16
+ // 100xprompt CLI has the same limit everywhere.)
17
+ import { timingSafeEqual } from "node:crypto"
18
+
19
+ export const AUTH_URL = "https://100xprompt.com"
20
+ export const TIMEOUT_MS = 5 * 60_000
21
+
22
+ /** PKCE (RFC 7636): a 43-character verifier and its S256 challenge, as the CLI makes them. */
23
+ export async function pkce(): Promise<{ verifier: string; challenge: string }> {
24
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
25
+ const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (b) => chars[b % chars.length]).join("")
26
+ const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString("base64url")
27
+ return { verifier, challenge }
28
+ }
29
+
30
+ /**
31
+ * Whether a pasted credential looks like a 100xprompt one, as the CLI decides it (Auth.validate): a 100x_live_ / 100x_test_ /
32
+ * sk- key, a sign-in token (JWT, not expired), or a 100x_auth_ one-time code wrapping either. Returns the key to store.
33
+ */
34
+ export function checkCredential(raw: string, now = Date.now()): { ok: true; key: string; email?: string } | { ok: false; why: string } {
35
+ const t = raw.trim()
36
+ if (!t || t.length < 16) return { ok: false, why: "that is too short to be a key or code" }
37
+ if (/\s/.test(t)) return { ok: false, why: "a key or code has no spaces in it" }
38
+ // The sign-in page shows the key shortened ("100x_live_AbCdEfGhIjKl...12345678"); only its Copy key button gives all of it.
39
+ if (/\.\.\.|…/.test(t)) return { ok: false, why: "that is the shortened key the page shows: use its Copy key button, then paste again" }
40
+ if (/^(?:100x_live_|100x_test_|sk-)/.test(t)) {
41
+ if (!/^(?:100x_live_|100x_test_|sk-)[A-Za-z0-9_-]+$/.test(t)) return { ok: false, why: "that key has characters a 100xprompt key never has" }
42
+ return t.length >= 24 ? { ok: true, key: t } : { ok: false, why: "that key is too short" }
43
+ }
44
+ const parts = t.split(".")
45
+ if (parts.length === 3) {
46
+ try {
47
+ const head = JSON.parse(Buffer.from(parts[0]!, "base64url").toString("utf8"))
48
+ const body = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"))
49
+ if (!head?.alg || !body?.sub) return { ok: false, why: "that sign-in token is malformed" }
50
+ if (typeof body.exp === "number" && body.exp < Math.floor(now / 1000)) return { ok: false, why: "that sign-in token has expired: sign in again" }
51
+ return { ok: true, key: t, ...(typeof body.email === "string" ? { email: body.email } : {}) }
52
+ } catch {
53
+ return { ok: false, why: "that sign-in token is malformed" }
54
+ }
55
+ }
56
+ if (t.startsWith("100x_auth_")) {
57
+ try {
58
+ const inner = JSON.parse(Buffer.from(t.slice("100x_auth_".length), "base64url").toString("utf8"))
59
+ if (typeof inner?.t === "string" && !inner.t.startsWith("100x_auth_")) {
60
+ const r = checkCredential(inner.t, now)
61
+ return r.ok && typeof inner.e === "string" && !r.email ? { ...r, email: inner.e } : r
62
+ }
63
+ } catch {}
64
+ return { ok: false, why: "that one-time code is malformed" }
65
+ }
66
+ return { ok: false, why: `that is not a 100xprompt key or one-time code (copy the code from ${AUTH_URL}/auth/cli, or a 100x_live_ key)` }
67
+ }
68
+
69
+ const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
70
+ const page = (title: string, body: string, status = 200) =>
71
+ new Response(
72
+ `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>` +
73
+ `<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#050505;color:#f1ecec}` +
74
+ `main{text-align:center;max-width:32rem;padding:1rem}h1{font-size:1.4rem}p{color:#a8a3a3}</style></head>` +
75
+ `<body><main><h1>${esc(title)}</h1><p>${esc(body)}</p></main></body></html>`,
76
+ // The callback URL carries the key: nothing on this page may cache it or pass it on.
77
+ { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-frame-options": "DENY" } },
78
+ )
79
+
80
+ /** Constant-time state check. Byte lengths are compared (a non-ASCII state of the right length in characters must not throw). */
81
+ const sameState = (got: string, want: string) => {
82
+ const a = Buffer.from(got)
83
+ const b = Buffer.from(want)
84
+ return a.length === b.length && timingSafeEqual(a, b)
85
+ }
86
+
87
+ /** The key in a callback's token: the CLI takes it as it is; brain also unwraps a one-time code and refuses junk. */
88
+ const callbackKey = (token: string): { ok: true; key: string; email?: string } | { ok: false; why: string } =>
89
+ token.startsWith("100x_auth_") ? checkCredential(token) : token.length >= 16 && !/\s/.test(token) ? { ok: true, key: token } : { ok: false, why: "no key came back from the sign-in page" }
90
+
91
+ /** The sign-in site from --auth-url / $HUNDREDXPROMPT_AUTH_URL (empty means unset, as for the CLI); undefined if not http(s). */
92
+ export function signInBase(given?: string): string | undefined {
93
+ const raw = given?.trim() || process.env.HUNDREDXPROMPT_AUTH_URL?.trim() || AUTH_URL
94
+ try {
95
+ const u = new URL(raw)
96
+ if (u.protocol !== "https:" && u.protocol !== "http:") return undefined
97
+ return `${u.origin}${u.pathname.replace(/\/+$/, "")}`
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+
103
+ export interface SignInDeps {
104
+ /** Opens the sign-in page; default: the system browser. */
105
+ open?: (url: string) => void
106
+ /** false: print the address only (a machine with no browser, or one the person opens elsewhere). */
107
+ browser?: boolean
108
+ /** The sign-in site; default $HUNDREDXPROMPT_AUTH_URL or https://100xprompt.com. */
109
+ authUrl?: string
110
+ timeoutMs?: number
111
+ /**
112
+ * Asks for a pasted code, input hidden; resolves undefined when the person gives up or `signal` aborts. Absent when
113
+ * there is no terminal to ask in (then the browser is the only way).
114
+ */
115
+ ask?: (message: string, signal: AbortSignal) => Promise<string | undefined>
116
+ say: (line: string) => void
117
+ }
118
+
119
+ export type SignIn = { ok: true; key: string; email?: string; via: "browser" | "code" } | { ok: false; why: string }
120
+
121
+ /** Signs in through the browser (or a pasted code). Does not store anything: the caller does. */
122
+ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
123
+ const base = signInBase(d.authUrl)
124
+ if (!base) return { ok: false, why: "HUNDREDXPROMPT_AUTH_URL must be an http:// or https:// address" }
125
+ const { challenge } = await pkce()
126
+ const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")
127
+ let finish!: (r: SignIn) => void
128
+ const done = new Promise<SignIn>((r) => (finish = r))
129
+ let settled = false
130
+ const settle = (r: SignIn) => {
131
+ if (settled) return
132
+ settled = true
133
+ finish(r)
134
+ }
135
+ const server = Bun.serve({
136
+ hostname: "127.0.0.1",
137
+ port: 0,
138
+ development: false,
139
+ // Anything the handler did not expect: a plain refusal, never a stack trace on the page or in the terminal.
140
+ error: () => new Response("Bad request", { status: 400 }),
141
+ fetch(req) {
142
+ const url = new URL(req.url)
143
+ if (url.pathname !== "/auth/callback" || req.method !== "GET") return new Response("Not found", { status: 404 })
144
+ if (settled) return page("Already signed in", "This sign-in has finished. You can close this tab.", 410)
145
+ const got = url.searchParams.get("state") ?? ""
146
+ const error = url.searchParams.get("error")
147
+ // Only the page this command opened knows the state: anything else is refused and the wait goes on.
148
+ if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", "This link was not started by this brain gateway login. Start again from your terminal.", 400)
149
+ if (error) {
150
+ settle({ ok: false, why: `the sign-in page said: ${error.slice(0, 200)}` })
151
+ return page("Sign-in failed", `${error.slice(0, 200)}. Go back to your terminal.`, 400)
152
+ }
153
+ const c = callbackKey(url.searchParams.get("token") ?? "")
154
+ if (!c.ok) {
155
+ settle({ ok: false, why: c.why })
156
+ return page("Sign-in failed", "No usable key came back. Go back to your terminal.", 400)
157
+ }
158
+ const email = url.searchParams.get("email") || c.email || undefined
159
+ settle({ ok: true, key: c.key, ...(email ? { email } : {}), via: "browser" })
160
+ return page("Signed in", `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.`)
161
+ },
162
+ })
163
+ const url = `${base}/auth/cli?port=${server.port}&state=${state}&challenge=${challenge}`
164
+ const stopAsking = new AbortController()
165
+ const timer = setTimeout(() => settle({ ok: false, why: `no sign-in came back within ${Math.round((d.timeoutMs ?? TIMEOUT_MS) / 60_000) || 1} minutes` }), d.timeoutMs ?? TIMEOUT_MS)
166
+ try {
167
+ if (d.browser === false) d.say(`Open this address in a browser to sign in to 100xprompt:\n ${url}`)
168
+ else {
169
+ d.say(`Opening your browser to sign in to 100xprompt:\n ${url}`)
170
+ try {
171
+ ;(d.open ?? openInBrowser)(url)
172
+ } catch {}
173
+ }
174
+ if (d.ask) {
175
+ d.say(
176
+ "Waiting for the browser. Signing in on another machine (SSH)? Paste the key from the page's Copy key button, or the address the browser could not open, then press Enter (Escape cancels).",
177
+ )
178
+ // The paste runs alongside the browser: whichever comes first wins. A bad paste asks again; Escape ends the sign-in
179
+ // (an abandoned hidden prompt would leave the terminal echoing whatever is pasted next, and the shell reading it).
180
+ void (async () => {
181
+ for (;;) {
182
+ const pasted = await d.ask!("key or address: ", stopAsking.signal)
183
+ if (settled) return
184
+ if (pasted === undefined) return settle({ ok: false, why: "sign-in cancelled" })
185
+ if (!pasted.trim()) continue
186
+ const c = fromPaste(pasted.trim(), state)
187
+ if (c.ok) return settle({ ok: true, key: c.key, ...(c.email ? { email: c.email } : {}), via: "code" })
188
+ if ("fatal" in c) return settle({ ok: false, why: c.why })
189
+ d.say(` ${c.why}`)
190
+ }
191
+ })()
192
+ } else d.say("Waiting for the browser…")
193
+ return await done
194
+ } finally {
195
+ clearTimeout(timer)
196
+ stopAsking.abort()
197
+ // The page that just answered must reach the browser before this returns: `brain` exits right after, and an exit
198
+ // with the answer still unsent leaves the person looking at a browser error after a sign-in that worked. A graceful
199
+ // stop waits for that answer; a browser holding the connection open gets cut off after 1.5 s.
200
+ await Promise.race([server.stop(), Bun.sleep(1_500)])
201
+ void server.stop(true)
202
+ }
203
+ }
204
+
205
+ /**
206
+ * A pasted key, one-time code or sign-in token; or the callback address itself (on SSH the browser is sent to
207
+ * 127.0.0.1 on the wrong machine, and the address it could not open holds the key). An address must carry this sign-in's
208
+ * state, so one from another sign-in, or one made up, is refused.
209
+ */
210
+ export function fromPaste(pasted: string, state: string): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
211
+ if (/^https?:\/\//i.test(pasted)) {
212
+ let u: URL
213
+ try {
214
+ u = new URL(pasted)
215
+ } catch {
216
+ return { ok: false, why: "that address could not be read" }
217
+ }
218
+ if (!u.pathname.endsWith("/auth/callback")) return { ok: false, why: "that is not the sign-in's return address (it ends in /auth/callback?token=...)" }
219
+ const got = u.searchParams.get("state") ?? ""
220
+ if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return { ok: false, why: "that address is from another sign-in: paste the one from this sign-in" }
221
+ const error = u.searchParams.get("error")
222
+ if (error) return { ok: false, why: `the sign-in page said: ${error.slice(0, 200)}`, fatal: true }
223
+ const c = callbackKey(u.searchParams.get("token") ?? "")
224
+ if (!c.ok) return c
225
+ const email = u.searchParams.get("email") || c.email
226
+ return { ok: true, key: c.key, ...(email ? { email } : {}) }
227
+ }
228
+ return checkCredential(pasted)
229
+ }
230
+
231
+ /**
232
+ * How to open the sign-in page on this platform: the command, and what to write to its stdin. On macOS the address goes
233
+ * to AppleScript's `open location` on stdin, so it (and its state) is on no command line.
234
+ */
235
+ export function browserCommand(url: string, platform: string = process.platform): { argv: string[]; stdin?: string } {
236
+ if (platform === "darwin") return { argv: ["osascript", "-"], stdin: `open location "${url.replace(/[\\"]/g, (c) => `\\${c}`)}"\n` }
237
+ if (platform === "win32") return { argv: ["cmd", "/c", "start", "", url.replace(/&/g, "^&")] }
238
+ return { argv: ["xdg-open", url] }
239
+ }
240
+
241
+ /** Opens the sign-in page in the default browser (browserCommand). */
242
+ export function openInBrowser(url: string): void {
243
+ const c = browserCommand(url)
244
+ try {
245
+ const p = Bun.spawn(c.argv, { stdin: c.stdin === undefined ? "ignore" : "pipe", stdout: "ignore", stderr: "ignore" })
246
+ if (c.stdin !== undefined && p.stdin && typeof p.stdin !== "number") {
247
+ p.stdin.write(c.stdin)
248
+ void p.stdin.end()
249
+ }
250
+ p.unref()
251
+ } catch {}
252
+ }
253
+
254
+ /**
255
+ * Applies typed or pasted terminal input to `buf`. Enter ends the line; Escape on its own, or Ctrl-D on an empty line,
256
+ * cancels; Ctrl-C interrupts. Arrow, Delete and Home keys, Alt+key and bracketed-paste markers arrive as escape sequences
257
+ * and are skipped, so they neither end the prompt nor land in the key.
258
+ */
259
+ export function applyKeys(buf: string, chunk: string): { buf: string; end?: "enter" | "cancel" | "interrupt" } {
260
+ for (let i = 0; i < chunk.length; i++) {
261
+ const ch = chunk[i]!
262
+ if (ch === "\r" || ch === "\n") return { buf, end: "enter" }
263
+ if (ch === "\u0003") return { buf, end: "interrupt" }
264
+ if (ch === "\u0004") {
265
+ if (!buf) return { buf, end: "cancel" }
266
+ continue
267
+ }
268
+ if (ch === "\u001b") {
269
+ const next = chunk[i + 1]
270
+ if (next === undefined) return { buf, end: "cancel" }
271
+ if (next === "[") {
272
+ // CSI: parameter bytes, then one final byte in 0x40-0x7E.
273
+ let j = i + 2
274
+ while (j < chunk.length && !(chunk.charCodeAt(j) >= 0x40 && chunk.charCodeAt(j) <= 0x7e)) j++
275
+ i = j
276
+ } else i += next === "O" ? 2 : 1 // SS3 (ESC O x) or Alt+key (ESC x)
277
+ continue
278
+ }
279
+ if (ch === "\u007f" || ch === "\b") buf = buf.slice(0, -1)
280
+ else if (ch >= " ") buf += ch
281
+ }
282
+ return { buf }
283
+ }
284
+
285
+ /**
286
+ * Reads one line from the terminal without showing it (the key must not land in the scrollback). undefined: no terminal,
287
+ * cancelled (Escape, Ctrl-D), or `signal` aborted. Ctrl-C ends the command, as it would anywhere else.
288
+ */
289
+ export function askHidden(message: string, signal: AbortSignal): Promise<string | undefined> {
290
+ const stdin = process.stdin
291
+ if (!stdin.isTTY || signal.aborted) return Promise.resolve(undefined)
292
+ return new Promise((resolve) => {
293
+ let buf = ""
294
+ let over = false
295
+ process.stderr.write(message)
296
+ stdin.setRawMode(true)
297
+ stdin.setEncoding("utf8")
298
+ stdin.resume()
299
+ const end = (v: string | undefined) => {
300
+ if (over) return
301
+ over = true
302
+ stdin.off("data", onData)
303
+ signal.removeEventListener("abort", onAbort)
304
+ stdin.setRawMode(false)
305
+ stdin.pause()
306
+ process.stderr.write("\n")
307
+ resolve(v)
308
+ }
309
+ const onAbort = () => end(undefined)
310
+ const onData = (chunk: string) => {
311
+ const r = applyKeys(buf, chunk)
312
+ buf = r.buf
313
+ if (r.end === "enter") end(buf)
314
+ else if (r.end === "cancel") end(undefined)
315
+ else if (r.end === "interrupt") {
316
+ end(undefined)
317
+ process.exit(130)
318
+ }
319
+ }
320
+ stdin.on("data", onData)
321
+ signal.addEventListener("abort", onAbort)
322
+ })
323
+ }