@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.
@@ -0,0 +1,430 @@
1
+ // Browser control for clco sessions, via Playwright MCP.
2
+ //
3
+ // Claude Code's own Chrome integration (--chrome) is gated on the session's
4
+ // OAuth scope and is therefore always off here: clco authenticates with
5
+ // ANTHROPIC_AUTH_TOKEN against its own adapter, which Claude Code treats as an
6
+ // env-var session limited to user:inference. An MCP server has no such gate,
7
+ // so browser control has to come from one.
8
+ //
9
+ // Playwright MCP in --extension mode attaches to a tab already open in the
10
+ // user's own browser, with their logins and cookies intact, rather than the
11
+ // fresh profile a headless run would get. Other projects do the same thing,
12
+ // but this is the one with ~4.6M weekly downloads and active releases, so
13
+ // clco supports exactly it rather than maintaining a catalogue.
14
+
15
+ import { readdir } from "node:fs/promises"
16
+ import { copilotFetch } from "./api"
17
+ import {
18
+ caBundle,
19
+ caChildPath,
20
+ caPaths,
21
+ isCertValidityError,
22
+ isTlsTrustError,
23
+ } from "./tls"
24
+ import { homedir, platform } from "node:os"
25
+ import { join } from "node:path"
26
+
27
+ export const EXTENSION_ID = "mmlmfjhmonkocbjadbfplnigmagldckm"
28
+ // The name the Web Store actually shows. It was "Playwright MCP Bridge" here,
29
+ // which is how the project describes the extension in its docs but not what
30
+ // the listing is called - so searching the store for it finds nothing, and
31
+ // following the link lands on a page with a different name, which reads like
32
+ // the wrong page.
33
+ export const EXTENSION_NAME = "Playwright Extension"
34
+ // The id-only form, which the Web Store 301s to the slug form. It is 73
35
+ // characters against the slug form's 94, and that is the difference between
36
+ // fitting inside a clack note box at 80 columns and being wrapped mid-URL -
37
+ // where it can be neither clicked nor copied, which is how a required install
38
+ // step went unnoticed.
39
+ export const EXTENSION_URL =
40
+ `https://chromewebstore.google.com/detail/${EXTENSION_ID}`
41
+ // @latest. The server half is fetched from npm each session, and the extension
42
+ // half auto-updates from the Web Store and cannot be pinned alongside it, so
43
+ // pinning the server lets the two drift. Pinning also does not
44
+ // make browser control work offline - the registry is consulted either way on
45
+ // any machine that has not just run that exact version.
46
+ //
47
+ // Neither spec is a supply-chain control. The repo's bun.lock does not cover
48
+ // this package: it is resolved by a bunx subprocess of claude, in its own
49
+ // generated lockfile, with no integrity hash clco ever sees. A pinned version
50
+ // string bounds the window and makes the choice attributable; it verifies
51
+ // nothing.
52
+ //
53
+ // CLCO_MCP_PACKAGE overrides the spec - an internal mirror, a pinned version,
54
+ // a rollback past a bad release.
55
+ export const MCP_PACKAGE = "@playwright/mcp@latest"
56
+
57
+ /** The spec clco will actually register, override included. */
58
+ export function mcpPackage(): string {
59
+ return process.env.CLCO_MCP_PACKAGE || MCP_PACKAGE
60
+ }
61
+
62
+ /**
63
+ * Whether probing registry.npmjs.org says anything about the configured spec.
64
+ *
65
+ * Compares the package NAME, not the whole spec: two of CLCO_MCP_PACKAGE's
66
+ * three documented uses - pinning a version and rolling back past a bad
67
+ * release - leave the registry exactly where it was, so keying this on the
68
+ * full spec switched the check off for a corporate user who had merely pinned.
69
+ * A file:/link: version needs no registry at all, so it counts as neither.
70
+ */
71
+ export function probesDefaultRegistry(pkg = mcpPackage()): boolean {
72
+ const at = pkg.lastIndexOf("@")
73
+ // at > 0, not >= 0: lastIndexOf returns -1 for a spec with no "@" and
74
+ // slice(0, -1) then quietly drops the last character ("playwright-mcp" ->
75
+ // "playwright-mc"), and index 0 is a scope marker, not a version separator.
76
+ const name = at > 0 ? pkg.slice(0, at) : pkg
77
+ const version = at > 0 ? pkg.slice(at + 1) : ""
78
+ if (version.includes(":") || version.includes("/")) return false
79
+ return name === MCP_PACKAGE.slice(0, MCP_PACKAGE.lastIndexOf("@"))
80
+ }
81
+ /** Set by the extension; with it the bridge attaches without a dialog. */
82
+ export const TOKEN_ENV = "PLAYWRIGHT_MCP_EXTENSION_TOKEN"
83
+
84
+ /**
85
+ * bunx when clco is running under bun, which it is — the launcher execs bun,
86
+ * so requiring Node as well was an extra dependency for no reason. Verified
87
+ * the server runs under bun, and bun honours NODE_EXTRA_CA_CERTS the same way,
88
+ * so the corporate CA still reaches it. npx remains the fallback for anyone
89
+ * running clco under Node.
90
+ */
91
+ export function runner(): string {
92
+ if (typeof Bun !== "undefined" && Bun.which("bunx")) return "bunx"
93
+ return "npx"
94
+ }
95
+
96
+ /**
97
+ * Per-profile extension directories, by platform. Edge is included because
98
+ * both the extension and --extension mode support it.
99
+ */
100
+ function browserRoots(home = homedir()): string[] {
101
+ switch (platform()) {
102
+ case "darwin":
103
+ return [
104
+ join(home, "Library", "Application Support", "Google", "Chrome"),
105
+ join(home, "Library", "Application Support", "Microsoft Edge"),
106
+ ]
107
+ case "win32": {
108
+ const local = process.env.LOCALAPPDATA ?? join(home, "AppData", "Local")
109
+ return [
110
+ join(local, "Google", "Chrome", "User Data"),
111
+ join(local, "Microsoft", "Edge", "User Data"),
112
+ ]
113
+ }
114
+ default:
115
+ return [
116
+ join(home, ".config", "google-chrome"),
117
+ join(home, ".config", "chromium"),
118
+ join(home, ".config", "microsoft-edge"),
119
+ ]
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Whether the bridge extension is installed.
125
+ *
126
+ * Deliberately not probed over the network: the MCP server is spawned per
127
+ * conversation, so at clco startup nothing is listening and a probe would
128
+ * report "missing" for a working install.
129
+ */
130
+ export async function extensionInstalled(home = homedir()): Promise<boolean> {
131
+ for (const root of browserRoots(home)) {
132
+ let profiles: string[]
133
+ try {
134
+ profiles = await readdir(root)
135
+ } catch {
136
+ continue
137
+ }
138
+ for (const profile of profiles) {
139
+ try {
140
+ const ids = await readdir(join(root, profile, "Extensions"))
141
+ if (ids.includes(EXTENSION_ID)) return true
142
+ } catch {
143
+ // not a profile directory, or no extensions in it
144
+ }
145
+ }
146
+ }
147
+ return false
148
+ }
149
+
150
+ /**
151
+ * The --mcp-config payload registering the server for this session only.
152
+ * Null without the extension: the server is only half of it, and registering
153
+ * it alone produces tools that fail on every call.
154
+ */
155
+ export function browserMcpConfig(
156
+ installed: boolean,
157
+ /** Null means "no CA", which an ambient CLCO_CA_BUNDLE must not override. */
158
+ caBundleValue: string | null = process.env.CLCO_CA_BUNDLE ?? null,
159
+ /** Read here rather than at module load so a test can set it. */
160
+ pkg = mcpPackage(),
161
+ ): string | null {
162
+ if (!installed) return null
163
+ const env: Record<string, string> = {}
164
+ // npx fetches from the registry over its own TLS, outside clco's
165
+ // copilotFetch, so a corporate CA has to be handed down explicitly -
166
+ // otherwise browser control is the one feature that still breaks on the
167
+ // network clco was hardened for.
168
+ //
169
+ // One path, and one clco actually read: NODE_EXTRA_CA_CERTS names a single
170
+ // file while CLCO_CA_BUNDLE takes several, and forwarding a path clco had
171
+ // already logged as unreadable left the child with nothing but a warning on
172
+ // a stderr claude's UI does not show.
173
+ const first =
174
+ caBundleValue === null
175
+ ? undefined
176
+ : caBundleValue === process.env.CLCO_CA_BUNDLE
177
+ ? caChildPath()
178
+ : caPaths(caBundleValue)[0]
179
+ if (first) env.NODE_EXTRA_CA_CERTS = first
180
+ // The extension token deliberately does NOT go here. This object becomes an
181
+ // --mcp-config argv element, and argv is readable by other local users, so
182
+ // naming the token here published it. It reaches the server through claude's
183
+ // environment instead (setupEnv), which MCP children inherit.
184
+ return JSON.stringify({
185
+ mcpServers: {
186
+ playwright: {
187
+ command: runner(),
188
+ args: ["-y", pkg, "--extension"],
189
+ ...(Object.keys(env).length > 0 ? { env } : {}),
190
+ },
191
+ },
192
+ })
193
+ }
194
+
195
+ // The extension mints a base64url value; nothing else should be accepted.
196
+ // A paste can easily pick up a shell prompt or a stray line, and storing that
197
+ // silently produces a token that never works and no clue why.
198
+ const TOKEN_SHAPE = /^[A-Za-z0-9_-]{20,}$/
199
+
200
+ /**
201
+ * Accept what the extension actually puts on screen. It shows the whole
202
+ * assignment, so pasting that verbatim is the obvious move — as is pasting
203
+ * just the value, or a line copied with `export` in front. A multi-line paste
204
+ * keeps only the line carrying the token, since a terminal submits at the
205
+ * first newline and the rest would be lost anyway.
206
+ *
207
+ * Returns null for input that is not a token, so the caller can say so rather
208
+ * than store it.
209
+ */
210
+ export function parseToken(input: string): string | null | undefined {
211
+ const lines = input.split(/[\r\n]+/).map((l) => l.trim()).filter(Boolean)
212
+ if (lines.length === 0) return undefined // skipped
213
+ const line =
214
+ lines.find((l) => l.includes(`${TOKEN_ENV}=`)) ??
215
+ lines.find((l) => TOKEN_SHAPE.test(l)) ??
216
+ lines[0]!
217
+ const bare = line.replace(/^export\s+/, "")
218
+ const value = bare.includes(`${TOKEN_ENV}=`)
219
+ ? bare.slice(bare.indexOf(`${TOKEN_ENV}=`) + TOKEN_ENV.length + 1)
220
+ : bare
221
+ const cleaned = value.trim().replace(/^(['"])(.*)\1$/, "$2").trim()
222
+ if (!cleaned) return undefined
223
+ return TOKEN_SHAPE.test(cleaned) ? cleaned : null
224
+ }
225
+
226
+ /** Why browser control will or will not work, in the words the user needs. */
227
+ export type RegistryStatus = "ok" | "tls" | "expired" | "blocked" | "slow"
228
+
229
+ const REGISTRY_PROBE_TIMEOUT_MS = 2_500
230
+
231
+ /**
232
+ * Whether the registry can actually be reached, and if not, why.
233
+ *
234
+ * The package is resolved from npm at session start, so on a network that
235
+ * blocks or re-signs it the server never starts - and the failure would
236
+ * otherwise surface only as an MCP connection error inside claude, with clco's
237
+ * own startup line still claiming success. This has to be a real request: an
238
+ * earlier version ran `bunx --version`, which prints locally and therefore
239
+ * returned "reachable" on an air-gapped machine.
240
+ *
241
+ * A TLS rejection is reported apart from a block because they need different
242
+ * things from the user - one needs their company CA, the other cannot be fixed
243
+ * from here at all.
244
+ */
245
+ export async function registryStatus(
246
+ timeoutMs = REGISTRY_PROBE_TIMEOUT_MS,
247
+ /** Overridden in tests; the probe has no other way to reach a TLS failure. */
248
+ url = "https://registry.npmjs.org/@playwright/mcp",
249
+ ): Promise<RegistryStatus> {
250
+ try {
251
+ // copilotFetch, so a corporate CA applies here exactly as it does to the
252
+ // Copilot calls - otherwise this would report "blocked" on the very
253
+ // networks the CA support exists for.
254
+ const res = await copilotFetch(url, {
255
+ method: "HEAD",
256
+ signal: AbortSignal.timeout(timeoutMs),
257
+ })
258
+ return res.ok ? "ok" : "blocked"
259
+ } catch (err) {
260
+ if (isTlsTrustError(err)) return "tls"
261
+ // The host answered and the chain may be fine; the dates are not. No CA
262
+ // fixes that, so it is not "tls" - but it is not "cannot reach" either,
263
+ // which is how a lapsed proxy certificate sent the user to their firewall
264
+ // team.
265
+ if (isCertValidityError(err)) return "expired"
266
+ // A 2.5s budget is clco's alone - the bunx inside claude has none - so a
267
+ // slow proxy must not be reported as a verdict that tools will not appear.
268
+ if ((err as Error)?.name === "TimeoutError") return "slow"
269
+ return "blocked"
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Fold a status line to the terminal, continuing with a two-space indent.
275
+ *
276
+ * These lines carry a verdict AND a remedy, which is the point of them, so
277
+ * they do not fit an 80-column terminal on one line - and a terminal-wrapped
278
+ * line breaks mid-word and mid-URL, which is how a required install step went
279
+ * unread. Shortening them instead would delete the remedy, so they fold.
280
+ */
281
+ function fold(text: string, width = 76): string {
282
+ const out: string[] = []
283
+ let line = ""
284
+ for (const word of text.split(" ")) {
285
+ const indent = out.length === 0 ? "" : " "
286
+ if (line === "") line = indent + word
287
+ else if (`${line} ${word}`.length <= width) line += ` ${word}`
288
+ else {
289
+ out.push(line)
290
+ line = ` ${word}`
291
+ }
292
+ }
293
+ if (line !== "") out.push(line)
294
+ return out.join("\n")
295
+ }
296
+
297
+ /**
298
+ * One line for the startup summary, alongside adapter and model. Whether a
299
+ * session is attached is not knowable here — the server starts per
300
+ * conversation — so this reports what clco did, and whether a connect dialog
301
+ * is coming.
302
+ */
303
+ export function startupLine(
304
+ enabled: boolean,
305
+ installed: boolean,
306
+ token?: string,
307
+ /** bunx ships with bun, which the installer guarantees; npx is a fallback. */
308
+ hasRunner = Bun.which("bunx") !== null || Bun.which("npx") !== null,
309
+ /** Undefined when not checked. */
310
+ registry?: RegistryStatus,
311
+ /** False when clco stood aside for a user-supplied --mcp-config. */
312
+ registered?: boolean,
313
+ pkg = mcpPackage(),
314
+ /** Whether a CA bundle actually LOADED - not merely whether the var is set. */
315
+ caLoaded = caBundle() !== undefined,
316
+ ): string | null {
317
+ if (!enabled) return null
318
+ if (!installed) {
319
+ // Two lines on purpose: the URL has to start a line to survive an 80-column
320
+ // terminal intact, and this is the one clco message whose whole job is to
321
+ // get a link in front of the user.
322
+ return (
323
+ `! browser: no tools - "${EXTENSION_NAME}" is not installed in Chrome,\n` +
324
+ ` and only you can add it:\n ${EXTENSION_URL}`
325
+ )
326
+ }
327
+ if (!hasRunner) {
328
+ return "! browser: neither bunx nor npx found - cannot start Playwright MCP"
329
+ }
330
+ if (registered === false) {
331
+ return "! browser: skipped - your own --mcp-config takes over"
332
+ }
333
+ // Exhaustive by construction: a new RegistryStatus that nobody handles used
334
+ // to fall through to the success line, i.e. silent success for a failure.
335
+ if (registry !== undefined && registry !== "ok") {
336
+ const line: Record<Exclude<RegistryStatus, "ok">, string> = {
337
+ tls:
338
+ "TLS rejected by registry.npmjs.org - browser tools will not appear." +
339
+ // Telling someone to set a variable they already set is the advice
340
+ // this line exists to avoid giving. Keyed on whether a bundle LOADED,
341
+ // not on whether the variable is set.
342
+ (caLoaded
343
+ ? " Your CLCO_CA_BUNDLE does not cover this chain."
344
+ : " Set CLCO_CA_BUNDLE to your company CA."),
345
+ expired:
346
+ "registry.npmjs.org presented an expired certificate - browser tools" +
347
+ " will not appear. No CA file fixes this; check the proxy, or your clock.",
348
+ blocked: "cannot reach registry.npmjs.org - browser tools will not appear",
349
+ // No number: the budget is clco's own and the bunx inside claude has none.
350
+ slow: "registry.npmjs.org is slow to answer - browser tools may be slow to appear",
351
+ }
352
+ return fold(`! browser: ${line[registry]}`)
353
+ }
354
+ // Name the exact spec: it is user-overridable, it is what runs, and after a
355
+ // bad upstream release "which version did that session run?" has to be
356
+ // answerable from something.
357
+ //
358
+ // And say when nothing was checked - a bare "+" would assert a check that
359
+ // never ran. "Not the default package" rather than "another registry":
360
+ // CLCO_MCP_PACKAGE names a package, and which registry serves it lives in
361
+ // .npmrc, so a fork published to npmjs is unprobed without being elsewhere.
362
+ const notes = [
363
+ registry === undefined && !probesDefaultRegistry(pkg)
364
+ ? "not the default package, so the registry check was skipped"
365
+ : null,
366
+ token ? null : "connect dialog each session",
367
+ ].filter(Boolean)
368
+ return fold(`+ browser: ${pkg}${notes.length > 0 ? ` (${notes.join("; ")})` : ""}`)
369
+ }
370
+
371
+ /**
372
+ * What setup shows before asking anything: the state, without advice to run
373
+ * the very command that is running.
374
+ */
375
+ export function setupNote(installed: boolean, pkg = mcpPackage()): string {
376
+ // Three separate things, and the old wording ran them together: it said
377
+ // "clco registers no browser server", which reads as though the extension
378
+ // WERE the server. It is not - clco registers the server, the extension is
379
+ // what lets that server drive the user's own tab, and the token only skips a
380
+ // click. Conflating them left people unsure what they had actually agreed to.
381
+ if (installed) {
382
+ return (
383
+ `${EXTENSION_NAME} found, so both halves are in place:\n` +
384
+ ` clco registers ${pkg} --extension each session\n` +
385
+ ` you click the extension to share a tab\n` +
386
+ `Tools then arrive as mcp__playwright__*.\n` +
387
+ `\n` +
388
+ `The token prompt after this is optional - press Enter to skip it.`
389
+ )
390
+ }
391
+ return (
392
+ `Browser control is two halves, and clco only owns one:\n` +
393
+ ` clco registers ${pkg}, which it fetches from npm\n` +
394
+ ` you install one Chrome extension, which clco cannot do\n` +
395
+ `\n` +
396
+ `The extension is what lets that server drive YOUR tab, with your\n` +
397
+ `logins, instead of a fresh throwaway profile. Without it clco\n` +
398
+ `registers no server at all, since a server with no tab to attach to\n` +
399
+ `would only fail on every call - so no browser tools appear.\n` +
400
+ `\n` +
401
+ `Install "${EXTENSION_NAME}", then restart clco:\n` +
402
+ `${EXTENSION_URL}\n` +
403
+ `\n` +
404
+ `Answering Yes now is fine - install it whenever you like.`
405
+ )
406
+ }
407
+
408
+ export function extensionHint(installed: boolean, token?: string, pkg = mcpPackage()): string {
409
+ if (!installed) {
410
+ return (
411
+ `Browser control is ON, but the Chrome extension it needs is not\n` +
412
+ `installed, so clco registers no server and no tools appear.\n` +
413
+ `Only you can install it - clco cannot:\n` +
414
+ `${EXTENSION_URL}`
415
+ )
416
+ }
417
+ // Whether a session is actually attached cannot be known here: the server
418
+ // starts per conversation and the extension connects to it afterwards. A
419
+ // stored token is the closest thing to a prediction, since it is exactly
420
+ // what removes the manual connect step.
421
+ return (
422
+ `${EXTENSION_NAME} found. Registering ${pkg} --extension\n` +
423
+ `each session. Tools arrive as mcp__playwright__*.\n` +
424
+ (token
425
+ ? `Token stored, so sessions attach without the connect dialog.`
426
+ : `No token stored - optional, and only affects the click: each\n` +
427
+ `session shows the connect dialog instead. The extension offers a\n` +
428
+ `${TOKEN_ENV} value; store it with:\n pbpaste | clco token`)
429
+ )
430
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,147 @@
1
+ // Bridge between Copilot's model slugs and Claude Code's own model catalog.
2
+ //
3
+ // Copilot spells versions with dots ("claude-haiku-4.5"); Claude Code's
4
+ // catalog uses dashes ("claude-haiku-4-5"). When the dashed form is a real
5
+ // catalog id we advertise that instead, and the row needs no `behavesAs` —
6
+ // Claude Code then applies the model's genuine context window, effort tiers
7
+ // and prompt profile. Only slugs with no catalog twin fall back to
8
+ // `behavesAs`, which borrows another model's client-side handling.
9
+
10
+ /**
11
+ * Fallback catalog, captured from Claude Code 2.1.268. Only used when the
12
+ * installed binary cannot be scanned (see loadCatalog) — pinning the real
13
+ * list to a clco release would make every claude upgrade a silent liability:
14
+ * an id that moves out from under us drops its row from /model with no error.
15
+ */
16
+ const FALLBACK_CATALOG_IDS: readonly string[] = [
17
+ "claude-fable-5",
18
+ "claude-fable-5-1",
19
+ "claude-haiku-4",
20
+ "claude-haiku-4-5",
21
+ "claude-opus-4",
22
+ "claude-opus-4-0",
23
+ "claude-opus-4-1",
24
+ "claude-opus-4-5",
25
+ "claude-opus-4-6",
26
+ "claude-opus-4-7",
27
+ "claude-opus-4-8",
28
+ "claude-opus-5",
29
+ "claude-sonnet-3-7",
30
+ "claude-sonnet-4",
31
+ "claude-sonnet-4-0",
32
+ "claude-sonnet-4-5",
33
+ "claude-sonnet-4-6",
34
+ "claude-sonnet-5",
35
+ ]
36
+
37
+ // Resolved once per process from the installed claude binary, cached on disk
38
+ // per version so the ~0.7s scan is paid only after a claude upgrade.
39
+ let catalogIds: Set<string> = new Set(FALLBACK_CATALOG_IDS)
40
+
41
+ /** Ids the installed Claude Code knows. */
42
+ export function catalogModelIds(): ReadonlySet<string> {
43
+ return catalogIds
44
+ }
45
+
46
+ /** Claude Code's newest id per family, used to resolve `behavesAs` targets. */
47
+ export const LATEST_PER_FAMILY: Readonly<Record<ModelFamily, string>> = {
48
+ opus: "claude-opus-5",
49
+ sonnet: "claude-sonnet-5",
50
+ haiku: "claude-haiku-4-5",
51
+ fable: "claude-fable-5-1",
52
+ }
53
+
54
+ export type ModelFamily = "opus" | "sonnet" | "haiku" | "fable"
55
+
56
+ export function familyOf(id: string): ModelFamily {
57
+ if (id.includes("opus")) return "opus"
58
+ if (id.includes("haiku")) return "haiku"
59
+ if (id.includes("fable")) return "fable"
60
+ return "sonnet"
61
+ }
62
+
63
+ /**
64
+ * The id to show Claude Code for an upstream slug, or null when the slug has
65
+ * no catalog twin. Returns the slug itself when it is already a catalog id.
66
+ */
67
+ /**
68
+ * Read the catalog out of the claude binary, so clco tracks whatever version
69
+ * is actually installed instead of whatever was current when clco shipped.
70
+ * Cached by binary path + size + mtime; any failure keeps the fallback.
71
+ */
72
+ export async function loadCatalog(binary: string): Promise<void> {
73
+ const { readFile, writeFile, mkdir, stat } = await import("node:fs/promises")
74
+ const { homedir } = await import("node:os")
75
+ const { join } = await import("node:path")
76
+ const dir = join(homedir(), ".config", "clco")
77
+ let key: string
78
+ try {
79
+ const s = await stat(binary)
80
+ key = `${s.size}-${Math.round(s.mtimeMs)}`
81
+ } catch {
82
+ return
83
+ }
84
+ const cache = join(dir, "catalog.json")
85
+ try {
86
+ const saved = JSON.parse(await readFile(cache, "utf8")) as {
87
+ key?: string
88
+ ids?: string[]
89
+ }
90
+ if (saved.key === key && saved.ids?.length) {
91
+ catalogIds = new Set(saved.ids)
92
+ return
93
+ }
94
+ } catch {
95
+ // no cache yet, or unreadable — rescan
96
+ }
97
+ try {
98
+ const bytes = await readFile(binary)
99
+ const found = new TextDecoder("latin1")
100
+ .decode(bytes)
101
+ .match(/claude-(?:opus|sonnet|haiku|fable)-\d[0-9a-z-]*/g)
102
+ if (!found?.length) return
103
+ const ids = [...new Set(found)]
104
+ catalogIds = new Set(ids)
105
+ await mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => {})
106
+ await writeFile(cache, JSON.stringify({ key, ids })).catch(() => {})
107
+ } catch {
108
+ // unreadable binary — the fallback list still applies
109
+ }
110
+ }
111
+
112
+ export function advertisedId(upstreamId: string): string | null {
113
+ if (catalogIds.has(upstreamId)) return upstreamId
114
+ const dashed = upstreamId.replace(/\./g, "-")
115
+ return catalogIds.has(dashed) ? dashed : null
116
+ }
117
+
118
+ /**
119
+ * A catalog id whose client-side handling a non-catalog model can borrow.
120
+ * Prefers a same-family model the upstream actually serves, so the mapping
121
+ * ages with both sides at once. The target only has to be an id Claude Code
122
+ * knows, not one the upstream serves, so a static per-family id backs it up —
123
+ * without that, an upstream carrying no Claude models at all would leave every
124
+ * row unborrowed and therefore unofferable, i.e. an empty /model.
125
+ */
126
+ export function resolveBehavesAs(
127
+ family: ModelFamily,
128
+ upstreamIds: readonly string[],
129
+ ): string {
130
+ const known = upstreamIds
131
+ .map(advertisedId)
132
+ .filter((id): id is string => id !== null)
133
+ const catalog = [...catalogIds]
134
+ return (
135
+ known.find((id) => familyOf(id) === family) ??
136
+ known.find((id) => familyOf(id) === "sonnet") ??
137
+ known[0] ??
138
+ // Nothing the upstream serves is catalog-known, so fall back to the
139
+ // installed catalog itself rather than to an id baked into clco.
140
+ (catalogIds.has(LATEST_PER_FAMILY[family])
141
+ ? LATEST_PER_FAMILY[family]
142
+ : (catalog.find((id) => familyOf(id) === family) ??
143
+ catalog.find((id) => familyOf(id) === "sonnet") ??
144
+ catalog[0] ??
145
+ LATEST_PER_FAMILY[family]))
146
+ )
147
+ }