@100xprompt/brain 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +66 -3
  2. package/package.json +1 -1
  3. package/src/cli.ts +28 -3
  4. package/src/dashboard/cli.ts +148 -0
  5. package/src/dashboard/conversions.ts +170 -0
  6. package/src/dashboard/report.ts +275 -0
  7. package/src/dashboard/send.ts +213 -0
  8. package/src/dashboard/state.ts +95 -0
  9. package/src/gateway/common.ts +232 -0
  10. package/src/gateway/connect.ts +164 -162
  11. package/src/gateway/connectors/aider.ts +356 -0
  12. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  13. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  14. package/src/gateway/connectors/antigravity.ts +6 -0
  15. package/src/gateway/connectors/auggie.ts +6 -0
  16. package/src/gateway/connectors/cline-sdk.ts +85 -0
  17. package/src/gateway/connectors/cline.ts +34 -0
  18. package/src/gateway/connectors/codebuff.ts +6 -0
  19. package/src/gateway/connectors/continue.ts +579 -0
  20. package/src/gateway/connectors/copilot-cli.ts +138 -0
  21. package/src/gateway/connectors/crush.ts +313 -0
  22. package/src/gateway/connectors/cursor-cli.ts +6 -0
  23. package/src/gateway/connectors/cursor.ts +6 -0
  24. package/src/gateway/connectors/devin.ts +6 -0
  25. package/src/gateway/connectors/factory-droid.ts +308 -0
  26. package/src/gateway/connectors/goose.ts +79 -0
  27. package/src/gateway/connectors/grok-cli.ts +82 -0
  28. package/src/gateway/connectors/hermes.ts +230 -0
  29. package/src/gateway/connectors/iflow-cli.ts +50 -0
  30. package/src/gateway/connectors/index.ts +86 -0
  31. package/src/gateway/connectors/kilo-cli.ts +434 -0
  32. package/src/gateway/connectors/kilo-code.ts +32 -0
  33. package/src/gateway/connectors/kimi-code.ts +241 -0
  34. package/src/gateway/connectors/kiro-cli.ts +6 -0
  35. package/src/gateway/connectors/kiro-ide.ts +6 -0
  36. package/src/gateway/connectors/letta-code.ts +170 -0
  37. package/src/gateway/connectors/mastracode.ts +91 -0
  38. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  39. package/src/gateway/connectors/muse.ts +27 -0
  40. package/src/gateway/connectors/omp.ts +279 -0
  41. package/src/gateway/connectors/openhands.ts +296 -0
  42. package/src/gateway/connectors/pearai.ts +52 -0
  43. package/src/gateway/connectors/pi.ts +357 -0
  44. package/src/gateway/connectors/qoder-cli.ts +6 -0
  45. package/src/gateway/connectors/qwen-code.ts +411 -0
  46. package/src/gateway/connectors/roo.ts +43 -0
  47. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  48. package/src/gateway/connectors/void.ts +6 -0
  49. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  50. package/src/gateway/connectors/windsurf.ts +6 -0
  51. package/src/gateway/connectors/zed.ts +29 -0
