@100xprompt/brain 0.1.3 → 0.1.5

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.
@@ -15,9 +15,10 @@
15
15
  // stores the key that comes back in <home>/.brain/gateway.key (0600). For scripts and CI, --key-env VAR takes it from an
16
16
  // environment variable instead; --from-cli reuses the 100xprompt CLI's sign-in. It is never printed. Tools read it at run time where they can (Claude Code's apiKeyHelper,
17
17
  // OpenCode's {file:...}); Codex and Gemini CLI can only take it in their own config, which is then made private (0600).
18
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"
18
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, statSync } from "node:fs"
19
19
  import { basename, dirname, join, resolve } from "node:path"
20
20
  import {
21
+ AUTO_ROUTE,
21
22
  brainDir,
22
23
  type Codec,
23
24
  type Connector,
@@ -44,6 +45,10 @@ import {
44
45
  } from "./common.ts"
45
46
  import { EXTRA_CONNECTORS } from "./connectors/index.ts"
46
47
  import { askHidden, browserSignIn, type SignInDeps } from "./signin.ts"
48
+ import { writeAtomic } from "../fsutil.ts"
49
+ import { withBrainLockSync } from "../lock.ts"
50
+ import { toolGatewayPrefs } from "../settings/values.ts"
51
+ import { readMachineToken } from "../dashboard/state.ts"
47
52
 
48
53
  export {
49
54
  AUTO_MODEL,
@@ -78,7 +83,17 @@ interface State {
78
83
  keySource?: string
79
84
  /** Who signed in (from the sign-in page), shown by status. */
80
85
  email?: string
81
- tools: Record<string, { at: string; changes: Change[]; created?: string[]; createdDirs?: string[]; modes?: Record<string, number> }>
86
+ tools: Record<string, ToolRecord>
87
+ }
88
+
89
+ interface ToolRecord {
90
+ at: string
91
+ changes: Change[]
92
+ created?: string[]
93
+ createdDirs?: string[]
94
+ modes?: Record<string, number>
95
+ /** The settings this tool was connected with (older records have none: `settings` above is the last connect's). */
96
+ settings?: GatewaySettings
82
97
  }
83
98
 
84
99
  function loadState(home: string): State {
@@ -97,11 +112,7 @@ function saveState(home: string, s: State) {
97
112
 
98
113
  /** Atomic write with mode 0600 (the key and the record of what was changed). */
99
114
  function writePrivate(path: string, text: string) {
100
- const tmp = `${path}.brain-tmp`
101
- rmSync(tmp, { force: true })
102
- writeFileSync(tmp, text, { mode: 0o600 })
103
- chmodSync(tmp, 0o600)
104
- renameSync(tmp, path)
115
+ writeAtomic(path, text, 0o600)
105
116
  }
106
117
 
107
118
  /**
@@ -124,11 +135,7 @@ function writeToolFile(home: string, tool: string, path: string, text: string, m
124
135
  keep = statSync(real).mode & 0o777
125
136
  } catch {}
126
137
  }
127
- const tmp = `${real}.brain-tmp`
128
- rmSync(tmp, { force: true })
129
- writeFileSync(tmp, text, keep === undefined ? {} : { mode: keep })
130
- if (keep !== undefined) chmodSync(tmp, keep)
131
- renameSync(tmp, real)
138
+ writeAtomic(real, text, keep)
132
139
  }
133
140
 
134
141
  // ---- the key ----------------------------------------------------------------------------------------------------------
@@ -169,6 +176,10 @@ export function hasKey(home: string): boolean {
169
176
  * on the old one while status names the new. Returns the tools whose copy was replaced.
170
177
  */
171
178
  export function storeKey(home: string, key: string, source: string, email?: string, env: Env = process.env): string[] {
179
+ return withBrainLockSync(home, () => storeKeyLocked(home, key, source, email, env), { what: "storing the gateway key" })
180
+ }
181
+
182
+ function storeKeyLocked(home: string, key: string, source: string, email: string | undefined, env: Env): string[] {
172
183
  const old = readKey(home)
173
184
  mkdirSync(brainDir(home), { recursive: true, mode: 0o700 })
174
185
  writePrivate(keyPath(home), `${key}\n`)
@@ -253,6 +264,10 @@ export async function signIn(home: string, d: SignInDeps, env: Env = process.env
253
264
 
254
265
  /** Forgets the key. Tools still connected stop working until the next login, which is said. */
255
266
  export function logout(home: string, env: Record<string, string | undefined> = process.env): string[] {
267
+ return withBrainLockSync(home, () => logoutLocked(home, env), { what: "signing out" })
268
+ }
269
+
270
+ function logoutLocked(home: string, env: Record<string, string | undefined>): string[] {
256
271
  const had = hasKey(home)
257
272
  rmSync(keyPath(home), { force: true })
258
273
  const s = loadState(home)
@@ -577,6 +592,8 @@ export async function probeKey(home: string, c: Connector, s: GatewaySettings, f
577
592
  export interface Result {
578
593
  ok: boolean
579
594
  lines: string[]
595
+ /** connect: the settings the tool was connected with. */
596
+ settings?: GatewaySettings
580
597
  }
581
598
 
582
599
  export function installedHere(c: Connector, home: string, env: Record<string, string | undefined>): boolean {
@@ -585,106 +602,219 @@ export function installedHere(c: Connector, home: string, env: Record<string, st
585
602
  return existsSync(f) || (resolve(dirname(f)) !== resolve(home) && existsSync(dirname(f))) || c.probe.some((p) => existsSync(join(home, p)))
586
603
  }
587
604
 
605
+ /** Whether a tool is on the gateway now, read from its files (gateway.json alone can be wrong after a hand edit). */
606
+ export interface Connection {
607
+ /** brain's values are all in the tool's files, some of them, or none (or brain never connected it). */
608
+ state: "connected" | "partial" | "not-connected"
609
+ /** The settings it was connected with; undefined when an older brain connected it and they cannot be told. */
610
+ settings?: GatewaySettings
611
+ /** What no longer holds brain's value, when it is not all there. */
612
+ why?: string
613
+ /** The values brain set that the files no longer hold (JSON), to tell which part was changed. */
614
+ changed?: string[]
615
+ }
616
+
617
+ /** Every string in a value (and every key of an object in it). */
618
+ const stringsOf = (v: unknown): string[] =>
619
+ typeof v === "string" ? [v] : Array.isArray(v) ? v.flatMap(stringsOf) : v && typeof v === "object" ? Object.entries(v).flatMap(([k, x]) => [k, ...stringsOf(x)]) : []
620
+
621
+ /**
622
+ * The settings a tool was connected with. Brains before 0.1.4 kept only the last connect's settings (for every tool): those
623
+ * are this tool's only when it was the last one connected. Otherwise they are read back from what connect wrote in the
624
+ * tool's files: the gateway address and route, the model, and whether the context-editing header is there.
625
+ */
626
+ function settingsOf(st: State, id: string, values: unknown[]): GatewaySettings | undefined {
627
+ const rec = st.tools[id]
628
+ if (!rec) return undefined
629
+ if (rec.settings) return rec.settings
630
+ const newest = Object.entries(st.tools).sort((a, b) => (b[1].at ?? "").localeCompare(a[1].at ?? ""))[0]?.[0]
631
+ if (newest === id && st.settings) return st.settings
632
+ let url: string | undefined
633
+ let route: string | undefined
634
+ let model: string | undefined
635
+ let edit = false
636
+ for (const t of values.flatMap(stringsOf)) {
637
+ if (t.includes(EDIT_HEADER)) edit = true
638
+ model ??= Object.keys(MODEL_NAMES).find((m) => t === m || t.endsWith(`/${m}`))
639
+ const m = /^(https?:\/\/\S+?)\/([a-z0-9-]{1,40})(?:\/v1)?\/?$/.exec(t)
640
+ if (m && !url) {
641
+ url = m[1]
642
+ route = m[2]
643
+ }
644
+ }
645
+ return url && route && model ? { url, route, model, contextEdit: edit } : undefined
646
+ }
647
+
648
+ export function connectionState(home: string, toolId: string, env: Env = process.env): Connection {
649
+ const c = connectorFor(toolId)
650
+ if (!c) return { state: "not-connected" }
651
+ const st = loadState(home)
652
+ const rec = st.tools[c.id]
653
+ if (!rec) return { state: "not-connected" }
654
+ const codecs = codecsOf(c, home, st, env)
655
+ const docs = new Map<string, Doc | { error: string }>()
656
+ let held = 0
657
+ let first: string | undefined
658
+ const changed: string[] = []
659
+ // What connect wrote: its recorded values, and for entries holding the key, what the file has (the key itself is never kept).
660
+ const written: unknown[] = []
661
+ for (const x of rec.changes) {
662
+ if (!docs.has(x.file)) docs.set(x.file, load(x.format, x.file, codecs.get(x.file)))
663
+ const d = docs.get(x.file)!
664
+ const current = "error" in d ? undefined : getKey(d, x.key)
665
+ const secret = x.after === "(contains the key)"
666
+ const ours = !("error" in d) && (secret ? current !== undefined : same(current, x.after))
667
+ written.push(secret ? current : x.after)
668
+ if (ours) held++
669
+ else {
670
+ first ??= `${basename(x.file)}: ${x.key.replace(/\//g, ".")}`
671
+ changed.push(JSON.stringify(x.after))
672
+ }
673
+ }
674
+ const settings = settingsOf(st, c.id, written)
675
+ const withSettings = settings ? { settings } : {}
676
+ if (held === rec.changes.length) return { state: "connected", ...withSettings }
677
+ if (held === 0) return { state: "not-connected", ...withSettings, why: `brain's settings are no longer in ${c.label}'s files (changed by hand?)`, changed }
678
+ return { state: "partial", ...withSettings, why: `${first} changed since brain connected it`, changed }
679
+ }
680
+
588
681
  export async function connect(
589
682
  home: string,
590
683
  toolId: string,
591
- o: { settings?: Partial<GatewaySettings>; dryRun?: boolean; probe?: boolean; env?: Record<string, string | undefined>; fetchImpl?: typeof fetch } = {},
684
+ o: {
685
+ settings?: Partial<GatewaySettings>
686
+ dryRun?: boolean
687
+ probe?: boolean
688
+ env?: Record<string, string | undefined>
689
+ fetchImpl?: typeof fetch
690
+ /** Where a flag leaves the model or context editing open, use the ones saved for this tool with `brain settings`. */
691
+ usePrefs?: boolean
692
+ /** Called after the files are written, still under the brain lock, with the settings used (to record them). */
693
+ onWritten?: (s: GatewaySettings) => void
694
+ } = {},
592
695
  ): Promise<Result> {
593
696
  const env = o.env ?? process.env
594
697
  const c = connectorFor(toolId)
595
698
  if (!c) return { ok: false, lines: [`unknown tool "${toolId}"; \`brain gateway tools\` lists the ones that can be connected`] }
596
699
  if (!hasKey(home) && !o.dryRun) return { ok: false, lines: ["not signed in to the gateway yet: run `brain gateway login`"] }
597
- const st = loadState(home)
598
- // --model auto goes with the auto route (and back to flash after it): common.ts routeAndModel.
599
- const rm = routeAndModel({ route: o.settings?.route, model: o.settings?.model }, st.settings ?? {})
600
- if ("why" in rm) return { ok: false, lines: [rm.why] }
601
- const s: GatewaySettings = {
602
- url: o.settings?.url ?? st.settings?.url ?? DEFAULT_URL,
603
- route: rm.route,
604
- model: rm.model,
605
- contextEdit: o.settings?.contextEdit ?? st.settings?.contextEdit ?? true,
606
- }
607
- 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)"] }
608
- 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"] }
609
- if (!/^[\w.:/-]{1,80}$/.test(s.model)) return { ok: false, lines: ["--model must be pro, flash, auto or a plain model name"] }
700
+ // The settings to connect with, from the flags, then (with usePrefs) what is saved for this tool, then the last connect's.
701
+ // Worked out again inside the lock: another brain may have changed them while the key was checked.
702
+ const resolveSettings = (st: State): GatewaySettings | { why: string } => {
703
+ const given = o.settings ?? {}
704
+ const prefs = o.usePrefs ? toolGatewayPrefs(home, c.id) : {}
705
+ // With --route, a saved model is used only when neither is auto (the auto model and the auto route go together).
706
+ const model = given.model ?? (prefs.model !== undefined && (given.route === undefined || (given.route !== AUTO_ROUTE && prefs.model !== "auto")) ? prefs.model : undefined)
707
+ // --model auto goes with the auto route (and back to flash after it): common.ts routeAndModel.
708
+ const rm = routeAndModel({ route: given.route, model }, st.settings ?? {})
709
+ if ("why" in rm) return rm
710
+ const s: GatewaySettings = { url: given.url ?? st.settings?.url ?? DEFAULT_URL, route: rm.route, model: rm.model, contextEdit: given.contextEdit ?? prefs.contextEdit ?? st.settings?.contextEdit ?? true }
711
+ if (!/^https?:\/\/[^\s]+$/.test(s.url) || (/^http:/.test(s.url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/.test(s.url))) return { why: "--url must be https:// (http:// only for a gateway on this machine)" }
712
+ if (!/^[a-z0-9-]{1,40}$/.test(s.route)) return { why: "--route must be a gateway route name such as flash or pro" }
713
+ if (!/^[\w.:/-]{1,80}$/.test(s.model)) return { why: "--model must be pro, flash, auto or a plain model name" }
714
+ return s
715
+ }
716
+ const first = resolveSettings(loadState(home))
717
+ if ("why" in first) return { ok: false, lines: [first.why] }
718
+ const checked: GatewaySettings = first
610
719
  const main = c.file(home, env)
611
720
  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`] }
612
721
  let limit: string | undefined
613
- if (o.probe !== false && !o.dryRun) {
614
- const p = await probeKey(home, c, s, o.fetchImpl)
722
+ const probed = o.probe !== false && !o.dryRun
723
+ if (probed) {
724
+ const p = await probeKey(home, c, checked, o.fetchImpl)
615
725
  limit = p.limit
616
- 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`] }
617
- if (p.verdict === "unreachable") return { ok: false, lines: [`could not reach ${routeUrl(s)}; nothing was changed (pass --no-probe to connect anyway)`] }
618
- }
619
- // Read and check every file first: nothing is written unless all of them can be changed.
620
- const edits = c.edits(home, s, env)
621
- const docs: Array<{ e: FileEdit; d: Doc }> = []
622
- for (const e of edits) {
623
- const d = load(e.format, e.path, e.codec)
624
- if ("error" in d) return { ok: false, lines: [`${e.path}: ${d.error}; nothing was changed`] }
625
- docs.push({ e, d })
626
- }
627
- const prior = st.tools[c.id]?.changes ?? []
628
- const changes: Change[] = []
629
- const shown: string[] = []
630
- try {
631
- for (const { e, d } of docs) {
632
- for (const [key, after] of Object.entries(e.values)) {
633
- const current = getKey(d, key)
634
- // A reconnect keeps the value from before the FIRST connect, so disconnect still restores the person's own.
635
- const was = prior.find((x) => x.file === e.path && x.key === key)
636
- const secret = isSecret(e, key)
637
- changes.push({ file: e.path, format: e.format, key, before: was ? was.before : current, after: secret ? "(contains the key)" : after })
638
- if (!same(current, after)) setKey(d, key, after)
639
- shown.push(` ${basename(e.path)}: ${key.replace(/\//g, ".")} = ${secret ? "(gateway settings and key)" : shownValue(after)}`)
640
- }
641
- // A key an earlier connect set that this one no longer wants (--no-context-edit after a connect with it): put it back now.
642
- for (const x of prior) {
643
- if (x.file !== e.path || x.key in e.values) continue
644
- const current = getKey(d, x.key)
645
- if (x.after === "(contains the key)" ? current !== undefined : same(current, x.after)) setKey(d, x.key, x.before)
726
+ if (p.verdict === "rejected") return { ok: false, lines: [`the gateway at ${routeUrl(checked)} refused the key the way ${c.label} sends it (${c.authHeader}); nothing was changed`] }
727
+ if (p.verdict === "unreachable") return { ok: false, lines: [`could not reach ${routeUrl(checked)}; nothing was changed (pass --no-probe to connect anyway)`] }
728
+ }
729
+ // Read, change and write under the brain lock (the network check above is done before taking it), from the state
730
+ // as it is now: another brain may have changed it while the key was checked.
731
+ const write = (): Result => {
732
+ const st = loadState(home)
733
+ // Signed out while the key was checked: nothing to put in the tool's files.
734
+ if (!o.dryRun && !hasKey(home)) return { ok: false, lines: ["not signed in to the gateway any more (signed out meanwhile); nothing was changed"] }
735
+ const fresh = resolveSettings(st)
736
+ if ("why" in fresh) return { ok: false, lines: [fresh.why] }
737
+ if (probed && routeUrl(fresh) !== routeUrl(checked)) return { ok: false, lines: [`the gateway address changed to ${routeUrl(fresh)} while the key was checked at ${routeUrl(checked)}; nothing was changed: run it again`] }
738
+ const s = fresh
739
+ // Read and check every file first: nothing is written unless all of them can be changed.
740
+ const edits = c.edits(home, s, env)
741
+ const docs: Array<{ e: FileEdit; d: Doc }> = []
742
+ for (const e of edits) {
743
+ const d = load(e.format, e.path, e.codec)
744
+ if ("error" in d) return { ok: false, lines: [`${e.path}: ${d.error}; nothing was changed`] }
745
+ docs.push({ e, d })
746
+ }
747
+ const prior = st.tools[c.id]?.changes ?? []
748
+ const changes: Change[] = []
749
+ const shown: string[] = []
750
+ try {
751
+ for (const { e, d } of docs) {
752
+ for (const [key, after] of Object.entries(e.values)) {
753
+ const current = getKey(d, key)
754
+ // A reconnect keeps the value from before the FIRST connect, so disconnect still restores the person's own.
755
+ const was = prior.find((x) => x.file === e.path && x.key === key)
756
+ const secret = isSecret(e, key)
757
+ changes.push({ file: e.path, format: e.format, key, before: was ? was.before : current, after: secret ? "(contains the key)" : after })
758
+ if (!same(current, after)) setKey(d, key, after)
759
+ shown.push(` ${basename(e.path)}: ${key.replace(/\//g, ".")} = ${secret ? "(gateway settings and key)" : shownValue(after)}`)
760
+ }
761
+ // A key an earlier connect set that this one no longer wants (--no-context-edit after a connect with it): put it back now.
762
+ for (const x of prior) {
763
+ if (x.file !== e.path || x.key in e.values) continue
764
+ const current = getKey(d, x.key)
765
+ if (x.after === "(contains the key)" ? current !== undefined : same(current, x.after)) setKey(d, x.key, x.before)
766
+ }
646
767
  }
768
+ } catch (err) {
769
+ // A file format the connector's codec could not change after all: nothing has been written yet.
770
+ return { ok: false, lines: [`could not change ${c.label}'s settings (${(err as Error).message.slice(0, 200)}); nothing was changed`] }
647
771
  }
648
- } catch (err) {
649
- // A file format the connector's codec could not change after all: nothing has been written yet.
650
- return { ok: false, lines: [`could not change ${c.label}'s settings (${(err as Error).message.slice(0, 200)}); nothing was changed`] }
651
- }
652
- 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)}`] : []
653
- if (o.dryRun) return { ok: true, lines: [`would change:`, ...shown, ...named, ...c.notes.map((n) => `note: ${n}`)] }
654
- const created = new Set(st.tools[c.id]?.created ?? [])
655
- for (const { e } of docs) if (!existsSync(e.path)) created.add(e.path)
656
- // Folders connect has to create (Goose's custom_providers/), so disconnect can take them away again when they are empty.
657
- const createdDirs = new Set(st.tools[c.id]?.createdDirs ?? [])
658
- for (const { e } of docs) for (let dir = dirname(e.path); !existsSync(dir) && dir.startsWith(`${resolve(home)}/`); dir = dirname(dir)) createdDirs.add(dir)
659
- // A file that is made private (it gets the key) goes back to the mode it had when disconnect gives it back.
660
- const modes: Record<string, number> = { ...(st.tools[c.id]?.modes ?? {}) }
661
- for (const { e } of docs) {
662
- if (!e.holdsKey || !existsSync(e.path) || modes[e.path] !== undefined) continue
772
+ 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)}`] : []
773
+ if (o.dryRun) return { ok: true, lines: [`would change:`, ...shown, ...named, ...c.notes.map((n) => `note: ${n}`)] }
774
+ const created = new Set(st.tools[c.id]?.created ?? [])
775
+ for (const { e } of docs) if (!existsSync(e.path)) created.add(e.path)
776
+ // Folders connect has to create (Goose's custom_providers/), so disconnect can take them away again when they are empty.
777
+ const createdDirs = new Set(st.tools[c.id]?.createdDirs ?? [])
778
+ for (const { e } of docs) for (let dir = dirname(e.path); !existsSync(dir) && dir.startsWith(`${resolve(home)}/`); dir = dirname(dir)) createdDirs.add(dir)
779
+ // A file that is made private (it gets the key) goes back to the mode it had when disconnect gives it back.
780
+ const modes: Record<string, number> = { ...(st.tools[c.id]?.modes ?? {}) }
781
+ for (const { e } of docs) {
782
+ if (!e.holdsKey || !existsSync(e.path) || modes[e.path] !== undefined) continue
783
+ try {
784
+ modes[e.path] = statSync(e.path).mode & 0o777
785
+ } catch {}
786
+ }
787
+ const first = !st.tools[c.id]
788
+ for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
789
+ st.settings = s
790
+ st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}), settings: s }
791
+ saveState(home, st)
663
792
  try {
664
- modes[e.path] = statSync(e.path).mode & 0o777
793
+ o.onWritten?.(s)
665
794
  } catch {}
795
+ return {
796
+ ok: true,
797
+ settings: s,
798
+ lines: [
799
+ `connected ${c.label} to ${routeUrl(s)} (${modelName(s.model)}${s.contextEdit ? ", context editing on" : ""})`,
800
+ ...(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).`] : []),
801
+ "changed:",
802
+ ...shown,
803
+ ...c.notes.map((n) => `note: ${n}`),
804
+ `undo: brain gateway disconnect --tool ${c.id}`,
805
+ ],
806
+ }
666
807
  }
667
- const first = !st.tools[c.id]
668
- for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
669
- st.settings = s
670
- st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}) }
671
- saveState(home, st)
672
- return {
673
- ok: true,
674
- lines: [
675
- `connected ${c.label} to ${routeUrl(s)} (${modelName(s.model)}${s.contextEdit ? ", context editing on" : ""})`,
676
- ...(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).`] : []),
677
- "changed:",
678
- ...shown,
679
- ...c.notes.map((n) => `note: ${n}`),
680
- `undo: brain gateway disconnect --tool ${c.id}`,
681
- ],
682
- }
808
+ return o.dryRun ? write() : withBrainLockSync(home, write, { what: `connecting ${c.label}` })
683
809
  }
684
810
 
685
811
  export function disconnect(home: string, toolId: string, o: { dryRun?: boolean; env?: Env } = {}): Result {
686
812
  const c = connectorFor(toolId)
687
813
  if (!c) return { ok: false, lines: [`unknown tool "${toolId}"`] }
814
+ return o.dryRun ? disconnectLocked(home, c, o) : withBrainLockSync(home, () => disconnectLocked(home, c, o), { what: `disconnecting ${c.label}` })
815
+ }
816
+
817
+ function disconnectLocked(home: string, c: Connector, o: { dryRun?: boolean; env?: Env }): Result {
688
818
  const st = loadState(home)
689
819
  const rec = st.tools[c.id]
690
820
  if (!rec) return { ok: true, lines: [`${c.label} is not connected by brain; nothing to undo`] }
@@ -749,6 +879,7 @@ export function status(home: string, env: Record<string, string | undefined> = p
749
879
 
750
880
  // ---- the command --------------------------------------------------------------------------------------------------------
751
881
 
882
+
752
883
  const USAGE = [
753
884
  "brain gateway login [--no-browser] sign in to 100xprompt in your browser, as `100xprompt auth login` does",
754
885
  "brain gateway login --key-env VAR | --from-cli take the key from VAR (scripts, CI) or from the 100xprompt CLI's sign-in",
@@ -787,6 +918,8 @@ export async function gatewayCommand(
787
918
  }
788
919
  const refreshedNote = (labels: string[]) => {
789
920
  if (labels.length) io.out(`the new key also replaced the old one in ${labels.join(" and ")}'s own config`)
921
+ // Signed in but not paired: the one step that puts this computer's settings in the dashboard.
922
+ if (!readMachineToken(home)) io.out("to see this computer's settings in your dashboard: brain pair")
790
923
  }
791
924
  // The browser sign-in, with a hidden paste prompt when there is a terminal to ask in.
792
925
  const browserLogin = async () => {
@@ -819,25 +952,43 @@ export async function gatewayCommand(
819
952
  io.out(`usage: ${USAGE[sub === "connect" ? 3 : 4]}`)
820
953
  return 2
821
954
  }
822
- if (sub === "disconnect") return print(disconnect(home, tool, { dryRun: has("dry-run"), env }))
955
+ // What connect and disconnect do is recorded as this computer's settings (`brain settings` shows who set what),
956
+ // inside the same hold of the brain lock as the change itself. Loaded here, before connecting: the settings modules
957
+ // import this one.
958
+ const { recordConnected } = await import("../settings/apply.ts")
959
+ if (sub === "disconnect") {
960
+ if (has("dry-run")) return print(disconnect(home, tool, { dryRun: true, env }))
961
+ const r = withBrainLockSync(
962
+ home,
963
+ () => {
964
+ const d = disconnect(home, tool, { env })
965
+ if (d.ok && connectorFor(tool)) recordConnected(home, connectorFor(tool)!.id, undefined)
966
+ return d
967
+ },
968
+ { what: "disconnecting a tool" },
969
+ )
970
+ return print(r)
971
+ }
823
972
  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 } : {}) }
824
973
  // Not signed in yet: sign in first, as the 100xprompt CLI would, then connect. Everything that can be checked here
825
974
  // (the tool, its files, the flags) is checked first, so nobody signs in for a connect that was bound to fail.
826
975
  if (!hasKey(home) && !has("dry-run")) {
827
- const pre = await connect(home, tool, { settings, dryRun: true, env })
976
+ const pre = await connect(home, tool, { settings, dryRun: true, env, usePrefs: true })
828
977
  if (!pre.ok) return print(pre)
829
978
  io.out("not signed in to the gateway yet; signing in first")
830
979
  if (!(await browserLogin())) return 1
831
980
  }
832
- return print(
833
- await connect(home, tool, {
834
- settings,
835
- dryRun: has("dry-run"),
836
- probe: !has("no-probe"),
837
- env,
838
- ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
839
- }),
840
- )
981
+ const id = connectorFor(tool)?.id
982
+ const r = await connect(home, tool, {
983
+ settings,
984
+ dryRun: has("dry-run"),
985
+ probe: !has("no-probe"),
986
+ env,
987
+ usePrefs: true,
988
+ ...(id ? { onWritten: (s: GatewaySettings) => recordConnected(home, id, s) } : {}),
989
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
990
+ })
991
+ return print(r)
841
992
  }
