@semanticist14/clco 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }