@hileeon/mcc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.claude/CLAUDE.md +204 -0
  2. package/.claude/agents/.gitkeep +0 -0
  3. package/.claude/settings.json +9 -0
  4. package/.claude/skills/.gitkeep +0 -0
  5. package/README.md +127 -0
  6. package/dist/accounts/instance-manager.d.ts +11 -0
  7. package/dist/accounts/instance-manager.d.ts.map +1 -0
  8. package/dist/accounts/instance-manager.js +89 -0
  9. package/dist/accounts/instance-manager.js.map +1 -0
  10. package/dist/accounts/shared-manager.d.ts +25 -0
  11. package/dist/accounts/shared-manager.d.ts.map +1 -0
  12. package/dist/accounts/shared-manager.js +186 -0
  13. package/dist/accounts/shared-manager.js.map +1 -0
  14. package/dist/accounts/store.d.ts +30 -0
  15. package/dist/accounts/store.d.ts.map +1 -0
  16. package/dist/accounts/store.js +128 -0
  17. package/dist/accounts/store.js.map +1 -0
  18. package/dist/core/model-router.d.ts +30 -0
  19. package/dist/core/model-router.d.ts.map +1 -0
  20. package/dist/core/model-router.js +64 -0
  21. package/dist/core/model-router.js.map +1 -0
  22. package/dist/dashboard-server.d.ts +5 -0
  23. package/dist/dashboard-server.d.ts.map +1 -0
  24. package/dist/dashboard-server.js +387 -0
  25. package/dist/dashboard-server.js.map +1 -0
  26. package/dist/mcc.d.ts +8 -0
  27. package/dist/mcc.d.ts.map +1 -0
  28. package/dist/mcc.js +474 -0
  29. package/dist/mcc.js.map +1 -0
  30. package/dist/mcp/external-registry.d.ts +24 -0
  31. package/dist/mcp/external-registry.d.ts.map +1 -0
  32. package/dist/mcp/external-registry.js +99 -0
  33. package/dist/mcp/external-registry.js.map +1 -0
  34. package/dist/mcp/installer.d.ts +31 -0
  35. package/dist/mcp/installer.d.ts.map +1 -0
  36. package/dist/mcp/installer.js +273 -0
  37. package/dist/mcp/installer.js.map +1 -0
  38. package/dist/mcp/mcp-config.d.ts +86 -0
  39. package/dist/mcp/mcp-config.d.ts.map +1 -0
  40. package/dist/mcp/mcp-config.js +178 -0
  41. package/dist/mcp/mcp-config.js.map +1 -0
  42. package/dist/mcp/registry.d.ts +23 -0
  43. package/dist/mcp/registry.d.ts.map +1 -0
  44. package/dist/mcp/registry.js +100 -0
  45. package/dist/mcp/registry.js.map +1 -0
  46. package/dist/proxy/proxy-daemon.d.ts +27 -0
  47. package/dist/proxy/proxy-daemon.d.ts.map +1 -0
  48. package/dist/proxy/proxy-daemon.js +192 -0
  49. package/dist/proxy/proxy-daemon.js.map +1 -0
  50. package/dist/proxy/proxy-entry.d.ts +11 -0
  51. package/dist/proxy/proxy-entry.d.ts.map +1 -0
  52. package/dist/proxy/proxy-entry.js +74 -0
  53. package/dist/proxy/proxy-entry.js.map +1 -0
  54. package/dist/proxy/proxy-paths.d.ts +27 -0
  55. package/dist/proxy/proxy-paths.d.ts.map +1 -0
  56. package/dist/proxy/proxy-paths.js +125 -0
  57. package/dist/proxy/proxy-paths.js.map +1 -0
  58. package/dist/proxy/proxy-server.d.ts +20 -0
  59. package/dist/proxy/proxy-server.d.ts.map +1 -0
  60. package/dist/proxy/proxy-server.js +280 -0
  61. package/dist/proxy/proxy-server.js.map +1 -0
  62. package/dist/proxy/upstream-url.d.ts +7 -0
  63. package/dist/proxy/upstream-url.d.ts.map +1 -0
  64. package/dist/proxy/upstream-url.js +38 -0
  65. package/dist/proxy/upstream-url.js.map +1 -0
  66. package/dist/shared/logger.d.ts +23 -0
  67. package/dist/shared/logger.d.ts.map +1 -0
  68. package/dist/shared/logger.js +184 -0
  69. package/dist/shared/logger.js.map +1 -0
  70. package/dist/shared/provider-preset-catalog.d.ts +41 -0
  71. package/dist/shared/provider-preset-catalog.d.ts.map +1 -0
  72. package/dist/shared/provider-preset-catalog.js +299 -0
  73. package/dist/shared/provider-preset-catalog.js.map +1 -0
  74. package/docs/decisions.md +33 -0
  75. package/docs/lessons.md +8 -0
  76. package/docs/product.md +37 -0
  77. package/lib/mcp/mcc-image-analysis-server.cjs +454 -0
  78. package/lib/mcp/mcc-websearch-server.cjs +339 -0
  79. package/lib/mcp-hooks/image-analysis-runtime.cjs +510 -0
  80. package/lib/mcp-hooks/image-analyzer-transformer.cjs +526 -0
  81. package/lib/mcp-hooks/websearch-transformer.cjs +1421 -0
  82. package/lib/proxy/config/config-loader-facade.js +24 -0
  83. package/lib/proxy/glmt/delta-accumulator.js +363 -0
  84. package/lib/proxy/glmt/glmt-transformer.js +204 -0
  85. package/lib/proxy/glmt/index.js +41 -0
  86. package/lib/proxy/glmt/locale-enforcer.js +69 -0
  87. package/lib/proxy/glmt/pipeline/content-transformer.js +162 -0
  88. package/lib/proxy/glmt/pipeline/index.js +20 -0
  89. package/lib/proxy/glmt/pipeline/request-transformer.js +116 -0
  90. package/lib/proxy/glmt/pipeline/response-builder.js +205 -0
  91. package/lib/proxy/glmt/pipeline/stream-parser.js +234 -0
  92. package/lib/proxy/glmt/pipeline/tool-call-handler.js +78 -0
  93. package/lib/proxy/glmt/pipeline/types.js +6 -0
  94. package/lib/proxy/glmt/reasoning-enforcer.js +151 -0
  95. package/lib/proxy/glmt/sse-parser.js +102 -0
  96. package/lib/proxy/services/logging.js +13 -0
  97. package/lib/proxy/transformers/request-transformer.js +452 -0
  98. package/lib/proxy/transformers/sse-stream-transformer.js +199 -0
  99. package/lib/shared/logger.cjs +138 -0
  100. package/package.json +35 -0
  101. package/src/accounts/instance-manager.ts +58 -0
  102. package/src/accounts/shared-manager.ts +154 -0
  103. package/src/accounts/store.ts +111 -0
  104. package/src/core/model-router.ts +82 -0
  105. package/src/dashboard-server.ts +407 -0
  106. package/src/mcc.ts +474 -0
  107. package/src/mcp/external-registry.ts +73 -0
  108. package/src/mcp/installer.ts +258 -0
  109. package/src/mcp/mcp-config.ts +168 -0
  110. package/src/mcp/registry.ts +89 -0
  111. package/src/proxy/proxy-daemon.ts +184 -0
  112. package/src/proxy/proxy-entry.ts +63 -0
  113. package/src/proxy/proxy-paths.ts +97 -0
  114. package/src/proxy/proxy-server.ts +278 -0
  115. package/src/proxy/upstream-url.ts +38 -0
  116. package/src/shared/logger.ts +140 -0
  117. package/src/shared/provider-preset-catalog.ts +340 -0
  118. package/tsconfig.json +33 -0
  119. package/ui/.prettierrc +9 -0
  120. package/ui/index.html +12 -0
  121. package/ui/package.json +33 -0
  122. package/ui/postcss.config.js +6 -0
  123. package/ui/src/App.tsx +753 -0
  124. package/ui/src/components/ui/button.tsx +48 -0
  125. package/ui/src/components/ui/card.tsx +50 -0
  126. package/ui/src/components/ui/input.tsx +21 -0
  127. package/ui/src/components/ui/label.tsx +20 -0
  128. package/ui/src/components/ui/select.tsx +80 -0
  129. package/ui/src/components/ui/switch.tsx +26 -0
  130. package/ui/src/components/ui/tabs.tsx +52 -0
  131. package/ui/src/index.css +33 -0
  132. package/ui/src/lib/api.ts +185 -0
  133. package/ui/src/lib/utils.ts +6 -0
  134. package/ui/src/main.tsx +10 -0
  135. package/ui/src/vite-env.d.ts +1 -0
  136. package/ui/tailwind.config.js +49 -0
  137. package/ui/tsconfig.json +25 -0
  138. package/ui/vite.config.ts +20 -0
@@ -0,0 +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
+ }