@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.
- package/LICENSE +48 -0
- package/README.md +172 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- 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
|
+
}
|