@boundless.network/setup 0.1.2 → 0.1.4

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/dist/app.js CHANGED
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react";
4
4
  // Async screens run their task exactly once, on mount; the wizard object is mutable and shared.
5
5
  import { HARNESSES } from "./harnesses/index.js";
6
6
  import { openBrowser } from "./lib/browser.js";
7
- import { keyWorks, listModels } from "./lib/catalog.js";
7
+ import { keyWorks, listModels, RECOMMENDED_BACKGROUND, RECOMMENDED_MODEL } from "./lib/catalog.js";
8
8
  import { CONSOLE, KEY_ENV, KEYS_URL, discoverSetup, SetupError } from "./lib/config.js";
9
9
  import { inspectEnvironment } from "./lib/env.js";
10
10
  import { exchangeForKey } from "./lib/exchange.js";
@@ -22,12 +22,30 @@ import { LOGO, LOGO_COLUMNS, LOGO_ROWS } from "./ui/logo.js";
22
22
  import { ModelSelect } from "./ui/table.js";
23
23
  import { BAD, BRAND, MUTED, OK, WARN } from "./ui/theme.js";
24
24
  const NAV = "↑↓ navigate enter select";
25
+ const STEPS = new Set(["welcome", "existing", "harnesses", "scope", "store", "model", "review"]);
26
+ const withBack = (hints, canBack) => (canBack ? `${hints} esc back` : hints);
25
27
  function message(error) {
26
28
  return error instanceof Error ? error.message : String(error);
27
29
  }
28
30
  export function App({ onFinish }) {
29
31
  const { exit } = useApp();
30
32
  const [screen, setScreen] = useState({ kind: "boot" });
33
+ const history = useRef([]).current;
34
+ const canBack = history.length > 0;
35
+ const go = (next) => {
36
+ if (STEPS.has(screen.kind)) {
37
+ history.push(screen);
38
+ }
39
+ setScreen(next);
40
+ };
41
+ const back = () => {
42
+ const previous = history.pop();
43
+ if (previous) {
44
+ setScreen(previous);
45
+ }
46
+ };
47
+ const forget = () => history.splice(0);
48
+ const pickModels = () => go(wizard.catalog.length > 0 ? { kind: "model", role: "primary" } : { kind: "catalog" });
31
49
  const wizard = useRef({
32
50
  background: "",
33
51
  backups: [],
@@ -76,17 +94,17 @@ export function App({ onFinish }) {
76
94
  smokes: wizard.smokes,
77
95
  tests: wizard.tests,
78
96
  });
79
- const hasBackgroundSlot = () => chosen().some((harness) => harness.backgroundDefault !== null);
97
+ const hasBackgroundSlot = () => chosen().some((harness) => harness.backgroundSlot);
80
98
  const chooseModel = (role, id) => {
81
99
  if (role === "primary") {
82
100
  wizard.primary = id;
83
101
  if (hasBackgroundSlot()) {
84
- setScreen({ kind: "model", role: "background" });
102
+ go({ kind: "model", role: "background" });
85
103
  return;
86
104
  }
87
105
  }
88
106
  wizard.background = id;
89
- setScreen({ kind: "review" });
107
+ go({ kind: "review" });
90
108
  };
91
109
  const finish = () => {
92
110
  onFinish(summary());
@@ -98,69 +116,70 @@ export function App({ onFinish }) {
98
116
  case "welcome":
99
117
  return (_jsx(Welcome, { onSelect: (choice) => {
100
118
  if (choice === "continue") {
101
- setScreen(wizard.existing?.valid ? { kind: "existing" } : { kind: "signin" });
119
+ go(wizard.key ? { kind: "harnesses" } : wizard.existing?.valid ? { kind: "existing" } : { kind: "signin" });
102
120
  }
103
121
  else if (choice === "cancel") {
104
122
  exit();
105
123
  }
106
124
  else {
107
- setScreen({ kind: "info", topic: choice });
125
+ go({ kind: "info", topic: choice });
108
126
  }
109
127
  }, wizard: wizard }));
110
128
  case "info":
111
- return _jsx(Info, { onBack: () => setScreen({ kind: "welcome" }), topic: screen.topic });
129
+ return _jsx(Info, { onBack: back, topic: screen.topic });
112
130
  case "existing":
113
- return (_jsx(Existing, { onSelect: (choice) => {
131
+ return (_jsx(Existing, { canBack: canBack, onBack: back, onSelect: (choice) => {
114
132
  if (choice === "reuse") {
115
133
  wizard.key = wizard.existing?.key ?? null;
116
134
  wizard.keyStore = wizard.existing?.source ?? "";
117
- setScreen({ kind: "harnesses" });
135
+ go({ kind: "harnesses" });
118
136
  }
119
137
  else if (choice === "new") {
120
- setScreen({ kind: "signin" });
138
+ go({ kind: "signin" });
121
139
  }
122
140
  else {
123
141
  exit();
124
142
  }
125
143
  }, source: wizard.existing?.source ?? "" }));
126
144
  case "signin":
127
- return (_jsx(SignIn, { config: wizard.config, env: wizard.env, onCancel: () => setScreen({ kind: "welcome" }), onError: (error) => fail(error, { kind: "signin" }), onToken: (token) => setScreen({ kind: "exchange", token }) }));
145
+ return (_jsx(SignIn, { config: wizard.config, env: wizard.env, onCancel: back, onError: (error) => fail(error, { kind: "signin" }), onToken: (token) => setScreen({ kind: "exchange", token }) }));
128
146
  case "exchange":
129
147
  return (_jsx(Exchange, { onDone: (key, name) => {
130
148
  wizard.key = key;
131
149
  wizard.keyName = name;
150
+ forget();
132
151
  setScreen({ kind: "harnesses" });
133
152
  }, onError: (error) => fail(error, error instanceof SetupError && error.code === "setup_recovery_required" ? null : screen), token: screen.token }));
134
153
  case "error":
135
154
  return (_jsx(ErrorScreen, { message: screen.message, onSelect: (choice) => (choice === "retry" && screen.retry ? setScreen(screen.retry) : exit()), retry: screen.retry !== null }));
136
155
  case "harnesses":
137
- return (_jsx(Harnesses, { initial: wizard.harnesses.length > 0 ? wizard.harnesses : [...wizard.installed], installed: wizard.installed, onSubmit: (ids) => {
156
+ return (_jsx(Harnesses, { canBack: canBack, initial: wizard.harnesses.length > 0 ? wizard.harnesses : [...wizard.installed], installed: wizard.installed, onBack: back, onSubmit: (ids) => {
138
157
  wizard.harnesses = ids;
139
- setScreen({ kind: "scope" });
158
+ go({ kind: "scope" });
140
159
  } }));
141
160
  case "scope":
142
- return (_jsxs(Frame, { hints: NAV, children: [_jsx(Title, { children: "Where should this apply?" }), _jsx(Select, { onSelect: (scope) => {
161
+ return (_jsxs(Frame, { hints: withBack(NAV, canBack), children: [_jsx(Title, { children: "Where should this apply?" }), _jsx(Select, { initial: wizard.scope, onCancel: back, onSelect: (scope) => {
143
162
  wizard.scope = scope;
144
163
  if (scope === "project") {
145
164
  wizard.store = null;
146
165
  wizard.keyFile = projectKeyFile(wizard.env.cwd);
147
166
  wizard.keyStore = tilde(wizard.keyFile);
148
- setScreen({ kind: "catalog" });
167
+ pickModels();
149
168
  }
150
169
  else {
151
- setScreen({ kind: "store" });
170
+ go({ kind: "store" });
152
171
  }
153
172
  }, options: [
154
173
  { hint: "recommended", label: "My user account", value: "user" },
155
174
  { hint: tilde(wizard.env.cwd), label: "Only this project", value: "project" },
156
175
  ] })] }));
157
176
  case "store":
158
- return (_jsxs(Frame, { hints: NAV, children: [_jsx(Title, { children: "Where should I keep the key?" }), _jsx(Muted, { center: true, children: "Your shell profile will read it from there; the key itself never goes into a profile." }), _jsx(Select, { onSelect: (kind) => {
177
+ return (_jsxs(Frame, { hints: withBack(NAV, canBack), children: [_jsx(Title, { children: "Where should I keep the key?" }), _jsx(Muted, { center: true, children: "Your shell profile will read it from there; the key itself never goes into a profile." }), _jsx(Select, { initial: wizard.store?.kind, onCancel: back, onSelect: (kind) => {
159
178
  const store = wizard.stores.find((candidate) => candidate.kind === kind) ?? null;
160
179
  wizard.store = store;
161
180
  wizard.keyFile = store?.path ?? null;
162
181
  wizard.keyStore = store?.label ?? "";
163
- setScreen({ kind: "catalog" });
182
+ pickModels();
164
183
  }, options: wizard.stores.map((store, index) => ({
165
184
  hint: index === 0 ? "recommended" : undefined,
166
185
  label: store.label,
@@ -172,11 +191,14 @@ export function App({ onFinish }) {
172
191
  setScreen({ kind: "model", role: "primary" });
173
192
  }, onError: (error) => fail(error, { kind: "catalog" }), wizard: wizard }));
174
193
  case "model":
175
- return (_jsx(ModelPicker, { catalog: wizard.catalog, harnesses: chosen(), onSelect: (id) => chooseModel(screen.role, id), role: screen.role }));
194
+ return (_jsx(ModelPicker, { canBack: canBack, catalog: wizard.catalog, initial: screen.role === "primary" ? wizard.primary : wizard.background, onBack: back, onSelect: (id) => chooseModel(screen.role, id), role: screen.role }));
176
195
  case "review":
177
- return (_jsx(Review, { harnesses: chosen(), input: planInput(), onCancel: () => exit(), onPlans: (plans) => {
196
+ return (_jsx(Review, { harnesses: chosen(), input: planInput(), canBack: canBack, onBack: back, onCancel: () => exit(), onPlans: (plans) => {
178
197
  wizard.plans = plans;
179
- }, onProceed: () => setScreen({ kind: "apply" }), wizard: wizard }));
198
+ }, onProceed: () => {
199
+ forget();
200
+ setScreen({ kind: "apply" });
201
+ }, wizard: wizard }));
180
202
  case "apply":
181
203
  return (_jsx(Apply, { onDone: () => setScreen(wizard.plans.some(({ plan }) => plan.manual.length > 0) ? { kind: "manual" } : { kind: "test" }), onError: (error) => fail(error, { kind: "review" }), wizard: wizard }));
182
204
  case "manual":
@@ -247,8 +269,8 @@ function Info({ onBack, topic }) {
247
269
  });
248
270
  return (_jsxs(Frame, { hints: "enter back", children: [_jsx(Title, { children: topic === "what" ? "What this does" : "Privacy & data" }), topic === "what" ? (_jsxs(_Fragment, { children: [_jsxs(Para, { children: ["1. Opens ", CONSOLE, "/authorize in your browser. You sign in (or sign up) and approve \"Boundless local setup\"."] }), _jsx(Para, { children: "2. Exchanges that approval for one inference key named setup-\u2026 on your account. The secret is shown once, to this program only." }), _jsxs(Para, { children: ["3. Stores the key in your credential store or a private 0600 file, and points your shell at it via ", KEY_ENV, "."] }), _jsx(Para, { children: "4. Backs up and merges each chosen harness's configuration, preserving unrelated providers and settings." }), _jsx(Para, { children: "5. Sends one small request per model to prove it works. That request uses account credits; nothing is purchased." })] })) : (_jsxs(_Fragment, { children: [_jsxs(Para, { children: ["Nothing from this directory is uploaded. The only network calls are to ", CONSOLE, ", its authorization server, and the inference gateway."] }), _jsx(Para, { children: "The sign-in token is used once for the key exchange and then discarded; no refresh token is kept." }), _jsx(Para, { children: "The API key is written only to the store you pick and to harness files that need it, all with owner-only permissions. It never appears in command arguments, shell history, logs, or this screen." }), _jsxs(Para, { children: ["Manage or revoke the key at ", KEYS_URL, "."] })] }))] }));
249
271
  }
250
- function Existing({ onSelect, source }) {
251
- return (_jsxs(Frame, { hints: NAV, children: [_jsx(Title, { children: "A Boundless key is already configured" }), _jsxs(Muted, { center: true, children: ["Found in ", source, ", and the gateway accepts it."] }), _jsx(Box, { justifyContent: "center", children: _jsx(Select, { onSelect: onSelect, options: [
272
+ function Existing({ canBack, onBack, onSelect, source, }) {
273
+ return (_jsxs(Frame, { hints: withBack(NAV, canBack), children: [_jsx(Title, { children: "A Boundless key is already configured" }), _jsxs(Muted, { center: true, children: ["Found in ", source, ", and the gateway accepts it."] }), _jsx(Box, { justifyContent: "center", children: _jsx(Select, { onCancel: onBack, onSelect: onSelect, options: [
252
274
  { hint: "recommended", label: "Reuse it", value: "reuse" },
253
275
  { label: "Sign in and create a separate key", value: "new" },
254
276
  { label: "Cancel", value: "cancel" },
@@ -294,14 +316,14 @@ function Exchange({ onDone, onError, token }) {
294
316
  function ErrorScreen({ message: text, onSelect, retry }) {
295
317
  return (_jsxs(Frame, { hints: NAV, children: [_jsx(Title, { children: "Something went wrong" }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: BAD, children: text }) }), _jsx(Box, { justifyContent: "center", children: _jsx(Select, { onSelect: onSelect, options: retry ? [{ label: "Try again", value: "retry" }, { label: "Cancel", value: "cancel" }] : [{ label: "Exit", value: "cancel" }] }) })] }));
296
318
  }
297
- function Harnesses({ initial, installed, onSubmit }) {
319
+ function Harnesses({ canBack, initial, installed, onBack, onSubmit, }) {
298
320
  const [empty, setEmpty] = useState(false);
299
321
  const options = HARNESSES.map((harness) => ({
300
322
  hint: installed.has(harness.id) ? "installed" : "not found",
301
323
  label: harness.name,
302
324
  value: harness.id,
303
325
  }));
304
- return (_jsxs(Frame, { hints: "space toggle enter continue", children: [_jsx(Title, { children: "Which harnesses should I configure?" }), _jsx(Muted, { center: true, children: "Installed ones are preselected. Harnesses that are not installed get their configuration written for when they are." }), _jsx(Box, { justifyContent: "center", children: _jsx(MultiSelect, { initial: initial, onSubmit: (ids) => (ids.length === 0 ? setEmpty(true) : onSubmit(ids)), options: options }) }), empty ? (_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: WARN, children: "Pick at least one." }) })) : null] }));
326
+ return (_jsxs(Frame, { hints: withBack("space toggle a all/none enter continue", canBack), children: [_jsx(Title, { children: "Which harnesses should I configure?" }), _jsx(Muted, { center: true, children: "Installed ones are preselected. Harnesses that are not installed get their configuration written for when they are." }), _jsx(Box, { justifyContent: "center", children: _jsx(MultiSelect, { initial: initial, onCancel: onBack, onSubmit: (ids) => (ids.length === 0 ? setEmpty(true) : onSubmit(ids)), options: options }) }), empty ? (_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: WARN, children: "Pick at least one." }) })) : null] }));
305
327
  }
306
328
  function Catalog({ onDone, onError, wizard }) {
307
329
  useEffect(() => {
@@ -309,12 +331,10 @@ function Catalog({ onDone, onError, wizard }) {
309
331
  }, []);
310
332
  return (_jsx(Frame, { hints: "", children: _jsx(Spinner, { label: "Reading the model catalog" }) }));
311
333
  }
312
- function ModelPicker({ catalog, harnesses, onSelect, role, }) {
313
- const lead = harnesses.find((harness) => role === "primary" || harness.backgroundDefault !== null) ?? harnesses[0];
314
- const recommended = role === "primary" ? lead?.defaultModel : lead?.backgroundDefault;
315
- return (_jsxs(Frame, { hints: NAV, children: [_jsx(Title, { children: role === "primary" ? "Which model should be the default?" : "Which model for background work?" }), _jsxs(Muted, { center: true, children: [role === "primary" ? "Every configured harness gets this model." : "Cheap, fast tasks (Claude Code haiku slot, OpenCode small_model, omp tiny).", " Prices are USD per 1M tokens from the live catalog", lead ? `; the recommendation is ${lead.name}'s documented default.` : "."] }), _jsx(ModelSelect, { models: catalog, onSelect: onSelect, recommended: recommended ?? undefined }, role)] }));
334
+ function ModelPicker({ canBack, catalog, initial, onBack, onSelect, role, }) {
335
+ return (_jsxs(Frame, { hints: withBack(NAV, canBack), children: [_jsx(Title, { children: role === "primary" ? "Which model should be the default?" : "Which model for background work?" }), _jsxs(Muted, { center: true, children: [role === "primary" ? "Every configured harness gets this model." : "Cheap, fast tasks (Claude Code haiku slot, OpenCode small_model, omp tiny).", " Prices are USD per 1M tokens from the live catalog."] }), _jsx(ModelSelect, { initial: initial, models: catalog, onBack: onBack, onSelect: onSelect, recommended: role === "primary" ? RECOMMENDED_MODEL : RECOMMENDED_BACKGROUND }, role)] }));
316
336
  }
317
- function Review({ harnesses, input, onCancel, onPlans, onProceed, wizard, }) {
337
+ function Review({ canBack, harnesses, input, onBack, onCancel, onPlans, onProceed, wizard, }) {
318
338
  const [plans, setPlans] = useState(null);
319
339
  useEffect(() => {
320
340
  Promise.all(harnesses.map(async (harness) => ({ harness, plan: await harness.plan(input) }))).then((planned) => {
@@ -331,11 +351,11 @@ function Review({ harnesses, input, onCancel, onPlans, onProceed, wizard, }) {
331
351
  : env.os === "windows"
332
352
  ? "user environment variable"
333
353
  : `exported as ${KEY_ENV} by your shell profile`;
334
- return (_jsxs(Frame, { hints: NAV, children: [_jsx(Title, { children: "Review" }), _jsx(Facts, { facts: [
354
+ return (_jsxs(Frame, { hints: withBack(NAV, canBack), children: [_jsx(Title, { children: "Review" }), _jsx(Facts, { facts: [
335
355
  { label: "Key", value: `${wizard.keyStore} · ${exposure}` },
336
356
  { label: "Model", value: wizard.primary },
337
357
  ...(wizard.background !== wizard.primary ? [{ label: "Background", value: wizard.background }] : []),
338
- ] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: plans.map(({ harness, plan }) => (_jsxs(Box, { children: [_jsx(Text, { children: harness.name.padEnd(16) }), _jsx(Text, { color: plan.changes.length > 0 ? BRAND : MUTED, children: plan.changes.length > 0 ? plan.changes.map((change) => tilde(change.path)).join(", ") : "manual steps, shown after apply" })] }, harness.id))) }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: plans.flatMap(({ plan }) => plan.notes.slice(0, 1)).map((note) => (_jsx(Text, { color: MUTED, children: note }, note))) }), _jsx(Muted, { children: "Existing files are backed up next to themselves before they change. Credentials are never displayed." }), _jsx(Box, { justifyContent: "center", children: _jsx(Select, { onSelect: (choice) => (choice === "apply" ? onProceed() : onCancel()), options: [
358
+ ] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: plans.map(({ harness, plan }) => (_jsxs(Box, { children: [_jsx(Text, { children: harness.name.padEnd(16) }), _jsx(Text, { color: plan.changes.length > 0 ? BRAND : MUTED, children: plan.changes.length > 0 ? plan.changes.map((change) => tilde(change.path)).join(", ") : "manual steps, shown after apply" })] }, harness.id))) }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: plans.flatMap(({ plan }) => plan.notes.slice(0, 1)).map((note) => (_jsx(Text, { color: MUTED, children: note }, note))) }), _jsx(Muted, { children: "Existing files are backed up next to themselves before they change. Credentials are never displayed." }), _jsx(Box, { justifyContent: "center", children: _jsx(Select, { onCancel: onBack, onSelect: (choice) => (choice === "apply" ? onProceed() : onCancel()), options: [
339
359
  { label: "Apply", value: "apply" },
340
360
  { label: "Cancel", value: "cancel" },
341
361
  ] }) })] }));
@@ -398,7 +418,7 @@ function Test({ harnesses, onDone, wizard }) {
398
418
  const pairs = new Map();
399
419
  for (const harness of harnesses) {
400
420
  pairs.set(`${harness.wire}:${wizard.primary}`, { model: wizard.primary, wire: harness.wire });
401
- if (harness.backgroundDefault !== null) {
421
+ if (harness.backgroundSlot) {
402
422
  pairs.set(`${harness.wire}:${wizard.background}`, { model: wizard.background, wire: harness.wire });
403
423
  }
404
424
  }
@@ -6,8 +6,7 @@ function settingsPath(input) {
6
6
  return input.scope === "user" ? join(claudeHome(), "settings.json") : join(input.env.cwd, ".claude", "settings.local.json");
7
7
  }
8
8
  export const claudeCode = {
9
- backgroundDefault: "dsv4",
10
- defaultModel: "glm-5.2",
9
+ backgroundSlot: true,
11
10
  detect: () => installed("claude"),
12
11
  id: "claude-code",
13
12
  name: "Claude Code",
@@ -0,0 +1,48 @@
1
+ import { KEY_ENV } from "../lib/config.js";
2
+ import { backup, home, installed, readJson, writePrivate } from "../lib/files.js";
3
+ const VENDOR = "Boundless";
4
+ export const codebuddy = {
5
+ backgroundSlot: false,
6
+ detect: () => installed("codebuddy"),
7
+ id: "codebuddy",
8
+ name: "CodeBuddy",
9
+ plan: (input) => {
10
+ const path = home(".codebuddy", "models.json");
11
+ return Promise.resolve({
12
+ changes: [
13
+ {
14
+ apply: async () => {
15
+ const current = await readJson(path);
16
+ const others = (Array.isArray(current.models) ? current.models : []).filter((entry) => !(typeof entry === "object" && entry !== null && !Array.isArray(entry) && entry.vendor === VENDOR));
17
+ const ours = input.catalog.map((model) => ({
18
+ apiKey: `\${${KEY_ENV}}`,
19
+ id: model.id,
20
+ maxInputTokens: model.contextTokens,
21
+ maxOutputTokens: 32_000,
22
+ name: model.id,
23
+ supportsReasoning: model.supports.includes("reasoning"),
24
+ supportsToolCall: model.supports.includes("tool-use"),
25
+ url: `${input.gatewayUrl}/v1/chat/completions`,
26
+ vendor: VENDOR,
27
+ }));
28
+ const available = Array.isArray(current.availableModels) ? current.availableModels : [];
29
+ const ids = new Set(input.catalog.map((model) => model.id));
30
+ const next = {
31
+ ...current,
32
+ availableModels: [...available.filter((id) => typeof id !== "string" || !ids.has(id)), ...ids],
33
+ models: [...others, ...ours],
34
+ };
35
+ const saved = await backup(path);
36
+ await writePrivate(path, `${JSON.stringify(next, null, 2)}\n`);
37
+ return { backup: saved };
38
+ },
39
+ path,
40
+ summary: `${input.catalog.length} Boundless models at ${input.gatewayUrl}/v1/chat/completions, key read from ${KEY_ENV}`,
41
+ },
42
+ ],
43
+ manual: [],
44
+ notes: [`CodeBuddy has no default-model setting: start it with codebuddy --model "${input.primary}" or pick it in the IDE (tagged custom).`],
45
+ });
46
+ },
47
+ wire: "openai",
48
+ };
@@ -5,8 +5,7 @@ import { codexHome } from "../lib/env.js";
5
5
  import { installed } from "../lib/files.js";
6
6
  import { backup, readText, tilde, writePrivate } from "../lib/files.js";
7
7
  export const codex = {
8
- backgroundDefault: null,
9
- defaultModel: "qwen3.6",
8
+ backgroundSlot: false,
10
9
  detect: () => installed("codex"),
11
10
  id: "codex",
12
11
  name: "Codex CLI",
@@ -1,4 +1,4 @@
1
- import { anyExists, home } from "../lib/files.js";
1
+ import { anyExists, home, installed } from "../lib/files.js";
2
2
  import { readExpression } from "../lib/store.js";
3
3
  function reveal(input) {
4
4
  if (input.keyFile) {
@@ -7,8 +7,7 @@ function reveal(input) {
7
7
  return input.store ? readExpression(input.store) : "echo $BOUNDLESS_API_KEY";
8
8
  }
9
9
  export const cursor = {
10
- backgroundDefault: null,
11
- defaultModel: "glm-5.2",
10
+ backgroundSlot: false,
12
11
  detect: () => anyExists(["/Applications/Cursor.app", home(".cursor")]),
13
12
  id: "cursor",
14
13
  name: "Cursor",
@@ -25,8 +24,7 @@ export const cursor = {
25
24
  wire: "openai",
26
25
  };
27
26
  export const copilot = {
28
- backgroundDefault: null,
29
- defaultModel: "glm-5.2",
27
+ backgroundSlot: false,
30
28
  detect: () => anyExists([home(".vscode"), home(".copilot")]),
31
29
  id: "copilot",
32
30
  name: "GitHub Copilot",
@@ -41,3 +39,50 @@ export const copilot = {
41
39
  }),
42
40
  wire: "openai",
43
41
  };
42
+ export const cline = {
43
+ backgroundSlot: false,
44
+ detect: () => anyExists([home(".vscode", "extensions"), home(".cursor", "extensions")]),
45
+ id: "cline",
46
+ name: "Cline",
47
+ plan: (input) => Promise.resolve({
48
+ changes: [],
49
+ manual: [
50
+ 'In Cline choose "OpenAI Compatible" as the API provider.',
51
+ `Base URL ${input.gatewayUrl}/v1, model ID ${input.primary}, key revealed with: ${reveal(input)}`,
52
+ "In Model Configuration set the context window and max output tokens from the model lineup; Cline's defaults are conservative.",
53
+ ],
54
+ notes: [],
55
+ }),
56
+ wire: "openai",
57
+ };
58
+ export const cherryStudio = {
59
+ backgroundSlot: false,
60
+ detect: () => anyExists(["/Applications/Cherry Studio.app", home(".config", "CherryStudio")]),
61
+ id: "cherry-studio",
62
+ name: "Cherry Studio",
63
+ plan: (input) => Promise.resolve({
64
+ changes: [],
65
+ manual: [
66
+ "Settings → Model Provider → Add Provider, endpoint type OpenAI.",
67
+ `API address ${input.gatewayUrl} (without /v1; Cherry appends it), key revealed with: ${reveal(input)}`,
68
+ `In the provider wizard select all models under "Choose models", then "Verify and enable"; start on ${input.primary}.`,
69
+ "Settings → Default Model: point Default Assistant Model, Quick Model and the translate model at this provider.",
70
+ ],
71
+ notes: ["Cherry Studio stores the key in its own database; it cannot read an environment variable."],
72
+ }),
73
+ wire: "openai",
74
+ };
75
+ export const muse = {
76
+ backgroundSlot: false,
77
+ detect: () => installed("muse"),
78
+ id: "muse",
79
+ name: "Muse",
80
+ plan: (input) => Promise.resolve({
81
+ changes: [],
82
+ manual: [
83
+ `Muse has no provider setting; run it as: META_API_KEY="$BOUNDLESS_API_KEY" muse --provider meta --base-url "${input.gatewayUrl}/v1" --model "${input.primary}"`,
84
+ ],
85
+ notes: ["Both Muse flags are undocumented and unverified against this gateway."],
86
+ }),
87
+ wire: "openai",
88
+ };
@@ -3,8 +3,7 @@ import { KEY_ENV } from "../lib/config.js";
3
3
  import { installed } from "../lib/files.js";
4
4
  import { backup, home, readText, writePrivate } from "../lib/files.js";
5
5
  export const hermes = {
6
- backgroundDefault: null,
7
- defaultModel: "qwen3.6",
6
+ backgroundSlot: false,
8
7
  detect: () => installed("hermes"),
9
8
  id: "hermes",
10
9
  name: "Hermes",
@@ -1,7 +1,28 @@
1
1
  import { claudeCode } from "./claude-code.js";
2
+ import { codebuddy } from "./codebuddy.js";
2
3
  import { codex } from "./codex.js";
3
- import { copilot, cursor } from "./gui.js";
4
+ import { cherryStudio, cline, copilot, cursor, muse } from "./gui.js";
4
5
  import { hermes } from "./hermes.js";
5
6
  import { omp } from "./omp.js";
6
- import { opencode } from "./opencode.js";
7
- export const HARNESSES = [claudeCode, codex, opencode, hermes, omp, cursor, copilot];
7
+ import { openclaw } from "./openclaw.js";
8
+ import { kiloCode, mimoCode, opencode } from "./opencode.js";
9
+ import { openhands } from "./openhands.js";
10
+ import { qwenCode } from "./qwen-code.js";
11
+ export const HARNESSES = [
12
+ claudeCode,
13
+ codex,
14
+ opencode,
15
+ hermes,
16
+ omp,
17
+ cursor,
18
+ copilot,
19
+ openhands,
20
+ cline,
21
+ openclaw,
22
+ kiloCode,
23
+ cherryStudio,
24
+ codebuddy,
25
+ mimoCode,
26
+ qwenCode,
27
+ muse,
28
+ ];
@@ -3,8 +3,7 @@ import { KEY_ENV } from "../lib/config.js";
3
3
  import { installed } from "../lib/files.js";
4
4
  import { backup, home, readText, writePrivate } from "../lib/files.js";
5
5
  export const omp = {
6
- backgroundDefault: "dsv4",
7
- defaultModel: "glm-5.2",
6
+ backgroundSlot: true,
8
7
  detect: () => installed("omp"),
9
8
  id: "omp",
10
9
  name: "omp (Oh My Pi)",
@@ -0,0 +1,47 @@
1
+ import { KEY_ENV } from "../lib/config.js";
2
+ import { backup, home, installed, merge, readJson, writePrivate } from "../lib/files.js";
3
+ export const openclaw = {
4
+ backgroundSlot: false,
5
+ detect: () => installed("openclaw"),
6
+ id: "openclaw",
7
+ name: "OpenClaw",
8
+ plan: (input) => {
9
+ const path = home(".openclaw", "openclaw.json");
10
+ return Promise.resolve({
11
+ changes: [
12
+ {
13
+ apply: async () => {
14
+ const current = await readJson(path);
15
+ const next = merge(current, {
16
+ agents: { defaults: { model: { primary: `boundless/${input.primary}` } } },
17
+ models: {
18
+ providers: {
19
+ boundless: {
20
+ api: "openai-completions",
21
+ apiKey: `\${${KEY_ENV}}`,
22
+ baseUrl: `${input.gatewayUrl}/v1`,
23
+ models: input.catalog.map((model) => ({
24
+ contextWindow: model.contextTokens,
25
+ id: model.id,
26
+ maxTokens: 32_000,
27
+ name: model.name,
28
+ reasoning: model.supports.includes("reasoning"),
29
+ })),
30
+ },
31
+ },
32
+ },
33
+ });
34
+ const saved = await backup(path);
35
+ await writePrivate(path, `${JSON.stringify(next, null, 2)}\n`);
36
+ return { backup: saved };
37
+ },
38
+ path,
39
+ summary: `provider boundless with ${input.catalog.length} models, primary boundless/${input.primary}, key read from ${KEY_ENV}`,
40
+ },
41
+ ],
42
+ manual: [],
43
+ notes: [`OpenClaw reads the key from ${KEY_ENV} in your shell environment.`],
44
+ });
45
+ },
46
+ wire: "openai",
47
+ };
@@ -1,7 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { KEY_ENV } from "../lib/config.js";
3
- import { installed } from "../lib/files.js";
4
- import { backup, home, merge, readJson, writePrivate } from "../lib/files.js";
3
+ import { backup, home, installed, merge, readJson, writePrivate } from "../lib/files.js";
5
4
  function modelEntry(model) {
6
5
  const inputs = ["text", ...["image", "audio", "video"].filter((kind) => model.supports.includes(`${kind} input`))];
7
6
  return {
@@ -12,55 +11,79 @@ function modelEntry(model) {
12
11
  ...(inputs.length > 1 ? { modalities: { input: inputs } } : {}),
13
12
  };
14
13
  }
15
- function configPath(input) {
16
- if (input.scope === "project") {
17
- return join(input.env.cwd, "opencode.json");
18
- }
19
- return join(process.env.XDG_CONFIG_HOME ?? home(".config"), "opencode", "opencode.json");
14
+ /** OpenCode and its forks share one config format: a provider block plus model/small_model. */
15
+ function opencodeFamily(family) {
16
+ const configPath = (input) => input.scope === "project"
17
+ ? join(input.env.cwd, family.file)
18
+ : join(process.env.XDG_CONFIG_HOME ?? home(".config"), family.dir, family.file);
19
+ return {
20
+ backgroundSlot: true,
21
+ detect: () => installed(family.binary),
22
+ id: family.id,
23
+ name: family.name,
24
+ plan: (input) => {
25
+ const path = configPath(input);
26
+ const apiKey = input.scope === "project" && input.keyFile ? `{file:${input.keyFile}}` : `{env:${KEY_ENV}}`;
27
+ return Promise.resolve({
28
+ changes: [
29
+ {
30
+ apply: async () => {
31
+ const current = await readJson(path);
32
+ const next = merge(current, {
33
+ $schema: family.schema,
34
+ model: `boundless/${input.primary}`,
35
+ provider: {
36
+ boundless: {
37
+ models: Object.fromEntries(input.catalog.map((model) => [model.id, modelEntry(model)])),
38
+ name: "Boundless",
39
+ npm: "@ai-sdk/openai-compatible",
40
+ options: { apiKey, baseURL: `${input.gatewayUrl}/v1` },
41
+ },
42
+ },
43
+ small_model: `boundless/${input.background}`,
44
+ });
45
+ const saved = await backup(path);
46
+ await writePrivate(path, `${JSON.stringify(next, null, 2)}\n`);
47
+ return { backup: saved };
48
+ },
49
+ path,
50
+ summary: `provider boundless with ${input.catalog.length} models, model boundless/${input.primary}, small_model boundless/${input.background}, apiKey ${apiKey}`,
51
+ },
52
+ ],
53
+ manual: [],
54
+ notes: family.file.endsWith(".jsonc") ? [`Comments in an existing ${family.file} are not preserved; the backup keeps the original.`] : [],
55
+ });
56
+ },
57
+ smoke: family.smoke,
58
+ wire: "openai",
59
+ };
20
60
  }
21
- export const opencode = {
22
- backgroundDefault: "dsv4",
23
- defaultModel: "qwen3.6",
24
- detect: () => installed("opencode"),
61
+ export const opencode = opencodeFamily({
62
+ binary: "opencode",
63
+ dir: "opencode",
64
+ file: "opencode.json",
25
65
  id: "opencode",
26
66
  name: "OpenCode",
27
- plan: (input) => {
28
- const path = configPath(input);
29
- const apiKey = input.scope === "project" && input.keyFile ? `{file:${input.keyFile}}` : `{env:${KEY_ENV}}`;
30
- return Promise.resolve({
31
- changes: [
32
- {
33
- apply: async () => {
34
- const current = await readJson(path);
35
- const next = merge(current, {
36
- $schema: "https://opencode.ai/config.json",
37
- model: `boundless/${input.primary}`,
38
- provider: {
39
- boundless: {
40
- models: Object.fromEntries(input.catalog.map((model) => [model.id, modelEntry(model)])),
41
- name: "Boundless",
42
- npm: "@ai-sdk/openai-compatible",
43
- options: { apiKey, baseURL: `${input.gatewayUrl}/v1` },
44
- },
45
- },
46
- small_model: `boundless/${input.background}`,
47
- });
48
- const saved = await backup(path);
49
- await writePrivate(path, `${JSON.stringify(next, null, 2)}\n`);
50
- return { backup: saved };
51
- },
52
- path,
53
- summary: `provider boundless with ${input.catalog.length} models, model boundless/${input.primary}, small_model boundless/${input.background}, apiKey ${apiKey}`,
54
- },
55
- ],
56
- manual: [],
57
- notes: [],
58
- });
59
- },
67
+ schema: "https://opencode.ai/config.json",
60
68
  smoke: (input) => ({
61
69
  args: ["run", "--model", `boundless/${input.primary}`, "Use the bash tool to run printf boundless, then report the output."],
62
70
  command: "opencode",
63
71
  expect: /boundless/i,
64
72
  }),
65
- wire: "openai",
66
- };
73
+ });
74
+ export const kiloCode = opencodeFamily({
75
+ binary: "kilo",
76
+ dir: "kilo",
77
+ file: "kilo.jsonc",
78
+ id: "kilo-code",
79
+ name: "Kilo Code",
80
+ schema: "https://app.kilo.ai/config.json",
81
+ });
82
+ export const mimoCode = opencodeFamily({
83
+ binary: "mimo",
84
+ dir: "mimocode",
85
+ file: "mimocode.jsonc",
86
+ id: "mimo-code",
87
+ name: "MiMo Code",
88
+ schema: "https://mimo.xiaomi.com/mimocode/config.json",
89
+ });
@@ -0,0 +1,36 @@
1
+ import { join } from "node:path";
2
+ import { parse, stringify } from "smol-toml";
3
+ import { backup, installed, readText, tilde, writePrivate } from "../lib/files.js";
4
+ export const openhands = {
5
+ backgroundSlot: false,
6
+ detect: () => installed("openhands"),
7
+ id: "openhands",
8
+ name: "OpenHands",
9
+ plan: (input) => {
10
+ const path = join(input.env.cwd, "config.toml");
11
+ return Promise.resolve({
12
+ changes: [
13
+ {
14
+ apply: async () => {
15
+ const current = parse((await readText(path)) ?? "");
16
+ const llm = (current.llm ?? {});
17
+ const next = {
18
+ ...current,
19
+ llm: { ...llm, api_key: input.key, base_url: `${input.gatewayUrl}/v1`, model: `openai/${input.primary}` },
20
+ };
21
+ const saved = await backup(path);
22
+ await writePrivate(path, stringify(next));
23
+ return { backup: saved };
24
+ },
25
+ path,
26
+ summary: `[llm] model openai/${input.primary}, base_url ${input.gatewayUrl}/v1, api_key (key kept in this 0600 file)`,
27
+ },
28
+ ],
29
+ manual: [],
30
+ notes: [
31
+ `OpenHands reads config.toml from the working directory, so it is written to ${tilde(path)} and holds the key itself; keep it out of git.`,
32
+ ],
33
+ });
34
+ },
35
+ wire: "openai",
36
+ };
@@ -0,0 +1,40 @@
1
+ import { KEY_ENV } from "../lib/config.js";
2
+ import { backup, home, installed, merge, readJson, writePrivate } from "../lib/files.js";
3
+ export const qwenCode = {
4
+ backgroundSlot: false,
5
+ detect: () => installed("qwen"),
6
+ id: "qwen-code",
7
+ name: "Qwen Code",
8
+ plan: (input) => {
9
+ const path = home(".qwen", "settings.json");
10
+ const baseUrl = `${input.gatewayUrl}/v1`;
11
+ return Promise.resolve({
12
+ changes: [
13
+ {
14
+ apply: async () => {
15
+ const current = await readJson(path);
16
+ const providers = current.modelProviders;
17
+ const existing = typeof providers === "object" && providers !== null && !Array.isArray(providers) && Array.isArray(providers.openai)
18
+ ? providers.openai
19
+ : [];
20
+ const others = existing.filter((entry) => !(typeof entry === "object" && entry !== null && !Array.isArray(entry) && entry.baseUrl === baseUrl));
21
+ const ours = input.catalog.map((model) => ({ baseUrl, envKey: KEY_ENV, id: model.id, name: model.id }));
22
+ const next = merge(current, {
23
+ model: { name: input.primary },
24
+ modelProviders: { openai: [...others, ...ours] },
25
+ security: { auth: { selectedType: "openai" } },
26
+ });
27
+ const saved = await backup(path);
28
+ await writePrivate(path, `${JSON.stringify(next, null, 2)}\n`);
29
+ return { backup: saved };
30
+ },
31
+ path,
32
+ summary: `${input.catalog.length} models under modelProviders.openai, model ${input.primary}, key read from ${KEY_ENV}`,
33
+ },
34
+ ],
35
+ manual: [],
36
+ notes: [`Qwen Code reads the key from ${KEY_ENV} in your shell environment (or ~/.qwen/.env).`],
37
+ });
38
+ },
39
+ wire: "openai",
40
+ };
@@ -1,4 +1,7 @@
1
1
  import { CONSOLE, request, SetupError } from "./config.js";
2
+ /** One recommendation for every harness: the default model, and the model for cheap background slots. */
3
+ export const RECOMMENDED_MODEL = "deepseek-v4.1-flash";
4
+ export const RECOMMENDED_BACKGROUND = "dsv4";
2
5
  export async function listModels(gatewayUrl, key) {
3
6
  const [catalog, served] = await Promise.all([
4
7
  request(`${CONSOLE}/api/catalog`),
@@ -24,6 +27,7 @@ export async function keyWorks(gatewayUrl, key) {
24
27
  }
25
28
  }
26
29
  /** Exact USD, never rounded away: trailing zeros trimmed, up to twelve decimals. */
30
+ export const usdPerMillion = (amount) => `$${amount.toFixed(2)}`;
27
31
  export function usd(amount) {
28
32
  return `$${amount.toFixed(12).replace(/0+$/, "").replace(/\.$/, "")}`;
29
33
  }
package/dist/lib/files.js CHANGED
@@ -85,12 +85,41 @@ export function merge(base, patch) {
85
85
  }
86
86
  return patch;
87
87
  }
88
+ // Drops line and block comments outside strings so a .jsonc file parses. Comments are not written back.
89
+ export function stripComments(text) {
90
+ let out = "";
91
+ let i = 0;
92
+ while (i < text.length) {
93
+ const ch = text[i];
94
+ if (ch === '"') {
95
+ let j = i + 1;
96
+ while (j < text.length && text[j] !== '"') {
97
+ j += text[j] === "\\" ? 2 : 1;
98
+ }
99
+ out += text.slice(i, j + 1);
100
+ i = j + 1;
101
+ }
102
+ else if (ch === "/" && text[i + 1] === "/") {
103
+ i = text.indexOf("\n", i);
104
+ i = i === -1 ? text.length : i;
105
+ }
106
+ else if (ch === "/" && text[i + 1] === "*") {
107
+ const end = text.indexOf("*/", i + 2);
108
+ i = end === -1 ? text.length : end + 2;
109
+ }
110
+ else {
111
+ out += ch;
112
+ i += 1;
113
+ }
114
+ }
115
+ return out;
116
+ }
88
117
  export async function readJson(path) {
89
118
  const text = await readText(path);
90
119
  if (text === null || text.trim() === "") {
91
120
  return {};
92
121
  }
93
- const parsed = JSON.parse(text);
122
+ const parsed = JSON.parse(stripComments(text).replace(/,(\s*[}\]])/g, "$1"));
94
123
  if (!isObject(parsed)) {
95
124
  throw new Error(`${path} is not a JSON object`);
96
125
  }
package/dist/ui/menu.js CHANGED
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { useState } from "react";
4
4
  import { BRAND, MUTED } from "./theme.js";
5
- export function Select({ onCancel, onSelect, options, }) {
6
- const [index, setIndex] = useState(0);
5
+ export function Select({ initial, onCancel, onSelect, options, }) {
6
+ const [index, setIndex] = useState(Math.max(options.findIndex((option) => option.value === initial), 0));
7
7
  const move = (delta) => {
8
8
  let next = index;
9
9
  for (let step = 0; step < options.length; step += 1) {
@@ -46,6 +46,9 @@ export function MultiSelect({ initial, onCancel, onSubmit, options, }) {
46
46
  else if (key.downArrow || input === "j") {
47
47
  setIndex((index + 1) % options.length);
48
48
  }
49
+ else if (input === "a") {
50
+ setChosen(chosen.size === options.length ? new Set() : new Set(options.map((option) => option.value)));
51
+ }
49
52
  else if (input === " ") {
50
53
  const value = options[index]?.value;
51
54
  if (value !== undefined) {
package/dist/ui/table.js CHANGED
@@ -1,21 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { useState } from "react";
4
- import { usd } from "../lib/catalog.js";
4
+ import { usdPerMillion } from "../lib/catalog.js";
5
5
  import { BRAND, MUTED } from "./theme.js";
6
6
  const HEADER = ["ID", "Input /1M", "Output /1M", "Context", ""];
7
7
  const cells = (model, recommended) => [
8
8
  model.id,
9
- usd(model.inputPerMillionTokens),
10
- usd(model.outputPerMillionTokens),
9
+ usdPerMillion(model.inputPerMillionTokens),
10
+ usdPerMillion(model.outputPerMillionTokens),
11
11
  model.contextTokens.toLocaleString("en-US"),
12
12
  model.id === recommended ? "recommended" : "",
13
13
  ];
14
- export function ModelSelect({ models, onSelect, recommended, }) {
15
- const start = Math.max(models.findIndex((model) => model.id === recommended), 0);
14
+ export function ModelSelect({ initial, models, onBack, onSelect, recommended, }) {
15
+ const start = Math.max(models.findIndex((model) => model.id === initial), models.findIndex((model) => model.id === recommended), 0);
16
16
  const [index, setIndex] = useState(start);
17
17
  useInput((input, key) => {
18
- if (key.upArrow || input === "k") {
18
+ if (key.escape) {
19
+ onBack();
20
+ }
21
+ else if (key.upArrow || input === "k") {
19
22
  setIndex((index + models.length - 1) % models.length);
20
23
  }
21
24
  else if (key.downArrow || input === "j") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boundless.network/setup",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Set up Boundless as the inference provider for your coding harness. Run: npx -y @boundless.network/setup@latest",
5
5
  "license": "MIT",
6
6
  "type": "module",