cc-viewer 1.7.2 → 1.7.3

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/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-CjsQEQc2.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-DidP9FCD.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-DI7JL-mE.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-B9c49dtM.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-B6cpBtIE.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/server/i18n.js CHANGED
@@ -142,6 +142,26 @@ const i18nData = {
142
142
  "tr": " PATH'te de \"claude\" native komutu bulunamadı. @anthropic-ai/claude-code'un yüklü olduğundan emin olun",
143
143
  "uk": " Також не знайдено нативну команду \"claude\" в PATH. Переконайтеся, що @anthropic-ai/claude-code встановлено"
144
144
  },
145
+ "cli.settingsMergeFailed": {
146
+ "zh": "⚠️ 无法加载用户 --settings 值 {value}({reason}),已仅以 CC Viewer 注入的 settings 启动",
147
+ "en": "⚠️ Could not load user --settings value {value} ({reason}); launching with CC Viewer's injected settings only",
148
+ "zh-TW": "⚠️ 無法載入使用者 --settings 值 {value}({reason}),已僅以 CC Viewer 注入的 settings 啟動",
149
+ "ko": "⚠️ 사용자 --settings 값 {value}을(를) 로드할 수 없음({reason}), CC Viewer가 주입한 settings만으로 시작합니다",
150
+ "ja": "⚠️ ユーザーの --settings 値 {value} を読み込めません({reason})。CC Viewer が注入した settings のみで起動します",
151
+ "de": "⚠️ Der --settings-Wert des Benutzers {value} konnte nicht geladen werden ({reason}); Start nur mit den von CC Viewer injizierten Settings",
152
+ "es": "⚠️ No se pudo cargar el valor --settings del usuario {value} ({reason}); se inicia solo con los settings inyectados por CC Viewer",
153
+ "fr": "⚠️ Impossible de charger la valeur --settings de l'utilisateur {value} ({reason}) ; lancement avec uniquement les settings injectés par CC Viewer",
154
+ "it": "⚠️ Impossibile caricare il valore --settings dell'utente {value} ({reason}); avvio solo con i settings iniettati da CC Viewer",
155
+ "da": "⚠️ Kunne ikke indlæse brugerens --settings-værdi {value} ({reason}); starter kun med CC Viewers injicerede settings",
156
+ "pl": "⚠️ Nie można wczytać wartości --settings użytkownika {value} ({reason}); uruchamianie tylko z ustawieniami wstrzykniętymi przez CC Viewer",
157
+ "ru": "⚠️ Не удалось загрузить пользовательское значение --settings {value} ({reason}); запуск только с настройками, внедрёнными CC Viewer",
158
+ "ar": "⚠️ تعذّر تحميل قيمة --settings الخاصة بالمستخدم {value} ({reason})؛ سيتم التشغيل بإعدادات CC Viewer المحقونة فقط",
159
+ "no": "⚠️ Kunne ikke laste brukerens --settings-verdi {value} ({reason}); starter kun med CC Viewers injiserte settings",
160
+ "pt-BR": "⚠️ Não foi possível carregar o valor --settings do usuário {value} ({reason}); iniciando apenas com os settings injetados pelo CC Viewer",
161
+ "th": "⚠️ ไม่สามารถโหลดค่า --settings ของผู้ใช้ {value} ({reason}) จะเริ่มด้วย settings ที่ CC Viewer ฉีดเข้าไปเท่านั้น",
162
+ "tr": "⚠️ Kullanıcının --settings değeri {value} yüklenemedi ({reason}); yalnızca CC Viewer'ın enjekte ettiği settings ile başlatılıyor",
163
+ "uk": "⚠️ Не вдалося завантажити користувацьке значення --settings {value} ({reason}); запуск лише з налаштуваннями, впровадженими CC Viewer"
164
+ },
145
165
  "cli.inject.notFound": {
146
166
  "zh": "❌ 找不到 Claude Code cli.js: {path}",
147
167
  "en": "❌ Claude Code cli.js not found: {path}",
@@ -306,7 +306,10 @@ export function getLiveLogSource() {
306
306
  // newest dir once its first main req is written) — handing back exactly the
307
307
  // blank in-flight session the strict gate rejected. Excluding it makes the
308
308
  // fallback actually land on the previous, renderable conversation.
309
- const fallback = latestMainSessionDir(join(LOG_DIR, sanitizePathComponent(_projectName)), { excludeDir: dir });
309
+ // skipForeignLive: a parallel ccv window's in-flight session must never be
310
+ // served as THIS window's cold load (multi-window isolation); a crashed
311
+ // window's claim expires with its pid, so its session stays selectable.
312
+ const fallback = latestMainSessionDir(join(LOG_DIR, sanitizePathComponent(_projectName)), { excludeDir: dir, skipForeignLive: true });
310
313
  // A current-but-empty session with nothing else on disk: '' (still empty,
311
314
  // but the live feed fills it in place — no worse than before the fix).
312
315
  return fallback || dir || '';
@@ -316,6 +319,47 @@ export function getLiveLogSource() {
316
319
  }
317
320
  }
318
321
 
322
+ // SessionStart hook notify (session-start-bridge.js → /api/session-start-notify
323
+ // → server.js deps). The only source acted on is 'resume': an in-terminal
324
+ // /resume switches the running claude to a PAST conversation while the wire
325
+ // session_id may stay the same, so without this signal the writer keeps
326
+ // routing the resumed conversation into the OLD session dir (and the panel —
327
+ // keyed on `_seqEpoch = v2:<dir identity>` — never switches). The transcript
328
+ // basename is the resumed conversation's stable identity; the hook session_id
329
+ // is a fresh uuid usable as the new dir identity when nothing was recorded.
330
+ // 'startup'/'clear'/'compact' are deliberately ignored (startup needs nothing;
331
+ // /clear already has epoch machinery; teammate processes inherit
332
+ // CCVIEWER_PORT and fire startup events at this endpoint — the source gate
333
+ // drops them).
334
+ export function markSessionStart(payload) {
335
+ try {
336
+ const { source, sessionId, transcriptPath, cwd } = payload || {};
337
+ if (source !== 'resume') return;
338
+ if (!transcriptPath || typeof transcriptPath !== 'string') {
339
+ console.warn('[ccv session-start] resume signal without transcript_path — ignored');
340
+ return;
341
+ }
342
+ // Soft cross-project guard: a foreign project's claude that somehow
343
+ // carries our CCVIEWER_PORT must not re-bind THIS project's writer. Only
344
+ // enforced when both sides are known (workspace mode may leave
345
+ // _projectName empty until bound).
346
+ if (cwd && typeof cwd === 'string' && _projectName) {
347
+ const cwdProject = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
348
+ if (cwdProject !== _projectName) {
349
+ console.warn(`[ccv session-start] resume signal from project "${cwdProject}" ignored (bound to "${_projectName}")`);
350
+ return;
351
+ }
352
+ }
353
+ const transcriptUuid = basename(transcriptPath, '.jsonl');
354
+ _v2Writer.beginResumeSwitch({
355
+ transcriptUuid,
356
+ hookSid: (typeof sessionId === 'string' && sessionId) ? sessionId : null,
357
+ });
358
+ } catch (err) {
359
+ reportSwallowed('session-start.mark', err);
360
+ }
361
+ }
362
+
319
363
  // P2 (-c migration guidance): three detection channels, any one marks this
320
364
  // launch as "continuing an older conversation" — the migrate prompt then
321
365
  // re-prompts even a dismissed user, because the conversation's first half
@@ -78,7 +78,7 @@ function _looksStaleManagedCommand(cmd) {
78
78
 
79
79
  function _purgeStaleManagedHooks(settings) {
80
80
  let removed = 0;
81
- for (const sectionKey of ['PreToolUse', 'Stop']) {
81
+ for (const sectionKey of ['PreToolUse', 'Stop', 'SessionStart']) {
82
82
  const arr = settings.hooks?.[sectionKey];
83
83
  if (!Array.isArray(arr)) continue;
84
84
  for (let i = arr.length - 1; i >= 0; i--) {
@@ -99,7 +99,7 @@ function _purgeStaleManagedHooks(settings) {
99
99
  // 调用方负责 atomic write-back。返回 removed 数量。
100
100
  export function removeAllManagedHooks(settings) {
101
101
  let removed = 0;
102
- for (const sectionKey of ['PreToolUse', 'Stop']) {
102
+ for (const sectionKey of ['PreToolUse', 'Stop', 'SessionStart']) {
103
103
  const arr = settings.hooks?.[sectionKey];
104
104
  if (!Array.isArray(arr)) continue;
105
105
  for (let i = arr.length - 1; i >= 0; i--) {
@@ -129,6 +129,7 @@ export function ensureHooks() {
129
129
  if (!settings.hooks) settings.hooks = {};
130
130
  if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];
131
131
  if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
132
+ if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
132
133
 
133
134
  let changed = false;
134
135
  // 先一次性清掉「path 已 stale 但还带 cc-viewer-managed marker」的老条目,
@@ -214,6 +215,31 @@ export function ensureHooks() {
214
215
  changed = true;
215
216
  }
216
217
 
218
+ // SessionStart hook → session-start-bridge.js. Fires on startup/resume/
219
+ // clear/compact; no matcher (= all sources) — the bridge forwards `source`
220
+ // and the server gates on it. The `resume` source is the conversation-
221
+ // switch signal the V2Writer needs to re-bind routing after an in-terminal
222
+ // /resume (the wire session_id may not change, so no wire-level signal
223
+ // exists). Same CCVIEWER_PORT guard pattern as the other bridges.
224
+ const sessionStartBridgePath = resolve(SERVER_LIB, 'session-start-bridge.js');
225
+ const sessionStartCmd = `[ -n "$CCVIEWER_PORT" ] && node "${sessionStartBridgePath}" || true ${CCV_HOOK_MARKER}`;
226
+ const sessionStartDesired = _buildHookObj(sessionStartCmd);
227
+ const sessionStartExisting = settings.hooks.SessionStart.find(h => {
228
+ const cmd = h.hooks?.[0]?.command || '';
229
+ return cmd.includes('session-start-bridge.js');
230
+ });
231
+ if (sessionStartExisting) {
232
+ if (!_hookObjEqual(sessionStartExisting.hooks?.[0], sessionStartDesired)) {
233
+ sessionStartExisting.hooks = [_mergeHookObj(sessionStartExisting.hooks?.[0], sessionStartDesired)];
234
+ changed = true;
235
+ }
236
+ } else {
237
+ settings.hooks.SessionStart.push({
238
+ hooks: [sessionStartDesired],
239
+ });
240
+ changed = true;
241
+ }
242
+
217
243
  if (changed) {
218
244
  mkdirSync(claudeDir, { recursive: true });
219
245
  // Atomic write(): write to a sibling temp file then rename. Concurrent
@@ -92,7 +92,12 @@ function _safeSseWrite(clients, client, payload) {
92
92
  return true;
93
93
  }
94
94
 
95
+ // Zero-client short-circuit (all four senders): the payload template is built
96
+ // BEFORE the client loop, so with no SSE consumer connected every broadcast
97
+ // still paid a full JSON.stringify — for reconstructed entries carrying
98
+ // cumulative messages that is a giant allocation for nothing.
95
99
  export function sendToClients(clients, entry) {
100
+ if (clients.length === 0) return;
96
101
  const payload = `data: ${JSON.stringify(entry)}\n\n`;
97
102
  for (let i = clients.length - 1; i >= 0; i--) {
98
103
  _safeSseWrite(clients, clients[i], payload);
@@ -100,6 +105,7 @@ export function sendToClients(clients, entry) {
100
105
  }
101
106
 
102
107
  export function sendEventToClients(clients, eventName, data) {
108
+ if (clients.length === 0) return;
103
109
  const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
104
110
  for (let i = clients.length - 1; i >= 0; i--) {
105
111
  _safeSseWrite(clients, clients[i], payload);
@@ -109,6 +115,7 @@ export function sendEventToClients(clients, eventName, data) {
109
115
  /** Like sendEventToClients but `json` is an ALREADY-serialized JSON string —
110
116
  * wire v3 forwards raw stored lines verbatim without a parse/stringify trip. */
111
117
  export function sendEventRawToClients(clients, eventName, json) {
118
+ if (clients.length === 0) return;
112
119
  const payload = `event: ${eventName}\ndata: ${json}\n\n`;
113
120
  for (let i = clients.length - 1; i >= 0; i--) {
114
121
  _safeSseWrite(clients, clients[i], payload);
@@ -116,6 +123,7 @@ export function sendEventRawToClients(clients, eventName, json) {
116
123
  }
117
124
 
118
125
  export function sendChunkToClients(clients, dataJson) {
126
+ if (clients.length === 0) return;
119
127
  const payload = `event: load_chunk\ndata: ${dataJson}\n\n`;
120
128
  for (let i = clients.length - 1; i >= 0; i--) {
121
129
  _safeSseWrite(clients, clients[i], payload);
@@ -0,0 +1,15 @@
1
+ // Process-liveness probe shared by pid-validity claims (session-owner.js).
2
+ // Deliberately dependency-free: im-lock.js's identical helper drags findcc.js
3
+ // (LOG_DIR env resolution, a top-level node:worker_threads threadId import)
4
+ // into every importer's module graph — the stats worker's loader shim has no
5
+ // threadId export, and the v2 writer's synchronous ingest path doesn't want
6
+ // node:http along for the ride. im-lock.js / async-file-lock.js keep their
7
+ // local copies (same semantics); consolidation is backlog.
8
+
9
+ /** Is `pid` alive? Signal 0 probes without delivering; EPERM means the
10
+ * process exists but belongs to another user (still alive). */
11
+ export function isPidAlive(pid) {
12
+ if (!Number.isInteger(pid) || pid <= 0) return false;
13
+ try { process.kill(pid, 0); return true; }
14
+ catch (e) { return e && e.code === 'EPERM'; }
15
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-start-bridge.js — SessionStart hook bridge for conversation-switch
4
+ * signals (in-terminal /resume, and future /clear / compact interest).
5
+ *
6
+ * Called by Claude Code whenever a session (re)starts: source is one of
7
+ * "startup" | "resume" | "clear" | "compact". The one that matters today is
8
+ * `resume` — an in-terminal /resume switches the running process to a PAST
9
+ * conversation while the wire session_id may stay the same, so without this
10
+ * signal cc-viewer keeps writing the resumed conversation into the OLD
11
+ * session dir and the [对话] panel never switches. The bridge forwards the
12
+ * hook payload to cc-viewer's /api/session-start-notify; the server gates on
13
+ * source and tells the V2Writer to re-bind routing (see v2-writer.js
14
+ * beginResumeSwitch).
15
+ *
16
+ * Hook config in ~/.claude/settings.json (injected by ensure-hooks.js, tagged
17
+ * `# cc-viewer-managed`):
18
+ * "hooks": {
19
+ * "SessionStart": [{ "hooks": [{ "type": "command",
20
+ * "command": "[ -n \"$CCVIEWER_PORT\" ] && node /path/to/session-start-bridge.js || true # cc-viewer-managed" }] }]
21
+ * }
22
+ *
23
+ * Output contract (same as turn-end-bridge.js): NOTHING on stdout — Claude
24
+ * Code interprets SessionStart hook stdout as context-injection JSON
25
+ * (hookSpecificOutput.additionalContext), and any stray bytes would pollute
26
+ * the conversation. Optional stderr only when CCVIEWER_DEBUG=1. Always exit 0
27
+ * so a failed notify never blocks Claude Code's hook chain.
28
+ */
29
+
30
+ import { readFileSync } from 'node:fs';
31
+ import http from 'node:http';
32
+ import https from 'node:https';
33
+
34
+ const debug = (msg) => {
35
+ if (process.env.CCVIEWER_DEBUG === '1') {
36
+ try { process.stderr.write(`[session-start-bridge] ${msg}\n`); } catch { /* ignore */ }
37
+ }
38
+ };
39
+
40
+ const port = process.env.CCVIEWER_PORT;
41
+ const rawProtocol = process.env.CCVIEWER_PROTOCOL;
42
+ const isHttps = rawProtocol === 'https';
43
+ const httpClient = isHttps ? https : http;
44
+
45
+ // cc-viewer not running — exit silently (stdout must stay clean, see header).
46
+ if (!port) {
47
+ debug('CCVIEWER_PORT unset — exit silently');
48
+ process.exit(0);
49
+ }
50
+
51
+ // Drain stdin best-effort. Claude Code passes the hook JSON payload
52
+ // ({session_id, transcript_path, source, cwd, ...}); capped to 64 KB to
53
+ // defang any malformed huge payload.
54
+ let stdinData = '';
55
+ try {
56
+ const buf = readFileSync(0);
57
+ stdinData = (buf.length > 64 * 1024 ? buf.slice(0, 64 * 1024) : buf).toString('utf-8');
58
+ } catch { /* stdin may not be piped — fine, still notify */ }
59
+ let sessionId = null;
60
+ let transcriptPath = null;
61
+ let source = null;
62
+ let cwd = null;
63
+ try {
64
+ const parsed = JSON.parse(stdinData);
65
+ sessionId = parsed?.session_id || null;
66
+ transcriptPath = parsed?.transcript_path || null;
67
+ source = parsed?.source || null;
68
+ cwd = parsed?.cwd || null;
69
+ } catch { /* fine — the server tolerates missing fields */ }
70
+ debug(`payload source=${source} session_id=${sessionId} transcript=${transcriptPath}`);
71
+
72
+ const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
73
+ const body = JSON.stringify({ source, sessionId, transcriptPath, cwd, ts: Date.now() });
74
+ const reqOpts = {
75
+ hostname: '127.0.0.1',
76
+ port: parseInt(port, 10),
77
+ path: '/api/session-start-notify',
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ 'Content-Length': Buffer.byteLength(body),
82
+ // Same anti-spoof header as turn-end-bridge: matched against the server's
83
+ // per-startup INTERNAL_TOKEN, env-leaked only to the claude child.
84
+ ...(internalToken ? { 'X-CCViewer-Internal': internalToken } : {}),
85
+ },
86
+ // Keep the timeout snappy so a stale cc-viewer never blocks the Claude Code
87
+ // hook chain for a noticeable beat.
88
+ timeout: 500,
89
+ };
90
+ if (isHttps) {
91
+ // Loopback HTTPS is typically self-signed; validation would reject.
92
+ reqOpts.rejectUnauthorized = false;
93
+ }
94
+
95
+ let exited = false;
96
+ const finish = (reason) => {
97
+ if (exited) return;
98
+ exited = true;
99
+ if (reason) debug(reason);
100
+ process.exit(0);
101
+ };
102
+
103
+ let req;
104
+ try {
105
+ req = httpClient.request(reqOpts, (res) => {
106
+ res.resume();
107
+ res.on('end', () => finish(`POST done (status=${res.statusCode})`));
108
+ });
109
+ req.on('error', (err) => finish(`POST error: ${err?.message}`));
110
+ req.on('timeout', () => { try { req.destroy(); } catch { /* ignore */ } finish('POST timeout'); });
111
+ // Wrap the synchronous write/end so an immediate EPIPE never bubbles into
112
+ // Claude Code's transcript (same defensive shape as turn-end-bridge).
113
+ try {
114
+ req.write(body);
115
+ req.end();
116
+ } catch (err) {
117
+ finish(`req.write threw: ${err?.message}`);
118
+ }
119
+ } catch (err) {
120
+ finish(`request() threw: ${err?.message}`);
121
+ }
@@ -0,0 +1,170 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ // Settings files are tiny in practice; this caps a hostile `--settings /dev/zero`
5
+ // (a never-EOF read that would hang the server event loop) or a huge-file read.
6
+ const MAX_SETTINGS_FILE_BYTES = 1024 * 1024;
7
+
8
+ // Merges a user-supplied `--settings` launch arg into cc-viewer's injected settings
9
+ // object, so the final claude argv carries a SINGLE `--settings` flag.
10
+ //
11
+ // Why: claude's argv parser is last-wins for duplicate `--settings` (empirically
12
+ // verified on 2.1.212 — not documented). cc-viewer prepends its injected flag before
13
+ // user args, so a user-supplied `--settings` used to silently clobber the injected
14
+ // env.ANTHROPIC_BASE_URL proxy override (breaking capture) and the CCV_IM_DENY
15
+ // permissions.deny hardening. Merging keeps both: injected keys win, everything
16
+ // else the user set rides along.
17
+ //
18
+ // Parse rules pinned by experiments against claude 2.1.212:
19
+ // - Tokens after a literal `--` are prompt text, never flags → scan stops there.
20
+ // - `--settings <next>` consumes the next token as its value unconditionally, even
21
+ // an option-like one (`--settings --print` → claude reads "--print" as a settings
22
+ // path and hard-errors). The helper mirrors that consumption.
23
+ // - A trailing valueless `--settings` makes claude exit with "argument missing" →
24
+ // left in place so claude surfaces its own usage error, exactly as pre-fix.
25
+ // - An empty `--settings=` does NOT error but still clobbers earlier `--settings`
26
+ // flags via last-wins (the most dangerous form) → always stripped.
27
+ //
28
+ // Known accepted limitation (shared with withDefaultThinkingDisplay / hasArg): a
29
+ // literal "--settings" appearing before `--` as ANOTHER value-flag's argument
30
+ // (e.g. `--append-system-prompt "--settings"`) is indistinguishable without a full
31
+ // claude flag table. Probability negligible; bounded by the stop-at-`--` rule.
32
+
33
+ const FLAG = '--settings';
34
+
35
+ function stripBom(s) {
36
+ return s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
37
+ }
38
+
39
+ function isPlainObject(v) {
40
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
41
+ }
42
+
43
+ // Remove C0 control chars (and DEL) so a value/reason echoed into the embedded xterm
44
+ // (via emitSpawnNotice) or a log line cannot inject ANSI escape sequences — the raw
45
+ // user value and a JSON.parse SyntaxError can both carry ESC bytes.
46
+ function stripControls(s) {
47
+ return String(s).replace(/[\u0000-\u001F\u007F]/g, '');
48
+ }
49
+
50
+ // Read + parse a user-pointed settings file DEFENSIVELY. The failure reason is echoed
51
+ // into a client-readable surface (the persistent terminal buffer + logs), so it must
52
+ // never disclose file contents or resolved paths — a raw readFileSync/JSON.parse error
53
+ // embeds the leading file bytes and the absolute path, turning the warning into a
54
+ // file-probing oracle. All failures collapse to generic, content-free messages; a
55
+ // statSync isFile()+size gate blocks /dev/zero-style hangs and oversized reads.
56
+ function readSettingsFile(path) {
57
+ let st;
58
+ try { st = statSync(path); } catch { throw new Error('settings file not found'); }
59
+ if (!st.isFile()) throw new Error('settings path is not a regular file');
60
+ if (st.size > MAX_SETTINGS_FILE_BYTES) throw new Error('settings file too large');
61
+ let raw;
62
+ try { raw = stripBom(readFileSync(path, 'utf8')); } catch { throw new Error('settings file could not be read'); }
63
+ let parsed;
64
+ try { parsed = JSON.parse(raw); } catch { throw new Error('settings file is not valid JSON'); }
65
+ if (!isPlainObject(parsed)) throw new Error('settings file must be a JSON object');
66
+ return parsed;
67
+ }
68
+
69
+ // Load a --settings value the way claude does: inline JSON when it looks like an
70
+ // object literal, otherwise a settings file path (resolved against the cwd claude
71
+ // itself will run with). Throws on any failure — caller turns that into a warning.
72
+ function loadSettingsValue(value, cwd) {
73
+ const trimmed = stripBom(value).trim();
74
+ if (trimmed === '') throw new Error('empty value');
75
+ if (trimmed.startsWith('{')) {
76
+ // Inline JSON is the user's own text; echoing it back on failure is not disclosure
77
+ // (control chars are stripped at the warning surface). File contents are different —
78
+ // handled by readSettingsFile, which never surfaces them.
79
+ const parsed = JSON.parse(trimmed);
80
+ if (!isPlainObject(parsed)) throw new Error('settings must be a JSON object');
81
+ return parsed;
82
+ }
83
+ return readSettingsFile(resolve(cwd, trimmed));
84
+ }
85
+
86
+ // Shallow top-level merge with two special cases; injected keys win. Deep-merging
87
+ // other keys (hooks, statusLine, ...) would splice into structures the user owns.
88
+ function mergeSettings(userSettings, injectedSettings) {
89
+ const merged = { ...userSettings };
90
+ for (const [key, injectedValue] of Object.entries(injectedSettings)) {
91
+ if (key === 'env' && isPlainObject(merged.env)) {
92
+ merged.env = { ...merged.env, ...injectedValue };
93
+ } else if (key === 'permissions' && isPlainObject(merged.permissions)) {
94
+ const userDeny = Array.isArray(merged.permissions.deny) ? merged.permissions.deny : [];
95
+ const injectedDeny = Array.isArray(injectedValue.deny) ? injectedValue.deny : [];
96
+ merged.permissions = {
97
+ ...merged.permissions,
98
+ ...injectedValue,
99
+ deny: [...new Set([...userDeny, ...injectedDeny])],
100
+ };
101
+ } else {
102
+ merged[key] = injectedValue;
103
+ }
104
+ }
105
+ return merged;
106
+ }
107
+
108
+ /**
109
+ * Extract any user `--settings` occurrences from launch args and merge the last
110
+ * one into the injected settings object (injected keys win; permissions.deny is
111
+ * unioned). Never throws — a value that cannot be loaded is dropped and reported
112
+ * via `warningDetail` so the caller can surface it (injection must never block spawn).
113
+ *
114
+ * @param {unknown} args launch args (user-controlled; may contain non-strings)
115
+ * @param {object} injectedSettings cc-viewer's settings object (env.ANTHROPIC_BASE_URL, optional permissions.deny)
116
+ * @param {{cwd?: string}} [opts] base dir for relative settings file paths — must match the cwd claude runs with
117
+ * @returns {{args: unknown[], settingsJson: string, merged: boolean, warningDetail: {value: string, reason: string}|null}}
118
+ * args: input args with consumed `--settings` occurrences removed;
119
+ * settingsJson: JSON for the single injected flag (byte-identical to
120
+ * JSON.stringify(injectedSettings) when nothing merged);
121
+ * merged: whether a user value was folded in; warningDetail: load-failure parts
122
+ * ({value, reason}, control-chars stripped) for the caller to render/localize —
123
+ * the English sentence lives only in the `cli.settingsMergeFailed` i18n key, so
124
+ * there is no duplicated copy here to drift.
125
+ */
126
+ export function mergeSettingsIntoArgs(args, injectedSettings, { cwd = process.cwd() } = {}) {
127
+ const passthrough = { merged: false, warningDetail: null, settingsJson: JSON.stringify(injectedSettings) };
128
+ if (!Array.isArray(args)) return { args: [], ...passthrough };
129
+
130
+ const cleaned = [];
131
+ let lastValue = null; // last consumed --settings value (claude is last-wins)
132
+ for (let i = 0; i < args.length; i++) {
133
+ const token = args[i];
134
+ if (typeof token !== 'string') { cleaned.push(token); continue; }
135
+ if (token === '--') { cleaned.push(...args.slice(i)); break; }
136
+ if (token.startsWith(FLAG + '=')) {
137
+ lastValue = token.slice(FLAG.length + 1); // may be '' — still stripped (empty form clobbers via last-wins)
138
+ continue;
139
+ }
140
+ if (token === FLAG) {
141
+ if (i + 1 >= args.length) { cleaned.push(token); continue; } // trailing valueless: claude errors "argument missing" itself
142
+ lastValue = args[i + 1];
143
+ i++;
144
+ continue;
145
+ }
146
+ cleaned.push(token);
147
+ }
148
+
149
+ if (lastValue === null) return { args: cleaned, ...passthrough };
150
+
151
+ try {
152
+ if (typeof lastValue !== 'string') throw new Error('value is not a string');
153
+ const userSettings = loadSettingsValue(lastValue, cwd);
154
+ return {
155
+ args: cleaned,
156
+ settingsJson: JSON.stringify(mergeSettings(userSettings, injectedSettings)),
157
+ merged: true,
158
+ warningDetail: null,
159
+ };
160
+ } catch (err) {
161
+ const shown = typeof lastValue === 'string' ? lastValue : String(lastValue);
162
+ // Strip control chars: `shown` is raw user input and `err.message` from an inline
163
+ // JSON.parse can echo user bytes — both flow into the terminal buffer / logs.
164
+ return {
165
+ args: cleaned,
166
+ ...passthrough,
167
+ warningDetail: { value: stripControls(shown), reason: stripControls(err?.message || err) },
168
+ };
169
+ }
170
+ }