cc-viewer 1.2.2 → 1.2.5

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/README.md CHANGED
@@ -6,19 +6,38 @@ A request monitoring system for Claude Code that captures and visualizes all API
6
6
 
7
7
  ## Usage
8
8
 
9
+ ### Installation
10
+
9
11
  ```bash
10
12
  npm install -g cc-viewer
11
13
  ```
12
14
 
13
- After installation, run:
15
+ ### Run & Auto-Configuration
14
16
 
15
17
  ```bash
16
18
  ccv
17
19
  ```
18
20
 
19
- Then use Claude Code as usual and open `http://localhost:7008` in your browser to view the monitoring interface.
21
+ This command automatically detects your Claude Code installation method (NPM or Native Install) and configures itself.
22
+
23
+ - **NPM Install**: Injects interceptor script into `cli.js` of Claude Code.
24
+ - **Native Install**: Detects `claude` binary, sets up a local transparent proxy, and configures a Zsh Shell Hook to route traffic through the proxy automatically.
25
+
26
+ ### Configuration Override
27
+
28
+ If you need to use a custom API endpoint (e.g., corporate proxy), simply configure it in `~/.claude/settings.json` or set `ANTHROPIC_BASE_URL` environment variable. `ccv` will respect these settings and forward requests correctly.
29
+
30
+ ### Silent Mode
31
+
32
+ By default, `ccv` runs in silent mode when wrapping `claude`, ensuring your terminal output remains clean and identical to the original Claude Code experience. All logs are captured in the background and visible at `http://localhost:7008`.
20
33
 
21
- After Claude Code updates, no manual action is needed — the next time you run `claude`, it will automatically detect and reconfigure.
34
+ Once configured, use `claude` as usual. Open `http://localhost:7008` to view the monitoring interface.
35
+
36
+ ### Troubleshooting
37
+
38
+ - **Mixed Output**: If you see `[CC-Viewer]` debug logs mixed with Claude's output, please update to the latest version (`npm install -g cc-viewer`).
39
+ - **Connection Refused**: Ensure `ccv` background process is running. Running `ccv` or `claude` (after hook installation) should start it automatically.
40
+ - **Empty Body**: If you see "No Body" in the viewer, it might be due to non-standard SSE formats. The viewer now attempts to capture raw content as a fallback.
22
41
 
23
42
  ### Uninstall
24
43
 
@@ -26,6 +45,12 @@ After Claude Code updates, no manual action is needed — the next time you run
26
45
  ccv --uninstall
27
46
  ```
28
47
 
48
+ ### Check Version
49
+
50
+ ```bash
51
+ ccv --version
52
+ ```
53
+
29
54
  ## Features
30
55
 
31
56
  ### Request Monitoring (Raw Mode)
package/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
- import { resolve } from 'node:path';
4
+ import { resolve, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { homedir } from 'node:os';
7
+ import { spawn, execSync } from 'node:child_process';
7
8
  import { t } from './i18n.js';
8
9
 
9
10
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -17,7 +18,41 @@ const INJECT_BLOCK = `${INJECT_START}\n${INJECT_IMPORT}\n${INJECT_END}`;
17
18
  const SHELL_HOOK_START = '# >>> CC-Viewer Auto-Inject >>>';
18
19
  const SHELL_HOOK_END = '# <<< CC-Viewer Auto-Inject <<<';
19
20
 
20
- const cliPath = resolve(__dirname, '../@anthropic-ai/claude-code/cli.js');
21
+ // Claude Code cli.js 包名候选列表,按优先级排列
22
+ const CLAUDE_CODE_PACKAGES = [
23
+ '@anthropic-ai/claude-code',
24
+ '@ali/claude-code',
25
+ ];
26
+
27
+ function getGlobalNodeModulesDir() {
28
+ try {
29
+ return execSync('npm root -g', { encoding: 'utf-8' }).trim();
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function resolveClaudeCodeCliPath() {
36
+ // 候选基础目录:__dirname 的上级(适用于常规 npm 安装)+ 全局 node_modules(适用于符号链接安装)
37
+ const baseDirs = [resolve(__dirname, '..')];
38
+ const globalRoot = getGlobalNodeModulesDir();
39
+ if (globalRoot && globalRoot !== resolve(__dirname, '..')) {
40
+ baseDirs.push(globalRoot);
41
+ }
42
+
43
+ for (const baseDir of baseDirs) {
44
+ for (const packageName of CLAUDE_CODE_PACKAGES) {
45
+ const candidate = join(baseDir, packageName, 'cli.js');
46
+ if (existsSync(candidate)) {
47
+ return candidate;
48
+ }
49
+ }
50
+ }
51
+ // 兜底:返回全局目录下的默认路径,便于错误提示
52
+ return join(globalRoot || resolve(__dirname, '..'), CLAUDE_CODE_PACKAGES[0], 'cli.js');
53
+ }
54
+
55
+ const cliPath = resolveClaudeCodeCliPath();
21
56
 
22
57
  function getShellConfigPath() {
23
58
  const shell = process.env.SHELL || '';
@@ -30,11 +65,31 @@ function getShellConfigPath() {
30
65
  return resolve(homedir(), '.zshrc');
31
66
  }
32
67
 
33
- function buildShellHook() {
68
+ function buildShellHook(isNative) {
69
+ if (isNative) {
70
+ return `${SHELL_HOOK_START}
71
+ claude() {
72
+ # Avoid recursion if ccv invokes claude
73
+ if [ "$1" = "--ccv-internal" ]; then
74
+ shift
75
+ command claude "$@"
76
+ return
77
+ fi
78
+ ccv run -- claude --ccv-internal "$@"
79
+ }
80
+ ${SHELL_HOOK_END}`;
81
+ }
82
+
34
83
  return `${SHELL_HOOK_START}
