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