@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,269 @@
|
|
|
1
|
+
// A private CLAUDE_CONFIG_DIR for clco sessions.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code persists a /model pick into its config dir ("saved as your
|
|
4
|
+
// default for new sessions"). Pointed at the user's own ~/.claude that leaks
|
|
5
|
+
// a Copilot slug into plain `claude` runs, and undoing it afterwards cannot
|
|
6
|
+
// be made correct: the value is wrong for as long as the session runs, so a
|
|
7
|
+
// second claude started meanwhile still reads it, and an abrupt exit leaves
|
|
8
|
+
// it behind for the next run to mistake for the user's real setting.
|
|
9
|
+
//
|
|
10
|
+
// So give claude somewhere else to write. Everything that shapes behaviour is
|
|
11
|
+
// symlinked from the real ~/.claude, so plugins, skills, agents and commands
|
|
12
|
+
// stay live and shared; only the files claude writes are private. This is the
|
|
13
|
+
// same split Anthropic's own self-hosted runner makes when it seeds a config
|
|
14
|
+
// dir ("settings, agents/, skills/, …; runtime state excluded").
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
copyFile,
|
|
18
|
+
lstat,
|
|
19
|
+
mkdir,
|
|
20
|
+
readFile,
|
|
21
|
+
readdir,
|
|
22
|
+
readlink,
|
|
23
|
+
rename,
|
|
24
|
+
rm,
|
|
25
|
+
symlink,
|
|
26
|
+
writeFile,
|
|
27
|
+
} from "node:fs/promises"
|
|
28
|
+
import { homedir } from "node:os"
|
|
29
|
+
import { join } from "node:path"
|
|
30
|
+
|
|
31
|
+
/** Written by claude during a session — must stay private to clco. */
|
|
32
|
+
const PRIVATE_ENTRIES = new Set([
|
|
33
|
+
// Holds the model default a /model pick writes.
|
|
34
|
+
"settings.json",
|
|
35
|
+
// Local/managed overlays sit next to it; keep the whole family private so a
|
|
36
|
+
// write never lands in the user's tree.
|
|
37
|
+
"settings.local.json",
|
|
38
|
+
"backups",
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Keep claude's settings private, without letting a Copilot slug survive in
|
|
43
|
+
* them. Three cases the previous version got wrong: a missing ~/.claude
|
|
44
|
+
* settings.json left the private copy (and its stale `model`) untouched; a
|
|
45
|
+
* JSONC one was copied verbatim, `model` and all; and rewriting from the
|
|
46
|
+
* user's file every launch discarded whatever claude had persisted in the
|
|
47
|
+
* private one.
|
|
48
|
+
*/
|
|
49
|
+
async function writeSettings(real: string, home: string): Promise<void> {
|
|
50
|
+
const target = join(home, "settings.json")
|
|
51
|
+
const source = await readFile(join(real, "settings.json"), "utf8").catch(
|
|
52
|
+
() => null,
|
|
53
|
+
)
|
|
54
|
+
const existing = await readFile(target, "utf8").catch(() => null)
|
|
55
|
+
const raw = source ?? existing
|
|
56
|
+
if (raw === null) return
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>
|
|
59
|
+
delete parsed.model
|
|
60
|
+
await atomicWrite(target, JSON.stringify(parsed, null, 2) + "\n")
|
|
61
|
+
} catch {
|
|
62
|
+
// claude tolerates comments where JSON.parse does not. A line-based regex
|
|
63
|
+
// was worse than useless here: deleting the last key's line left a
|
|
64
|
+
// dangling comma and broke the file outright, it reached "model" keys
|
|
65
|
+
// nested in other objects, and it missed the key when it shared a line.
|
|
66
|
+
// Strip comments, parse, and re-emit — losing the comments is a smaller
|
|
67
|
+
// loss than losing the settings.
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(stripJsonComments(raw)) as Record<string, unknown>
|
|
70
|
+
delete parsed.model
|
|
71
|
+
await atomicWrite(target, JSON.stringify(parsed, null, 2) + "\n")
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// Neither form parses: keep the previous private file rather than
|
|
74
|
+
// replace it with something broken, and say so - a stale `model` here
|
|
75
|
+
// is exactly what this function exists to prevent.
|
|
76
|
+
const first = existing === null
|
|
77
|
+
console.error(
|
|
78
|
+
`[clco] could not parse ${join(real, "settings.json")} (${(err as Error).message}) - ` +
|
|
79
|
+
(first
|
|
80
|
+
? "this session starts with NO settings: no env, no hooks, no permissions."
|
|
81
|
+
: "clco's copy was left as it was."),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Comments only — string contents are left alone. */
|
|
88
|
+
function stripJsonComments(input: string): string {
|
|
89
|
+
let out = ""
|
|
90
|
+
let inString = false
|
|
91
|
+
let escaped = false
|
|
92
|
+
// Positions in `out` of commas that sit outside any string.
|
|
93
|
+
const commaIndices = new Set<number>()
|
|
94
|
+
for (let i = 0; i < input.length; i++) {
|
|
95
|
+
const c = input[i]!
|
|
96
|
+
if (inString) {
|
|
97
|
+
out += c
|
|
98
|
+
if (escaped) escaped = false
|
|
99
|
+
else if (c === "\\") escaped = true
|
|
100
|
+
else if (c === '"') inString = false
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (c === '"') {
|
|
104
|
+
inString = true
|
|
105
|
+
out += c
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (c === ",") commaIndices.add(out.length)
|
|
109
|
+
if (c === "/" && input[i + 1] === "/") {
|
|
110
|
+
while (i < input.length && input[i] !== "\n") i++
|
|
111
|
+
out += "\n"
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (c === "/" && input[i + 1] === "*") {
|
|
115
|
+
i += 2
|
|
116
|
+
while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++
|
|
117
|
+
i++
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
out += c
|
|
121
|
+
}
|
|
122
|
+
// Trailing commas are legal in JSONC and fatal to JSON.parse — but removing
|
|
123
|
+
// them with a regex over the whole document ate commas inside string values
|
|
124
|
+
// (a permission rule like "Bash(awk '{print $1,}')" lost one). Only drop a
|
|
125
|
+
// comma the scanner emitted outside a string.
|
|
126
|
+
let result = ""
|
|
127
|
+
for (let i = 0; i < out.length; i++) {
|
|
128
|
+
const c = out[i]!
|
|
129
|
+
if (c === "," && commaIndices.has(i)) {
|
|
130
|
+
let j = i + 1
|
|
131
|
+
while (j < out.length && /\s/.test(out[j]!)) j++
|
|
132
|
+
if (out[j] === "}" || out[j] === "]") continue
|
|
133
|
+
}
|
|
134
|
+
result += c
|
|
135
|
+
}
|
|
136
|
+
return result
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A second clco launch refreshes this file while the first session may be
|
|
140
|
+
// reading it; config.ts already writes auth.json this way.
|
|
141
|
+
async function atomicWrite(path: string, data: string): Promise<void> {
|
|
142
|
+
const tmp = `${path}.tmp.${process.pid}`
|
|
143
|
+
try {
|
|
144
|
+
await writeFile(tmp, data, { mode: 0o600 })
|
|
145
|
+
await rename(tmp, path)
|
|
146
|
+
} catch {
|
|
147
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `.claude.json` carries trust decisions, MCP server definitions (with their
|
|
153
|
+
* env secrets) and project history. Seeded once so the first session inherits
|
|
154
|
+
* it — but the security-relevant halves are re-synced every launch, because a
|
|
155
|
+
* copy frozen at first run means deleting a compromised MCP server, or
|
|
156
|
+
* withdrawing trust from a project, silently does not apply to clco sessions.
|
|
157
|
+
* Everything else stays clco's own.
|
|
158
|
+
*/
|
|
159
|
+
async function syncClaudeState(userHome: string, home: string): Promise<void> {
|
|
160
|
+
const target = join(home, ".claude.json")
|
|
161
|
+
const source = await readFile(join(userHome, ".claude.json"), "utf8").catch(
|
|
162
|
+
() => null,
|
|
163
|
+
)
|
|
164
|
+
if (source === null) return
|
|
165
|
+
const existing = await readFile(target, "utf8").catch(() => null)
|
|
166
|
+
if (existing === null) {
|
|
167
|
+
await copyFile(join(userHome, ".claude.json"), target).catch(() => {})
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const real = JSON.parse(source) as Record<string, unknown>
|
|
172
|
+
const mine = JSON.parse(existing) as Record<string, unknown>
|
|
173
|
+
mine.mcpServers = real.mcpServers
|
|
174
|
+
const realProjects = (real.projects ?? {}) as Record<string, { hasTrustDialogAccepted?: boolean }>
|
|
175
|
+
const myProjects = (mine.projects ?? {}) as Record<string, { hasTrustDialogAccepted?: boolean }>
|
|
176
|
+
for (const [path, entry] of Object.entries(myProjects)) {
|
|
177
|
+
// Absent means "no opinion", not "withdrawn". clco sessions run against
|
|
178
|
+
// the private config dir, so a project only ever used through clco is
|
|
179
|
+
// never recorded in the real file at all — forcing false there re-asked
|
|
180
|
+
// the trust question on every single launch.
|
|
181
|
+
const trusted = realProjects[path]?.hasTrustDialogAccepted
|
|
182
|
+
if (trusted !== undefined) entry.hasTrustDialogAccepted = trusted
|
|
183
|
+
}
|
|
184
|
+
await atomicWrite(target, JSON.stringify(mine, null, 2) + "\n")
|
|
185
|
+
} catch {
|
|
186
|
+
// Unparseable on either side: leave what is there rather than lose it.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function claudeHome(home = homedir()): string {
|
|
191
|
+
return join(home, ".config", "clco", "claude-home")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build (or refresh) the private config dir and return it.
|
|
196
|
+
*
|
|
197
|
+
* Symlinks are refreshed every launch so entries the user adds to ~/.claude
|
|
198
|
+
* show up without any cache to invalidate. Returns null when the real config
|
|
199
|
+
* dir cannot be read, so the caller can fall back to the shared one.
|
|
200
|
+
*/
|
|
201
|
+
export async function prepareClaudeHome(
|
|
202
|
+
/** Overridden in tests; os.homedir() ignores $HOME on macOS. */
|
|
203
|
+
userHome = homedir(),
|
|
204
|
+
): Promise<string | null> {
|
|
205
|
+
const real = join(userHome, ".claude")
|
|
206
|
+
const home = claudeHome(userHome)
|
|
207
|
+
// A missing ~/.claude is no reason to skip isolation - the private dir works
|
|
208
|
+
// fine empty. Only a real read failure is a problem, and returning null then
|
|
209
|
+
// hands the child the user's own config dir, where a /model pick persists.
|
|
210
|
+
// That must never happen quietly.
|
|
211
|
+
let entries: string[]
|
|
212
|
+
try {
|
|
213
|
+
entries = await readdir(real)
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
216
|
+
console.error(
|
|
217
|
+
`[clco] cannot read ${real} (${(err as Error).message}) - refusing to run` +
|
|
218
|
+
" against your own claude config, where a /model pick would persist.",
|
|
219
|
+
)
|
|
220
|
+
throw err
|
|
221
|
+
}
|
|
222
|
+
entries = []
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await mkdir(home, { recursive: true, mode: 0o700 })
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.error(
|
|
228
|
+
`[clco] cannot create ${home} (${(err as Error).message}) - refusing to run` +
|
|
229
|
+
" against your own claude config, where a /model pick would persist.",
|
|
230
|
+
)
|
|
231
|
+
throw err
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const name of entries) {
|
|
235
|
+
if (PRIVATE_ENTRIES.has(name)) continue
|
|
236
|
+
const link = join(home, name)
|
|
237
|
+
const target = join(real, name)
|
|
238
|
+
try {
|
|
239
|
+
const existing = await lstat(link).catch(() => null)
|
|
240
|
+
if (existing?.isSymbolicLink()) {
|
|
241
|
+
// Trusting an existing link without checking left ones pointing at a
|
|
242
|
+
// previous $HOME pointing there forever.
|
|
243
|
+
if ((await readlink(link).catch(() => null)) === target) continue
|
|
244
|
+
}
|
|
245
|
+
if (existing) await rm(link, { recursive: true, force: true })
|
|
246
|
+
await symlink(target, link)
|
|
247
|
+
} catch {
|
|
248
|
+
// One unlinkable entry should not sink the session.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Reap links whose source is gone: the loop above only visits what exists
|
|
253
|
+
// now, so a deleted entry otherwise dangles here forever.
|
|
254
|
+
const live = new Set(entries)
|
|
255
|
+
for (const name of await readdir(home).catch(() => [])) {
|
|
256
|
+
if (PRIVATE_ENTRIES.has(name) || live.has(name) || name === ".claude.json") {
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
const link = join(home, name)
|
|
260
|
+
const stat = await lstat(link).catch(() => null)
|
|
261
|
+
if (stat?.isSymbolicLink()) await rm(link, { force: true }).catch(() => {})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await writeSettings(real, home)
|
|
265
|
+
|
|
266
|
+
await syncClaudeState(userHome, home)
|
|
267
|
+
|
|
268
|
+
return home
|
|
269
|
+
}
|