@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/LICENSE +21 -0
- package/README.md +279 -0
- package/bin/clco +27 -0
- package/bun.lock +39 -0
- package/install.sh +161 -0
- package/package.json +35 -0
- package/scripts/mock-upstream.ts +129 -0
- package/scripts/write-launcher.sh +47 -0
- package/src/api.ts +90 -0
- package/src/auth.ts +130 -0
- package/src/blocks.ts +198 -0
- package/src/browsermcp.ts +430 -0
- package/src/catalog.ts +147 -0
- package/src/claudehome.ts +269 -0
- package/src/cli.ts +889 -0
- package/src/config.ts +164 -0
- package/src/responses.ts +435 -0
- package/src/route.ts +52 -0
- package/src/server.ts +641 -0
- package/src/setup.ts +218 -0
- package/src/spawn.ts +453 -0
- package/src/stream.ts +235 -0
- package/src/tls.ts +234 -0
- package/src/token.ts +298 -0
- package/src/tokens.ts +55 -0
- package/src/translate.ts +384 -0
- package/src/wire.ts +149 -0
- package/uninstall.sh +42 -0
|
@@ -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
|
+
}
|