35
84
  claude() {
36
- local cli_js="$HOME/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"
37
- if [ -f "$cli_js" ] && ! grep -q "CC Viewer" "$cli_js" 2>/dev/null; then
85
+ local cli_js=""
86
+ for candidate in "$HOME/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js" "$HOME/.npm-global/lib/node_modules/@ali/claude-code/cli.js"; do
87
+ if [ -f "$candidate" ]; then
88
+ cli_js="$candidate"
89
+ break
90
+ fi
91
+ done
92
+ if [ -n "$cli_js" ] && ! grep -q "CC Viewer" "$cli_js" 2>/dev/null; then
38
93
  ccv 2>/dev/null
39
94
  fi
40
95
  command claude "$@"
@@ -42,14 +97,23 @@ claude() {
42
97
  ${SHELL_HOOK_END}`;
43
98
  }
44
99
 
45
- function installShellHook() {
100
+ function installShellHook(isNative) {
46
101
  const configPath = getShellConfigPath();
47
102
  try {
48
- const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '';
103
+ let content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '';
104
+
49
105
  if (content.includes(SHELL_HOOK_START)) {
50
- return { path: configPath, status: 'exists' };
106
+ // Check if existing hook matches desired mode
107
+ const isNativeHook = content.includes('ccv run -- claude');
108
+ if (!!isNative === !!isNativeHook) {
109
+ return { path: configPath, status: 'exists' };
110
+ }
111
+ // Mismatch: remove old hook first
112
+ removeShellHook();
113
+ content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '';
51
114
  }
52
- const hook = buildShellHook();
115
+
116
+ const hook = buildShellHook(isNative);
53
117
  const newContent = content.endsWith('\n') ? content + '\n' + hook + '\n' : content + '\n\n' + hook + '\n';
54
118
  writeFileSync(configPath, newContent);
55
119
  return { path: configPath, status: 'installed' };
@@ -97,25 +161,160 @@ function removeCliJsInjection() {
97
161
  }
98
162
  }
99
163
 
164
+ function getNativeInstallPath() {
165
+ // 1. 尝试 which/command -v(继承当前 process.env PATH)
166
+ for (const cmd of ['which claude', 'command -v claude']) {
167
+ try {
168
+ const result = execSync(cmd, { encoding: 'utf-8', shell: true, env: process.env }).trim();
169
+ // 排除 shell function 的输出(多行说明不是路径)
170
+ if (result && !result.includes('\n') && existsSync(result)) {
171
+ return result;
172
+ }
173
+ } catch {
174
+ // ignore
175
+ }
176
+ }
177
+
178
+ // 2. 检查常见 native 安装路径
179
+ const home = homedir();
180
+ const candidates = [
181
+ join(home, '.claude', 'local', 'claude'),
182
+ '/usr/local/bin/claude',
183
+ join(home, '.local', 'bin', 'claude'),
184
+ '/opt/homebrew/bin/claude',
185
+ ];
186
+ for (const p of candidates) {
187
+ if (existsSync(p)) {
188
+ return p;
189
+ }
190
+ }
191
+
192
+ return null;
193
+ }
194
+
195
+ async function runProxyCommand(args) {
196
+ try {
197
+ // Dynamic import to avoid side effects when just installing
198
+ const { startProxy } = await import('./proxy.js');
199
+ const proxyPort = await startProxy();
200
+
201
+ // args = ['run', '--', 'command', 'claude', ...] or ['run', 'claude', ...]
202
+ // Our hook uses: ccv run -- claude --ccv-internal "$@"
203
+ // args[0] is 'run'.
204
+ // If args[1] is '--', then command starts at args[2].
205
+
206
+ let cmdStartIndex = 1;
207
+ if (args[1] === '--') {
208
+ cmdStartIndex = 2;
209
+ }
210
+
211
+ let cmd = args[cmdStartIndex];
212
+ if (!cmd) {
213
+ console.error('No command provided to run.');
214
+ process.exit(1);
215
+ }
216
+ let cmdArgs = args.slice(cmdStartIndex + 1);
217
+
218
+ // If cmd is 'claude' and next arg is '--ccv-internal', remove it
219
+ // and we must use 'command claude' to avoid infinite recursion of the shell function?
220
+ // Node spawn doesn't use shell functions, so 'claude' should resolve to the binary in PATH.
221
+ // BUT, if 'claude' is a function in the current shell, spawn won't see it unless we use shell:true.
222
+ // We are using shell:false (default).
223
+ // So spawn('claude') should find /usr/local/bin/claude (the binary).
224
+ // The issue might be that ccv itself is running in a way that PATH is weird?
225
+
226
+ // Wait, the shell hook adds '--ccv-internal'. We should strip it before spawning.
227
+ if (cmdArgs[0] === '--ccv-internal') {
228
+ cmdArgs.shift();
229
+ }
230
+
231
+ const env = { ...process.env };
232
+ // [Debug] Verify hook execution
233
+ // console.error(`[CC-Viewer] Hook triggered for: ${cmd} ${cmdArgs.join(' ')}`);
234
+
235
+ // Determine the path to the native 'claude' executable
236
+ if (cmd === 'claude') {
237
+ const nativePath = getNativeInstallPath();
238
+ if (nativePath) {
239
+ cmd = nativePath;
240
+ }
241
+ }
242
+ env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${proxyPort}`;
243
+
244
+ // [Debug] Force ANTHROPIC_BASE_URL via process.env is not enough for some reason?
245
+ // Let's also check if we can pass it via command line args if supported, but claude cli doesn't seem to have a --base-url arg documented.
246
+ // However, maybe the issue is that 'env' in spawn options isn't overriding what claude internal config has?
247
+ // Claude Code likely reads from ~/.claude/settings.json which might take precedence over env vars?
248
+ // No, usually env vars take precedence.
249
+
250
+ // [Fix] Check if user has ANTHROPIC_BASE_URL in settings.json and use it in proxy.js
251
+ // We already do that in proxy.js: getOriginalBaseUrl().
252
+
253
+ // [Crucial Fix]
254
+ // Use --settings JSON argument to force ANTHROPIC_BASE_URL configuration
255
+ // This is safer and more reliable than env vars which might be ignored.
256
+
257
+ // console.error(`[CC-Viewer] Setting ANTHROPIC_BASE_URL to ${env.ANTHROPIC_BASE_URL}`);
258
+
259
+ // Construct settings JSON string
260
+ // Note: We need to be careful with quoting for the shell/spawn.
261
+ // Since we use spawn without shell, we can pass the JSON string directly as an argument.
262
+ const settingsJson = JSON.stringify({
263
+ env: {
264
+ ANTHROPIC_BASE_URL: env.ANTHROPIC_BASE_URL
265
+ }
266
+ });
267
+
268
+ // Inject --settings argument
269
+ // We put it at the beginning of args to ensure it's picked up
270
+ cmdArgs.unshift(settingsJson);
271
+ cmdArgs.unshift('--settings');
272
+
273
+ // Force non-interactive if needed? No, we want interactive.
274
+
275
+ const child = spawn(cmd, cmdArgs, { stdio: 'inherit', env });
276
+
277
+ child.on('exit', (code) => {
278
+ process.exit(code);
279
+ });
280
+
281
+ child.on('error', (err) => {
282
+ console.error('Failed to start command:', err);
283
+ process.exit(1);
284
+ });
285
+ } catch (err) {
286
+ console.error('Proxy error:', err);
287
+ process.exit(1);
288
+ }
289
+ }
290
+
100
291
  // === 主逻辑 ===
101
292
 
102
- const isUninstall = process.argv.includes('--uninstall');
103
- const isVersion = process.argv.includes('--v') || process.argv.includes('--version');
293
+ const args = process.argv.slice(2);
294
+ const isUninstall = args.includes('--uninstall');
295
+ const isVersion = args.includes('--v') || args.includes('--version') || args.includes('-v');
104
296
 
105
297
  if (isVersion) {
106
- const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'));
107
- console.log(`cc-viewer v${pkg.version}`);
298
+ try {
299
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'));
300
+ console.log(`cc-viewer v${pkg.version}`);
301
+ } catch (e) {
302
+ console.error('Failed to read version:', e.message);
303
+ }
108
304
  process.exit(0);
109
305
  }
110
306
 
111
- if (isUninstall) {
307
+ if (args[0] === 'run') {
308
+ runProxyCommand(args);
309
+ } else if (args.includes('--uninstall')) {
112
310
  const cliResult = removeCliJsInjection();
113
311
  const shellResult = removeShellHook();
114
312
 
115
313
  if (cliResult === 'removed' || cliResult === 'clean') {
116
314
  console.log(t('cli.uninstall.cliCleaned'));
117
315
  } else if (cliResult === 'not_found') {
118
- console.log(t('cli.uninstall.cliNotFound'));
316
+ // console.log(t('cli.uninstall.cliNotFound'));
317
+ // Silent is better for mixed mode uninstall
119
318
  } else {
120
319
  console.log(t('cli.uninstall.cliFail'));
121
320
  }
@@ -130,36 +329,72 @@ if (isUninstall) {
130
329
 
131
330
  console.log(t('cli.uninstall.done'));
132
331
  process.exit(0);
133
- }
134
-
135
- // 正常安装流程
136
- try {
137
- const cliResult = injectCliJs();
138
- const shellResult = installShellHook();
139
-
140
- if (cliResult === 'exists' && shellResult.status === 'exists') {
141
- console.log(t('cli.alreadyWorking'));
332
+ } else {
333
+ // Installation Logic
334
+ let mode = 'unknown';
335
+ if (existsSync(cliPath)) {
336
+ mode = 'npm';
142
337
  } else {
143
- if (cliResult === 'exists') {
144
- console.log(t('cli.inject.exists'));
145
- } else {
146
- console.log(t('cli.inject.success'));
147
- }
148
-
149
- if (shellResult.status === 'installed') {
150
- console.log('All READY!');
151
- } else if (shellResult.status !== 'exists') {
152
- console.log(t('cli.hook.fail', { error: shellResult.error }));
338
+ const nativePath = getNativeInstallPath();
339
+ if (nativePath) {
340
+ mode = 'native';
153
341
  }
154
342
  }
155
343
 
156
- console.log(t('cli.usage.hint'));
157
- } catch (err) {
158
- if (err.code === 'ENOENT') {
344
+ if (mode === 'unknown') {
159
345
  console.error(t('cli.inject.notFound', { path: cliPath }));
160
- console.error(t('cli.inject.notFoundHint'));
346
+ console.error('Also could not find native "claude" command in PATH.');
347
+ console.error('Please make sure @anthropic-ai/claude-code is installed.');
348
+ process.exit(1);
349
+ }
350
+
351
+ if (mode === 'npm') {
352
+ try {
353
+ const cliResult = injectCliJs();
354
+ const shellResult = installShellHook(false);
355
+
356
+ if (cliResult === 'exists' && shellResult.status === 'exists') {
357
+ console.log(t('cli.alreadyWorking'));
358
+ } else {
359
+ if (cliResult === 'exists') {
360
+ console.log(t('cli.inject.exists'));
361
+ } else {
362
+ console.log(t('cli.inject.success'));
363
+ }
364
+
365
+ if (shellResult.status === 'installed') {
366
+ console.log('All READY!');
367
+ } else if (shellResult.status !== 'exists') {
368
+ console.log(t('cli.hook.fail', { error: shellResult.error }));
369
+ }
370
+ }
371
+ console.log(t('cli.usage.hint'));
372
+ } catch (err) {
373
+ if (err.code === 'ENOENT') {
374
+ console.error(t('cli.inject.notFound', { path: cliPath }));
375
+ console.error(t('cli.inject.notFoundHint'));
376
+ } else {
377
+ console.error(t('cli.inject.fail', { error: err.message }));
378
+ }
379
+ process.exit(1);
380
+ }
161
381
  } else {
162
- console.error(t('cli.inject.fail', { error: err.message }));
382
+ // Native Mode
383
+ try {
384
+ console.log('Detected Claude Code Native Install.');
385
+ const shellResult = installShellHook(true);
386
+
387
+ if (shellResult.status === 'exists') {
388
+ console.log(t('cli.alreadyWorking'));
389
+ } else if (shellResult.status === 'installed') {
390
+ console.log('Native Hook Installed! All READY!');
391
+ } else {
392
+ console.log(t('cli.hook.fail', { error: shellResult.error }));
393
+ }
394
+ console.log(t('cli.usage.hint'));
395
+ } catch (err) {
396
+ console.error('Failed to install native hook:', err);
397
+ process.exit(1);
398
+ }
163
399
  }
164
- process.exit(1);
165
400
  }
package/interceptor.js CHANGED
@@ -31,7 +31,7 @@ function generateNewLogFilePath() {
31
31
  try { cwd = process.cwd(); } catch { cwd = homedir(); }
32
32
  const projectName = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
33
33
  const dir = join(homedir(), '.claude', 'cc-viewer', projectName);
34
- try { mkdirSync(dir, { recursive: true }); } catch {}
34
+ try { mkdirSync(dir, { recursive: true }); } catch { }
35
35
  return { filePath: join(dir, `${projectName}_${ts}.jsonl`), dir, projectName };
36
36
  }
37
37
 
@@ -44,7 +44,7 @@ function findRecentLog(dir, projectName) {
44
44
  .reverse();
45
45
  if (files.length === 0) return null;
46
46
  return join(dir, files[0]);
47
- } catch {}
47
+ } catch { }
48
48
  return null;
49
49
  }
50
50
 
@@ -67,9 +67,9 @@ function cleanupTempFiles(dir, projectName) {
67
67
  } else {
68
68
  renameSync(tempPath, newPath);
69
69
  }
70
- } catch {}
70
+ } catch { }
71
71
  }
