@hileeon/mcc 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +229 -127
- package/dist/accounts/store.d.ts +12 -0
- package/dist/accounts/store.d.ts.map +1 -1
- package/dist/accounts/store.js.map +1 -1
- package/dist/commands/launch.d.ts +9 -0
- package/dist/commands/launch.d.ts.map +1 -0
- package/dist/commands/launch.js +162 -0
- package/dist/commands/launch.js.map +1 -0
- package/dist/commands/mcp.d.ts +9 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +112 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/profile-test.d.ts +17 -0
- package/dist/commands/profile-test.d.ts.map +1 -0
- package/dist/commands/profile-test.js +188 -0
- package/dist/commands/profile-test.js.map +1 -0
- package/dist/commands/profile.d.ts +8 -0
- package/dist/commands/profile.d.ts.map +1 -0
- package/dist/commands/profile.js +125 -0
- package/dist/commands/profile.js.map +1 -0
- package/dist/core/model-router.d.ts.map +1 -1
- package/dist/core/model-router.js +36 -3
- package/dist/core/model-router.js.map +1 -1
- package/dist/{dashboard-server.d.ts → dashboard/server.d.ts} +1 -1
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/{dashboard-server.js → dashboard/server.js} +254 -48
- package/dist/dashboard/server.js.map +1 -0
- package/dist/mcc.d.ts +4 -2
- package/dist/mcc.d.ts.map +1 -1
- package/dist/mcc.js +127 -408
- package/dist/mcc.js.map +1 -1
- package/dist/mcp/installer.d.ts.map +1 -1
- package/dist/mcp/installer.js +5 -2
- package/dist/mcp/installer.js.map +1 -1
- package/dist/mcp/mcp-config.d.ts +33 -3
- package/dist/mcp/mcp-config.d.ts.map +1 -1
- package/dist/mcp/mcp-config.js +81 -29
- package/dist/mcp/mcp-config.js.map +1 -1
- package/dist/proxy/proxy-daemon.d.ts +1 -1
- package/dist/proxy/proxy-daemon.d.ts.map +1 -1
- package/dist/proxy/proxy-daemon.js +27 -7
- package/dist/proxy/proxy-daemon.js.map +1 -1
- package/dist/proxy/proxy-entry.js +11 -4
- package/dist/proxy/proxy-entry.js.map +1 -1
- package/dist/proxy/proxy-paths.d.ts +1 -0
- package/dist/proxy/proxy-paths.d.ts.map +1 -1
- package/dist/proxy/proxy-paths.js.map +1 -1
- package/dist/proxy/proxy-server.d.ts +1 -0
- package/dist/proxy/proxy-server.d.ts.map +1 -1
- package/dist/proxy/proxy-server.js +114 -6
- package/dist/proxy/proxy-server.js.map +1 -1
- package/dist/shared/config.d.ts +15 -0
- package/dist/shared/config.d.ts.map +1 -0
- package/dist/shared/config.js +79 -0
- package/dist/shared/config.js.map +1 -0
- package/dist/shared/logger.d.ts +23 -18
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/logger.js +17 -178
- package/dist/shared/logger.js.map +1 -1
- package/dist/shared/provider-preset-catalog.d.ts +8 -2
- package/dist/shared/provider-preset-catalog.d.ts.map +1 -1
- package/dist/shared/provider-preset-catalog.js +61 -31
- package/dist/shared/provider-preset-catalog.js.map +1 -1
- package/dist/shared/test-image.d.ts +15 -0
- package/dist/shared/test-image.d.ts.map +1 -0
- package/dist/shared/test-image.js +89 -0
- package/dist/shared/test-image.js.map +1 -0
- package/dist/ui/assets/index-7kowlJw9.js +40 -0
- package/dist/ui/assets/index-DnC-lskL.css +1 -0
- package/dist/ui/index.html +21 -13
- package/dist/update.d.ts +31 -0
- package/dist/update.d.ts.map +1 -0
- package/dist/update.js +196 -0
- package/dist/update.js.map +1 -0
- package/lib/mcp/mcc-image-analysis-server.cjs +454 -454
- package/lib/mcp/mcc-websearch-server.cjs +339 -339
- package/lib/mcp-hooks/image-analysis-runtime.cjs +563 -510
- package/lib/mcp-hooks/image-analyzer-transformer.cjs +526 -526
- package/lib/mcp-hooks/websearch-transformer.cjs +1597 -1421
- package/lib/proxy/config/config-loader-facade.js +24 -24
- package/lib/proxy/glmt/delta-accumulator.js +362 -362
- package/lib/proxy/glmt/glmt-transformer.js +203 -203
- package/lib/proxy/glmt/index.js +40 -40
- package/lib/proxy/glmt/locale-enforcer.js +68 -68
- package/lib/proxy/glmt/pipeline/content-transformer.js +161 -161
- package/lib/proxy/glmt/pipeline/index.js +19 -19
- package/lib/proxy/glmt/pipeline/request-transformer.js +115 -115
- package/lib/proxy/glmt/pipeline/response-builder.js +204 -204
- package/lib/proxy/glmt/pipeline/stream-parser.js +233 -233
- package/lib/proxy/glmt/pipeline/tool-call-handler.js +77 -77
- package/lib/proxy/glmt/pipeline/types.js +5 -5
- package/lib/proxy/glmt/reasoning-enforcer.js +150 -150
- package/lib/proxy/glmt/sse-parser.js +101 -101
- package/lib/proxy/services/logging.js +13 -13
- package/lib/proxy/transformers/request-transformer.js +471 -471
- package/lib/proxy/transformers/sse-stream-transformer.js +198 -198
- package/lib/shared/logger.cjs +160 -138
- package/package.json +58 -41
- package/dist/dashboard-server.d.ts.map +0 -1
- package/dist/dashboard-server.js.map +0 -1
- package/dist/ui/assets/index-B16lhKZ6.js +0 -40
- package/dist/ui/assets/index-jEfiB6-h.css +0 -1
|
@@ -1,526 +1,526 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* MCC Image Analyzer Hook - Read Tool Interceptor
|
|
4
|
-
*
|
|
5
|
-
* Intercepts Claude's Read tool for image/PDF files and analyzes them via CLIProxy.
|
|
6
|
-
* Returns detailed text descriptions instead of allowing direct visual access.
|
|
7
|
-
*
|
|
8
|
-
* Environment Variables (set by CCS):
|
|
9
|
-
* MCC_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely
|
|
10
|
-
* MCC_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1)
|
|
11
|
-
* MCC_IMAGE_ANALYSIS_PROVIDER_MODELS - Provider:model mapping (e.g., agy:gemini-2.5-flash,gemini:gemini-2.5-flash)
|
|
12
|
-
* MCC_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex)
|
|
13
|
-
* MCC_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60)
|
|
14
|
-
* MCC_PROFILE_TYPE - Profile type (account/default skip)
|
|
15
|
-
* ANTHROPIC_MODEL - Chat model env (not used for image analysis fallback)
|
|
16
|
-
* MCC_LOG_LEVEL=debug - Enable debug output
|
|
17
|
-
*
|
|
18
|
-
* Exit codes:
|
|
19
|
-
* 0 - Allow tool (pass-through to native Read)
|
|
20
|
-
* 2 - Block tool (deny with analysis/message)
|
|
21
|
-
*
|
|
22
|
-
* @module hooks/image-analyzer-transformer
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
const fs = require('fs');
|
|
26
|
-
const path = require('path');
|
|
27
|
-
const { analyzeFile, isAnalyzableFile, parseProviderModels } = require('./image-analysis-runtime.cjs');
|
|
28
|
-
|
|
29
|
-
// ============================================================================
|
|
30
|
-
// PLATFORM DETECTION
|
|
31
|
-
// ============================================================================
|
|
32
|
-
|
|
33
|
-
const isWindows = process.platform === 'win32';
|
|
34
|
-
|
|
35
|
-
// ============================================================================
|
|
36
|
-
// CONFIGURATION
|
|
37
|
-
// ============================================================================
|
|
38
|
-
|
|
39
|
-
const DEFAULT_MODEL = 'gemini-2.5-flash';
|
|
40
|
-
const DEFAULT_TIMEOUT_SEC = 60;
|
|
41
|
-
|
|
42
|
-
// ============================================================================
|
|
43
|
-
// ERROR CODES (for categorization)
|
|
44
|
-
// ============================================================================
|
|
45
|
-
|
|
46
|
-
const ERROR_CODES = {
|
|
47
|
-
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
|
|
48
|
-
CLIPROXY_UNAVAILABLE: 'CLIPROXY_UNAVAILABLE',
|
|
49
|
-
AUTH_FAILED: 'AUTH_FAILED',
|
|
50
|
-
TIMEOUT: 'TIMEOUT',
|
|
51
|
-
RATE_LIMIT: 'RATE_LIMIT',
|
|
52
|
-
API_ERROR: 'API_ERROR',
|
|
53
|
-
PARSE_ERROR: 'PARSE_ERROR',
|
|
54
|
-
UNKNOWN: 'UNKNOWN',
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Output debug information to stderr
|
|
59
|
-
* Only outputs when MCC_LOG_LEVEL=debug
|
|
60
|
-
*/
|
|
61
|
-
const logger = require('../shared/logger.cjs');
|
|
62
|
-
const log = require('../shared/logger.cjs');
|
|
63
|
-
|
|
64
|
-
function debugLog(message, data) {
|
|
65
|
-
const msg = data && Object.keys(data).length > 0
|
|
66
|
-
? message + ' ' + Object.entries(data).map(([k, v]) => `${k}=${v}`).join(' ')
|
|
67
|
-
: message;
|
|
68
|
-
log.debug('ImageAnalyzerTransformer', msg);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Get detailed debug context
|
|
73
|
-
*/
|
|
74
|
-
function getDebugContext(filePath, stats) {
|
|
75
|
-
const currentProvider = process.env.MCC_CURRENT_PROVIDER || 'unknown';
|
|
76
|
-
const model =
|
|
77
|
-
process.env.MCC_IMAGE_ANALYSIS_MODEL ||
|
|
78
|
-
parseProviderModels(process.env.MCC_IMAGE_ANALYSIS_PROVIDER_MODELS)[currentProvider] ||
|
|
79
|
-
DEFAULT_MODEL;
|
|
80
|
-
const timeout = parseInt(process.env.MCC_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
file: path.basename(filePath),
|
|
84
|
-
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
|
|
85
|
-
provider: currentProvider,
|
|
86
|
-
model: model,
|
|
87
|
-
timeout: `${timeout}s`,
|
|
88
|
-
endpoint: process.env.MCC_IMAGE_ANALYSIS_RUNTIME_BASE_URL || '(runtime fallback)',
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Get current provider/model context for error messages
|
|
94
|
-
*/
|
|
95
|
-
function getProviderContext() {
|
|
96
|
-
const provider = process.env.MCC_CURRENT_PROVIDER || 'unknown';
|
|
97
|
-
const model =
|
|
98
|
-
process.env.MCC_IMAGE_ANALYSIS_MODEL ||
|
|
99
|
-
parseProviderModels(process.env.MCC_IMAGE_ANALYSIS_PROVIDER_MODELS)[provider] ||
|
|
100
|
-
DEFAULT_MODEL;
|
|
101
|
-
return { provider, model };
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Format analysis description for Claude (matches websearch format)
|
|
105
|
-
*/
|
|
106
|
-
function formatDescription(filePath, description, model, fileSize) {
|
|
107
|
-
const sizeKB = fileSize ? (fileSize / 1024).toFixed(1) : '?';
|
|
108
|
-
return [
|
|
109
|
-
`[Image Analysis via CLIProxy]`,
|
|
110
|
-
'',
|
|
111
|
-
`File: ${path.basename(filePath)} (${sizeKB} KB)`,
|
|
112
|
-
`Model: ${model}`,
|
|
113
|
-
'',
|
|
114
|
-
'---',
|
|
115
|
-
'',
|
|
116
|
-
description,
|
|
117
|
-
'',
|
|
118
|
-
'---',
|
|
119
|
-
'*Use this description to understand the image content.*',
|
|
120
|
-
].join('\n');
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ============================================================================
|
|
124
|
-
// SPECIALIZED ERROR HANDLERS
|
|
125
|
-
// ============================================================================
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Format error output for Claude hook
|
|
129
|
-
*/
|
|
130
|
-
function formatErrorOutput(filePath, errorCode, message, troubleshooting) {
|
|
131
|
-
const { provider, model } = getProviderContext();
|
|
132
|
-
|
|
133
|
-
const lines = [
|
|
134
|
-
`[Image Analysis - Error]`,
|
|
135
|
-
'',
|
|
136
|
-
`File: ${path.basename(filePath)}`,
|
|
137
|
-
`Provider: ${provider} | Model: ${model}`,
|
|
138
|
-
'',
|
|
139
|
-
`Error: ${message}`,
|
|
140
|
-
];
|
|
141
|
-
|
|
142
|
-
if (troubleshooting && troubleshooting.length > 0) {
|
|
143
|
-
lines.push('');
|
|
144
|
-
lines.push('Troubleshooting:');
|
|
145
|
-
troubleshooting.forEach((step, i) => {
|
|
146
|
-
lines.push(` ${i + 1}. ${step}`);
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
lines.push('');
|
|
151
|
-
lines.push('For help: mcc config image-analysis --help');
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
decision: 'block',
|
|
155
|
-
reason: `Image analysis failed: ${errorCode}`,
|
|
156
|
-
systemMessage: `[Image Analysis] Failed: ${message}`,
|
|
157
|
-
hookSpecificOutput: {
|
|
158
|
-
hookEventName: 'PreToolUse',
|
|
159
|
-
permissionDecision: 'deny',
|
|
160
|
-
permissionDecisionReason: lines.join('\n'),
|
|
161
|
-
},
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* File too large error
|
|
167
|
-
*/
|
|
168
|
-
function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) {
|
|
169
|
-
const output = formatErrorOutput(
|
|
170
|
-
filePath,
|
|
171
|
-
ERROR_CODES.FILE_TOO_LARGE,
|
|
172
|
-
`File too large (${actualSizeMB.toFixed(2)}MB > ${maxSizeMB}MB limit)`,
|
|
173
|
-
[
|
|
174
|
-
'Reduce image resolution or use compression',
|
|
175
|
-
'For screenshots: use PNG optimizer (pngquant, optipng)',
|
|
176
|
-
'For photos: resize to max 2048px width',
|
|
177
|
-
`Current limit: ${maxSizeMB}MB per file`,
|
|
178
|
-
]
|
|
179
|
-
);
|
|
180
|
-
console.log(JSON.stringify(output));
|
|
181
|
-
process.exit(2);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function outputAuthError(filePath, statusCode) {
|
|
185
|
-
const { provider } = getProviderContext();
|
|
186
|
-
const output = formatErrorOutput(
|
|
187
|
-
filePath,
|
|
188
|
-
ERROR_CODES.AUTH_FAILED,
|
|
189
|
-
`Authentication failed (HTTP ${statusCode})`,
|
|
190
|
-
[
|
|
191
|
-
`Re-authenticate: mcc ${provider} --auth`,
|
|
192
|
-
`Check accounts: mcc ${provider} --accounts`,
|
|
193
|
-
'Verify OAuth token is valid',
|
|
194
|
-
'Check: mcc doctor',
|
|
195
|
-
]
|
|
196
|
-
);
|
|
197
|
-
console.log(JSON.stringify(output));
|
|
198
|
-
process.exit(2);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Timeout error
|
|
203
|
-
*/
|
|
204
|
-
function outputTimeoutError(filePath, timeoutSec) {
|
|
205
|
-
const { model } = getProviderContext();
|
|
206
|
-
const output = formatErrorOutput(
|
|
207
|
-
filePath,
|
|
208
|
-
ERROR_CODES.TIMEOUT,
|
|
209
|
-
`Request timed out after ${timeoutSec}s`,
|
|
210
|
-
[
|
|
211
|
-
'Large files or complex images take longer',
|
|
212
|
-
`Increase timeout: mcc config image-analysis --timeout ${timeoutSec * 2}`,
|
|
213
|
-
'Or via env: MCC_IMAGE_ANALYSIS_TIMEOUT=120',
|
|
214
|
-
`Current model (${model}) may be slow - try a faster variant`,
|
|
215
|
-
'Check CLIProxy health: curl http://127.0.0.1:8317',
|
|
216
|
-
]
|
|
217
|
-
);
|
|
218
|
-
console.log(JSON.stringify(output));
|
|
219
|
-
process.exit(2);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Rate limit error
|
|
224
|
-
*/
|
|
225
|
-
function outputRateLimitError(filePath, retryAfterSec) {
|
|
226
|
-
const { provider } = getProviderContext();
|
|
227
|
-
const retryHint = retryAfterSec ? `Retry after ${retryAfterSec}s` : 'Wait a moment and retry';
|
|
228
|
-
const output = formatErrorOutput(
|
|
229
|
-
filePath,
|
|
230
|
-
ERROR_CODES.RATE_LIMIT,
|
|
231
|
-
'Rate limit exceeded',
|
|
232
|
-
[
|
|
233
|
-
retryHint,
|
|
234
|
-
`Provider ${provider} has usage limits`,
|
|
235
|
-
'Consider switching accounts: mcc account list',
|
|
236
|
-
'Check quota: mcc doctor',
|
|
237
|
-
]
|
|
238
|
-
);
|
|
239
|
-
console.log(JSON.stringify(output));
|
|
240
|
-
process.exit(2);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Generic API error
|
|
245
|
-
*/
|
|
246
|
-
function outputApiError(filePath, statusCode, responseBody) {
|
|
247
|
-
// Try to extract error message from response
|
|
248
|
-
let errorDetail = `HTTP ${statusCode}`;
|
|
249
|
-
try {
|
|
250
|
-
const parsed = JSON.parse(responseBody);
|
|
251
|
-
if (parsed.error?.message) {
|
|
252
|
-
errorDetail = parsed.error.message;
|
|
253
|
-
} else if (parsed.message) {
|
|
254
|
-
errorDetail = parsed.message;
|
|
255
|
-
}
|
|
256
|
-
} catch {
|
|
257
|
-
// Use raw body if not JSON (truncated)
|
|
258
|
-
if (responseBody && responseBody.length < 100) {
|
|
259
|
-
errorDetail = responseBody;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const output = formatErrorOutput(
|
|
264
|
-
filePath,
|
|
265
|
-
ERROR_CODES.API_ERROR,
|
|
266
|
-
`API error: ${errorDetail}`,
|
|
267
|
-
[
|
|
268
|
-
'Check CLIProxy logs: mcc cleanup --show-logs',
|
|
269
|
-
'Verify provider is authenticated: mcc doctor',
|
|
270
|
-
'Try a different provider or model',
|
|
271
|
-
'Report persistent issues: https://github.com/kaitranntt/mcc/issues',
|
|
272
|
-
]
|
|
273
|
-
);
|
|
274
|
-
console.log(JSON.stringify(output));
|
|
275
|
-
process.exit(2);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* File permission error
|
|
280
|
-
*/
|
|
281
|
-
function outputFileAccessError(filePath, error) {
|
|
282
|
-
const output = formatErrorOutput(
|
|
283
|
-
filePath,
|
|
284
|
-
ERROR_CODES.UNKNOWN,
|
|
285
|
-
`File access denied: ${error}`,
|
|
286
|
-
[
|
|
287
|
-
'Check file permissions: ls -l ' + filePath,
|
|
288
|
-
isWindows ? 'Run terminal as Administrator if needed' : 'Use sudo or adjust file ownership',
|
|
289
|
-
'Verify file is readable by current user',
|
|
290
|
-
'Move file to accessible location',
|
|
291
|
-
]
|
|
292
|
-
);
|
|
293
|
-
console.log(JSON.stringify(output));
|
|
294
|
-
process.exit(2);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Unknown/fallback error (replaces old outputError)
|
|
299
|
-
*/
|
|
300
|
-
function outputUnknownError(filePath, error) {
|
|
301
|
-
const output = formatErrorOutput(
|
|
302
|
-
filePath,
|
|
303
|
-
ERROR_CODES.UNKNOWN,
|
|
304
|
-
error || 'Unknown error occurred',
|
|
305
|
-
[
|
|
306
|
-
'Check CLIProxy is running: curl http://127.0.0.1:8317',
|
|
307
|
-
'Verify authentication: mcc doctor',
|
|
308
|
-
'Check file is valid image/PDF',
|
|
309
|
-
'Enable debug: MCC_LOG_LEVEL=debug mcc <provider>',
|
|
310
|
-
]
|
|
311
|
-
);
|
|
312
|
-
console.log(JSON.stringify(output));
|
|
313
|
-
process.exit(2);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* CLIProxy unavailable fallback - blocks Read to prevent context overflow
|
|
318
|
-
* When CLIProxy is not running, we cannot analyze the image.
|
|
319
|
-
* Blocking prevents the image from loading into Claude's context (100K+ tokens).
|
|
320
|
-
*/
|
|
321
|
-
function outputCliProxyUnavailableFallback(filePath) {
|
|
322
|
-
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
|
323
|
-
|
|
324
|
-
// Keep message minimal to avoid context pollution and hallucination
|
|
325
|
-
const message = [
|
|
326
|
-
'[Image Read Blocked]',
|
|
327
|
-
'',
|
|
328
|
-
`File: ${fileName}`,
|
|
329
|
-
'',
|
|
330
|
-
'CLIProxy unavailable. Image blocked to prevent context overflow.',
|
|
331
|
-
].join('\n');
|
|
332
|
-
|
|
333
|
-
const output = {
|
|
334
|
-
decision: 'block',
|
|
335
|
-
reason: 'CLIProxy unavailable - image blocked to prevent context overflow',
|
|
336
|
-
systemMessage: `[Image Blocked] ${fileName} - CLIProxy unavailable. Start: mcc config`,
|
|
337
|
-
hookSpecificOutput: {
|
|
338
|
-
hookEventName: 'PreToolUse',
|
|
339
|
-
permissionDecision: 'deny',
|
|
340
|
-
permissionDecisionReason: message,
|
|
341
|
-
},
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
console.log(JSON.stringify(output));
|
|
345
|
-
process.exit(2);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Output success response and exit
|
|
350
|
-
*/
|
|
351
|
-
function outputSuccess(filePath, description, model, fileSize) {
|
|
352
|
-
debugLog('Returning analysis result', {
|
|
353
|
-
file: path.basename(filePath),
|
|
354
|
-
model: model,
|
|
355
|
-
descriptionLength: `${description.length} chars`,
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
const formattedDescription = formatDescription(filePath, description, model, fileSize);
|
|
359
|
-
|
|
360
|
-
const output = {
|
|
361
|
-
decision: 'block',
|
|
362
|
-
reason: `Image analyzed: ${path.basename(filePath)}`,
|
|
363
|
-
systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy (${model})`,
|
|
364
|
-
hookSpecificOutput: {
|
|
365
|
-
hookEventName: 'PreToolUse',
|
|
366
|
-
permissionDecision: 'deny',
|
|
367
|
-
permissionDecisionReason: formattedDescription,
|
|
368
|
-
},
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
console.log(JSON.stringify(output));
|
|
372
|
-
process.exit(2);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Determine if hook should skip, with debug logging
|
|
377
|
-
*/
|
|
378
|
-
function shouldSkipHook() {
|
|
379
|
-
if (process.env.MCC_IMAGE_ANALYSIS_SKIP_HOOK === '1') {
|
|
380
|
-
debugLog('Skipping: MCC_IMAGE_ANALYSIS_SKIP_HOOK=1');
|
|
381
|
-
return true;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Explicit skip signal
|
|
385
|
-
if (process.env.MCC_IMAGE_ANALYSIS_SKIP === '1') {
|
|
386
|
-
debugLog('Skipping: MCC_IMAGE_ANALYSIS_SKIP=1');
|
|
387
|
-
return true;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Explicit disable
|
|
391
|
-
if (process.env.MCC_IMAGE_ANALYSIS_ENABLED === '0') {
|
|
392
|
-
debugLog('Skipping: image analysis disabled (MCC_IMAGE_ANALYSIS_ENABLED=0)');
|
|
393
|
-
return true;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Account/default profiles - use native Read
|
|
397
|
-
const profileType = process.env.MCC_PROFILE_TYPE;
|
|
398
|
-
if (profileType === 'account' || profileType === 'default') {
|
|
399
|
-
debugLog(`Skipping: profile type "${profileType}" uses native Read`);
|
|
400
|
-
return true;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Check if current provider has a vision model configured
|
|
404
|
-
const explicitModel = process.env.MCC_IMAGE_ANALYSIS_MODEL;
|
|
405
|
-
const currentProvider = process.env.MCC_CURRENT_PROVIDER || '';
|
|
406
|
-
const providerModels = parseProviderModels(process.env.MCC_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
|
407
|
-
|
|
408
|
-
if (!explicitModel?.trim() && !providerModels[currentProvider]) {
|
|
409
|
-
debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, {
|
|
410
|
-
configured_providers: Object.keys(providerModels).join(', ') || 'none',
|
|
411
|
-
});
|
|
412
|
-
return true;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
return false;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// ============================================================================
|
|
419
|
-
// MAIN HOOK LOGIC
|
|
420
|
-
// ============================================================================
|
|
421
|
-
|
|
422
|
-
// Read input from stdin
|
|
423
|
-
let input = '';
|
|
424
|
-
process.stdin.setEncoding('utf8');
|
|
425
|
-
process.stdin.on('data', (chunk) => {
|
|
426
|
-
input += chunk;
|
|
427
|
-
});
|
|
428
|
-
process.stdin.on('end', () => {
|
|
429
|
-
processHook();
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
// Handle stdin not being available
|
|
433
|
-
process.stdin.on('error', () => {
|
|
434
|
-
process.exit(0);
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Main hook processing logic
|
|
439
|
-
*
|
|
440
|
-
* Two-phase design: Phase 1 filters non-image Read calls silently (exit 0).
|
|
441
|
-
* Phase 2 only runs for confirmed image/PDF files, so error messages are
|
|
442
|
-
* always relevant and never confuse users reading code or text files.
|
|
443
|
-
*/
|
|
444
|
-
async function processHook() {
|
|
445
|
-
// Phase 1: Fast bail-out for non-image files
|
|
446
|
-
// Any failure here → pass through silently to native Read
|
|
447
|
-
let filePath;
|
|
448
|
-
try {
|
|
449
|
-
const data = JSON.parse(input);
|
|
450
|
-
|
|
451
|
-
// Only handle Read tool
|
|
452
|
-
if (data.tool_name !== 'Read') {
|
|
453
|
-
process.exit(0);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
filePath = data.tool_input?.file_path || '';
|
|
457
|
-
|
|
458
|
-
if (!filePath) {
|
|
459
|
-
process.exit(0);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Check file extension BEFORE any other processing — this is the key gate
|
|
463
|
-
// that ensures non-image Read calls never see hook errors
|
|
464
|
-
if (!isAnalyzableFile(filePath)) {
|
|
465
|
-
process.exit(0);
|
|
466
|
-
}
|
|
467
|
-
} catch {
|
|
468
|
-
// stdin parse failure or unexpected error → pass through silently
|
|
469
|
-
process.exit(0);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Phase 2: Image/PDF file processing — errors here are relevant to the user
|
|
473
|
-
try {
|
|
474
|
-
// Skip for native accounts or explicit disable
|
|
475
|
-
if (shouldSkipHook()) {
|
|
476
|
-
process.exit(0);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (!fs.existsSync(filePath)) {
|
|
480
|
-
process.exit(0);
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
const debugContext = getDebugContext(filePath, null);
|
|
484
|
-
debugLog('Image analysis runtime prepared', debugContext);
|
|
485
|
-
|
|
486
|
-
const result = await analyzeFile(filePath);
|
|
487
|
-
outputSuccess(filePath, result.description, result.model, result.fileSize);
|
|
488
|
-
} catch (err) {
|
|
489
|
-
log.debug(`Error: ${err.message}`);
|
|
490
|
-
|
|
491
|
-
// filePath is guaranteed set by Phase 1 — only image files reach here
|
|
492
|
-
|
|
493
|
-
// Categorize error by message pattern
|
|
494
|
-
const errMsg = err.message || '';
|
|
495
|
-
|
|
496
|
-
if (errMsg.startsWith('FILE_TOO_LARGE:')) {
|
|
497
|
-
const fileSizeMb = Number.parseInt(errMsg.split(':')[1], 10) / 1024 / 1024;
|
|
498
|
-
outputFileTooLargeError(filePath, fileSizeMb, 10);
|
|
499
|
-
} else if (errMsg.startsWith('AUTH_ERROR:')) {
|
|
500
|
-
const statusCode = parseInt(errMsg.split(':')[1], 10);
|
|
501
|
-
outputAuthError(filePath, statusCode);
|
|
502
|
-
} else if (errMsg.startsWith('RATE_LIMIT:')) {
|
|
503
|
-
const retryAfter = errMsg.split(':')[1];
|
|
504
|
-
outputRateLimitError(filePath, retryAfter ? parseInt(retryAfter, 10) : null);
|
|
505
|
-
} else if (errMsg.startsWith('API_ERROR:')) {
|
|
506
|
-
const parts = errMsg.split(':');
|
|
507
|
-
const statusCode = parseInt(parts[1], 10);
|
|
508
|
-
const body = parts.slice(2).join(':');
|
|
509
|
-
outputApiError(filePath, statusCode, body);
|
|
510
|
-
} else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) {
|
|
511
|
-
const timeout = parseInt(process.env.MCC_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
|
512
|
-
outputTimeoutError(filePath, timeout);
|
|
513
|
-
} else if (
|
|
514
|
-
errMsg.includes('ECONNREFUSED') ||
|
|
515
|
-
errMsg.includes('ENOTFOUND') ||
|
|
516
|
-
errMsg.includes('ENETUNREACH') ||
|
|
517
|
-
errMsg.includes('EAI_AGAIN')
|
|
518
|
-
) {
|
|
519
|
-
outputCliProxyUnavailableFallback(filePath);
|
|
520
|
-
} else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) {
|
|
521
|
-
outputFileAccessError(filePath, errMsg);
|
|
522
|
-
} else {
|
|
523
|
-
outputUnknownError(filePath, errMsg);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCC Image Analyzer Hook - Read Tool Interceptor
|
|
4
|
+
*
|
|
5
|
+
* Intercepts Claude's Read tool for image/PDF files and analyzes them via CLIProxy.
|
|
6
|
+
* Returns detailed text descriptions instead of allowing direct visual access.
|
|
7
|
+
*
|
|
8
|
+
* Environment Variables (set by CCS):
|
|
9
|
+
* MCC_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely
|
|
10
|
+
* MCC_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1)
|
|
11
|
+
* MCC_IMAGE_ANALYSIS_PROVIDER_MODELS - Provider:model mapping (e.g., agy:gemini-2.5-flash,gemini:gemini-2.5-flash)
|
|
12
|
+
* MCC_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex)
|
|
13
|
+
* MCC_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60)
|
|
14
|
+
* MCC_PROFILE_TYPE - Profile type (account/default skip)
|
|
15
|
+
* ANTHROPIC_MODEL - Chat model env (not used for image analysis fallback)
|
|
16
|
+
* MCC_LOG_LEVEL=debug - Enable debug output
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
* 0 - Allow tool (pass-through to native Read)
|
|
20
|
+
* 2 - Block tool (deny with analysis/message)
|
|
21
|
+
*
|
|
22
|
+
* @module hooks/image-analyzer-transformer
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { analyzeFile, isAnalyzableFile, parseProviderModels } = require('./image-analysis-runtime.cjs');
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// PLATFORM DETECTION
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
const isWindows = process.platform === 'win32';
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// CONFIGURATION
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
const DEFAULT_MODEL = 'gemini-2.5-flash';
|
|
40
|
+
const DEFAULT_TIMEOUT_SEC = 60;
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// ERROR CODES (for categorization)
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
const ERROR_CODES = {
|
|
47
|
+
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
|
|
48
|
+
CLIPROXY_UNAVAILABLE: 'CLIPROXY_UNAVAILABLE',
|
|
49
|
+
AUTH_FAILED: 'AUTH_FAILED',
|
|
50
|
+
TIMEOUT: 'TIMEOUT',
|
|
51
|
+
RATE_LIMIT: 'RATE_LIMIT',
|
|
52
|
+
API_ERROR: 'API_ERROR',
|
|
53
|
+
PARSE_ERROR: 'PARSE_ERROR',
|
|
54
|
+
UNKNOWN: 'UNKNOWN',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Output debug information to stderr
|
|
59
|
+
* Only outputs when MCC_LOG_LEVEL=debug
|
|
60
|
+
*/
|
|
61
|
+
const logger = require('../shared/logger.cjs');
|
|
62
|
+
const log = require('../shared/logger.cjs');
|
|
63
|
+
|
|
64
|
+
function debugLog(message, data) {
|
|
65
|
+
const msg = data && Object.keys(data).length > 0
|
|
66
|
+
? message + ' ' + Object.entries(data).map(([k, v]) => `${k}=${v}`).join(' ')
|
|
67
|
+
: message;
|
|
68
|
+
log.debug('ImageAnalyzerTransformer', msg);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get detailed debug context
|
|
73
|
+
*/
|
|
74
|
+
function getDebugContext(filePath, stats) {
|
|
75
|
+
const currentProvider = process.env.MCC_CURRENT_PROVIDER || 'unknown';
|
|
76
|
+
const model =
|
|
77
|
+
process.env.MCC_IMAGE_ANALYSIS_MODEL ||
|
|
78
|
+
parseProviderModels(process.env.MCC_IMAGE_ANALYSIS_PROVIDER_MODELS)[currentProvider] ||
|
|
79
|
+
DEFAULT_MODEL;
|
|
80
|
+
const timeout = parseInt(process.env.MCC_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
file: path.basename(filePath),
|
|
84
|
+
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
|
|
85
|
+
provider: currentProvider,
|
|
86
|
+
model: model,
|
|
87
|
+
timeout: `${timeout}s`,
|
|
88
|
+
endpoint: process.env.MCC_IMAGE_ANALYSIS_RUNTIME_BASE_URL || '(runtime fallback)',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get current provider/model context for error messages
|
|
94
|
+
*/
|
|
95
|
+
function getProviderContext() {
|
|
96
|
+
const provider = process.env.MCC_CURRENT_PROVIDER || 'unknown';
|
|
97
|
+
const model =
|
|
98
|
+
process.env.MCC_IMAGE_ANALYSIS_MODEL ||
|
|
99
|
+
parseProviderModels(process.env.MCC_IMAGE_ANALYSIS_PROVIDER_MODELS)[provider] ||
|
|
100
|
+
DEFAULT_MODEL;
|
|
101
|
+
return { provider, model };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Format analysis description for Claude (matches websearch format)
|
|
105
|
+
*/
|
|
106
|
+
function formatDescription(filePath, description, model, fileSize) {
|
|
107
|
+
const sizeKB = fileSize ? (fileSize / 1024).toFixed(1) : '?';
|
|
108
|
+
return [
|
|
109
|
+
`[Image Analysis via CLIProxy]`,
|
|
110
|
+
'',
|
|
111
|
+
`File: ${path.basename(filePath)} (${sizeKB} KB)`,
|
|
112
|
+
`Model: ${model}`,
|
|
113
|
+
'',
|
|
114
|
+
'---',
|
|
115
|
+
'',
|
|
116
|
+
description,
|
|
117
|
+
'',
|
|
118
|
+
'---',
|
|
119
|
+
'*Use this description to understand the image content.*',
|
|
120
|
+
].join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// SPECIALIZED ERROR HANDLERS
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Format error output for Claude hook
|
|
129
|
+
*/
|
|
130
|
+
function formatErrorOutput(filePath, errorCode, message, troubleshooting) {
|
|
131
|
+
const { provider, model } = getProviderContext();
|
|
132
|
+
|
|
133
|
+
const lines = [
|
|
134
|
+
`[Image Analysis - Error]`,
|
|
135
|
+
'',
|
|
136
|
+
`File: ${path.basename(filePath)}`,
|
|
137
|
+
`Provider: ${provider} | Model: ${model}`,
|
|
138
|
+
'',
|
|
139
|
+
`Error: ${message}`,
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
if (troubleshooting && troubleshooting.length > 0) {
|
|
143
|
+
lines.push('');
|
|
144
|
+
lines.push('Troubleshooting:');
|
|
145
|
+
troubleshooting.forEach((step, i) => {
|
|
146
|
+
lines.push(` ${i + 1}. ${step}`);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push('');
|
|
151
|
+
lines.push('For help: mcc config image-analysis --help');
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
decision: 'block',
|
|
155
|
+
reason: `Image analysis failed: ${errorCode}`,
|
|
156
|
+
systemMessage: `[Image Analysis] Failed: ${message}`,
|
|
157
|
+
hookSpecificOutput: {
|
|
158
|
+
hookEventName: 'PreToolUse',
|
|
159
|
+
permissionDecision: 'deny',
|
|
160
|
+
permissionDecisionReason: lines.join('\n'),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* File too large error
|
|
167
|
+
*/
|
|
168
|
+
function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) {
|
|
169
|
+
const output = formatErrorOutput(
|
|
170
|
+
filePath,
|
|
171
|
+
ERROR_CODES.FILE_TOO_LARGE,
|
|
172
|
+
`File too large (${actualSizeMB.toFixed(2)}MB > ${maxSizeMB}MB limit)`,
|
|
173
|
+
[
|
|
174
|
+
'Reduce image resolution or use compression',
|
|
175
|
+
'For screenshots: use PNG optimizer (pngquant, optipng)',
|
|
176
|
+
'For photos: resize to max 2048px width',
|
|
177
|
+
`Current limit: ${maxSizeMB}MB per file`,
|
|
178
|
+
]
|
|
179
|
+
);
|
|
180
|
+
console.log(JSON.stringify(output));
|
|
181
|
+
process.exit(2);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function outputAuthError(filePath, statusCode) {
|
|
185
|
+
const { provider } = getProviderContext();
|
|
186
|
+
const output = formatErrorOutput(
|
|
187
|
+
filePath,
|
|
188
|
+
ERROR_CODES.AUTH_FAILED,
|
|
189
|
+
`Authentication failed (HTTP ${statusCode})`,
|
|
190
|
+
[
|
|
191
|
+
`Re-authenticate: mcc ${provider} --auth`,
|
|
192
|
+
`Check accounts: mcc ${provider} --accounts`,
|
|
193
|
+
'Verify OAuth token is valid',
|
|
194
|
+
'Check: mcc doctor',
|
|
195
|
+
]
|
|
196
|
+
);
|
|
197
|
+
console.log(JSON.stringify(output));
|
|
198
|
+
process.exit(2);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Timeout error
|
|
203
|
+
*/
|
|
204
|
+
function outputTimeoutError(filePath, timeoutSec) {
|
|
205
|
+
const { model } = getProviderContext();
|
|
206
|
+
const output = formatErrorOutput(
|
|
207
|
+
filePath,
|
|
208
|
+
ERROR_CODES.TIMEOUT,
|
|
209
|
+
`Request timed out after ${timeoutSec}s`,
|
|
210
|
+
[
|
|
211
|
+
'Large files or complex images take longer',
|
|
212
|
+
`Increase timeout: mcc config image-analysis --timeout ${timeoutSec * 2}`,
|
|
213
|
+
'Or via env: MCC_IMAGE_ANALYSIS_TIMEOUT=120',
|
|
214
|
+
`Current model (${model}) may be slow - try a faster variant`,
|
|
215
|
+
'Check CLIProxy health: curl http://127.0.0.1:8317',
|
|
216
|
+
]
|
|
217
|
+
);
|
|
218
|
+
console.log(JSON.stringify(output));
|
|
219
|
+
process.exit(2);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Rate limit error
|
|
224
|
+
*/
|
|
225
|
+
function outputRateLimitError(filePath, retryAfterSec) {
|
|
226
|
+
const { provider } = getProviderContext();
|
|
227
|
+
const retryHint = retryAfterSec ? `Retry after ${retryAfterSec}s` : 'Wait a moment and retry';
|
|
228
|
+
const output = formatErrorOutput(
|
|
229
|
+
filePath,
|
|
230
|
+
ERROR_CODES.RATE_LIMIT,
|
|
231
|
+
'Rate limit exceeded',
|
|
232
|
+
[
|
|
233
|
+
retryHint,
|
|
234
|
+
`Provider ${provider} has usage limits`,
|
|
235
|
+
'Consider switching accounts: mcc account list',
|
|
236
|
+
'Check quota: mcc doctor',
|
|
237
|
+
]
|
|
238
|
+
);
|
|
239
|
+
console.log(JSON.stringify(output));
|
|
240
|
+
process.exit(2);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Generic API error
|
|
245
|
+
*/
|
|
246
|
+
function outputApiError(filePath, statusCode, responseBody) {
|
|
247
|
+
// Try to extract error message from response
|
|
248
|
+
let errorDetail = `HTTP ${statusCode}`;
|
|
249
|
+
try {
|
|
250
|
+
const parsed = JSON.parse(responseBody);
|
|
251
|
+
if (parsed.error?.message) {
|
|
252
|
+
errorDetail = parsed.error.message;
|
|
253
|
+
} else if (parsed.message) {
|
|
254
|
+
errorDetail = parsed.message;
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// Use raw body if not JSON (truncated)
|
|
258
|
+
if (responseBody && responseBody.length < 100) {
|
|
259
|
+
errorDetail = responseBody;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const output = formatErrorOutput(
|
|
264
|
+
filePath,
|
|
265
|
+
ERROR_CODES.API_ERROR,
|
|
266
|
+
`API error: ${errorDetail}`,
|
|
267
|
+
[
|
|
268
|
+
'Check CLIProxy logs: mcc cleanup --show-logs',
|
|
269
|
+
'Verify provider is authenticated: mcc doctor',
|
|
270
|
+
'Try a different provider or model',
|
|
271
|
+
'Report persistent issues: https://github.com/kaitranntt/mcc/issues',
|
|
272
|
+
]
|
|
273
|
+
);
|
|
274
|
+
console.log(JSON.stringify(output));
|
|
275
|
+
process.exit(2);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* File permission error
|
|
280
|
+
*/
|
|
281
|
+
function outputFileAccessError(filePath, error) {
|
|
282
|
+
const output = formatErrorOutput(
|
|
283
|
+
filePath,
|
|
284
|
+
ERROR_CODES.UNKNOWN,
|
|
285
|
+
`File access denied: ${error}`,
|
|
286
|
+
[
|
|
287
|
+
'Check file permissions: ls -l ' + filePath,
|
|
288
|
+
isWindows ? 'Run terminal as Administrator if needed' : 'Use sudo or adjust file ownership',
|
|
289
|
+
'Verify file is readable by current user',
|
|
290
|
+
'Move file to accessible location',
|
|
291
|
+
]
|
|
292
|
+
);
|
|
293
|
+
console.log(JSON.stringify(output));
|
|
294
|
+
process.exit(2);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Unknown/fallback error (replaces old outputError)
|
|
299
|
+
*/
|
|
300
|
+
function outputUnknownError(filePath, error) {
|
|
301
|
+
const output = formatErrorOutput(
|
|
302
|
+
filePath,
|
|
303
|
+
ERROR_CODES.UNKNOWN,
|
|
304
|
+
error || 'Unknown error occurred',
|
|
305
|
+
[
|
|
306
|
+
'Check CLIProxy is running: curl http://127.0.0.1:8317',
|
|
307
|
+
'Verify authentication: mcc doctor',
|
|
308
|
+
'Check file is valid image/PDF',
|
|
309
|
+
'Enable debug: MCC_LOG_LEVEL=debug mcc <provider>',
|
|
310
|
+
]
|
|
311
|
+
);
|
|
312
|
+
console.log(JSON.stringify(output));
|
|
313
|
+
process.exit(2);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* CLIProxy unavailable fallback - blocks Read to prevent context overflow
|
|
318
|
+
* When CLIProxy is not running, we cannot analyze the image.
|
|
319
|
+
* Blocking prevents the image from loading into Claude's context (100K+ tokens).
|
|
320
|
+
*/
|
|
321
|
+
function outputCliProxyUnavailableFallback(filePath) {
|
|
322
|
+
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
|
323
|
+
|
|
324
|
+
// Keep message minimal to avoid context pollution and hallucination
|
|
325
|
+
const message = [
|
|
326
|
+
'[Image Read Blocked]',
|
|
327
|
+
'',
|
|
328
|
+
`File: ${fileName}`,
|
|
329
|
+
'',
|
|
330
|
+
'CLIProxy unavailable. Image blocked to prevent context overflow.',
|
|
331
|
+
].join('\n');
|
|
332
|
+
|
|
333
|
+
const output = {
|
|
334
|
+
decision: 'block',
|
|
335
|
+
reason: 'CLIProxy unavailable - image blocked to prevent context overflow',
|
|
336
|
+
systemMessage: `[Image Blocked] ${fileName} - CLIProxy unavailable. Start: mcc config`,
|
|
337
|
+
hookSpecificOutput: {
|
|
338
|
+
hookEventName: 'PreToolUse',
|
|
339
|
+
permissionDecision: 'deny',
|
|
340
|
+
permissionDecisionReason: message,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
console.log(JSON.stringify(output));
|
|
345
|
+
process.exit(2);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Output success response and exit
|
|
350
|
+
*/
|
|
351
|
+
function outputSuccess(filePath, description, model, fileSize) {
|
|
352
|
+
debugLog('Returning analysis result', {
|
|
353
|
+
file: path.basename(filePath),
|
|
354
|
+
model: model,
|
|
355
|
+
descriptionLength: `${description.length} chars`,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const formattedDescription = formatDescription(filePath, description, model, fileSize);
|
|
359
|
+
|
|
360
|
+
const output = {
|
|
361
|
+
decision: 'block',
|
|
362
|
+
reason: `Image analyzed: ${path.basename(filePath)}`,
|
|
363
|
+
systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy (${model})`,
|
|
364
|
+
hookSpecificOutput: {
|
|
365
|
+
hookEventName: 'PreToolUse',
|
|
366
|
+
permissionDecision: 'deny',
|
|
367
|
+
permissionDecisionReason: formattedDescription,
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
console.log(JSON.stringify(output));
|
|
372
|
+
process.exit(2);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Determine if hook should skip, with debug logging
|
|
377
|
+
*/
|
|
378
|
+
function shouldSkipHook() {
|
|
379
|
+
if (process.env.MCC_IMAGE_ANALYSIS_SKIP_HOOK === '1') {
|
|
380
|
+
debugLog('Skipping: MCC_IMAGE_ANALYSIS_SKIP_HOOK=1');
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Explicit skip signal
|
|
385
|
+
if (process.env.MCC_IMAGE_ANALYSIS_SKIP === '1') {
|
|
386
|
+
debugLog('Skipping: MCC_IMAGE_ANALYSIS_SKIP=1');
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Explicit disable
|
|
391
|
+
if (process.env.MCC_IMAGE_ANALYSIS_ENABLED === '0') {
|
|
392
|
+
debugLog('Skipping: image analysis disabled (MCC_IMAGE_ANALYSIS_ENABLED=0)');
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Account/default profiles - use native Read
|
|
397
|
+
const profileType = process.env.MCC_PROFILE_TYPE;
|
|
398
|
+
if (profileType === 'account' || profileType === 'default') {
|
|
399
|
+
debugLog(`Skipping: profile type "${profileType}" uses native Read`);
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Check if current provider has a vision model configured
|
|
404
|
+
const explicitModel = process.env.MCC_IMAGE_ANALYSIS_MODEL;
|
|
405
|
+
const currentProvider = process.env.MCC_CURRENT_PROVIDER || '';
|
|
406
|
+
const providerModels = parseProviderModels(process.env.MCC_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
|
407
|
+
|
|
408
|
+
if (!explicitModel?.trim() && !providerModels[currentProvider]) {
|
|
409
|
+
debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, {
|
|
410
|
+
configured_providers: Object.keys(providerModels).join(', ') || 'none',
|
|
411
|
+
});
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ============================================================================
|
|
419
|
+
// MAIN HOOK LOGIC
|
|
420
|
+
// ============================================================================
|
|
421
|
+
|
|
422
|
+
// Read input from stdin
|
|
423
|
+
let input = '';
|
|
424
|
+
process.stdin.setEncoding('utf8');
|
|
425
|
+
process.stdin.on('data', (chunk) => {
|
|
426
|
+
input += chunk;
|
|
427
|
+
});
|
|
428
|
+
process.stdin.on('end', () => {
|
|
429
|
+
processHook();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Handle stdin not being available
|
|
433
|
+
process.stdin.on('error', () => {
|
|
434
|
+
process.exit(0);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Main hook processing logic
|
|
439
|
+
*
|
|
440
|
+
* Two-phase design: Phase 1 filters non-image Read calls silently (exit 0).
|
|
441
|
+
* Phase 2 only runs for confirmed image/PDF files, so error messages are
|
|
442
|
+
* always relevant and never confuse users reading code or text files.
|
|
443
|
+
*/
|
|
444
|
+
async function processHook() {
|
|
445
|
+
// Phase 1: Fast bail-out for non-image files
|
|
446
|
+
// Any failure here → pass through silently to native Read
|
|
447
|
+
let filePath;
|
|
448
|
+
try {
|
|
449
|
+
const data = JSON.parse(input);
|
|
450
|
+
|
|
451
|
+
// Only handle Read tool
|
|
452
|
+
if (data.tool_name !== 'Read') {
|
|
453
|
+
process.exit(0);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
filePath = data.tool_input?.file_path || '';
|
|
457
|
+
|
|
458
|
+
if (!filePath) {
|
|
459
|
+
process.exit(0);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Check file extension BEFORE any other processing — this is the key gate
|
|
463
|
+
// that ensures non-image Read calls never see hook errors
|
|
464
|
+
if (!isAnalyzableFile(filePath)) {
|
|
465
|
+
process.exit(0);
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
// stdin parse failure or unexpected error → pass through silently
|
|
469
|
+
process.exit(0);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Phase 2: Image/PDF file processing — errors here are relevant to the user
|
|
473
|
+
try {
|
|
474
|
+
// Skip for native accounts or explicit disable
|
|
475
|
+
if (shouldSkipHook()) {
|
|
476
|
+
process.exit(0);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!fs.existsSync(filePath)) {
|
|
480
|
+
process.exit(0);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const debugContext = getDebugContext(filePath, null);
|
|
484
|
+
debugLog('Image analysis runtime prepared', debugContext);
|
|
485
|
+
|
|
486
|
+
const result = await analyzeFile(filePath);
|
|
487
|
+
outputSuccess(filePath, result.description, result.model, result.fileSize);
|
|
488
|
+
} catch (err) {
|
|
489
|
+
log.debug(`Error: ${err.message}`);
|
|
490
|
+
|
|
491
|
+
// filePath is guaranteed set by Phase 1 — only image files reach here
|
|
492
|
+
|
|
493
|
+
// Categorize error by message pattern
|
|
494
|
+
const errMsg = err.message || '';
|
|
495
|
+
|
|
496
|
+
if (errMsg.startsWith('FILE_TOO_LARGE:')) {
|
|
497
|
+
const fileSizeMb = Number.parseInt(errMsg.split(':')[1], 10) / 1024 / 1024;
|
|
498
|
+
outputFileTooLargeError(filePath, fileSizeMb, 10);
|
|
499
|
+
} else if (errMsg.startsWith('AUTH_ERROR:')) {
|
|
500
|
+
const statusCode = parseInt(errMsg.split(':')[1], 10);
|
|
501
|
+
outputAuthError(filePath, statusCode);
|
|
502
|
+
} else if (errMsg.startsWith('RATE_LIMIT:')) {
|
|
503
|
+
const retryAfter = errMsg.split(':')[1];
|
|
504
|
+
outputRateLimitError(filePath, retryAfter ? parseInt(retryAfter, 10) : null);
|
|
505
|
+
} else if (errMsg.startsWith('API_ERROR:')) {
|
|
506
|
+
const parts = errMsg.split(':');
|
|
507
|
+
const statusCode = parseInt(parts[1], 10);
|
|
508
|
+
const body = parts.slice(2).join(':');
|
|
509
|
+
outputApiError(filePath, statusCode, body);
|
|
510
|
+
} else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) {
|
|
511
|
+
const timeout = parseInt(process.env.MCC_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
|
512
|
+
outputTimeoutError(filePath, timeout);
|
|
513
|
+
} else if (
|
|
514
|
+
errMsg.includes('ECONNREFUSED') ||
|
|
515
|
+
errMsg.includes('ENOTFOUND') ||
|
|
516
|
+
errMsg.includes('ENETUNREACH') ||
|
|
517
|
+
errMsg.includes('EAI_AGAIN')
|
|
518
|
+
) {
|
|
519
|
+
outputCliProxyUnavailableFallback(filePath);
|
|
520
|
+
} else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) {
|
|
521
|
+
outputFileAccessError(filePath, errMsg);
|
|
522
|
+
} else {
|
|
523
|
+
outputUnknownError(filePath, errMsg);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|