@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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// clco — run the stock Claude Code CLI backed by a GitHub Copilot
|
|
3
|
+
// subscription: device-flow OAuth, a local Anthropic-compatible adapter, and
|
|
4
|
+
// claude launched with injected settings.
|
|
5
|
+
|
|
6
|
+
import * as p from "@clack/prompts"
|
|
7
|
+
import { existsSync, mkdirSync, renameSync } from "node:fs"
|
|
8
|
+
import { appendFile, mkdir, mkdtemp, readFile, rename, rm } from "node:fs/promises"
|
|
9
|
+
import { homedir } from "node:os"
|
|
10
|
+
import { dirname, join } from "node:path"
|
|
11
|
+
import { clearAuth, loadPrefs, savePrefs, saveAuth } from "./config"
|
|
12
|
+
import { ensureGithubToken, runDeviceFlow } from "./auth"
|
|
13
|
+
import {
|
|
14
|
+
GITHUB_API_BASE_URL,
|
|
15
|
+
copilotFetch,
|
|
16
|
+
githubRequestHeaders,
|
|
17
|
+
isMockMode,
|
|
18
|
+
} from "./api"
|
|
19
|
+
import {
|
|
20
|
+
copilotTokenFacts,
|
|
21
|
+
discoverModels,
|
|
22
|
+
takeDiscoverySoftFailure,
|
|
23
|
+
upstreamModels,
|
|
24
|
+
} from "./token"
|
|
25
|
+
import { setAdapterLogSink, startServer } from "./server"
|
|
26
|
+
import { buildModelPickerFrom, resolveClaude, runClaude } from "./spawn"
|
|
27
|
+
import { isTlsTrustError, tlsHint } from "./tls"
|
|
28
|
+
import {
|
|
29
|
+
extensionHint,
|
|
30
|
+
extensionInstalled,
|
|
31
|
+
parseToken,
|
|
32
|
+
probesDefaultRegistry,
|
|
33
|
+
registryStatus,
|
|
34
|
+
startupLine,
|
|
35
|
+
} from "./browsermcp"
|
|
36
|
+
import {
|
|
37
|
+
loadSetup,
|
|
38
|
+
runSetup,
|
|
39
|
+
setupClaudeArgs,
|
|
40
|
+
setupEnv,
|
|
41
|
+
modelWithoutPrompt,
|
|
42
|
+
shouldSelectModel,
|
|
43
|
+
type SetupOverrides,
|
|
44
|
+
} from "./setup"
|
|
45
|
+
import { normalizeModel } from "./translate"
|
|
46
|
+
|
|
47
|
+
// Single source: the package manifest. appDir() resolves the install even
|
|
48
|
+
// when clco is started through the launcher from another directory.
|
|
49
|
+
const VERSION: string = await (async () => {
|
|
50
|
+
try {
|
|
51
|
+
const dir = appDir() ?? join(import.meta.dir, "..")
|
|
52
|
+
const raw = await readFile(join(dir, "package.json"), "utf8")
|
|
53
|
+
return (JSON.parse(raw) as { version?: string }).version ?? "unknown"
|
|
54
|
+
} catch {
|
|
55
|
+
return "unknown"
|
|
56
|
+
}
|
|
57
|
+
})()
|
|
58
|
+
|
|
59
|
+
interface Args {
|
|
60
|
+
command:
|
|
61
|
+
| "run" | "serve" | "auth" | "login" | "logout" | "update" | "status"
|
|
62
|
+
| "setup" | "version" | "token"
|
|
63
|
+
port?: number
|
|
64
|
+
/** Saved setup options turned off for this run via --no-*. */
|
|
65
|
+
overrides: SetupOverrides
|
|
66
|
+
claudeArgs: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// One list, two renderings: help and the typo path used to drift apart, and
|
|
70
|
+
// the typo path was the only place `setup` was mentioned.
|
|
71
|
+
const COMMANDS: ReadonlyArray<[string, string]> = [
|
|
72
|
+
["clco", "Start a session (prompts for a model)"],
|
|
73
|
+
["clco serve", "Run only the adapter; point your own claude at it"],
|
|
74
|
+
["clco status", "Show account, plan, model policy and routing"],
|
|
75
|
+
["clco setup", "Set startup defaults (permissions, browser control, model prompt)"],
|
|
76
|
+
["clco token", "Store the Playwright MCP token: pbpaste | clco token"],
|
|
77
|
+
["clco token --clear", "Remove the stored token"],
|
|
78
|
+
["clco login|auth", "(Re)authenticate with GitHub; also switches accounts"],
|
|
79
|
+
["clco logout", "Delete the stored GitHub token"],
|
|
80
|
+
["clco update", "Update this install to the latest revision"],
|
|
81
|
+
["clco version", "Print the version"],
|
|
82
|
+
["clco help", "Show this help"],
|
|
83
|
+
["clco --port N", "Pin the adapter port"],
|
|
84
|
+
["clco --no-bypass", "Re-enable permission prompts for this run"],
|
|
85
|
+
["clco --no-select", "Skip the model prompt for this run"],
|
|
86
|
+
["clco --no-browser", "Skip Playwright MCP for this run"],
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
const ENVIRONMENT: ReadonlyArray<[string, string]> = [
|
|
90
|
+
["CLCO_CA_BUNDLE", "CA file(s) to add to the OS trust store (TLS-inspecting proxies)"],
|
|
91
|
+
["CLCO_OPUS/SONNET/HAIKU/FABLE", "Override the model slug for a slot"],
|
|
92
|
+
["CLCO_MIN_WINDOW", "Hide models with a smaller context window"],
|
|
93
|
+
["CLCO_SHOW_INTERNAL", "Also list Copilot's internal search/exec models"],
|
|
94
|
+
["CLCO_NO_SELECT=1", "Skip the startup model prompt"],
|
|
95
|
+
["CLCO_NO_PASSTHROUGH=1", "Disable the native /v1/messages route (always translate)"],
|
|
96
|
+
["CLCO_DEBUG=1", "Write adapter request logs to ~/.config/clco/adapter.log"],
|
|
97
|
+
["CLCO_MCP_PACKAGE", "Override the Playwright MCP package spec (mirror, pin, rollback)"],
|
|
98
|
+
["CLCO_UPSTREAM", "Override the upstream base URL (mock testing; skips auth)"],
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
const pad = (rows: ReadonlyArray<[string, string]>, width: number) =>
|
|
102
|
+
rows.map(([k, v]) => ` ${k.padEnd(width)} ${v}`).join("\n")
|
|
103
|
+
|
|
104
|
+
export const COMMAND_LIST = `Commands:\n${pad(COMMANDS, 20)}`
|
|
105
|
+
|
|
106
|
+
const HELP = `clco v${VERSION} — run Claude Code on your GitHub Copilot subscription
|
|
107
|
+
|
|
108
|
+
Usage:
|
|
109
|
+
clco [--port N] [claude args...]
|
|
110
|
+
GitHub auth (first run only) -> pick a model -> start the local
|
|
111
|
+
adapter -> launch claude. Any dashed argument clco does not own is
|
|
112
|
+
passed straight through:
|
|
113
|
+
clco -p "ask" / clco --resume / clco --model gpt-4.1
|
|
114
|
+
The first interactive run also asks for your startup defaults.
|
|
115
|
+
|
|
116
|
+
${COMMAND_LIST}
|
|
117
|
+
|
|
118
|
+
Environment:
|
|
119
|
+
${pad(ENVIRONMENT, 28)}
|
|
120
|
+
|
|
121
|
+
claude's own flags pass through unchanged.`
|
|
122
|
+
|
|
123
|
+
export function parseArgs(rawArgv: string[]): Args {
|
|
124
|
+
// The launcher replaces a leading "--" with this sentinel because bun
|
|
125
|
+
// strips the bare separator before scripts ever see it; it is consumed in
|
|
126
|
+
// the scan below.
|
|
127
|
+
const argv = rawArgv
|
|
128
|
+
const sep = argv.indexOf("--")
|
|
129
|
+
const leading = sep === -1 ? argv : argv.slice(0, sep)
|
|
130
|
+
const trailing = sep === -1 ? [] : argv.slice(sep + 1)
|
|
131
|
+
|
|
132
|
+
let command: Args["command"] = "run"
|
|
133
|
+
let port: number | undefined
|
|
134
|
+
const overrides: SetupOverrides = {}
|
|
135
|
+
let i = 0
|
|
136
|
+
while (i < leading.length) {
|
|
137
|
+
const arg = leading[i]
|
|
138
|
+
if (
|
|
139
|
+
(arg === "serve" || arg === "auth" || arg === "login" ||
|
|
140
|
+
arg === "logout" || arg === "update" || arg === "status" ||
|
|
141
|
+
arg === "setup" || arg === "version" || arg === "token") &&
|
|
142
|
+
command === "run"
|
|
143
|
+
) {
|
|
144
|
+
command = arg
|
|
145
|
+
i++
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (arg === "--port") {
|
|
149
|
+
const raw = leading[i + 1]
|
|
150
|
+
const n = Number(raw)
|
|
151
|
+
if (raw === undefined || !Number.isInteger(n) || n <= 0) {
|
|
152
|
+
throw new Error(`--port needs a positive integer\n\n${COMMAND_LIST}`)
|
|
153
|
+
}
|
|
154
|
+
port = n
|
|
155
|
+
i += 2
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
// Turn a saved setup option off for this run. Consumed here so it never
|
|
159
|
+
// reaches claude, which has no --no-* form for any of these.
|
|
160
|
+
if (
|
|
161
|
+
arg === "--no-bypass" ||
|
|
162
|
+
arg === "--no-select" ||
|
|
163
|
+
arg === "--no-browser"
|
|
164
|
+
) {
|
|
165
|
+
overrides[arg.slice(5) as keyof SetupOverrides] = false
|
|
166
|
+
i++
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
if (arg === "__clco_passthrough__") {
|
|
170
|
+
return { command, port, overrides, claudeArgs: [...leading.slice(i + 1), ...trailing] }
|
|
171
|
+
}
|
|
172
|
+
// A dashed flag we don't own is claude's (--chrome, -p,
|
|
173
|
+
// --dangerously-skip-permissions, ...). A bare word is almost always a
|
|
174
|
+
// mistyped subcommand, so that still fails loudly.
|
|
175
|
+
if (arg !== undefined && arg.startsWith("-")) {
|
|
176
|
+
return { command, port, overrides, claudeArgs: [...leading.slice(i), ...trailing] }
|
|
177
|
+
}
|
|
178
|
+
throw new Error(
|
|
179
|
+
`unknown command: "${arg}"\n(for a claude argument, put it after --: clco -- ${leading.slice(i).join(" ")})\n\n${COMMAND_LIST}`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
return { command, port, overrides, claudeArgs: trailing }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const interactive = process.stdout.isTTY === true
|
|
186
|
+
|
|
187
|
+
function seconds(ms: number): string {
|
|
188
|
+
return `${(ms / 1000).toFixed(1)}s`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function step<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
192
|
+
const t0 = Date.now()
|
|
193
|
+
if (interactive) {
|
|
194
|
+
const spinner = p.spinner()
|
|
195
|
+
spinner.start(name)
|
|
196
|
+
try {
|
|
197
|
+
const value = await fn()
|
|
198
|
+
spinner.stop(`${name} ✓ ${seconds(Date.now() - t0)}`)
|
|
199
|
+
return value
|
|
200
|
+
} catch (err) {
|
|
201
|
+
spinner.stop(`${name} failed (${seconds(Date.now() - t0)})`)
|
|
202
|
+
throw err
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
console.error(`… ${name}`)
|
|
206
|
+
const value = await fn()
|
|
207
|
+
console.error(`✓ ${name} (${seconds(Date.now() - t0)})`)
|
|
208
|
+
return value
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Where the running installation lives: explicit env from the installed
|
|
213
|
+
// launcher, then the standard install location, then a dev checkout.
|
|
214
|
+
function appDir(): string | null {
|
|
215
|
+
const recover = (candidate: string): string | null => {
|
|
216
|
+
if (existsSync(join(candidate, ".git"))) return candidate
|
|
217
|
+
const previous = `${candidate}.previous`
|
|
218
|
+
if (!existsSync(candidate) && existsSync(join(previous, ".git"))) {
|
|
219
|
+
try {
|
|
220
|
+
renameSync(previous, candidate)
|
|
221
|
+
} catch {
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return existsSync(join(candidate, ".git")) ? candidate : null
|
|
226
|
+
}
|
|
227
|
+
if (process.env.CLCO_APP_DIR) return recover(process.env.CLCO_APP_DIR)
|
|
228
|
+
const installed = join(homedir(), ".local", "share", "clco")
|
|
229
|
+
const recovered = recover(installed)
|
|
230
|
+
if (recovered) return recovered
|
|
231
|
+
const devRoot = join(import.meta.dir, "..")
|
|
232
|
+
return recover(devRoot)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const CANONICAL_REPOSITORY = "https://github.com/semanticist21/clco.git"
|
|
236
|
+
|
|
237
|
+
/** Update one checkout without changing it until the replacement is healthy. */
|
|
238
|
+
export async function updateInstall(
|
|
239
|
+
dir: string,
|
|
240
|
+
binDir: string,
|
|
241
|
+
canonical = CANONICAL_REPOSITORY,
|
|
242
|
+
bunCommand = "bun",
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
console.error(`... updating: ${dir}`)
|
|
245
|
+
if (bunCommand === "bun" && !Bun.which("bun")) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
"Bun is required to update clco. Install it with `curl -fsSL https://bun.sh/install | bash`, then reopen your terminal. Node is not a runtime fallback yet.",
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const lock = `${dir}.update.lock`
|
|
252
|
+
try {
|
|
253
|
+
await mkdir(lock)
|
|
254
|
+
} catch {
|
|
255
|
+
throw new Error(
|
|
256
|
+
"another clco update is already running (or left an update lock; remove the lock after verifying)",
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const run = (args: string[], cwd?: string) =>
|
|
262
|
+
Bun.spawnSync(args, { cwd, stdout: "pipe", stderr: "pipe" })
|
|
263
|
+
const output = (result: { stdout: Uint8Array; stderr: Uint8Array }) =>
|
|
264
|
+
(result.stderr.toString().trim() || result.stdout.toString().trim()).replace(
|
|
265
|
+
/([a-z][a-z0-9+.-]*:\/\/)([^/\s@]+)@/gi,
|
|
266
|
+
"$1[redacted]@",
|
|
267
|
+
)
|
|
268
|
+
const commandFailure = (name: string, result: { stdout: Uint8Array; stderr: Uint8Array }) =>
|
|
269
|
+
`${name}: ${output(result) || "unknown error"}`
|
|
270
|
+
|
|
271
|
+
// Never mutate an install that contains local work. The staged checkout is
|
|
272
|
+
// what makes dependency failures safe: the live revision and node_modules
|
|
273
|
+
// stay untouched until the new checkout passes both install and smoke test.
|
|
274
|
+
const status = run(["git", "-C", dir, "status", "--porcelain"])
|
|
275
|
+
if (status.exitCode !== 0) throw new Error(commandFailure("git status failed", status))
|
|
276
|
+
if (status.stdout.toString().trim()) {
|
|
277
|
+
throw new Error("update aborted: local changes exist; commit or stash them first")
|
|
278
|
+
}
|
|
279
|
+
const currentHead = run(["git", "-C", dir, "rev-parse", "HEAD"])
|
|
280
|
+
if (currentHead.exitCode !== 0) {
|
|
281
|
+
throw new Error(commandFailure("could not read current revision", currentHead))
|
|
282
|
+
}
|
|
283
|
+
const branch = run(["git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD"])
|
|
284
|
+
if (branch.exitCode !== 0 || branch.stdout.toString().trim() === "HEAD") {
|
|
285
|
+
throw new Error("update requires a checked-out branch")
|
|
286
|
+
}
|
|
287
|
+
const branchName = branch.stdout.toString().trim()
|
|
288
|
+
try {
|
|
289
|
+
const manifest = JSON.parse(await readFile(join(dir, "package.json"), "utf8")) as {
|
|
290
|
+
name?: string
|
|
291
|
+
}
|
|
292
|
+
if (manifest.name !== "clco" && manifest.name !== "@semanticist14/clco") {
|
|
293
|
+
throw new Error("not clco")
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
throw new Error("update refused: the target is not a clco installation")
|
|
297
|
+
}
|
|
298
|
+
const stageRoot = await mkdtemp(join(dirname(dir), ".clco-update-"))
|
|
299
|
+
const stage = join(stageRoot, "app")
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const clone = run(["git", "clone", "--local", dir, stage])
|
|
303
|
+
if (clone.exitCode !== 0) throw new Error(commandFailure("staging checkout failed", clone))
|
|
304
|
+
|
|
305
|
+
// A legacy install may have a stale or credential-bearing origin. The
|
|
306
|
+
// staged clone never contacts it: replace it before the fetch and never
|
|
307
|
+
// print the old URL.
|
|
308
|
+
let origin = run(["git", "-C", stage, "remote", "set-url", "origin", canonical])
|
|
309
|
+
if (origin.exitCode !== 0) {
|
|
310
|
+
origin = run(["git", "-C", stage, "remote", "add", "origin", canonical])
|
|
311
|
+
}
|
|
312
|
+
if (origin.exitCode !== 0) {
|
|
313
|
+
throw new Error(commandFailure("could not set the canonical origin", origin))
|
|
314
|
+
}
|
|
315
|
+
const fetch = run(["git", "-C", stage, "fetch", "--quiet", "origin", branchName])
|
|
316
|
+
if (fetch.exitCode !== 0) throw new Error(commandFailure("git fetch failed", fetch))
|
|
317
|
+
const remoteHead = `origin/${branchName}`
|
|
318
|
+
const fastForward = run(["git", "-C", stage, "merge-base", "--is-ancestor", currentHead.stdout.toString().trim(), remoteHead])
|
|
319
|
+
if (fastForward.exitCode !== 0) {
|
|
320
|
+
throw new Error("update is not a fast-forward; resolve the branch manually before updating")
|
|
321
|
+
}
|
|
322
|
+
const checkout = run(["git", "-C", stage, "checkout", "--quiet", "-B", branchName, remoteHead])
|
|
323
|
+
if (checkout.exitCode !== 0) throw new Error(commandFailure("staged checkout failed", checkout))
|
|
324
|
+
|
|
325
|
+
let install = run([bunCommand, "install", "--frozen-lockfile"], stage)
|
|
326
|
+
const frozenFailure = output(install)
|
|
327
|
+
const incompatibleLockfile =
|
|
328
|
+
/unknown lockfile version|unknownlockfileversion|failed to parse lockfile/i.test(
|
|
329
|
+
frozenFailure,
|
|
330
|
+
)
|
|
331
|
+
if (install.exitCode !== 0 && incompatibleLockfile) {
|
|
332
|
+
install = run([bunCommand, "install", "--no-save"], stage)
|
|
333
|
+
}
|
|
334
|
+
if (install.exitCode !== 0) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`${commandFailure("bun install failed; the previous revision is still active", install)}`,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
const smoke = run([bunCommand, "run", "--no-install", "src/cli.ts", "version"], stage)
|
|
340
|
+
if (smoke.exitCode !== 0) {
|
|
341
|
+
throw new Error(commandFailure("updated checkout failed its smoke test", smoke))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Detect another updater or a local process changing the live checkout
|
|
345
|
+
// while the staged work was running.
|
|
346
|
+
const liveHead = run(["git", "-C", dir, "rev-parse", "HEAD"])
|
|
347
|
+
const liveStatus = run(["git", "-C", dir, "status", "--porcelain"])
|
|
348
|
+
if (
|
|
349
|
+
liveHead.exitCode !== 0 ||
|
|
350
|
+
liveHead.stdout.toString().trim() !== currentHead.stdout.toString().trim() ||
|
|
351
|
+
liveStatus.exitCode !== 0 ||
|
|
352
|
+
liveStatus.stdout.toString().trim()
|
|
353
|
+
) {
|
|
354
|
+
throw new Error("update aborted: the live checkout changed while it was being updated")
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const sanitizedOrigin = run([
|
|
358
|
+
"git",
|
|
359
|
+
"-C",
|
|
360
|
+
dir,
|
|
361
|
+
"config",
|
|
362
|
+
"--replace-all",
|
|
363
|
+
"remote.origin.url",
|
|
364
|
+
canonical,
|
|
365
|
+
])
|
|
366
|
+
if (sanitizedOrigin.exitCode !== 0) {
|
|
367
|
+
throw new Error(commandFailure("could not sanitize the live origin", sanitizedOrigin))
|
|
368
|
+
}
|
|
369
|
+
run(["git", "-C", dir, "config", "--unset-all", "remote.origin.pushurl"])
|
|
370
|
+
const remainingPushUrl = run([
|
|
371
|
+
"git",
|
|
372
|
+
"-C",
|
|
373
|
+
dir,
|
|
374
|
+
"config",
|
|
375
|
+
"--get-all",
|
|
376
|
+
"remote.origin.pushurl",
|
|
377
|
+
])
|
|
378
|
+
if (remainingPushUrl.stdout.toString().trim()) {
|
|
379
|
+
throw new Error("could not remove credentials from the live origin")
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const previous = `${dir}.previous`
|
|
383
|
+
if (existsSync(previous) && !existsSync(join(previous, ".git"))) {
|
|
384
|
+
throw new Error("refusing to replace a non-clco rollback directory")
|
|
385
|
+
}
|
|
386
|
+
await rm(previous, { recursive: true, force: true })
|
|
387
|
+
await rename(dir, previous)
|
|
388
|
+
try {
|
|
389
|
+
await rename(stage, dir)
|
|
390
|
+
} catch (err) {
|
|
391
|
+
await rename(previous, dir)
|
|
392
|
+
throw err
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Refresh the launcher too. It used to be baked once by install.sh, so an
|
|
396
|
+
// existing install never picked up launcher changes no matter how often it
|
|
397
|
+
// updated. A launcher failure is non-fatal because it still points at the
|
|
398
|
+
// same app directory.
|
|
399
|
+
const launcher = run([
|
|
400
|
+
"bash",
|
|
401
|
+
join(dir, "scripts", "write-launcher.sh"),
|
|
402
|
+
dir,
|
|
403
|
+
binDir,
|
|
404
|
+
bunCommand,
|
|
405
|
+
])
|
|
406
|
+
if (launcher.exitCode === 0) {
|
|
407
|
+
console.error(`... launcher updated: ${join(binDir, "clco")}`)
|
|
408
|
+
} else {
|
|
409
|
+
console.error(
|
|
410
|
+
`! launcher update failed - re-run install.sh (${output(launcher) || "unknown error"})`,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
const head = run(["git", "-C", dir, "rev-parse", "--short", "HEAD"])
|
|
414
|
+
console.error(`+ updated (${head.stdout.toString().trim()}) - applies from the next run`)
|
|
415
|
+
} finally {
|
|
416
|
+
await rm(stageRoot, { recursive: true, force: true })
|
|
417
|
+
}
|
|
418
|
+
} finally {
|
|
419
|
+
await rm(lock, { recursive: true, force: true })
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function npmUpdateMessage(): string {
|
|
424
|
+
return "This clco installation came from npm. Update it with `npm install --global @semanticist14/clco@latest`."
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function runUpdate(): Promise<void> {
|
|
428
|
+
if (process.env.CLCO_NPM_INSTALL === "1") {
|
|
429
|
+
throw new Error(npmUpdateMessage())
|
|
430
|
+
}
|
|
431
|
+
const dir = appDir()
|
|
432
|
+
if (!dir) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
"no installation to update (need an install directory or git repo)",
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
await updateInstall(
|
|
438
|
+
dir,
|
|
439
|
+
process.env.CLCO_BIN_DIR ?? join(homedir(), ".local", "bin"),
|
|
440
|
+
CANONICAL_REPOSITORY,
|
|
441
|
+
process.env.CLCO_BUN_BIN ?? process.execPath,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Show what this account can actually reach: which models are enabled, which
|
|
446
|
+
// dialect each one routes through, and their declared effort/context limits.
|
|
447
|
+
async function runStatus(): Promise<void> {
|
|
448
|
+
const identity = await ensureGithubToken()
|
|
449
|
+
// The stored token may predate login capture. Ask GitHub once, remember the
|
|
450
|
+
// answer, and say plainly why it is missing when the lookup is refused —
|
|
451
|
+
// corporate policy blocks personal-account API calls on some networks.
|
|
452
|
+
let login = identity.login
|
|
453
|
+
let lookupNote = ""
|
|
454
|
+
if (!login && !isMockMode()) {
|
|
455
|
+
try {
|
|
456
|
+
const res = await copilotFetch(`${GITHUB_API_BASE_URL}/user`, {
|
|
457
|
+
headers: githubRequestHeaders(identity.token),
|
|
458
|
+
signal: AbortSignal.timeout(10_000),
|
|
459
|
+
})
|
|
460
|
+
if (res.ok) {
|
|
461
|
+
login = ((await res.json()) as { login?: string }).login
|
|
462
|
+
if (login) {
|
|
463
|
+
// Cache it so the next run needs no network at all.
|
|
464
|
+
await saveAuth({ github_token: identity.token, login }).catch(() => {})
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
lookupNote = `lookup refused (HTTP ${res.status})`
|
|
468
|
+
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
lookupNote = `lookup failed (${err instanceof Error ? err.message : String(err)})`
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
console.log(`clco v${VERSION}`)
|
|
474
|
+
console.log(
|
|
475
|
+
`Account: ${login ? `@${login}` : `(signed in${lookupNote ? ` - username ${lookupNote}` : ""})`}`,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
const facts = await copilotTokenFacts().catch(() => ({}) as { sku?: string })
|
|
479
|
+
if (facts.sku) console.log(`Plan: ${facts.sku}`)
|
|
480
|
+
await discoverModels()
|
|
481
|
+
// Populates the model catalog from the installed binary so the /model
|
|
482
|
+
// preview below matches what a real session would offer. Diagnostics must
|
|
483
|
+
// still work without claude present, so a failure is not fatal here.
|
|
484
|
+
await resolveClaude().catch(() => undefined)
|
|
485
|
+
const discoveryNote = takeDiscoverySoftFailure()
|
|
486
|
+
if (discoveryNote) console.error(discoveryNote)
|
|
487
|
+
const models = upstreamModels()
|
|
488
|
+
if (models.length === 0) {
|
|
489
|
+
console.log("Could not fetch the model list (check your network or subscription)")
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
const route = (m: (typeof models)[number]) =>
|
|
493
|
+
m.endpoints.includes("/v1/messages")
|
|
494
|
+
? "native"
|
|
495
|
+
: m.endpoints.includes("/responses")
|
|
496
|
+
? "responses"
|
|
497
|
+
: "chat"
|
|
498
|
+
// `model_picker_enabled` is not filtered here: GitHub returns false for
|
|
499
|
+
// every model, so honouring it emptied this table (and /model) entirely.
|
|
500
|
+
const picker = buildModelPickerFrom(models)
|
|
501
|
+
const offered = new Map(
|
|
502
|
+
(picker?.options ?? []).map((o) => [normalizeModel(o.model), o] as const),
|
|
503
|
+
)
|
|
504
|
+
console.log(
|
|
505
|
+
`\n${"model".padEnd(26)} ${"shown in /model".padEnd(38)} ${"route".padEnd(10)} ` +
|
|
506
|
+
`${"policy".padEnd(10)} ${"context".padEnd(10)} effort`,
|
|
507
|
+
)
|
|
508
|
+
for (const m of models) {
|
|
509
|
+
const row = offered.get(m.id)
|
|
510
|
+
const shown = row
|
|
511
|
+
? row.model + (row.behavesAs ? ` (→${row.behavesAs})` : "")
|
|
512
|
+
: "-"
|
|
513
|
+
const ctx = m.maxPromptTokens ?? m.maxContextTokens
|
|
514
|
+
console.log(
|
|
515
|
+
`${m.id.padEnd(26)} ${shown.padEnd(38)} ${route(m).padEnd(10)} ` +
|
|
516
|
+
`${(m.policyState ?? "?").padEnd(10)} ` +
|
|
517
|
+
`${String(ctx ?? "?").padEnd(10)} ${m.efforts ? m.efforts.join(",") : "-"}`,
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
console.log(
|
|
521
|
+
`\n${picker?.options.length ?? 0} rows in /model, ${models.length} models upstream`,
|
|
522
|
+
)
|
|
523
|
+
await reportClaudeInstallNotes()
|
|
524
|
+
console.log(
|
|
525
|
+
"\nQuota is only known at request time - Copilot has no API to check it\n" +
|
|
526
|
+
"up front. When it runs out you get a 402 with the details.",
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Things in the user's own claude install that change what clco's lineup
|
|
531
|
+
// does, plus leftovers older clco versions wrote there.
|
|
532
|
+
// Claude Code gates the Chrome extension on the OAuth scope of the session:
|
|
533
|
+
// "[Claude in Chrome] Disabled: OAuth token has no scope accepted by
|
|
534
|
+
// /api/oauth/validate (needs user:profile, user:office, or user:ccr_inference;
|
|
535
|
+
// env-var and setup-token sessions default to user:inference only)". clco
|
|
536
|
+
// authenticates with ANTHROPIC_AUTH_TOKEN against its own adapter, so it is
|
|
537
|
+
// always an env-var session and the browser tools are never registered —
|
|
538
|
+
// measured: mcp__claude-in-chrome__* appears in zero requests. clco therefore
|
|
539
|
+
// never passes --chrome itself; this only catches someone passing it by hand.
|
|
540
|
+
// A terminal submits a text prompt at the first newline, so pasting anything
|
|
541
|
+
// with a trailing line break loses the rest — and the leftover runs as shell
|
|
542
|
+
// input. Reading stdin sidesteps that entirely: `pbpaste | clco token`.
|
|
543
|
+
async function runToken(clear = false): Promise<void> {
|
|
544
|
+
const prefs = await loadPrefs()
|
|
545
|
+
if (!prefs.setup) {
|
|
546
|
+
throw new Error("Run `clco setup` first, then store the token.")
|
|
547
|
+
}
|
|
548
|
+
if (clear) {
|
|
549
|
+
await savePrefs({ ...prefs, setup: { ...prefs.setup, browserToken: undefined } })
|
|
550
|
+
console.log("+ token cleared (sessions show the connect dialog)")
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
// Reading stdin on a terminal waits forever, and this is the command the
|
|
554
|
+
// setup outro tells people to run.
|
|
555
|
+
if (process.stdin.isTTY) {
|
|
556
|
+
throw new Error(
|
|
557
|
+
"usage: pbpaste | clco token (or `clco token --clear` to remove it)",
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
const parsed = parseToken(await new Response(Bun.stdin.stream()).text())
|
|
561
|
+
if (parsed === null) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
"That does not look like the token - it is a long string of letters, digits, - and _.",
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
// An empty pipe is an accident, not a request to delete a working token.
|
|
567
|
+
if (parsed === undefined) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
"nothing on stdin - to remove a stored token, run `clco token --clear`",
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
await savePrefs({ ...prefs, setup: { ...prefs.setup, browserToken: parsed } })
|
|
573
|
+
console.log("+ token stored")
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function reportBrowserNotes(): Promise<void> {
|
|
577
|
+
if (process.argv.includes("--chrome")) {
|
|
578
|
+
console.log(
|
|
579
|
+
"\nNote: --chrome cannot work here - the Claude Chrome extension needs a\n" +
|
|
580
|
+
" claude.ai login, and clco is always an env-var session.",
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
const prefs = await loadPrefs().catch(() => ({}) as Awaited<ReturnType<typeof loadPrefs>>)
|
|
584
|
+
if (prefs.setup?.browser) {
|
|
585
|
+
console.log(
|
|
586
|
+
"\n" + extensionHint(await extensionInstalled(), prefs.setup?.browserToken),
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Matches LAUNCHER_VERSION in scripts/write-launcher.sh. */
|
|
592
|
+
const LAUNCHER_VERSION = 4
|
|
593
|
+
|
|
594
|
+
function reportStaleLauncher(): void {
|
|
595
|
+
// CLCO_APP_DIR is set by every launcher; without it clco was started
|
|
596
|
+
// directly (bun run src/cli.ts), where there is no launcher to be stale.
|
|
597
|
+
if (!process.env.CLCO_APP_DIR) return
|
|
598
|
+
const running = Number(process.env.CLCO_LAUNCHER_VERSION ?? "0")
|
|
599
|
+
if (running >= LAUNCHER_VERSION) return
|
|
600
|
+
console.log(
|
|
601
|
+
`\nNote: the clco launcher is out of date (v${running || "?"} < v${LAUNCHER_VERSION}).` +
|
|
602
|
+
" Refresh it with `clco update`, or by re-running install.sh with the same custom paths.",
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function reportClaudeInstallNotes(): Promise<void> {
|
|
607
|
+
reportStaleLauncher()
|
|
608
|
+
await reportBrowserNotes()
|
|
609
|
+
const home = homedir()
|
|
610
|
+
const stale = join(home, ".claude", "cache", "gateway-models.json")
|
|
611
|
+
if (await Bun.file(stale).exists()) {
|
|
612
|
+
console.log(
|
|
613
|
+
`\nNote: ${stale} is left over from an older clco - safe to delete.`,
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const raw = await readFile(join(home, ".claude", "settings.json"), "utf8")
|
|
618
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>
|
|
619
|
+
// Both outrank or filter what --settings injects.
|
|
620
|
+
if (parsed.availableModels) {
|
|
621
|
+
console.log(
|
|
622
|
+
"Warning: availableModels in ~/.claude/settings.json filters clco's /model rows.",
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
if (parsed.modelPicker) {
|
|
626
|
+
console.log(
|
|
627
|
+
"Note: ~/.claude/settings.json defines modelPicker - clco's --settings wins outright; the two are not merged.",
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
// no settings file, or not strict JSON — nothing to warn about
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function main(): Promise<void> {
|
|
636
|
+
const argv = process.argv.slice(2)
|
|
637
|
+
if (["help", "--help", "-h"].includes(argv[0] ?? "")) {
|
|
638
|
+
console.log(HELP)
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
const args = parseArgs(argv)
|
|
642
|
+
// Ask whenever a human is actually there. Print mode (-p) and non-TTY runs
|
|
643
|
+
// must stay unattended, but ordinary flags like --dangerously-skip-permissions
|
|
644
|
+
// or --chrome should not cost you the model choice.
|
|
645
|
+
const printMode = args.claudeArgs.some(
|
|
646
|
+
(a) => a === "-p" || a === "--print" || a.startsWith("--print="),
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
if (args.command === "update") {
|
|
650
|
+
await runUpdate()
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (args.command === "token") {
|
|
655
|
+
await runToken(args.claudeArgs.includes("--clear"))
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (args.command === "version") {
|
|
660
|
+
console.log(VERSION)
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (args.command === "setup") {
|
|
665
|
+
await runSetup()
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (args.command === "status") {
|
|
670
|
+
await runStatus()
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (args.command === "logout") {
|
|
675
|
+
await clearAuth()
|
|
676
|
+
console.log("+ Signed out - removed ~/.config/clco/auth.json. Sign in again: clco login")
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (args.command === "auth" || args.command === "login") {
|
|
681
|
+
if (args.port !== undefined) {
|
|
682
|
+
console.error(`[clco] --port is ignored in ${args.command} mode`)
|
|
683
|
+
}
|
|
684
|
+
if (isMockMode()) {
|
|
685
|
+
console.log("Mock mode (CLCO_UPSTREAM) - skipping auth")
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
const { token, login } = await runDeviceFlow()
|
|
689
|
+
console.log(
|
|
690
|
+
`+ GitHub authenticated${login ? ` (@${login})` : ""} - saved to ~/.config/clco/auth.json`,
|
|
691
|
+
)
|
|
692
|
+
if (process.env.CLCO_DEBUG) {
|
|
693
|
+
console.error(`[clco:debug] token prefix: ${token.slice(0, 4)}…`)
|
|
694
|
+
}
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (
|
|
699
|
+
args.claudeArgs.some(
|
|
700
|
+
(a) => a === "--settings" || a.startsWith("--settings="),
|
|
701
|
+
)
|
|
702
|
+
) {
|
|
703
|
+
throw new Error(
|
|
704
|
+
"--settings is injected by clco; passing your own would overwrite the adapter routing",
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
if (args.command === "serve" && args.claudeArgs.length > 0) {
|
|
708
|
+
throw new Error(
|
|
709
|
+
`serve mode takes no claude arguments: ${args.claudeArgs.join(" ")}`,
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Fail fast, before binding the adapter port. `serve` is adapter-only and
|
|
714
|
+
// never spawns claude, so it must not require the binary.
|
|
715
|
+
if (args.command !== "serve") {
|
|
716
|
+
await resolveClaude()
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (isMockMode()) {
|
|
720
|
+
console.error(
|
|
721
|
+
`! Mock mode: CLCO_UPSTREAM=${process.env.CLCO_UPSTREAM}\n` +
|
|
722
|
+
` Every request goes there and GitHub auth is skipped. Unset it for real use.`,
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
await step("Checking GitHub token", async () => {
|
|
727
|
+
const identity = await ensureGithubToken()
|
|
728
|
+
if (identity.fresh && !isMockMode()) {
|
|
729
|
+
console.error(
|
|
730
|
+
` └ newly authenticated: @${identity.login ?? "unknown"} - saved to ~/.config/clco/auth.json`,
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
let setup = await loadSetup()
|
|
736
|
+
if (!setup && args.command === "run") {
|
|
737
|
+
if (interactive && !printMode) {
|
|
738
|
+
setup = await runSetup()
|
|
739
|
+
} else {
|
|
740
|
+
// Never block an unattended run on a prompt; everything stays off,
|
|
741
|
+
// which is exactly how clco behaved before setup existed.
|
|
742
|
+
console.error(
|
|
743
|
+
"[clco] No startup defaults yet - run `clco setup` to set them.",
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const models = await step("Fetching Copilot token and model list", () =>
|
|
749
|
+
discoverModels(),
|
|
750
|
+
)
|
|
751
|
+
// Printed here, not inside discoverModels: step() runs a spinner that would
|
|
752
|
+
// paint over anything written while it is active.
|
|
753
|
+
const discoveryNote = takeDiscoverySoftFailure()
|
|
754
|
+
if (discoveryNote) console.error(discoveryNote)
|
|
755
|
+
const list = upstreamModels()
|
|
756
|
+
|
|
757
|
+
let defaultModel: string | undefined
|
|
758
|
+
const prefs = await loadPrefs()
|
|
759
|
+
// Offer exactly what /model will: the raw upstream list also carries
|
|
760
|
+
// embeddings and Copilot's internal plumbing, which cannot hold a
|
|
761
|
+
// conversation at all, and repeats display names across several slugs.
|
|
762
|
+
const pickerRows = buildModelPickerFrom(list)?.options ?? []
|
|
763
|
+
const offered = pickerRows.map((o) => normalizeModel(o.model))
|
|
764
|
+
// A remembered model the current account no longer offers would preselect a
|
|
765
|
+
// row that is not there.
|
|
766
|
+
const remembered =
|
|
767
|
+
prefs.last_model && offered.includes(prefs.last_model)
|
|
768
|
+
? prefs.last_model
|
|
769
|
+
: undefined
|
|
770
|
+
if (
|
|
771
|
+
args.command === "run" &&
|
|
772
|
+
!printMode &&
|
|
773
|
+
interactive &&
|
|
774
|
+
!process.env.CLCO_NO_SELECT &&
|
|
775
|
+
shouldSelectModel(setup, args.overrides) &&
|
|
776
|
+
list.length > 0
|
|
777
|
+
) {
|
|
778
|
+
const rows = pickerRows
|
|
779
|
+
const last = remembered
|
|
780
|
+
const selected = await p.autocomplete({
|
|
781
|
+
message: "Pick a model - type to search",
|
|
782
|
+
placeholder: "Search models...",
|
|
783
|
+
initialValue: last,
|
|
784
|
+
maxItems: 12,
|
|
785
|
+
options: rows.map((o) => {
|
|
786
|
+
const id = normalizeModel(o.model)
|
|
787
|
+
return {
|
|
788
|
+
value: id,
|
|
789
|
+
label: o.label ?? id,
|
|
790
|
+
hint: id === last ? `${o.description} · last used` : o.description,
|
|
791
|
+
}
|
|
792
|
+
}),
|
|
793
|
+
})
|
|
794
|
+
if (p.isCancel(selected)) {
|
|
795
|
+
p.cancel("Cancelled")
|
|
796
|
+
process.exit(0)
|
|
797
|
+
}
|
|
798
|
+
defaultModel = selected as string
|
|
799
|
+
try {
|
|
800
|
+
await savePrefs({ last_model: defaultModel })
|
|
801
|
+
} catch {
|
|
802
|
+
console.error("[clco] ! could not save the model choice (check permissions on the config directory)")
|
|
803
|
+
}
|
|
804
|
+
} else if (args.command !== "serve") {
|
|
805
|
+
// serve never calls runClaude, so a model resolved here is unreachable -
|
|
806
|
+
// and a live-looking variable on a dead path is a trap for the next reader.
|
|
807
|
+
defaultModel = modelWithoutPrompt(remembered, offered, models.sonnet)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const server = await startServer({ port: args.port })
|
|
811
|
+
if (args.command === "run") {
|
|
812
|
+
// claude's TUI owns the terminal; adapter request logs must not paint
|
|
813
|
+
// over it. Debug mode appends them to a file instead.
|
|
814
|
+
if (process.env.CLCO_DEBUG) {
|
|
815
|
+
const logDir = join(homedir(), ".config", "clco")
|
|
816
|
+
mkdirSync(logDir, { recursive: true })
|
|
817
|
+
const logPath = join(logDir, "adapter.log")
|
|
818
|
+
setAdapterLogSink((line) => {
|
|
819
|
+
void appendFile(logPath, `${line}\n`).catch(() => {})
|
|
820
|
+
})
|
|
821
|
+
console.error(`+ adapter log: ${logPath}`)
|
|
822
|
+
} else {
|
|
823
|
+
setAdapterLogSink(null)
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
console.error(`+ adapter: ${server.url}`)
|
|
827
|
+
// serve never spawns claude, so naming a session model there would advertise
|
|
828
|
+
// a routing decision no process makes. The slot list is still useful to
|
|
829
|
+
// someone pointing their own claude at the adapter.
|
|
830
|
+
const slots = `sonnet=${models.sonnet} opus=${models.opus} haiku=${models.haiku} fable=${models.fable}`
|
|
831
|
+
console.error(
|
|
832
|
+
args.command === "serve"
|
|
833
|
+
? `+ slots: ${slots}`
|
|
834
|
+
: `+ model: ${defaultModel ?? "(claude default)"} (${slots})`,
|
|
835
|
+
)
|
|
836
|
+
const browserEnabled =
|
|
837
|
+
setup?.browser === true && args.overrides.browser !== false
|
|
838
|
+
const browserExtension = browserEnabled ? await extensionInstalled() : false
|
|
839
|
+
// Computed from the arguments actually produced: clco stands aside when the
|
|
840
|
+
// user passes their own --mcp-config, and claiming success there would be
|
|
841
|
+
// the same trap as registering a server with no extension.
|
|
842
|
+
const injected = setupClaudeArgs(
|
|
843
|
+
setup,
|
|
844
|
+
args.overrides,
|
|
845
|
+
args.claudeArgs,
|
|
846
|
+
browserExtension,
|
|
847
|
+
)
|
|
848
|
+
const registered = injected.includes("--mcp-config")
|
|
849
|
+
const browserLine = startupLine(
|
|
850
|
+
browserEnabled,
|
|
851
|
+
browserExtension,
|
|
852
|
+
setup?.browserToken,
|
|
853
|
+
undefined,
|
|
854
|
+
// Only worth asking when everything else is in place - and only about the
|
|
855
|
+
// default spec, since the probe hits registry.npmjs.org and can say
|
|
856
|
+
// nothing true about an internal mirror named by CLCO_MCP_PACKAGE.
|
|
857
|
+
browserExtension && registered && probesDefaultRegistry()
|
|
858
|
+
? await registryStatus()
|
|
859
|
+
: undefined,
|
|
860
|
+
browserEnabled && browserExtension ? registered : undefined,
|
|
861
|
+
)
|
|
862
|
+
if (browserLine) console.error(browserLine)
|
|
863
|
+
|
|
864
|
+
if (args.command === "serve") {
|
|
865
|
+
console.error("Adapter running... (Ctrl+C to stop)")
|
|
866
|
+
await new Promise<never>(() => {})
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const code = await runClaude({
|
|
870
|
+
baseUrl: server.url,
|
|
871
|
+
models,
|
|
872
|
+
defaultModel,
|
|
873
|
+
claudeArgs: [...injected, ...args.claudeArgs],
|
|
874
|
+
extraEnv: setupEnv(setup, args.overrides),
|
|
875
|
+
})
|
|
876
|
+
server.stop()
|
|
877
|
+
process.exit(code)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Only run when invoked as the CLI — tests import parseArgs from here.
|
|
881
|
+
if (import.meta.main) {
|
|
882
|
+
main().catch((err) => {
|
|
883
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
884
|
+
console.error(
|
|
885
|
+
`Error: ${message}${isTlsTrustError(err) ? tlsHint() : ""}`,
|
|
886
|
+
)
|
|
887
|
+
process.exit(1)
|
|
888
|
+
})
|
|
889
|
+
}
|