72
- } catch {}
72
+ } catch { }
73
73
  }
74
74
 
75
75
  // Resume 状态(供 server.js 使用)
@@ -128,7 +128,7 @@ const _initPromise = (async () => {
128
128
  tempFile,
129
129
  };
130
130
  }
131
- } catch {}
131
+ } catch { }
132
132
  })();
133
133
 
134
134
  export { LOG_FILE, _initPromise, _resumeState, _choicePromise, resolveResumeChoice, _projectName };
@@ -167,7 +167,7 @@ function migrateConversationContext(oldFile, newFile) {
167
167
  break;
168
168
  }
169
169
  }
170
- } catch {}
170
+ } catch { }
171
171
  }
172
172
 
173
173
  if (originIndex < 0) return; // 找不到起点,不迁移
@@ -180,7 +180,7 @@ function migrateConversationContext(oldFile, newFile) {
180
180
  if (isPreflightEntry(prev)) {
181
181
  migrationStart = originIndex - 1;
182
182
  }
183
- } catch {}
183
+ } catch { }
184
184
  }
185
185
 
186
186
  // 迁移条目写入新文件
@@ -194,7 +194,7 @@ function migrateConversationContext(oldFile, newFile) {
194
194
  } else {
195
195
  writeFileSync(oldFile, '');
196
196
  }
