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 +28 -3
- package/cli.js +275 -40
- package/interceptor.js +37 -20
- package/package.json +3 -2
- package/proxy.js +215 -0
- package/server.js +2 -2
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
|
-
|
|
15
|
+
### Run & Auto-Configuration
|
|
14
16
|
|
|
15
17
|
```bash
|
|
16
18
|
ccv
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
37
|
-
|
|
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
|
-
|
|
103
|
+
let content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '';
|
|
104
|
+
|
|
49
105
|
if (content.includes(SHELL_HOOK_START)) {
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
const
|
|
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
|
-
|
|
107
|
-
|
|
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 (
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
534
|
+
return JSON.parse(jsonStr);
|
|
521
535
|
} catch {
|
|
522
|
-
return
|
|
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
|
-
|
|
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('
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|