@@ -0,0 +1,579 @@
1
+ // Gateway connector for Continue (continuedev/continue): its VS Code and JetBrains extensions and its `cn` CLI, which share
2
+ // one settings folder. Checked against the `cn` CLI 1.5.47 (~/.cache/brain-wire/continue, @continuedev/cli, its src/ is
3
+ // shipped in the package) and the VS Code extension 2.0.0 (continue.continue-2.0.0-darwin-arm64, out/extension.js).
4
+ //
5
+ // Where Continue reads its settings:
6
+ // <dir> = $CONTINUE_GLOBAL_DIR, else ~/.continue cli src/env.ts:10-11; extension getContinueGlobalPath
7
+ // <dir>/config.yaml the CLI's local config: used whenever it exists, else the CLI loads Continue's hosted default
8
+ // config (cli src/configLoader.ts:94-99). The extension uses it too, and creates it with
9
+ // `name: Main Config / version: 1.0.0 / schema: v1 / models: []` when neither file exists
10
+ // (extension.js getConfigYamlPath ~56261, defaultConfig ~54871).
11
+ // <dir>/config.json the older format. The extension still loads it when there is no config.yaml (extension.js
12
+ // ~799466-799496: config.yaml if it exists, else loadContinueConfigFromJson); the CLI ignores it.
13
+ // A model is an entry of the `models:` list (config-yaml schema, extension.js ~222352): name, provider, model, apiBase,
14
+ // apiKey, roles, capabilities, defaultCompletionOptions.contextLength, requestOptions.headers (~222227: a string record,
15
+ // sent with every request). provider `openai` speaks Chat Completions at <apiBase>/chat/completions.
16
+ //
17
+ // The key. Continue cannot read a key from a file or a command: apiKey is a string, and `${{ secrets.NAME }}` in it is
18
+ // filled at load time from, in order, <dir>/.env, <workspace>/.continue/.env, <workspace>/.env and the process
19
+ // environment (extension: LocalPlatformClient ~798790-798870, getContinueDotEnv ~56467; CLI: src/CLIPlatformClient.ts:57-87,
20
+ // process environment first; template filling: fillTemplateVariables/renderTemplateData ~223583-223700). config.json does
21
+ // the same with its top-level `"env": ["NAME"]` list, which replaces every "NAME" string in the file with the value from
22
+ // <dir>/.env or the environment (extension.js resolveSerializedConfig ~797703). So brain copies the key to <dir>/.env as
23
+ // BRAIN_GATEWAY_KEY (that file is made 0600, and refreshed on a new sign-in), and the config only names it: config.yaml
24
+ // stays free of the key, as Continue's docs recommend for secrets.
25
+ //
26
+ // What is written:
27
+ // <dir>/.env BRAIN_GATEWAY_KEY=<key>
28
+ // config.yaml one entry first in `models:`, found again by its apiKey naming BRAIN_GATEWAY_KEY:
29
+ // name: 100xprompt Pro, provider: openai, model: <gateway id>, apiBase: <route>/v1,
30
+ // apiKey: ${{ secrets.BRAIN_GATEWAY_KEY }}, roles chat/edit/apply/summarize,
31
+ // capabilities tool_use and image_input (Continue only guesses tool support for provider openai from
32
+ // gpt-/o- model names, toolSupport.ts ~277661, so without it Agent mode would be off),
33
+ // contextLength 1048576 (an unknown model gets 32768, ~238017), and the context-edit header.
34
+ // First in the list makes it the default: the extension and the CLI take the first chat model unless
35
+ // one was picked before (extension rectifySelectedModelsFromGlobalContext ~798703; CLI
36
+ // services/ModelService.ts:53-120 and util/modelPersistence.ts, `cliSelectedModel`).
37
+ // A config.yaml brain creates also gets name/version/schema, which Continue requires; they are
38
+ // removed again only when nothing else is left in it.
39
+ // config.json (only when it is the file Continue uses) the same entry with `title`, its apiKey "BRAIN_GATEWAY_KEY",
40
+ // and "BRAIN_GATEWAY_KEY" added to the top-level "env" list.
41
+ // The two codecs below edit only that list entry (and the env list item) in place, as text: comments and every other byte
42
+ // stay, and removing the entry gives back the exact bytes, including an empty `models: []` or a list that was not there.
43
+ import { existsSync, readFileSync } from "node:fs"
44
+ import { isAbsolute, join, resolve } from "node:path"
45
+ import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, modelName, readKey, routeUrl, yamlTopLevel } from "../common.ts"
46
+
47
+ /** The name the key is kept under in the tool's .env, and that its config refers to. */
48
+ export const SECRET = "BRAIN_GATEWAY_KEY"
49
+ /** The `name` of a config.yaml that brain created (Continue requires one). */
50
+ const OWN_CONFIG = "100xprompt gateway"
51
+ /** The codec key of brain's entry in the models list (the entry itself is found by its apiKey, so a model switch keeps it). */
52
+ export const ENTRY_KEY = "models/100xprompt gateway"
53
+ /** Gemini models' context window; Continue assumes 32768 for a model it does not know. */
54
+ const CONTEXT = 1048576
55
+
56
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
57
+ const isPlainObject = (v: unknown): v is Json => !!v && typeof v === "object" && !Array.isArray(v)
58
+
59
+ /**
60
+ * The list keys a codec understands: `<list>/<label>` is the one item of top-level list <list> for which `match` holds.
61
+ * get returns that item; when there is none, undefined if the list is there and null if it is not (so that removing the
62
+ * item later, with that value, also removes a list brain added). The YAML codec also returns [] for an empty `models: []`.
63
+ */
64
+ export interface ListKey {
65
+ list: string
66
+ match(item: unknown): boolean
67
+ /** JSON only: write the list on one line (a list of short strings). */
68
+ inline?: boolean
69
+ }
70
+
71
+ // ---- YAML: block lists ------------------------------------------------------------------------------------------------
72
+
73
+ const YAML11_WORDS = /^(y|yes|n|no|on|off|true|false|null|~)$/i
74
+ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
75
+
76
+ /** A string as YAML reads it back unchanged: plain when that is safe, else JSON (valid YAML). */
77
+ function yScalar(v: unknown, flow = false): string {
78
+ if (typeof v === "string") {
79
+ if (/^[A-Za-z0-9_./@-][A-Za-z0-9_./:@+=-]*$/.test(v) && !YAML11_WORDS.test(v) && !(flow && /[,[\]{}]/.test(v))) {
80
+ try {
81
+ if ((Bun.YAML.parse(`k: ${v}`) as Json).k === v) return v
82
+ } catch {}
83
+ }
84
+ return JSON.stringify(v)
85
+ }
86
+ if (typeof v === "number" || typeof v === "boolean") return String(v)
87
+ return JSON.stringify(v ?? null)
88
+ }
89
+ const yKey = (k: string) => (/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(k) && !YAML11_WORDS.test(k) ? k : JSON.stringify(k))
90
+
91
+ /** An object as block-style YAML lines at `indent` (lists on one line, objects nested). */
92
+ function yBlock(o: Json, indent: string): string[] {
93
+ const out: string[] = []
94
+ for (const [k, v] of Object.entries(o)) {
95
+ if (v === undefined) continue
96
+ if (Array.isArray(v)) out.push(`${indent}${yKey(k)}: [${v.map((x) => yScalar(x, true)).join(", ")}]`)
97
+ else if (isPlainObject(v)) {
98
+ if (!Object.keys(v).length) out.push(`${indent}${yKey(k)}: {}`)
99
+ else out.push(`${indent}${yKey(k)}:`, ...yBlock(v, `${indent} `))
100
+ } else out.push(`${indent}${yKey(k)}: ${yScalar(v)}`)
101
+ }
102
+ return out
103
+ }
104
+ /** A list item: `<indent>- first: ...` and the rest under it. */
105
+ function yItem(o: Json, indent: string): string[] {
106
+ const lines = yBlock(o, `${indent} `)
107
+ if (!lines.length) return [`${indent}- {}`]
108
+ lines[0] = `${indent}- ${lines[0]!.slice(indent.length + 2)}`
109
+ return lines
110
+ }
111
+
112
+ /** Where the ` # comment` at the end of a `key: value` line starts (with the spaces before it, outside quotes), or -1. */
113
+ function yCommentAt(line: string): number {
114
+ let quote: string | undefined
115
+ for (let i = line.indexOf(":") + 1; i > 0 && i < line.length; i++) {
116
+ const c = line[i]!
117
+ if (quote) {
118
+ if (c === "\\" && quote === '"') i++
119
+ else if (c === quote) quote = undefined
120
+ } else if (c === '"' || c === "'") quote = c
121
+ else if (c === "#" && /[ \t]/.test(line[i - 1]!)) {
122
+ let j = i
123
+ while (j > 0 && /[ \t]/.test(line[j - 1]!)) j--
124
+ return j
125
+ }
126
+ }
127
+ return -1
128
+ }
129
+
130
+ interface YList {
131
+ /** The line of `<list>:`, or -1. */
132
+ at: number
133
+ /** The key as written (`models`, `"models"`). */
134
+ key: string
135
+ /** block: items (or nothing) below the key; empty: `models: []`; other: anything else on the key line. */
136
+ form: "block" | "empty" | "other"
137
+ comment: string
138
+ /** Each item's lines [start, end): from its `-` line to its last content line (trailing blank and comment lines excluded). */
139
+ items: Array<{ start: number; end: number; indent: string }>
140
+ }
141
+
142
+ const content = (l: string) => l.trim() !== "" && !/^\s*#/.test(l)
143
+
144
+ function yLocate(lines: string[], list: string): YList {
145
+ const head = new RegExp(`^(?:${esc(list)}|"${esc(list)}"|'${esc(list)}')[ \\t]*:(?=[ \\t]|$)`)
146
+ const at = lines.findIndex((l) => head.test(l))
147
+ if (at < 0) return { at, key: list, form: "block", comment: "", items: [] }
148
+ const line = lines[at]!
149
+ const colon = line.indexOf(":", line.startsWith('"') || line.startsWith("'") ? list.length + 2 : 0)
150
+ const key = line.slice(0, colon).trimEnd()
151
+ const j = yCommentAt(line)
152
+ const comment = j < 0 ? "" : line.slice(j).trimEnd()
153
+ const value = line.slice(colon + 1, j < 0 ? undefined : j).trim()
154
+ if (value === "[]") return { at, key, form: "empty", comment, items: [] }
155
+ if (value !== "") return { at, key, form: "other", comment, items: [] }
156
+ // The block: indented lines, `-` lines (a list may sit at the key's own indentation), blank lines and comments.
157
+ let end = at + 1
158
+ while (end < lines.length && (/^[ \t]/.test(lines[end]!) || lines[end]!.trim() === "" || /^-(\s|$)/.test(lines[end]!) || /^#/.test(lines[end]!))) end++
159
+ while (end > at + 1 && !content(lines[end - 1]!)) end--
160
+ const first = lines.slice(at + 1, end).find(content)
161
+ const m = first === undefined ? null : /^([ \t]*)-(?:[ \t]|$)/.exec(first)
162
+ if (first !== undefined && !m) return { at, key, form: "other", comment, items: [] }
163
+ const indent = m?.[1] ?? " "
164
+ const starts: number[] = []
165
+ for (let i = at + 1; i < end; i++) if (new RegExp(`^${esc(indent)}-(?:[ \\t]|$)`).test(lines[i]!)) starts.push(i)
166
+ const items = starts.map((s, k) => {
167
+ let e = k + 1 < starts.length ? starts[k + 1]! : end
168
+ while (e > s + 1 && !content(lines[e - 1]!)) e--
169
+ return { start: s, end: e, indent }
170
+ })
171
+ return { at, key, form: "block", comment, items }
172
+ }
173
+
174
+ const yParse = (text: string): Json | null => {
175
+ if (!text.trim()) return null
176
+ const v = Bun.YAML.parse(text)
177
+ return isPlainObject(v) ? v : null
178
+ }
179
+
180
+ /**
181
+ * Continue's config.yaml: `<list>/<label>` keys (see ListKey) edit one item of a block list, every other key is a top-level
182
+ * key (yamlTopLevel). Items are added first in the list, at its indentation; nothing else in the file changes.
183
+ */
184
+ export function yamlListCodec(keys: Record<string, ListKey>): Codec {
185
+ return {
186
+ check(text) {
187
+ const base = yamlTopLevel.check!(text)
188
+ if (base) return base
189
+ const doc = yParse(text)
190
+ if (!doc) return undefined
191
+ const lines = text.replace(/\n$/, "").split("\n")
192
+ for (const list of new Set(Object.values(keys).map((k) => k.list))) {
193
+ if (!(list in doc)) continue
194
+ const v = doc[list]
195
+ if (v !== null && !Array.isArray(v)) return `its ${list} is not a list; left as it is`
196
+ const loc = yLocate(lines, list)
197
+ if (loc.at < 0 || loc.form === "other") return `its ${list} list is written in a form brain does not edit (only a block list of "- " entries, or []); left as it is`
198
+ if (loc.form === "block" && loc.items.length !== (v?.length ?? 0)) return `its ${list} list is laid out in a way brain does not edit; left as it is`
199
+ }
200
+ return undefined
201
+ },
202
+ get(text, key) {
203
+ const k = keys[key]
204
+ if (!k) return yamlTopLevel.get(text, key)
205
+ const doc = yParse(text)
206
+ if (!doc || !(k.list in doc)) return null
207
+ const v = doc[k.list]
208
+ if (!Array.isArray(v)) return undefined
209
+ const found = v.find((x) => k.match(x))
210
+ return found !== undefined ? found : v.length ? undefined : []
211
+ },
212
+ set(text, key, value) {
213
+ const k = keys[key]
214
+ if (!k) return same(yamlTopLevel.get(text, key), value) ? text : yamlTopLevel.set(text, key, value)
215
+ const lines = text ? text.replace(/\n$/, "").split("\n") : []
216
+ const doc = yParse(text)
217
+ const v = doc?.[k.list]
218
+ const idx = Array.isArray(v) ? v.findIndex((x) => k.match(x)) : -1
219
+ const loc = yLocate(lines, k.list)
220
+ if (isPlainObject(value)) {
221
+ if (idx >= 0) {
222
+ const it = loc.items[idx]!
223
+ lines.splice(it.start, it.end - it.start, ...yItem(value, it.indent))
224
+ } else if (loc.at < 0) lines.push(`${k.list}:`, ...yItem(value, " "))
225
+ else if (loc.form === "empty") lines.splice(loc.at, 1, `${loc.key}:${loc.comment}`, ...yItem(value, " "))
226
+ else if (loc.items.length) lines.splice(loc.items[0]!.start, 0, ...yItem(value, loc.items[0]!.indent))
227
+ else lines.splice(loc.at + 1, 0, ...yItem(value, " "))
228
+ } else {
229
+ // undefined: the item goes, the list stays; [] the list goes back to `[]`; null: a list left empty goes too.
230
+ if (idx >= 0) {
231
+ const it = loc.items[idx]!
232
+ lines.splice(it.start, it.end - it.start)
233
+ }
234
+ const after = yLocate(lines, k.list)
235
+ if (after.at >= 0 && after.form === "block" && !after.items.length) {
236
+ if (value === null) lines.splice(after.at, 1)
237
+ else if (Array.isArray(value) && !value.length) lines[after.at] = `${after.key}: []${after.comment}`
238
+ }
239
+ }
240
+ return lines.length ? `${lines.join("\n")}\n` : ""
241
+ },
242
+ }
243
+ }
244
+
245
+ // ---- JSON with comments: lists edited as text ---------------------------------------------------------------------------
246
+
247
+ type JNode =
248
+ | { kind: "obj"; start: number; end: number; members: Array<{ key: string; start: number; value: JNode; comma: number }> }
249
+ | { kind: "arr"; start: number; end: number; elems: Array<{ value: JNode; comma: number }> }
250
+ | { kind: "lit"; start: number; end: number; value: unknown }
251
+
252
+ /** JSON with // and /* comments and trailing commas, with the position of every value (start, end exclusive) and comma. */
253
+ export function jsoncTree(text: string): JNode {
254
+ let i = 0
255
+ const fail = (): never => {
256
+ throw new Error(`not JSON (at character ${i})`)
257
+ }
258
+ const ws = () => {
259
+ for (;;) {
260
+ while (i < text.length && /\s/.test(text[i]!)) i++
261
+ if (text.startsWith("//", i)) {
262
+ const n = text.indexOf("\n", i)
263
+ i = n < 0 ? text.length : n + 1
264
+ } else if (text.startsWith("/*", i)) {
265
+ const n = text.indexOf("*/", i + 2)
266
+ if (n < 0) fail()
267
+ i = n + 2
268
+ } else return
269
+ }
270
+ }
271
+ const str = (): string => {
272
+ const s = i++
273
+ while (i < text.length && text[i] !== '"') {
274
+ if (text[i] === "\\") i++
275
+ else if (text[i] === "\n") fail()
276
+ i++
277
+ }
278
+ if (i >= text.length) fail()
279
+ i++
280
+ return JSON.parse(text.slice(s, i))
281
+ }
282
+ const val = (): JNode => {
283
+ ws()
284
+ const s = i
285
+ const c = text[i]
286
+ if (c === "{") {
287
+ i++
288
+ const members: Array<{ key: string; start: number; value: JNode; comma: number }> = []
289
+ for (;;) {
290
+ ws()
291
+ if (text[i] === "}") break
292
+ if (text[i] !== '"') fail()
293
+ const ks = i
294
+ const key = str()
295
+ ws()
296
+ if (text[i] !== ":") fail()
297
+ i++
298
+ const value = val()
299
+ ws()
300
+ if (text[i] === ",") {
301
+ members.push({ key, start: ks, value, comma: i++ })
302
+ continue
303
+ }
304
+ members.push({ key, start: ks, value, comma: -1 })
305
+ if (text[i] !== "}") fail()
306
+ break
307
+ }
308
+ i++
309
+ return { kind: "obj", start: s, end: i, members }
310
+ }
311
+ if (c === "[") {
312
+ i++
313
+ const elems: Array<{ value: JNode; comma: number }> = []
314
+ for (;;) {
315
+ ws()
316
+ if (text[i] === "]") break
317
+ const value = val()
318
+ ws()
319
+ if (text[i] === ",") {
320
+ elems.push({ value, comma: i++ })
321
+ continue
322
+ }
323
+ elems.push({ value, comma: -1 })
324
+ if (text[i] !== "]") fail()
325
+ break
326
+ }
327
+ i++
328
+ return { kind: "arr", start: s, end: i, elems }
329
+ }
330
+ if (c === '"') {
331
+ const value = str()
332
+ return { kind: "lit", start: s, end: i, value }
333
+ }
334
+ const re = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/y
335
+ re.lastIndex = i
336
+ const m = re.exec(text)
337
+ if (!m) fail()
338
+ i += m![0].length
339
+ return { kind: "lit", start: s, end: i, value: JSON.parse(m![0]) }
340
+ }
341
+ const root = val()
342
+ ws()
343
+ if (i !== text.length) fail()
344
+ return root
345
+ }
346
+
347
+ export function jsonValue(n: JNode): unknown {
348
+ if (n.kind === "lit") return n.value
349
+ if (n.kind === "arr") return n.elems.map((e) => jsonValue(e.value))
350
+ return Object.fromEntries(n.members.map((m) => [m.key, jsonValue(m.value)]))
351
+ }
352
+
353
+ type JList = Extract<JNode, { kind: "obj" | "arr" }>
354
+ const spans = (n: JList) =>
355
+ n.kind === "arr" ? n.elems.map((e) => ({ start: e.value.start, end: e.value.end, comma: e.comma })) : n.members.map((m) => ({ start: m.start, end: m.value.end, comma: m.comma }))
356
+ const lineIndent = (text: string, pos: number) => /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", pos - 1) + 1))![0]
357
+
358
+ /** `item(indent)` added as the first item of an array or object; removing it again (jRemove) gives back the same bytes. */
359
+ function jInsertFront(text: string, n: JList, item: (indent: string) => string, unit: string, inline = false): string {
360
+ const items = spans(n)
361
+ const open = n.start
362
+ const close = n.end - 1
363
+ const base = lineIndent(text, open)
364
+ if (!items.length) {
365
+ if (/^\s*$/.test(text.slice(open + 1, close))) {
366
+ if (inline) return text.slice(0, open + 1) + item("") + text.slice(close)
367
+ return `${text.slice(0, open + 1)}\n${base}${unit}${item(base + unit)}\n${base}${text.slice(close)}`
368
+ }
369
+ // Only comments inside: the item goes right after the bracket, before them.
370
+ return `${text.slice(0, open + 1)}\n${base}${unit}${item(base + unit)}${text.slice(open + 1)}`
371
+ }
372
+ const first = items[0]!.start
373
+ const multi = text.slice(open + 1, first).includes("\n")
374
+ const ind = multi ? lineIndent(text, first) : base + unit
375
+ return `${text.slice(0, first)}${item(ind)},${multi ? `\n${ind}` : " "}${text.slice(first)}`
376
+ }
377
+
378
+ /** The text without item `idx` of an array or object (and the separator that went with it). */
379
+ function jRemove(text: string, n: JList, idx: number): string {
380
+ const items = spans(n)
381
+ const it = items[idx]!
382
+ const open = n.start
383
+ const close = n.end - 1
384
+ if (items.length === 1) {
385
+ if (/^\s*$/.test(text.slice(open + 1, it.start)) && /^\s*,?\s*$/.test(text.slice(it.end, close))) return text.slice(0, open + 1) + text.slice(close)
386
+ return text.slice(0, open + 1) + text.slice(it.end)
387
+ }
388
+ if (idx === 0) return text.slice(0, it.start) + text.slice(items[1]!.start)
389
+ return text.slice(0, items[idx - 1]!.comma) + text.slice(it.end)
390
+ }
391
+
392
+ const jUnit = (text: string) => /\n([ \t]+)\S/.exec(text)?.[1] ?? " "
393
+ const lastMember = (root: Extract<JNode, { kind: "obj" }>, key: string) => {
394
+ for (let i = root.members.length - 1; i >= 0; i--) if (root.members[i]!.key === key) return i
395
+ return -1
396
+ }
397
+
398
+ /**
399
+ * A JSON config that may hold comments (Continue's config.json, PearAI's), edited as text: `<list>/<label>` keys (see
400
+ * ListKey) add, replace or remove one item of a top-level list. Comments and formatting everywhere else stay.
401
+ */
402
+ export function jsonListCodec(keys: Record<string, ListKey>): Codec {
403
+ const root = (text: string) => {
404
+ const t = jsoncTree(text.trim() ? text : "{}")
405
+ if (t.kind !== "obj") throw new Error("not a JSON object")
406
+ return t
407
+ }
408
+ return {
409
+ check(text) {
410
+ let r: Extract<JNode, { kind: "obj" }>
411
+ try {
412
+ r = root(text)
413
+ } catch {
414
+ return "it is not a JSON object (with or without comments); left as it is"
415
+ }
416
+ for (const list of new Set(Object.values(keys).map((k) => k.list))) {
417
+ const i = lastMember(r, list)
418
+ if (i >= 0 && r.members[i]!.value.kind !== "arr") return `its "${list}" is not a list; left as it is`
419
+ }
420
+ return undefined
421
+ },
422
+ get(text, key) {
423
+ const k = keys[key]
424
+ if (!k) return undefined
425
+ const o = jsonValue(root(text)) as Json
426
+ if (!(k.list in o)) return null
427
+ const v = o[k.list]
428
+ if (!Array.isArray(v)) return undefined
429
+ const found = v.find((x) => k.match(x))
430
+ return found !== undefined ? found : undefined
431
+ },
432
+ set(text, key, value) {
433
+ const k = keys[key]
434
+ if (!k) return text
435
+ const base = text.trim() ? text : "{}\n"
436
+ const r = root(base)
437
+ const unit = jUnit(base)
438
+ const mi = lastMember(r, k.list)
439
+ const arr = mi >= 0 && r.members[mi]!.value.kind === "arr" ? (r.members[mi]!.value as Extract<JNode, { kind: "arr" }>) : undefined
440
+ const ei = arr ? arr.elems.findIndex((e) => k.match(jsonValue(e.value))) : -1
441
+ const render = (ind: string) => JSON.stringify(value, null, unit).replace(/\n/g, `\n${ind}`)
442
+ if (value === undefined || value === null) {
443
+ // undefined: the item goes, the list stays; null (there was no list): a list left empty goes too.
444
+ if (!arr) return text
445
+ let t = ei >= 0 ? jRemove(base, arr, ei) : base
446
+ if (value === null) {
447
+ const r2 = root(t)
448
+ const m2 = lastMember(r2, k.list)
449
+ const a2 = m2 >= 0 ? r2.members[m2]!.value : undefined
450
+ if (a2?.kind === "arr" && !a2.elems.length && /^\s*$/.test(t.slice(a2.start + 1, a2.end - 1))) t = jRemove(t, r2, m2)
451
+ }
452
+ return t
453
+ }
454
+ if (mi < 0) {
455
+ const list = (ind: string) => `${JSON.stringify(k.list)}: ${k.inline ? `[${render("")}]` : `[\n${ind}${unit}${render(ind + unit)}\n${ind}]`}`
456
+ return jInsertFront(base, r, list, unit)
457
+ }
458
+ if (!arr) return text
459
+ if (ei >= 0) {
460
+ const el = arr.elems[ei]!.value
461
+ return base.slice(0, el.start) + render(lineIndent(base, el.start)) + base.slice(el.end)
462
+ }
463
+ return jInsertFront(base, arr, render, unit, k.inline)
464
+ },
465
+ }
466
+ }
467
+
468
+ // ---- the connector ------------------------------------------------------------------------------------------------------
469
+
470
+ /** Continue's settings folder; $CONTINUE_GLOBAL_DIR describes the person's real home, so it counts only there. */
471
+ export function continueDir(home: string, env: Env): string {
472
+ const own = !!env.HOME && resolve(env.HOME) === resolve(home)
473
+ const d = env.CONTINUE_GLOBAL_DIR
474
+ return own && d && isAbsolute(d) && existsSync(d) ? d : join(home, ".continue")
475
+ }
476
+
477
+ /** The config Continue loads: config.yaml, or the older config.json when that is the only one. */
478
+ export function continueConfig(dir: string): { path: string; kind: "yaml" | "json" } {
479
+ const yaml = join(dir, "config.yaml")
480
+ const json = join(dir, "config.json")
481
+ return !existsSync(yaml) && existsSync(json) ? { path: json, kind: "json" } : { path: yaml, kind: "yaml" }
482
+ }
483
+
484
+ const mentionsSecret = (x: unknown) => isPlainObject(x) && typeof x.apiKey === "string" && x.apiKey.includes(SECRET)
485
+ const yamlList = yamlListCodec({ [ENTRY_KEY]: { list: "models", match: mentionsSecret } })
486
+ /**
487
+ * The codec key for the name/version/schema a config.yaml needs (Continue refuses one without them), set only in a
488
+ * config.yaml brain creates. Written at the top; removed only when nothing else is left in the file, so a model the person
489
+ * added to that file meanwhile keeps a config Continue can load.
490
+ */
491
+ export const HEADER_KEY = "name+version+schema"
492
+ const HEADER = ["name", "version", "schema"]
493
+ const headerOf = (text: string) => {
494
+ const doc = yParse(text)
495
+ const present = doc ? HEADER.filter((f) => f in doc) : []
496
+ return present.length ? Object.fromEntries(present.map((f) => [f, doc![f]])) : undefined
497
+ }
498
+ const withoutHeader = (text: string) => HEADER.reduce((t, f) => yamlTopLevel.set(t, f, undefined), text)
499
+ /** Continue's config.yaml: brain's models entry (found by its apiKey) and, in a config brain created, the header. */
500
+ export const yamlCodec: Codec = {
501
+ check: yamlList.check!,
502
+ get: (text, key) => (key === HEADER_KEY ? headerOf(text) : yamlList.get(text, key)),
503
+ set(text, key, value) {
504
+ if (key !== HEADER_KEY) return yamlList.set(text, key, value)
505
+ if (isPlainObject(value)) {
506
+ if (same(headerOf(text), value)) return text
507
+ const rest = withoutHeader(text)
508
+ return `${[...Object.entries(value).map(([k, v]) => `${k}: ${yScalar(v)}`), ...(rest ? [rest.replace(/\n$/, "")] : [])].join("\n")}\n`
509
+ }
510
+ const doc = yParse(text)
511
+ return doc && Object.keys(doc).every((k) => HEADER.includes(k)) ? withoutHeader(text) : text
512
+ },
513
+ }
514
+ /** Continue's and PearAI's config.json: the entry (its apiKey is the env name) and the env list item. */
515
+ export const jsonCodec = jsonListCodec({
516
+ [ENTRY_KEY]: { list: "models", match: (x) => isPlainObject(x) && x.apiKey === SECRET },
517
+ [`env/${SECRET}`]: { list: "env", match: (x) => x === SECRET, inline: true },
518
+ })
519
+
520
+ const headers = (s: GatewaySettings) => (s.contextEdit ? { requestOptions: { headers: { [EDIT_HEADER]: "on" } } } : {})
521
+
522
+ /** The models entry in config.json form (older Continue, PearAI): `title`, the key by env name. */
523
+ export function jsonEntry(s: GatewaySettings, extra: Json = {}): Json {
524
+ return { title: modelName(s.model), provider: "openai", model: s.model, apiBase: `${routeUrl(s)}/v1`, apiKey: SECRET, contextLength: CONTEXT, ...extra, ...headers(s) }
525
+ }
526
+
527
+ /** The .env edit both Continue and PearAI read their secret from. */
528
+ export const envEdit = (dir: string, home: string) => ({ path: join(dir, ".env"), format: "dotenv" as const, holdsKey: true, secretKeys: [SECRET], values: { [SECRET]: readKey(home) } })
529
+
530
+ /** Whether config.yaml is missing, empty, or one brain created (it then needs, and keeps, name/version/schema). */
531
+ function ownYaml(path: string): boolean {
532
+ try {
533
+ const text = readFileSync(path, "utf8")
534
+ return !text.trim() || yParse(text)?.name === OWN_CONFIG
535
+ } catch {
536
+ return true
537
+ }
538
+ }
539
+
540
+ export const connectors: Connector[] = [
541
+ {
542
+ id: "continue",
543
+ label: "Continue",
544
+ file: (home, env) => continueConfig(continueDir(home, env)).path,
545
+ probe: [".continue"],
546
+ authHeader: "authorization",
547
+ edits: (home, s, env) => {
548
+ const dir = continueDir(home, env)
549
+ const cfg = continueConfig(dir)
550
+ if (cfg.kind === "json")
551
+ return [envEdit(dir, home), { path: cfg.path, format: "custom", codec: jsonCodec, values: { [ENTRY_KEY]: jsonEntry(s, { capabilities: { tools: true, uploadImage: true } }), [`env/${SECRET}`]: SECRET } }]
552
+ const entry = {
553
+ name: modelName(s.model),
554
+ provider: "openai",
555
+ model: s.model,
556
+ apiBase: `${routeUrl(s)}/v1`,
557
+ apiKey: `\${{ secrets.${SECRET} }}`,
558
+ roles: ["chat", "edit", "apply", "summarize"],
559
+ capabilities: ["tool_use", "image_input"],
560
+ defaultCompletionOptions: { contextLength: CONTEXT },
561
+ ...headers(s),
562
+ }
563
+ return [
564
+ envEdit(dir, home),
565
+ {
566
+ path: cfg.path,
567
+ format: "custom",
568
+ codec: yamlCodec,
569
+ values: { [ENTRY_KEY]: entry, ...(ownYaml(cfg.path) ? { [HEADER_KEY]: { name: OWN_CONFIG, version: "1.0.0", schema: "v1" } } : {}) },
570
+ },
571
+ ]
572
+ },
573
+ notes: [
574
+ "Continue (its extensions and the cn CLI) now lists the gateway's model first in its config; it is the default unless you picked another model in Continue before: then pick it once in Continue's model menu.",
575
+ `Continue cannot read a key from another file, so the key is kept in ~/.continue/.env as ${SECRET} (readable by you only), and the config refers to it.`,
576
+ "With only the older config.json (no config.yaml) the extensions use the gateway; the cn CLI reads config.yaml only.",
577
+ ],
578
+ },
579
+ ]