@semanticist14/clco 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.ts ADDED
@@ -0,0 +1,218 @@
1
+ // One-time startup preferences, so the flags you always pass don't have to be
2
+ // typed every run. Asked once on the first interactive launch, changed later
3
+ // with `clco setup`, and overridable per run with the --no-* flags.
4
+
5
+ import * as p from "@clack/prompts"
6
+ import {
7
+ TOKEN_ENV,
8
+ browserMcpConfig,
9
+ extensionInstalled,
10
+ parseToken,
11
+ setupNote,
12
+ } from "./browsermcp"
13
+ import { loadPrefs, savePrefs, type SetupPrefs } from "./config"
14
+
15
+ /** Bump when an option is added, so existing users get told once. */
16
+ export const SETUP_VERSION = 2
17
+
18
+ export interface SetupOverrides {
19
+ bypass?: boolean
20
+ select?: boolean
21
+ browser?: boolean
22
+ }
23
+
24
+ // What the first-run prompts come pre-filled with: these are the options
25
+ // people install clco for, so Enter-through should land on the useful setup.
26
+ // A future option added to an EXISTING setup is absent from the stored
27
+ // object, i.e. off, which is the conservative direction for a change nobody
28
+ // asked for.
29
+ export const SETUP_DEFAULTS: Omit<SetupPrefs, "version"> = {
30
+ bypass: true,
31
+ select: true,
32
+ browser: true,
33
+ }
34
+
35
+ export async function runSetup(): Promise<SetupPrefs> {
36
+ const prefs = await loadPrefs()
37
+ const current = prefs.setup ?? { ...SETUP_DEFAULTS, version: SETUP_VERSION }
38
+
39
+ p.intro("clco setup - save the flags you would otherwise type every run")
40
+ const ask = async (message: string, initialValue: boolean): Promise<boolean> => {
41
+ const answer = await p.confirm({ message, initialValue })
42
+ if (p.isCancel(answer)) {
43
+ p.cancel("Cancelled - keeping the previous settings")
44
+ process.exit(0)
45
+ }
46
+ return answer as boolean
47
+ }
48
+
49
+ // Sequential rather than one object literal: the browser follow-ups have to
50
+ // run between the browser answer and the next question, and inside a literal
51
+ // every property is evaluated before any code after it.
52
+ const setup: SetupPrefs = {
53
+ ...SETUP_DEFAULTS,
54
+ version: SETUP_VERSION,
55
+ // Carried regardless of the answers below: turning browser control off for
56
+ // a while should not make the user paste the token again afterwards.
57
+ browserToken: current.browserToken,
58
+ }
59
+
60
+ setup.bypass = await ask(
61
+ "Run without permission prompts? (--dangerously-skip-permissions)",
62
+ current.bypass,
63
+ )
64
+
65
+ // Claude's own Chrome integration cannot work here, so there is nothing to
66
+ // ask about it - only an alternative to offer.
67
+ setup.browser = await ask(
68
+ "Claude Chrome is not available in clco.\n" +
69
+ " Enable Playwright MCP for browser control instead?",
70
+ current.browser ?? true,
71
+ )
72
+ if (setup.browser) {
73
+ p.note(setupNote(await extensionInstalled()), "Playwright MCP")
74
+ const token = await p.text({
75
+ message:
76
+ `${TOKEN_ENV} (optional) - skips the connect dialog every session.\n` +
77
+ " Paste the whole line from the extension, or just the value.\n" +
78
+ " Enter to skip, or set it later with: clco token",
79
+ placeholder: "leave empty to skip",
80
+ defaultValue: current.browserToken ?? "",
81
+ // Rejecting here beats storing a stray paste that would never work.
82
+ validate: (value) =>
83
+ parseToken(value ?? "") === null
84
+ ? "That does not look like the token - it is a long string of letters, digits, - and _."
85
+ : undefined,
86
+ })
87
+ if (!p.isCancel(token)) {
88
+ const parsed = parseToken(String(token))
89
+ if (parsed) setup.browserToken = parsed
90
+ }
91
+ }
92
+
93
+ setup.select = await ask(
94
+ "Pick a model each time clco starts?",
95
+ current.select,
96
+ )
97
+
98
+ await savePrefs({ ...prefs, setup })
99
+ p.outro(
100
+ "Saved. Re-run `clco setup` to change it, or turn one off for a single\n" +
101
+ "run with --no-bypass / --no-select / --no-browser.",
102
+ )
103
+ return setup
104
+ }
105
+
106
+ /**
107
+ * The saved setup, or null when it has never been run. Also reports a version
108
+ * bump once: a new option defaults to off, and saying so beats leaving it
109
+ * undiscovered — but nagging on every launch afterwards does not.
110
+ */
111
+ export async function loadSetup(): Promise<SetupPrefs | null> {
112
+ const prefs = await loadPrefs()
113
+ if (!prefs.setup) return null
114
+ if (prefs.setup.version < SETUP_VERSION) {
115
+ console.error(
116
+ "[clco] New setup options are available (off by default) - run `clco setup` to enable them",
117
+ )
118
+ // Record that the notice was shown; the options themselves stay off.
119
+ await savePrefs({
120
+ ...prefs,
121
+ setup: { ...prefs.setup, version: SETUP_VERSION },
122
+ }).catch(() => {})
123
+ }
124
+ return prefs.setup
125
+ }
126
+
127
+ /**
128
+ * Claude flags implied by the saved setup, minus anything the user already
129
+ * passed by hand or turned off for this run. Passing a flag twice is not
130
+ * harmless for every claude flag, so each is added only when absent.
131
+ */
132
+ export function setupClaudeArgs(
133
+ setup: SetupPrefs | null,
134
+ overrides: SetupOverrides,
135
+ claudeArgs: string[],
136
+ /** Whether the Playwright MCP Bridge extension is installed. */
137
+ browserExtension = false,
138
+ ): string[] {
139
+ if (!setup) return []
140
+ // Match the `--flag=value` form too: an exact comparison let a user's own
141
+ // --mcp-config=x.json through, and clco then injected a second one.
142
+ const has = (flag: string) =>
143
+ claudeArgs.some((a) => a === flag || a.startsWith(`${flag}=`))
144
+ const out: string[] = []
145
+ if (
146
+ setup.bypass &&
147
+ overrides.bypass !== false &&
148
+ !has("--dangerously-skip-permissions") &&
149
+ // An explicit permission mode is a deliberate choice; do not override it.
150
+ !has("--permission-mode")
151
+ ) {
152
+ out.push("--dangerously-skip-permissions")
153
+ }
154
+ // Registered per session rather than written into the user's MCP config, so
155
+ // clco never edits configuration that outlives it.
156
+ if (
157
+ setup.browser &&
158
+ overrides.browser !== false &&
159
+ !has("--mcp-config")
160
+ ) {
161
+ const config = browserMcpConfig(browserExtension)
162
+ if (config) out.push("--mcp-config", config)
163
+ }
164
+ return out
165
+ }
166
+
167
+ /**
168
+ * Environment the saved setup implies for the claude child.
169
+ *
170
+ * The extension token travels here rather than in the --mcp-config payload:
171
+ * that payload is an argv element, and argv is readable by other local users.
172
+ * MCP servers inherit claude's environment, so this route reaches the same
173
+ * place without publishing it.
174
+ */
175
+ export function setupEnv(
176
+ setup: SetupPrefs | null,
177
+ overrides: SetupOverrides,
178
+ ): Record<string, string> {
179
+ if (!setup?.browserToken || overrides.browser === false) return {}
180
+ // An exported value wins: it is the more immediate intent.
181
+ if (process.env[TOKEN_ENV]) return {}
182
+ return { [TOKEN_ENV]: setup.browserToken }
183
+ }
184
+
185
+ /**
186
+ * Which model a session runs when the startup prompt is skipped, or undefined
187
+ * to pass no --model and leave claude on its own default.
188
+ *
189
+ * Turning the prompt off means "stop asking me", not "forget what I picked", so
190
+ * the remembered choice is reused. An explicit `clco --model X` still wins: it
191
+ * reaches claude through claudeArgs and runClaude stands aside when it sees one.
192
+ *
193
+ * Both candidates are checked against what the account actually serves. The
194
+ * fallback needs it as much as the remembered pick does: discoverModels caches
195
+ * the model list BEFORE deciding it found no claude-sonnet slug, so a plan
196
+ * without Claude models yields a non-empty list and a sonnet slot that is only
197
+ * a default guess. Asserting that as --model would fail a session that used to
198
+ * work by passing nothing at all.
199
+ */
200
+ export function modelWithoutPrompt(
201
+ remembered: string | undefined,
202
+ /** Slugs the account currently offers, as the picker would list them. */
203
+ offered: ReadonlyArray<string>,
204
+ fallback: string,
205
+ ): string | undefined {
206
+ if (remembered && offered.includes(remembered)) return remembered
207
+ return offered.includes(fallback) ? fallback : undefined
208
+ }
209
+
210
+ /** Whether to show the startup model picker for this run. */
211
+ export function shouldSelectModel(
212
+ setup: SetupPrefs | null,
213
+ overrides: SetupOverrides,
214
+ ): boolean {
215
+ if (overrides.select === false) return false
216
+ // Before setup runs, keep the long-standing behaviour of asking.
217
+ return setup === null ? true : setup.select
218
+ }
package/src/spawn.ts ADDED
@@ -0,0 +1,453 @@
1
+ // Launch the stock claude CLI pointed at the local adapter. Endpoint and
2
+ // model overrides are injected via `claude --settings '<json>'`, which ranks
3
+ // above user/project settings (so an existing ~/.claude/settings.json env
4
+ // block cannot swallow it) without touching any config file. The same env is
5
+ // also merged into the child process environment.
6
+
7
+ import { readFile, writeFile } from "node:fs/promises"
8
+ import { homedir } from "node:os"
9
+ import { join } from "node:path"
10
+ import {
11
+ advertisedId,
12
+ familyOf,
13
+ loadCatalog,
14
+ resolveBehavesAs,
15
+ } from "./catalog"
16
+ import {
17
+ modelInfo,
18
+ upstreamModels,
19
+ type ModelMapping,
20
+ type UpstreamModel,
21
+ } from "./token"
22
+ import { prepareClaudeHome } from "./claudehome"
23
+ import { normalizeModel } from "./translate"
24
+ import { ONE_MILLION_TOKENS, fallbackInputWindow } from "./tokens"
25
+
26
+ /** Endpoints a model must serve to hold a conversation at all. */
27
+ const CHAT_ENDPOINTS = ["/v1/messages", "/responses", "/chat/completions"]
28
+
29
+ // Copilot plumbing that answers like a model but is not one to pick: these
30
+ // declare a role as their capability family ("search-agent") where a real
31
+ // model declares its own name ("gpt-4o"). Hidden by default; CLCO_SHOW_INTERNAL
32
+ // brings them back, which matters on plans where little else is available.
33
+ const INTERNAL_FAMILIES = new Set([
34
+ "search-agent",
35
+ "exec-agent",
36
+ "trajectory-compaction",
37
+ ])
38
+ const CLAUDE_COMPACT_FLOOR = 100_000
39
+
40
+ export function windowOf(m: UpstreamModel): number | undefined {
41
+ return m.maxPromptTokens ?? m.maxContextTokens
42
+ }
43
+
44
+ /** Reject a known custom model that Claude's global compact setting cannot represent. */
45
+ export function validateModelSelection(
46
+ selected: string | undefined,
47
+ list: UpstreamModel[],
48
+ ): void {
49
+ if (!selected) return
50
+ const normalized = normalizeModel(selected)
51
+ const model = list.find(
52
+ (candidate) =>
53
+ normalizeModel(candidate.id) === normalized ||
54
+ advertisedId(candidate.id) === normalized,
55
+ )
56
+ if (!model || advertisedId(model.id)) return
57
+ const window = windowOf(model)
58
+ if (window !== undefined && window < CLAUDE_COMPACT_FLOOR) {
59
+ throw new Error(
60
+ `model ${selected} has a ${Math.round(window / 1000)}k context window, below Claude Code's 100k compact floor; choose another model`,
61
+ )
62
+ }
63
+ }
64
+
65
+ function smallestWindow(windows: Array<number | undefined>): number {
66
+ const fallback = fallbackInputWindow([])
67
+ const safe = windows.map((n) =>
68
+ n !== undefined && Number.isFinite(n) && n > 0 ? n : fallback,
69
+ )
70
+ return safe.length > 0 ? Math.min(...safe) : fallback
71
+ }
72
+
73
+ export function buildSettingsEnv(
74
+ baseUrl: string,
75
+ models: ModelMapping,
76
+ defaultModel?: string,
77
+ /** Defaults to the discovery cache; injected directly in tests. */
78
+ modelMeta?: UpstreamModel | null,
79
+ ): Record<string, string> {
80
+ const selected = defaultModel
81
+ // The selection may be an advertised catalog-form id; the discovery cache
82
+ // is keyed by upstream slug, so resolve before looking it up.
83
+ const info =
84
+ modelMeta === undefined ? (selected ? modelInfo(normalizeModel(selected)) : undefined) : modelMeta
85
+ // Claude models are served through Copilot's native Anthropic endpoint, so
86
+ // the adapter forwards thinking blocks untouched; only the translation
87
+ // dialects need them suppressed.
88
+ const native = info?.endpoints.includes("/v1/messages") === true
89
+ const compactWindow = modelMeta === undefined
90
+ ? compactWindowFor(upstreamModels())
91
+ : modelMeta === null
92
+ ? fallbackInputWindow([])
93
+ : advertisedId(modelMeta.id)
94
+ ? undefined
95
+ : Math.max(100_000, smallestWindow([windowOf(modelMeta)]))
96
+ return {
97
+ ANTHROPIC_BASE_URL: baseUrl,
98
+ ANTHROPIC_AUTH_TOKEN: "clco-local",
99
+ // ANTHROPIC_MODEL is deliberately NOT set: it pins the session model, so
100
+ // claude reports "ANTHROPIC_MODEL is set to X — new sessions use that
101
+ // while it is set" and every /model switch becomes cosmetic. The startup
102
+ // choice travels as `--model` instead, which /model can override.
103
+ ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
104
+ ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
105
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
106
+ ANTHROPIC_DEFAULT_FABLE_MODEL: models.fable,
107
+ // Gateway discovery is deliberately NOT enabled: Claude Code keeps only
108
+ // ids matching /claude|anthropic/i, so it would add nothing the picker
109
+ // lineup does not already carry — while writing a stale adapter port
110
+ // into the user's own ~/.claude/cache/gateway-models.json.
111
+ // Keep traffic contained: no auto-update, telemetry, or side calls.
112
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
113
+ DISABLE_TELEMETRY: "1",
114
+ DISABLE_AUTOUPDATER: "1",
115
+ DISABLE_NON_ESSENTIAL_MODEL_CALLS: "1",
116
+ // Translation dialects drop thinking blocks; the native one keeps them.
117
+ ...(native ? {} : { CLAUDE_CODE_DISABLE_THINKING: "1" }),
118
+ // Catalog-known rows let Claude Code recalculate the effective window
119
+ // when /model switches. Non-catalog rows borrow Claude handling and need
120
+ // a conservative ceiling because Claude cannot know their real limit.
121
+ CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT: "1",
122
+ ...(compactWindow === undefined
123
+ ? {}
124
+ : { CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(compactWindow) }),
125
+ // Slow upstream: never abort a stream for idling.
126
+ API_FORCE_IDLE_TIMEOUT: "0",
127
+ API_TIMEOUT_MS: "3000000",
128
+ }
129
+ }
130
+
131
+ let resolvedClaude: string | null = null
132
+
133
+ // Resolve and verify the claude binary. Called before the adapter binds a
134
+ // port so a missing binary fails fast with an actionable message.
135
+ export async function resolveClaude(): Promise<string> {
136
+ if (resolvedClaude) return resolvedClaude
137
+ const candidate = Bun.which("claude") ?? `${homedir()}/.local/bin/claude`
138
+ if (!(await Bun.file(candidate).exists())) {
139
+ throw new Error(
140
+ `claude executable not found: ${candidate} (check PATH or ~/.local/bin)`,
141
+ )
142
+ }
143
+ resolvedClaude = candidate
144
+ // Read the model catalog out of the binary we are about to launch, so the
145
+ // picker tracks the installed version rather than whatever was current when
146
+ // clco shipped. Cached per binary; a failure keeps the built-in fallback.
147
+ await loadCatalog(candidate)
148
+ return candidate
149
+ }
150
+
151
+ // One process-level SIGTERM handler tracking the current child — registering
152
+ // per runClaude call would stack handlers whose stale closures can kill a
153
+ // newer run.
154
+ let currentChild: Bun.Subprocess<"inherit", "inherit", "inherit"> | null = null
155
+ let escalateTimer: ReturnType<typeof setTimeout> | undefined
156
+ function shutdown(signal: NodeJS.Signals, code: number): void {
157
+ if (!currentChild) process.exit(code) // serve mode: no child to forward to
158
+ try {
159
+ currentChild.kill(signal)
160
+ } catch {
161
+ // already exited
162
+ }
163
+ escalateTimer ??= setTimeout(() => {
164
+ try {
165
+ currentChild?.kill("SIGKILL")
166
+ } catch {
167
+ // already exited
168
+ }
169
+ process.exit(code)
170
+ }, 5000)
171
+ }
172
+
173
+ process.on("SIGTERM", () => shutdown("SIGTERM", 143))
174
+ process.on("SIGHUP", () => shutdown("SIGHUP", 129))
175
+ process.on("SIGINT", () => shutdown("SIGINT", 130))
176
+
177
+ // Claude Code's /model lineup. The picker row shape is the one the binary
178
+ // validates against: { model, label?, description?, behavesAs? }, plus a
179
+ // sibling `replaceBuiltInOptions`. Rows are validated individually — a row
180
+ // the binary rejects is dropped with a warning while the rest still apply,
181
+ // so depending on `behavesAs` degrades gracefully if the schema changes.
182
+ interface PickerOption {
183
+ model: string
184
+ label?: string
185
+ description?: string
186
+ behavesAs?: string
187
+ }
188
+
189
+ interface ModelPicker {
190
+ options: PickerOption[]
191
+ replaceBuiltInOptions: true
192
+ }
193
+
194
+ function routeOf(m: UpstreamModel): string {
195
+ if (m.endpoints.includes("/v1/messages")) return "native"
196
+ if (m.endpoints.includes("/responses")) return "responses"
197
+ return "chat"
198
+ }
199
+
200
+ // Copilot's `model_picker_enabled` is VS Code UI metadata, not an
201
+ // entitlement — GitHub currently returns false for every model, which is why
202
+ // filtering on it emptied the lineup entirely. Filter on declared capability
203
+ // instead: `capabilities.type` is "chat" for everything you can hold a turn
204
+ // with, which drops embeddings and nothing else. Older entries omit both that
205
+ // and `supported_endpoints`; Copilot serves those over /chat/completions, and
206
+ // so does the adapter, so absent metadata must not exclude them.
207
+ function conversational(m: UpstreamModel): boolean {
208
+ if (
209
+ m.family &&
210
+ INTERNAL_FAMILIES.has(m.family) &&
211
+ !process.env.CLCO_SHOW_INTERNAL
212
+ ) {
213
+ return false
214
+ }
215
+ if (m.type) return m.type === "chat"
216
+ if (m.endpoints.length === 0) return true
217
+ return m.endpoints.some((e) => CHAT_ENDPOINTS.includes(e))
218
+ }
219
+
220
+ export function buildModelPickerFrom(
221
+ list: UpstreamModel[],
222
+ opts?: { selected?: string; sessionWindow?: number },
223
+ ): ModelPicker | null {
224
+ const floor = Number(process.env.CLCO_MIN_WINDOW ?? "0")
225
+ const usable = list.filter(
226
+ (m) => conversational(m) && (windowOf(m) ?? 0) >= floor,
227
+ )
228
+ const ids = list.map((m) => m.id)
229
+ const selected = opts?.selected ? normalizeModel(opts.selected) : undefined
230
+
231
+ const options: PickerOption[] = []
232
+ for (const m of usable) {
233
+ const advertised = advertisedId(m.id)
234
+ // A row whose id Claude Code cannot resolve is silently not offered
235
+ // unless it carries `behavesAs`, so every non-catalog row borrows one.
236
+ const borrowed = advertised ? null : resolveBehavesAs(familyOf(m.id), ids)
237
+
238
+ const ctx = windowOf(m)
239
+ // Claude Code's global auto-compact override cannot represent a window
240
+ // below 100k. Do not expose a custom row whose known limit is smaller;
241
+ // catalog-known rows remain safe because Claude owns their per-model
242
+ // context handling.
243
+ if (borrowed && ctx !== undefined && ctx < 100_000) continue
244
+ // [1m] is the only per-row window channel the schema has, and it is
245
+ // binary: 200k or 1M, nothing between. The real window goes in the
246
+ // description instead.
247
+ const suffix = (ctx ?? 0) >= ONE_MILLION_TOKENS ? "[1m]" : ""
248
+ // Several slugs share one display name (five are "GPT-4o", two are
249
+ // "GPT-5.6 Luna"), so the subtitle leads with the id: it tells the rows
250
+ // apart without cluttering every title with a parenthetical.
251
+ const parts = [m.id, routeOf(m)]
252
+ if (ctx) parts.push(`${Math.round(ctx / 1000)}k`)
253
+ // Claude offers effort tiers to any row with behavesAs; say so where the
254
+ // upstream model will just ignore them.
255
+ if (!m.efforts) parts.push("no effort tiers")
256
+ // Only the shrinking direction is a hazard: the session's auto-compact
257
+ // budget is fixed at launch, so a smaller model can sail past its real
258
+ // limit. A larger one merely leaves headroom unused. Warning on both
259
+ // would mark nearly every row and stop meaning anything.
260
+ if (ctx && opts?.sessionWindow && ctx < opts.sessionWindow) {
261
+ parts.push(`! caps at ${Math.round(ctx / 1000)}k`)
262
+ }
263
+ options.push({
264
+ model: (advertised ?? m.id) + suffix,
265
+ ...(m.name && m.name !== m.id ? { label: m.name } : {}),
266
+ description: parts.join(" · "),
267
+ ...(borrowed ? { behavesAs: borrowed } : {}),
268
+ })
269
+ }
270
+
271
+ if (options.length === 0) return null
272
+ options.sort((a, b) => rank(a, list, selected) - rank(b, list, selected))
273
+ // Only ever set with a non-empty lineup: replacing the built-in options
274
+ // while offering none of our own leaves /model completely empty.
275
+ return { options: options.slice(0, 200), replaceBuiltInOptions: true }
276
+ }
277
+
278
+ /**
279
+ * A global compact ceiling is needed only when the exact selectable lineup
280
+ * contains a custom row. Catalog-known rows are handled by Claude Code's own
281
+ * per-model context table after a /model switch.
282
+ */
283
+ export function compactWindowFor(list: UpstreamModel[]): number | undefined {
284
+ if (list.length === 0) return fallbackInputWindow([])
285
+ const picker = buildModelPickerFrom(list)
286
+ if (!picker) return fallbackInputWindow([])
287
+ if (!picker.options.some((option) => option.behavesAs)) {
288
+ return undefined
289
+ }
290
+ const byId = new Map(list.map((model) => [normalizeModel(model.id), model]))
291
+ const windows = picker.options.map((option) => {
292
+ const model = byId.get(normalizeModel(option.model))
293
+ return model ? windowOf(model) : undefined
294
+ })
295
+ return Math.max(100_000, smallestWindow(windows))
296
+ }
297
+
298
+ // Selected model first, then native rows, then widest window first.
299
+ function rank(
300
+ o: PickerOption,
301
+ list: UpstreamModel[],
302
+ selected?: string,
303
+ ): number {
304
+ const id = normalizeModel(o.model)
305
+ if (selected && id === selected) return -1_000_000
306
+ const m = list.find((u) => u.id === id)
307
+ if (!m) return 0
308
+ return (routeOf(m) === "native" ? -100_000 : 0) - (windowOf(m) ?? 0) / 1000
309
+ }
310
+
311
+ function buildModelPicker(selected?: string, sessionWindow?: number) {
312
+ return buildModelPickerFrom(upstreamModels(), { selected, sessionWindow })
313
+ }
314
+
315
+ // The documented channel for "this provider id is really that model": a map
316
+ // from the id clco advertises to the slug the upstream wants. Where it is
317
+ // honoured the adapter receives the upstream slug directly; where it is not,
318
+ // translate.ts's alias table catches the same case. They compose.
319
+ export function buildModelOverridesFrom(
320
+ list: UpstreamModel[],
321
+ ): Record<string, string> | null {
322
+ const out: Record<string, string> = {}
323
+ for (const m of list) {
324
+ const advertised = advertisedId(m.id)
325
+ if (advertised && advertised !== m.id) out[advertised] = m.id
326
+ }
327
+ return Object.keys(out).length > 0 ? out : null
328
+ }
329
+
330
+ /** What the launch needs, minus anything that touches the filesystem. */
331
+ export interface LaunchPlan {
332
+ baseUrl: string
333
+ models: ModelMapping
334
+ defaultModel?: string
335
+ claudeArgs: string[]
336
+ /** The private CLAUDE_CONFIG_DIR, or null when clco could not build one. */
337
+ configDir?: string | null
338
+ }
339
+
340
+ /**
341
+ * The argv clco hands claude, as a pure function.
342
+ *
343
+ * Extracted from runClaude so the settings blob can be asserted without
344
+ * spawning a process: the model-pinning bug below was invisible to tests
345
+ * precisely because this was tangled up with Bun.spawn.
346
+ */
347
+ export function buildLaunchArgs(plan: LaunchPlan): string[] {
348
+ const userPickedModel = plan.claudeArgs.some(
349
+ (a) => a === "--model" || a.startsWith("--model="),
350
+ )
351
+ const userModelIndex = plan.claudeArgs.findIndex(
352
+ (a) => a === "--model" || a.startsWith("--model="),
353
+ )
354
+ const userModel = userModelIndex < 0
355
+ ? undefined
356
+ : plan.claudeArgs[userModelIndex]!.startsWith("--model=")
357
+ ? plan.claudeArgs[userModelIndex]!.slice("--model=".length)
358
+ : plan.claudeArgs[userModelIndex + 1]
359
+ validateModelSelection(userModel ?? plan.defaultModel, upstreamModels())
360
+ const env = buildSettingsEnv(plan.baseUrl, plan.models, plan.defaultModel)
361
+ const picker = buildModelPicker(plan.defaultModel ?? plan.models.sonnet)
362
+ const overrides = buildModelOverridesFrom(upstreamModels())
363
+ const configDir = plan.configDir ?? undefined
364
+ // The model rides in the settings blob as well as in --model, because
365
+ // CLAUDE_CONFIG_DIR only moves the USER settings file. Claude Code also
366
+ // reads a PROJECT settings file at ./.claude/settings.json, which the
367
+ // private config dir does not touch - and when the working directory is the
368
+ // home directory those are the same file, so the user's real
369
+ // ~/.claude/settings.json comes back in through the project tier and its
370
+ // `model` key pins the session. Observed: clco printed
371
+ // "+ model: gpt-4.1-2025-04-14" while /model reported ".claude/settings.json
372
+ // pins Claude Fable 5.1". The binary's own settings docs say projectSettings
373
+ // and localSettings are repo-controllable and only policy/user/flag settings
374
+ // outrank them, so the --settings tier is the one that wins.
375
+ //
376
+ // Not written into the private settings FILE: that would persist, and a
377
+ // /model pick landing there for good is what the private config dir exists
378
+ // to prevent.
379
+ const settings = JSON.stringify({
380
+ env: { ...env, ...(configDir ? { CLAUDE_CONFIG_DIR: configDir } : {}) },
381
+ ...(picker ? { modelPicker: picker } : {}),
382
+ ...(overrides ? { modelOverrides: overrides } : {}),
383
+ ...(userPickedModel || !plan.defaultModel ? {} : { model: plan.defaultModel }),
384
+ })
385
+ // Seeded rather than pinned (see buildSettingsEnv); a --model the user
386
+ // passed themselves always wins.
387
+ const modelArgs =
388
+ userPickedModel || !plan.defaultModel ? [] : ["--model", plan.defaultModel]
389
+ return ["--settings", settings, ...modelArgs, ...plan.claudeArgs]
390
+ }
391
+
392
+ /** The child environment, kept next to the argv it belongs with. */
393
+ export function buildLaunchEnv(
394
+ plan: LaunchPlan,
395
+ extraEnv?: Record<string, string>,
396
+ ): Record<string, string | undefined> {
397
+ const userModelIndex = plan.claudeArgs.findIndex(
398
+ (a) => a === "--model" || a.startsWith("--model="),
399
+ )
400
+ const userModel = userModelIndex < 0
401
+ ? undefined
402
+ : plan.claudeArgs[userModelIndex]!.startsWith("--model=")
403
+ ? plan.claudeArgs[userModelIndex]!.slice("--model=".length)
404
+ : plan.claudeArgs[userModelIndex + 1]
405
+ validateModelSelection(userModel ?? plan.defaultModel, upstreamModels())
406
+ const env = buildSettingsEnv(plan.baseUrl, plan.models, plan.defaultModel)
407
+ const configDir = plan.configDir ?? undefined
408
+ const childEnv: Record<string, string | undefined> = {
409
+ ...process.env,
410
+ ...env,
411
+ ...extraEnv,
412
+ ...(configDir ? { CLAUDE_CONFIG_DIR: configDir } : {}),
413
+ }
414
+ // Never let a parent-exported key override the adapter routing.
415
+ delete childEnv.ANTHROPIC_API_KEY
416
+ return childEnv
417
+ }
418
+
419
+ export async function runClaude(opts: {
420
+ baseUrl: string
421
+ models: ModelMapping
422
+ defaultModel?: string
423
+ claudeArgs: string[]
424
+ /** Extra environment the saved setup implies (see setupEnv). */
425
+ extraEnv?: Record<string, string>
426
+ }): Promise<number> {
427
+ const claude = await resolveClaude()
428
+ // Claude persists a /model pick into its config dir. Give it a private one
429
+ // so that write can never reach the user's ~/.claude.
430
+ const configDir = await prepareClaudeHome()
431
+ const plan: LaunchPlan = { ...opts, configDir }
432
+ const launchArgs = buildLaunchArgs(plan)
433
+ const childEnv = buildLaunchEnv(plan, opts.extraEnv)
434
+
435
+ const proc = Bun.spawn(
436
+ [claude, ...launchArgs],
437
+ {
438
+ stdio: ["inherit", "inherit", "inherit"],
439
+ env: childEnv,
440
+ },
441
+ )
442
+ currentChild = proc
443
+
444
+ const code = await proc.exited
445
+ if (currentChild === proc) {
446
+ currentChild = null
447
+ if (escalateTimer) {
448
+ clearTimeout(escalateTimer)
449
+ escalateTimer = undefined
450
+ }
451
+ }
452
+ return code ?? 0
453
+ }