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