197
- } catch {}
197
+ } catch { }
198
198
  }
199
199
 
200
200
  function checkAndRotateLogFile() {
@@ -207,7 +207,7 @@ function checkAndRotateLogFile() {
207
207
  LOG_FILE = filePath;
208
208
  migrateConversationContext(oldFile, filePath);
209
209
  }
210
- } catch {}
210
+ } catch { }
211
211
  }
212
212
 
213
213
  // 从环境变量 ANTHROPIC_BASE_URL 提取域名用于请求匹配
@@ -217,7 +217,7 @@ function getBaseUrlHost() {
217
217
  if (baseUrl) {
218
218
  return new URL(baseUrl).hostname;
219
219
  }
220
- } catch {}
220
+ } catch { }
221
221
  return null;
222
222
  }
223
223
  const CUSTOM_API_HOST = getBaseUrlHost();
@@ -227,7 +227,7 @@ function isAnthropicApiPath(urlStr) {
227
227
  try {
228
228
  const pathname = new URL(urlStr).pathname;
229
229
  return /^\/v1\/messages(\/count_tokens|\/batches(\/.*)?)?$/.test(pathname)
230
- || /^\/api\/eval\/sdk-/.test(pathname);
230
+ || /^\/api\/eval\/sdk-/.test(pathname);
231
231
  } catch {
232
232
  return /\/v1\/messages/.test(urlStr);
233
233
  }
@@ -374,7 +374,7 @@ export function setupInterceptor() {
374
374
 
375
375
  const _originalFetch = globalThis.fetch;
376
376
 
377
- globalThis.fetch = async function(url, options) {
377
+ globalThis.fetch = async function (url, options) {
378
378
  // cc-viewer 内部请求(翻译等)直接透传,不拦截
379
379
  const internalHeader = options?.headers?.['x-cc-viewer-internal']
380
380
  || (options?.headers instanceof Headers && options.headers.get('x-cc-viewer-internal'));
@@ -387,7 +387,17 @@ export function setupInterceptor() {
387
387
 
388
388
  try {
389
389
  const urlStr = typeof url === 'string' ? url : url?.url || String(url);
390
- if (urlStr.includes('anthropic') || urlStr.includes('claude') || (CUSTOM_API_HOST && urlStr.includes(CUSTOM_API_HOST)) || isAnthropicApiPath(urlStr)) {
390
+ // 检查 headers 中是否包含 x-cc-viewer-trace 标记
391
+ const headers = options?.headers || {};
392
+ const isProxyTrace = headers['x-cc-viewer-trace'] === 'true' || headers['x-cc-viewer-trace'] === true;
393
+
394
+ // 如果是 proxy 转发的,或者符合 URL 规则
395
+ if (isProxyTrace || urlStr.includes('anthropic') || urlStr.includes('claude') || (CUSTOM_API_HOST && urlStr.includes(CUSTOM_API_HOST)) || isAnthropicApiPath(urlStr)) {
396
+ // 如果是 proxy 转发的,需要清理掉标记 header 避免发给上游
397
+ if (isProxyTrace && options?.headers) {
398
+ delete options.headers['x-cc-viewer-trace'];
399
+ }
400
+
391
401
  const timestamp = new Date().toISOString();
392
402
  let body = null;
393
403
  if (options?.body) {
@@ -463,7 +473,7 @@ export function setupInterceptor() {
463
473
  })()
464
474
  };
465
475
  }
466
- } catch {}
476
+ } catch { }
467
477
 
468
478
  // 用户新指令边界:检查日志文件大小,超过 500MB 则切换新文件
469
479
  if (requestEntry?.mainAgent) {
@@ -514,23 +524,28 @@ export function setupInterceptor() {
514
524
  .map(block => {
515
525
  // SSE 块可能包含多行: event: xxx\ndata: {...}
516
526
  const lines = block.split('\n');
517
- const dataLine = lines.find(l => l.startsWith('data: '));
527
+ const dataLine = lines.find(l => l.startsWith('data:'));
518
528
  if (dataLine) {
529
+ // 处理 "data:" 或 "data: " 两种格式
530
+ const jsonStr = dataLine.startsWith('data: ')
531
+ ? dataLine.substring(6)
532
+ : dataLine.substring(5);
519
533
  try {
520
- return JSON.parse(dataLine.substring(6));
534
+ return JSON.parse(jsonStr);
521
535
  } catch {
522
- return dataLine.substring(6);
536
+ return jsonStr;
523
537
  }
524
538
  }
525
539
  return null;
526
540
  })
527
541
  .filter(Boolean);
528
542
 
529
- // 组装完整的 message 对象
543
+ // 组装完整的 message 对象(GLM 使用标准格式,但 data: 后无空格)
530
544
  const assembledMessage = assembleStreamMessage(events);
531
545
 
532
546
  // 直接使用组装后的 message 对象作为 response.body
533
- requestEntry.response.body = assembledMessage;
547
+ // 如果组装失败(例如非标准 SSE),则使用原始流内容
548
+ requestEntry.response.body = assembledMessage || streamedContent;
534
549
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
535
550
  } catch (err) {
536
551
  requestEntry.response.body = streamedContent.slice(0, 1000);
@@ -599,4 +614,6 @@ export function setupInterceptor() {
599
614
  setupInterceptor();
600
615
 
601
616
  // 等待日志文件初始化完成后启动 Web Viewer 服务
602
- _initPromise.then(() => import('cc-viewer/server.js')).catch(() => {});
617
+ _initPromise.then(() => import('./server.js')).catch((err) => {
618
+ console.error('[CC-Viewer] Failed to start viewer server:', err);
619
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.2.2",
3
+ "version": "1.2.5",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -44,6 +44,7 @@
44
44
  "dist/",
45
45
  "server.js",
46
46
  "cli.js",
47
+ "proxy.js",
47
48
  "interceptor.js",
48
49
  "i18n.js",
49
50
  "locales/",
@@ -58,4 +59,4 @@
58
59
  "vite": "^6.3.5",
59
60
  "@vitejs/plugin-react": "^4.5.2"
60
61
  }
61
- }
62
+ }
package/proxy.js ADDED
@@ -0,0 +1,215 @@
1
+
2
+ import { createServer } from 'node:http';
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { setupInterceptor } from './interceptor.js';
7
+
8
+ // Setup interceptor to patch fetch
9
+ setupInterceptor();
10
+
11
+ function getOriginalBaseUrl() {
12
+ // 1. Check settings.json (Priority 1)
13
+ try {
14
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
15
+ if (existsSync(settingsPath)) {
16
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
17
+ if (settings.env && settings.env.ANTHROPIC_BASE_URL) {
18
+ return settings.env.ANTHROPIC_BASE_URL;
19
+ }
20
+ }
21
+ } catch (e) {
22
+ // ignore
23
+ }
24
+
25
+ // 2. Check env var (Priority 2)
26
+ if (process.env.ANTHROPIC_BASE_URL) {
27
+ return process.env.ANTHROPIC_BASE_URL;
28
+ }
29
+
30
+ // 3. Default
31
+ return 'https://api.anthropic.com';
32
+ }
33
+
34
+ export function startProxy() {
35
+ return new Promise((resolve, reject) => {
36
+ const server = createServer(async (req, res) => {
37
+ // [Debug] Log incoming request
38
+ // console.error(`[CC-Viewer Proxy] Received request: ${req.method} ${req.url}`);
39
+
40
+ // Handle CORS preflight if needed (though claude cli probably doesn't send OPTIONS)
41
+
42
+ const originalBaseUrl = getOriginalBaseUrl();
43
+ const targetUrl = new URL(req.url, originalBaseUrl);
44
+
45
+ // Use the patched fetch (which logs to cc-viewer)
46
+ try {
47
+ // Convert incoming headers
48
+ const headers = { ...req.headers };
49
+ delete headers.host; // Let fetch set the host
50
+
51
+ // [Fix] Handle compressed body
52
+ // If content-encoding is set (e.g. gzip), and we read the raw stream into a buffer,
53
+ // we are passing the compressed buffer as body.
54
+ // fetch will automatically add content-length, but might not handle content-encoding correctly if we just pass the buffer?
55
+ // Actually, fetch should handle it fine if we pass the headers.
56
+
57
+ // However, if we are reading the body here to pass it to fetch, we are buffering it.
58
+ // For large uploads this might be bad, but for text prompts it's fine.
59
+
60
+ // We need to read the body if any
61
+ const buffers = [];
62
+ for await (const chunk of req) {
63
+ buffers.push(chunk);
64
+ }
65
+ const body = Buffer.concat(buffers);
66
+
67
+ // [Debug] Log body size
68
+ // console.error(`[CC-Viewer Proxy] Request body size: ${body.length}`);
69
+
70
+ const fetchOptions = {
71
+ method: req.method,
72
+ headers: headers,
73
+ };
74
+
75
+ // 标记此请求为 CC-Viewer 代理转发的 Claude API 请求
76
+ // 拦截器识别到此 Header 会强制记录,忽略 URL 匹配规则
77
+ fetchOptions.headers['x-cc-viewer-trace'] = 'true';
78
+
79
+ if (body.length > 0) {
80
+ fetchOptions.body = body;
81
+ }
82
+
83
+ // [Crucial Fix]
84
+ // If originalBaseUrl is also a proxy or special endpoint, make sure we construct the full URL correctly.
85
+ // originalBaseUrl might end with /v1 or not.
86
+ // req.url from proxy server is usually just the path (e.g. /v1/messages) if client is configured with base_url=http://localhost:port
87
+ // So new URL(req.url, originalBaseUrl) should work.
88
+
89
+ // However, if originalBaseUrl already contains a path (e.g. /api/anthropic), and req.url is /v1/messages,
90
+ // new URL(req.url, originalBaseUrl) might treat req.url as absolute path if it starts with /, replacing the path in originalBaseUrl?
91
+ // Let's test: new URL('/v1/messages', 'https://example.com/api').toString() -> 'https://example.com/v1/messages' (path replaced!)
92
+
93
+ // This is why we get 404! The user's base URL is https://antchat.alipay.com/api/anthropic
94
+ // But our proxy constructs: https://antchat.alipay.com/v1/messages
95
+ // It lost the /api/anthropic part!
96
+
97
+ // We need to append req.url to the pathname of originalBaseUrl, carefully avoiding double slashes.
98
+
99
+ const originalUrlObj = new URL(originalBaseUrl);
100
+ // Ensure original pathname doesn't end with slash if we append
101
+ let basePath = originalUrlObj.pathname;
102
+ if (basePath.endsWith('/')) basePath = basePath.slice(0, -1);
103
+
104
+ // req.url starts with / usually
105
+ const reqPath = req.url.startsWith('/') ? req.url : '/' + req.url;
106
+
107
+ // Check if we should join them
108
+ // If req.url is /v1/messages, and base is /api/anthropic, we want /api/anthropic/v1/messages
109
+ originalUrlObj.pathname = basePath + reqPath;
110
+ // Search params are already in req.url? Yes, req.url includes query string in Node http server.
111
+ // Wait, new URL(req.url, base) parses query string correctly.
112
+ // But if we manually concat pathname, we need to handle query string separately.
113
+
114
+ // Let's do it simpler: use string concatenation for the full URL
115
+ // But we need to handle the origin correctly.
116
+
117
+ // Better approach:
118
+ // 1. Remove trailing slash from originalBaseUrl
119
+ const cleanBase = originalBaseUrl.endsWith('/') ? originalBaseUrl.slice(0, -1) : originalBaseUrl;
120
+ // 2. Remove leading slash from req.url
121
+ const cleanReq = req.url.startsWith('/') ? req.url.slice(1) : req.url;
122
+ // 3. Join
123
+ const fullUrl = `${cleanBase}/${cleanReq}`;
124
+
125
+ // [Debug] Proxying to
126
+ // console.error(`[CC-Viewer Proxy] Forwarding to: ${fullUrl}`);
127
+
128
+ const response = await fetch(fullUrl, fetchOptions);
129
+
130
+ // [Crucial Fix]
131
+ // Handle decompression manually if needed.
132
+ // Node's fetch automatically decompresses if 'compress: true' is default?
133
+ // Actually, fetch handles gzip/deflate by default.
134
+ // But if we pipe the response body to the client response (res), we need to be careful.
135
+ // The issue is likely that we are trying to read the body as text/json for logging,
136
+ // but it might be compressed or binary.
137
+
138
+ // Let's modify how we handle the response body.
139
+ // We need to:
140
+ // 1. Pipe the response to the client (res) so Claude Code gets the data.
141
+ // 2. Clone the response to read it for logging? fetch response.clone() might not work with streaming body easily.
142
+
143
+ // Better approach: intercept the stream.
144
+ // Or simply: don't log the response body for now to avoid breaking the stream.
145
+ // User just wants to see the request in the viewer.
146
+ // If we want to log response, we need to handle it carefully.
147
+
148
+ // Let's check where ZlibError comes from. It likely comes from `response.text()` or `response.json()`
149
+ // if the content-encoding header is set but fetch didn't decompress it automatically?
150
+ // Or maybe we are double decompressing?
151
+
152
+ // Wait, if we use `response.body.pipe(res)`, that's fine.
153
+ // But do we read the body elsewhere?
154
+
155
+ // Let's look at how we log the response.
156
+ // We are not logging response body in this proxy.js currently.
157
+ // Wait, line 105: `response.body.pipe(res);`
158
+
159
+ // If the error happens, it might be because `fetch` failed to decompress?
160
+ // Or maybe `response.body` is already decompressed stream, but we are piping it to `res` which expects raw?
161
+ // No, `res` (http.ServerResponse) expects raw data.
162
+
163
+ // If `fetch` decompresses automatically, then `response.body` yields decompressed chunks.
164
+ // But `res` writes those chunks to the client.
165
+ // The client (Claude Code) expects compressed data if it sent `Accept-Encoding: gzip`.
166
+ // If we send decompressed data but keep `Content-Encoding: gzip` header, client will try to decompress again -> ZlibError!
167
+
168
+ // FIX: Remove content-encoding header from response headers before piping to client.
169
+ // This tells the client "the data I'm sending you is NOT compressed" (because fetch already decompressed it).
170
+
171
+ const responseHeaders = {};
172
+ for (const [key, value] of response.headers.entries()) {
173
+ // Skip Content-Encoding and Transfer-Encoding to let Node/Client handle it
174
+ if (key.toLowerCase() !== 'content-encoding' && key.toLowerCase() !== 'transfer-encoding' && key.toLowerCase() !== 'content-length') {
175
+ responseHeaders[key] = value;
176
+ }
177
+ }
178
+
179
+ res.writeHead(response.status, responseHeaders);
180
+
181
+ // Also log that we are piping
182
+ // console.error(`[CC-Viewer Proxy] Response status: ${response.status}`);
183
+
184
+ if (response.body) {
185
+ // We need to convert Web Stream (response.body) to Node Stream for piping to res
186
+ // Node 18+ fetch returns a Web ReadableStream.
187
+ // We can use Readable.fromWeb(response.body)
188
+ const { Readable } = await import('node:stream');
189
+ // @ts-ignore
190
+ const nodeStream = Readable.fromWeb(response.body);
191
+ nodeStream.pipe(res);
192
+
193
+ // Optional: Log response body for debugging (careful with streams)
194
+ // For now, let's just ensure reliability.
195
+ } else {
196
+ res.end();
197
+ }
198
+ } catch (err) {
199
+ console.error('[CC-Viewer Proxy] Error:', err);
200
+ res.statusCode = 502;
201
+ res.end('Proxy Error');
202
+ }
203
+ });
204
+
205
+ // Start on random port
206
+ server.listen(0, '127.0.0.1', () => {
207
+ const address = server.address();
208
+ resolve(address.port);
209
+ });
210
+
211
+ server.on('error', (err) => {
212
+ reject(err);
213
+ });
214
+ });
215
+ }
package/server.js CHANGED
@@ -598,7 +598,7 @@ export async function startViewer() {
598
598
  return new Promise((resolve, reject) => {
599
599
  function tryListen(port) {
600
600
  if (port > MAX_PORT) {
601
- console.log(t('server.portsBusy', { start: START_PORT, end: MAX_PORT }));
601
+ console.error(t('server.portsBusy', { start: START_PORT, end: MAX_PORT }));
602
602
  resolve(null);
603
603
  return;
604
604
  }
@@ -609,7 +609,7 @@ export async function startViewer() {
609
609
  server = currentServer;
610
610
  actualPort = port;
611
611
  const url = `http://${HOST}:${port}`;
612
- console.log(t('server.started', { host: HOST, port }));
612
+ console.error(t('server.started', { host: HOST, port }));
613
613
  // v2.0.69 之前的版本会清空控制台,自动打开浏览器确保用户能看到界面
614
614
  try {
615
615
  const ccPkgPath = join(__dirname, '..', '@anthropic-ai', 'claude-code', 'package.json');