842
993
  case "status":
843
994
  status(home, env).forEach((l) => io.out(l))
@@ -105,7 +105,7 @@ function section(lines: string[], name: string): Section | undefined {
105
105
  const at = lines.findIndex((l) => keyLine("", name).test(l))
106
106
  if (at < 0) return undefined
107
107
  const inline = inlineValue(lines[at]!)
108
- if (inline && !/^(?:\{\s*\}|null|~)$/.test(inline)) throw new Error(`${name}: is written on one line (${inline.slice(0, 40)}), not as a section`)
108
+ if (inline && !/^(?:\{\s*\}|null|~)$/.test(inline)) throw new Error(`${name}: is written on one line, not as a section`)
109
109
  const end = blockEnd(lines, at)
110
110
  const first = lines.slice(at + 1, end).find((l) => !blank(l))
111
111
  const indent = first ? indentOf(first) : ""
@@ -118,8 +118,38 @@ export interface SignInDeps {
118
118
 
119
119
  export type SignIn = { ok: true; key: string; email?: string; via: "browser" | "code" } | { ok: false; why: string }
120
120
 
121
+ type Took = { ok: true; key: string; email?: string } | { ok: false; why: string }
122
+
123
+ /**
124
+ * Which browser round trip to run: the sign-in (`brain gateway login`: /auth/cli, a gateway key comes back) or pairing
125
+ * (`brain pair`: /auth/brain, the computer's own token comes back). Everything else (the loopback server, the state,
126
+ * the paste fallback) is the same.
127
+ */
128
+ export interface BrowserFlow {
129
+ /** The command the person ran, named in the refusal page. */
130
+ command: string
131
+ /** The site's page, and what else it is told. */
132
+ path: string
133
+ params?: Record<string, string>
134
+ /** What a callback's token or a pasted text must be to be kept. */
135
+ accept(token: string): Took
136
+ /** Words: what the browser is opened for, the page shown when it worked, and what to paste. */
137
+ opening: string
138
+ done: (email?: string) => { title: string; body: string }
139
+ paste: string
140
+ }
141
+
142
+ export const SIGN_IN_FLOW: BrowserFlow = {
143
+ command: "brain gateway login",
144
+ path: "/auth/cli",
145
+ accept: (token) => callbackKey(token),
146
+ opening: "sign in to 100xprompt",
147
+ done: (email) => ({ title: "Signed in", body: `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.` }),
148
+ paste: "Paste the key from the page's Copy key button, or the address the browser could not open",
149
+ }
150
+
121
151
  /** Signs in through the browser (or a pasted code). Does not store anything: the caller does. */
122
- export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
152
+ export async function browserSignIn(d: SignInDeps, flow: BrowserFlow = SIGN_IN_FLOW): Promise<SignIn> {
123
153
  const base = signInBase(d.authUrl)
124
154
  if (!base) return { ok: false, why: "HUNDREDXPROMPT_AUTH_URL must be an http:// or https:// address" }
125
155
  const { challenge } = await pkce()
@@ -145,35 +175,37 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
145
175
  const got = url.searchParams.get("state") ?? ""
146
176
  const error = url.searchParams.get("error")
147
177
  // Only the page this command opened knows the state: anything else is refused and the wait goes on.
148
- if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", "This link was not started by this brain gateway login. Start again from your terminal.", 400)
178
+ if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", `This link was not started by this ${flow.command}. Start again from your terminal.`, 400)
149
179
  if (error) {
150
180
  settle({ ok: false, why: `the sign-in page said: ${error.slice(0, 200)}` })
151
181
  return page("Sign-in failed", `${error.slice(0, 200)}. Go back to your terminal.`, 400)
152
182
  }
153
- const c = callbackKey(url.searchParams.get("token") ?? "")
183
+ const c = flow.accept(url.searchParams.get("token") ?? "")
154
184
  if (!c.ok) {
155
185
  settle({ ok: false, why: c.why })
156
186
  return page("Sign-in failed", "No usable key came back. Go back to your terminal.", 400)
157
187
  }
158
188
  const email = url.searchParams.get("email") || c.email || undefined
159
189
  settle({ ok: true, key: c.key, ...(email ? { email } : {}), via: "browser" })
160
- return page("Signed in", `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.`)
190
+ const done = flow.done(email)
191
+ return page(done.title, done.body)
161
192
  },
162
193
  })
163
- const url = `${base}/auth/cli?port=${server.port}&state=${state}&challenge=${challenge}`
194
+ const extra = Object.entries(flow.params ?? {}).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("")
195
+ const url = `${base}${flow.path}?port=${server.port}&state=${state}&challenge=${challenge}${extra}`
164
196
  const stopAsking = new AbortController()
165
197
  const timer = setTimeout(() => settle({ ok: false, why: `no sign-in came back within ${Math.round((d.timeoutMs ?? TIMEOUT_MS) / 60_000) || 1} minutes` }), d.timeoutMs ?? TIMEOUT_MS)
166
198
  try {
167
- if (d.browser === false) d.say(`Open this address in a browser to sign in to 100xprompt:\n ${url}`)
199
+ if (d.browser === false) d.say(`Open this address in a browser to ${flow.opening}:\n ${url}`)
168
200
  else {
169
- d.say(`Opening your browser to sign in to 100xprompt:\n ${url}`)
201
+ d.say(`Opening your browser to ${flow.opening}:\n ${url}`)
170
202
  try {
171
203
  ;(d.open ?? openInBrowser)(url)
172
204
  } catch {}
173
205
  }
174
206
  if (d.ask) {
175
207
  d.say(
176
- "Waiting for the browser. Signing in on another machine (SSH)? Paste the key from the page's Copy key button, or the address the browser could not open, then press Enter (Escape cancels).",
208
+ `Waiting for the browser. Signing in on another machine (SSH)? ${flow.paste}, then press Enter (Escape cancels).`,
177
209
  )
178
210
  // The paste runs alongside the browser: whichever comes first wins. A bad paste asks again; Escape ends the sign-in
179
211
  // (an abandoned hidden prompt would leave the terminal echoing whatever is pasted next, and the shell reading it).
@@ -183,7 +215,7 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
183
215
  if (settled) return
184
216
  if (pasted === undefined) return settle({ ok: false, why: "sign-in cancelled" })
185
217
  if (!pasted.trim()) continue
186
- const c = fromPaste(pasted.trim(), state)
218
+ const c = fromPaste(pasted.trim(), state, flow)
187
219
  if (c.ok) return settle({ ok: true, key: c.key, ...(c.email ? { email: c.email } : {}), via: "code" })
188
220
  if ("fatal" in c) return settle({ ok: false, why: c.why })
189
221
  d.say(` ${c.why}`)
@@ -207,7 +239,7 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
207
239
  * 127.0.0.1 on the wrong machine, and the address it could not open holds the key). An address must carry this sign-in's
208
240
  * state, so one from another sign-in, or one made up, is refused.
209
241
  */
210
- export function fromPaste(pasted: string, state: string): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
242
+ export function fromPaste(pasted: string, state: string, flow: BrowserFlow = SIGN_IN_FLOW): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
211
243
  if (/^https?:\/\//i.test(pasted)) {
212
244
  let u: URL
213
245
  try {
@@ -220,12 +252,12 @@ export function fromPaste(pasted: string, state: string): { ok: true; key: strin
220
252
  if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return { ok: false, why: "that address is from another sign-in: paste the one from this sign-in" }
221
253
  const error = u.searchParams.get("error")
222
254
  if (error) return { ok: false, why: `the sign-in page said: ${error.slice(0, 200)}`, fatal: true }
223
- const c = callbackKey(u.searchParams.get("token") ?? "")
255
+ const c = flow.accept(u.searchParams.get("token") ?? "")
224
256
  if (!c.ok) return c
225
257
  const email = u.searchParams.get("email") || c.email
226
258
  return { ok: true, key: c.key, ...(email ? { email } : {}) }
227
259
  }
228
- return checkCredential(pasted)
260
+ return flow === SIGN_IN_FLOW ? checkCredential(pasted) : flow.accept(pasted)
229
261
  }
230
262
 
231
263
  /**