codeflow-hook 1.1.0 → 1.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.
Files changed (2) hide show
  1. package/bin/codeflow-hook.js +284 -99
  2. package/package.json +1 -1
@@ -6,7 +6,7 @@ import ora from 'ora';
6
6
  import axios from 'axios';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
- import { execSync } from 'child_process';
9
+ import os from 'os'; // Make sure os is imported
10
10
  import readline from 'readline';
11
11
 
12
12
  const program = new Command();
@@ -25,94 +25,168 @@ program
25
25
  .option('-u, --url <url>', 'Custom API URL (optional)')
26
26
  .option('-m, --model <model>', 'AI model name (optional - uses provider default)')
27
27
  .action(async (options) => {
28
- const configDir = path.join(process.env.HOME, '.codeflow-hook');
28
+ // Use USERPROFILE on Windows instead of HOME which might be undefined
29
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
30
+ const configDir = path.join(homeDir, '.codeflow-hook');
29
31
  if (!fs.existsSync(configDir)) {
30
32
  fs.mkdirSync(configDir, { recursive: true });
31
33
  }
32
34
 
33
35
  const configPath = path.join(configDir, 'config.json');
36
+
37
+ // Configuration cascade: Check for project-level config first, then fall back to global
38
+ const projectConfigPath = path.join(process.cwd(), '.codeflowrc.json');
39
+ const projectConfig = fs.existsSync(projectConfigPath) ? JSON.parse(fs.readFileSync(projectConfigPath, 'utf8')) : {};
40
+
34
41
  const existingConfig = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {};
35
42
 
36
- // Initialize config with existing values, then override with CLI options
43
+ // Show config cascade message
44
+ if (Object.keys(projectConfig).length > 0) {
45
+ console.log(chalk.blue('📁 Using configuration cascade (project → global):'));
46
+ console.log(chalk.gray(` Project config: ${projectConfigPath}`));
47
+ if (fs.existsSync(configPath)) {
48
+ console.log(chalk.gray(` Global config: ${configPath}`));
49
+ }
50
+ }
51
+
52
+ // Determine what the new provider and API key should be
53
+ const requestedProvider = (options.provider || existingConfig.provider || 'gemini').toLowerCase();
54
+ const requestedApiKey = options.key || existingConfig.apiKey;
55
+
56
+ // FIX: Explicit boolean coercion to prevent || operator from returning non-boolean values
57
+ // Step by step to ensure proper boolean evaluation
58
+ const hasNewKey = !!options.key;
59
+ const isFirstTimeSetup = !existingConfig.provider && !existingConfig.apiKey;
60
+ const shouldValidate = hasNewKey || isFirstTimeSetup;
61
+
62
+ console.log('VALIDATION DEBUG - provider:', requestedProvider, 'key_exists:', !!options.key, 'shouldValidate:', shouldValidate);
63
+
64
+ if (shouldValidate && requestedApiKey) {
65
+ console.log(chalk.blue('🔐 Validating API key for provider:', requestedProvider));
66
+ const validationSpinner = ora('Checking key permissions...').start();
67
+
68
+ try {
69
+ await validateApiKey(requestedProvider, requestedApiKey);
70
+ validationSpinner.succeed('API key validated');
71
+ } catch (error) {
72
+ validationSpinner.fail('Validation failed');
73
+ console.error(chalk.red(`❌ ${error.message}`));
74
+ console.error(chalk.red(`💡 Make sure you're using a valid ${requestedProvider.toUpperCase()} API key for the ${requestedProvider} provider.`));
75
+ process.exit(1);
76
+ }
77
+ } else {
78
+ console.log(chalk.yellow('âš ī¸ Validation skipped - no API key validation needed'));
79
+ }
80
+
81
+ // Initialize config with existing values, then override with verified CLI options
37
82
  const config = {
38
- provider: options.provider || existingConfig.provider || 'gemini',
39
- apiKey: options.key || existingConfig.apiKey,
83
+ provider: requestedProvider,
84
+ apiKey: requestedApiKey,
40
85
  apiUrl: options.url || existingConfig.apiUrl,
41
86
  model: options.model || existingConfig.model
42
87
  };
43
88
 
44
- // Set default API URL and model if not provided by options or existing config
89
+ // Interactive model selection if model is not explicitly provided
90
+ if (!options.model) {
91
+ // FIX: More robust logic for when to fetch new models
92
+ const isNewProvider = existingConfig.provider && (existingConfig.provider.toLowerCase() !== config.provider.toLowerCase());
93
+ const hasNewKey = !!options.key;
94
+
95
+ const shouldFetchModels = !existingConfig.model || isNewProvider || hasNewKey;
96
+
97
+ if (shouldFetchModels) {
98
+ console.log(chalk.blue('🔍 Fetching available models...'));
99
+
100
+ const modelSpinner = ora('Contacting API...').start();
101
+ try {
102
+ const models = await fetchModels(config.provider, config.apiKey);
103
+ modelSpinner.succeed('Models fetched successfully');
104
+
105
+ console.log(chalk.blue('Available models:'));
106
+ models.forEach((model, index) => {
107
+ console.log(chalk.gray(` ${index + 1}. ${model}`));
108
+ });
109
+
110
+ const rl = readline.createInterface({
111
+ input: process.stdin,
112
+ output: process.stdout
113
+ });
114
+
115
+ const selectedModel = await new Promise(resolve => {
116
+ rl.question(chalk.blue(`Enter the model name (or number): `), (input) => {
117
+ rl.close();
118
+ resolve(input.trim());
119
+ });
120
+ });
121
+
122
+ // Check if user entered a number
123
+ const index = parseInt(selectedModel) - 1;
124
+ if (!isNaN(index) && index >= 0 && index < models.length) {
125
+ config.model = models[index];
126
+ } else {
127
+ config.model = selectedModel;
128
+ }
129
+
130
+ console.log(chalk.green(`✓ Selected model: ${config.model}`));
131
+
132
+ } catch (error) {
133
+ modelSpinner.fail('Failed to fetch models');
134
+ console.error(chalk.red(`❌ API Error: ${error.message}`));
135
+ // Fallback to hardcoded list if API call fails
136
+ const fallbackModels = getFallbackModels(config.provider);
137
+
138
+ console.log(chalk.yellow('âš ī¸ Using fallback model list:'));
139
+ fallbackModels.forEach((model, index) => {
140
+ console.log(chalk.gray(` ${index + 1}. ${model}`));
141
+ });
142
+
143
+ const rl = readline.createInterface({
144
+ input: process.stdin,
145
+ output: process.stdout
146
+ });
147
+
148
+ const selectedModel = await new Promise(resolve => {
149
+ rl.question(chalk.blue(`Select a model (name or number): `), (input) => {
150
+ rl.close();
151
+ resolve(input.trim());
152
+ });
153
+ });
154
+
155
+ const index = parseInt(selectedModel) - 1;
156
+ if (!isNaN(index) && index >= 0 && index < fallbackModels.length) {
157
+ config.model = fallbackModels[index];
158
+ } else {
159
+ config.model = selectedModel;
160
+ }
161
+
162
+ console.log(chalk.green(`✓ Selected fallback model: ${config.model}`));
163
+ }
164
+ } else {
165
+ // Reuse existing model and config
166
+ console.log(chalk.blue(`📚 Using existing configuration (provider: ${existingConfig.provider}, model: ${existingConfig.model})`));
167
+ }
168
+ } else {
169
+ // User explicitly provided model
170
+ console.log(chalk.green(`✓ Using specified model: ${config.model}`));
171
+ }
172
+
173
+ // Set default API URL if not provided by options or existing config
45
174
  if (!config.apiUrl) {
46
175
  switch (config.provider) {
47
176
  case 'openai':
48
177
  config.apiUrl = 'https://api.openai.com/v1/chat/completions';
49
- config.model = config.model || 'gpt-4'; // Default model for OpenAI
50
178
  break;
51
179
  case 'claude':
52
180
  config.apiUrl = 'https://api.anthropic.com/v1/messages';
53
- config.model = config.model || 'claude-3-sonnet-20240229'; // Default model for Claude
54
181
  break;
55
182
  case 'gemini':
56
183
  default:
57
- // Updated Gemini API URL to v1
58
- config.apiUrl = 'https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent';
59
- // Default model for Gemini
60
- config.model = config.model || 'gemini-pro'; // Using 'gemini-pro' as a common default
184
+ // Updated Gemini API URL to v1 - using a base URL
185
+ config.apiUrl = 'https://generativelanguage.googleapis.com/v1/models';
61
186
  break;
62
187
  }
63
188
  }
64
189
 
65
- // Interactive model selection if model is not explicitly provided via CLI option
66
- // AND if the model is not already set (either from existing config or default)
67
- if (!options.model && !config.model) {
68
- try {
69
- switch (config.provider) {
70
- case 'gemini':
71
- const geminiModels = ['gemini-1.5-pro-latest', 'gemini-1.5-flash-latest', 'gemini-pro'];
72
- const geminiRl = readline.createInterface({
73
- input: process.stdin,
74
- output: process.stdout
75
- });
76
- config.model = await new Promise(resolve => {
77
- geminiRl.question(chalk.blue(`Select a Gemini model (${geminiModels.join(', ')}): `), (model) => {
78
- geminiRl.close();
79
- resolve(model || config.model); // Use input or current model if empty
80
- });
81
- });
82
- break;
83
- case 'openai':
84
- const openaiModels = ['gpt-4o', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'];
85
- const openaiRl = readline.createInterface({
86
- input: process.stdin,
87
- output: process.stdout
88
- });
89
- config.model = await new Promise(resolve => {
90
- openaiRl.question(chalk.blue(`Select an OpenAI model (${openaiModels.join(', ')}): `), (model) => {
91
- openaiRl.close();
92
- resolve(model || config.model);
93
- });
94
- });
95
- break;
96
- case 'claude':
97
- const claudeModels = ['claude-3-opus-20240229', 'claude-3-sonnet-20240229', 'claude-3-haiku-20240307'];
98
- const claudeRl = readline.createInterface({
99
- input: process.stdin,
100
- output: process.stdout
101
- });
102
- config.model = await new Promise(resolve => {
103
- claudeRl.question(chalk.blue(`Select a Claude model (${claudeModels.join(', ')}): `), (model) => {
104
- claudeRl.close();
105
- resolve(model || config.model);
106
- });
107
- });
108
- break;
109
- }
110
- } catch (error) {
111
- console.error(chalk.red(`Error selecting model: ${error.message}`));
112
- process.exit(1); // Exit if model selection fails
113
- }
114
- }
115
-
116
190
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
117
191
  console.log(chalk.green(`✅ Configuration saved for ${config.provider} provider`));
118
192
  if (config.model) {
@@ -127,63 +201,45 @@ program
127
201
  .option('--hooks-dir <dir>', 'Custom hooks directory', '.git/hooks')
128
202
  .action(async (options) => {
129
203
  const spinner = ora('Installing git hooks...').start();
130
-
131
204
  try {
132
205
  const hooksDir = path.resolve(options.hooksDir);
133
206
  if (!fs.existsSync(hooksDir)) {
134
207
  fs.mkdirSync(hooksDir, { recursive: true });
135
208
  }
136
209
 
137
- // Create pre-commit hook
210
+ // CHANGE 1: The git hooks are modified to PIPE the diff content via stdin
138
211
  const preCommitHook = `#!/usr/bin/env bash
139
212
  # Codeflow pre-commit hook
140
- # Auto-generated by codeflow-hook CLI
141
-
142
213
  set -e
143
-
144
214
  echo "đŸ”Ŧ Running Codeflow AI Code Analysis..."
145
-
146
- # Get staged changes
147
215
  STAGED_DIFF=$(git diff --cached --no-color)
148
-
149
216
  if [ -z "$STAGED_DIFF" ]; then
150
217
  echo "â„šī¸ No staged changes to analyze"
151
218
  exit 0
152
219
  fi
153
-
154
- # Run AI analysis
155
- npx codeflow-hook analyze-diff "$STAGED_DIFF"
220
+ # Use stdin to avoid "command line too long" error
221
+ echo "$STAGED_DIFF" | npx codeflow-hook analyze-diff
156
222
  `;
157
223
 
158
224
  fs.writeFileSync(path.join(hooksDir, 'pre-commit'), preCommitHook, { mode: 0o755 });
159
225
 
160
- // Create pre-push hook (enhanced version)
161
226
  const prePushHook = `#!/usr/bin/env bash
162
227
  # Codeflow pre-push hook
163
- # Auto-generated by codeflow-hook CLI
164
-
165
228
  set -e
166
-
167
229
  echo "🚀 Running Codeflow CI/CD simulation..."
168
-
169
- # Run tests if available
170
230
  if [ -f "package.json" ]; then
171
231
  echo "đŸ§Ē Running tests..."
172
232
  npm test || (echo "❌ Tests failed" && exit 1)
173
233
  fi
174
-
175
- # Get staged changes for AI analysis
176
234
  STAGED_DIFF=$(git diff --cached --no-color)
177
-
178
235
  if [ -n "$STAGED_DIFF" ]; then
179
236
  echo "đŸ”Ŧ Running AI Code Review..."
180
- npx codeflow-hook analyze-diff "$STAGED_DIFF" || exit 1
237
+ # Use stdin to avoid "command line too long" error
238
+ echo "$STAGED_DIFF" | npx codeflow-hook analyze-diff || exit 1
181
239
  fi
182
-
183
240
  echo "✅ All checks passed!"
184
241
  exit 0
185
242
  `;
186
-
187
243
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), prePushHook, { mode: 0o755 });
188
244
 
189
245
  spinner.succeed('Git hooks installed successfully');
@@ -202,10 +258,21 @@ exit 0
202
258
  program
203
259
  .command('analyze-diff')
204
260
  .description('Analyze git diff with configured AI provider')
205
- .argument('<diff>', 'Git diff content')
261
+ // CHANGE 2: The argument is now OPTIONAL (square brackets)
262
+ .argument('[diff]', 'Git diff content')
206
263
  .action(async (diff) => {
207
264
  try {
208
- const configPath = path.join(process.env.HOME, '.codeflow-hook', 'config.json');
265
+ // CHANGE 3: New logic block to read from stdin if no argument is given
266
+ let diffContent = diff;
267
+ if (!diffContent) {
268
+ const chunks = [];
269
+ for await (const chunk of process.stdin) {
270
+ chunks.push(chunk);
271
+ }
272
+ diffContent = Buffer.concat(chunks).toString('utf8');
273
+ }
274
+
275
+ const configPath = path.join(os.homedir(), '.codeflow-hook', 'config.json');
209
276
 
210
277
  if (!fs.existsSync(configPath)) {
211
278
  console.log(chalk.red('No configuration found. Run: codeflow-hook config -k <api-key>'));
@@ -214,13 +281,13 @@ program
214
281
 
215
282
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
216
283
 
217
- if (diff.trim() === '') {
284
+ if (diffContent.trim() === '') {
218
285
  console.log(chalk.gray('â„šī¸ No changes to analyze'));
219
286
  return;
220
287
  }
221
288
 
222
289
  const spinner = ora(`Analyzing code with ${config.provider}...`).start();
223
- const prompt = generateCodeReviewPrompt(diff);
290
+ const prompt = generateCodeReviewPrompt(diffContent);
224
291
 
225
292
  let result;
226
293
  try {
@@ -232,8 +299,6 @@ program
232
299
  }
233
300
 
234
301
  spinner.succeed('Analysis complete');
235
-
236
- // Parse and display results
237
302
  displayAnalysisResults(result);
238
303
 
239
304
  } catch (error) {
@@ -250,12 +315,27 @@ program
250
315
  console.log(chalk.blue('🔍 Codeflow Hook Status'));
251
316
  console.log();
252
317
 
253
- // Check configuration
254
- const configPath = path.join(process.env.HOME, '.codeflow-hook', 'config.json');
255
- if (fs.existsSync(configPath)) {
256
- console.log(chalk.green('✅ Configuration: Found'));
318
+ // Check configuration cascade
319
+ const globalConfigPath = path.join(os.homedir(), '.codeflow-hook', 'config.json');
320
+ const projectConfigPath = path.join(process.cwd(), '.codeflowrc.json');
321
+
322
+ const hasGlobalConfig = fs.existsSync(globalConfigPath);
323
+ const hasProjectConfig = fs.existsSync(projectConfigPath);
324
+
325
+ if (hasProjectConfig) {
326
+ console.log(chalk.green('✅ Project Configuration: Found (.codeflowrc.json)'));
327
+ } else {
328
+ console.log(chalk.gray('â„šī¸ Project Configuration: Not found'));
329
+ }
330
+
331
+ if (hasGlobalConfig) {
332
+ console.log(chalk.green('✅ Global Configuration: Found'));
257
333
  } else {
258
- console.log(chalk.red('❌ Configuration: Not found (run: codeflow-hook config)'));
334
+ console.log(chalk.red('❌ Global Configuration: Not found (run: codeflow-hook config)'));
335
+ }
336
+
337
+ if (!hasGlobalConfig && !hasProjectConfig) {
338
+ console.log(chalk.red('❌ No configuration found. Run: codeflow-hook config -k <api-key>'));
259
339
  }
260
340
 
261
341
  // Check git hooks
@@ -274,8 +354,111 @@ program
274
354
  } else {
275
355
  console.log(chalk.red('❌ Git Hook (pre-push): Not installed'));
276
356
  }
357
+
358
+ console.log();
359
+ console.log(chalk.blue('💡 Tips:'));
360
+ console.log(chalk.gray(' â€ĸ Create .codeflowrc.json in project root for project-specific settings'));
361
+ console.log(chalk.gray(' â€ĸ Large diffs (>20KB) will prompt for confirmation to avoid high costs'));
362
+ console.log(chalk.gray(' â€ĸ Run "codeflow-hook config -h" for configuration options'));
277
363
  });
278
364
 
365
+
366
+ async function validateApiKey(provider, apiKey) {
367
+ console.log(`DEBUG: validateApiKey called for provider: ${provider}`);
368
+ if (!apiKey) {
369
+ throw new Error('No API key provided');
370
+ }
371
+
372
+ try {
373
+ console.log(`DEBUG: Making API call for ${provider} validation...`);
374
+ switch (provider) {
375
+ case 'gemini':
376
+ // Test Gemini API key by making a simple models list request
377
+ const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`;
378
+ console.log(`DEBUG: Gemini URL: ${geminiUrl.substring(0, 80)}...`);
379
+ await axios.get(geminiUrl);
380
+ console.log(`DEBUG: Gemini API call succeeded`);
381
+ break;
382
+ case 'openai':
383
+ const openaiUrl = 'https://api.openai.com/v1/models';
384
+ console.log(`DEBUG: OpenAI URL: ${openaiUrl} with Bearer token`);
385
+ await axios.get(openaiUrl, {
386
+ headers: { 'Authorization': `Bearer ${apiKey}` }
387
+ });
388
+ console.log(`DEBUG: OpenAI API call succeeded`);
389
+ break;
390
+ case 'claude':
391
+ // Test Claude API key with a minimal request (Anthropic doesn't have models endpoint, so we use a very basic check)
392
+ console.log(`DEBUG: Claude validation call`);
393
+ try {
394
+ await axios.post('https://api.anthropic.com/v1/messages', {
395
+ model: 'claude-3-haiku-20240307',
396
+ max_tokens: 1,
397
+ messages: [{ role: 'user', content: 'Test' }]
398
+ }, {
399
+ headers: {
400
+ 'x-api-key': apiKey,
401
+ 'anthropic-version': '2023-06-01',
402
+ 'Content-Type': 'application/json'
403
+ }
404
+ });
405
+ } catch (claudeError) {
406
+ console.log(`DEBUG: Claude error status: ${claudeError.response?.status}`);
407
+ if (claudeError.response?.status === 401) {
408
+ throw new Error('Invalid Claude API key');
409
+ }
410
+ // If it's a different error (like rate limit), we'll allow it through
411
+ }
412
+ break;
413
+ default:
414
+ throw new Error(`Unsupported provider: ${provider}`);
415
+ }
416
+ } catch (error) {
417
+ console.log(`DEBUG: Validation failed with error: ${error.message}`);
418
+ console.log(`DEBUG: Error response status: ${error.response?.status}`);
419
+ if (error.response?.status === 401) {
420
+ throw new Error(`Invalid ${provider} API key`);
421
+ } else if (error.response?.status === 403) {
422
+ throw new Error(`API key lacks permissions for ${provider}`);
423
+ } else if (error.response?.status === 429) {
424
+ throw new Error(`API rate limit exceeded. Please try again later.`);
425
+ } else {
426
+ throw new Error(`${provider} API is currently unavailable: ${error.message}`);
427
+ }
428
+ }
429
+ }
430
+
431
+ async function fetchModels(provider, apiKey) {
432
+ switch (provider) {
433
+ case 'gemini':
434
+ const geminiResponse = await axios.get(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
435
+ return geminiResponse.data.models.map(m => m.name.replace('models/', ''));
436
+ case 'openai':
437
+ const openaiResponse = await axios.get('https://api.openai.com/v1/models', {
438
+ headers: { 'Authorization': `Bearer ${apiKey}` }
439
+ });
440
+ return openaiResponse.data.data.map(m => m.id);
441
+ case 'claude':
442
+ console.log(chalk.yellow("Claude does not support dynamic model fetching. Using a standard list."));
443
+ return getFallbackModels('claude');
444
+ default:
445
+ return [];
446
+ }
447
+ }
448
+
449
+ function getFallbackModels(provider) {
450
+ switch (provider) {
451
+ case 'gemini':
452
+ return ['gemini-1.5-pro-latest', 'gemini-1.5-flash-latest', 'gemini-pro'];
453
+ case 'openai':
454
+ return ['gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'];
455
+ case 'claude':
456
+ return ['claude-3-opus-20240229', 'claude-3-sonnet-20240229', 'claude-3-haiku-20240307'];
457
+ default:
458
+ return [];
459
+ }
460
+ }
461
+
279
462
  function generateCodeReviewPrompt(diff) {
280
463
  return `You are "Codeflow", a world-class AI software engineering assistant acting as a Principal Engineer. Your mission is to perform a rigorous and constructive code review on the provided code changes.
281
464
 
@@ -330,7 +513,8 @@ async function callGemini(config, prompt) {
330
513
  }
331
514
  };
332
515
 
333
- const response = await axios.post(`${config.apiUrl}?key=${config.apiKey}`, payload, {
516
+ const url = `${config.apiUrl}/${config.model}:generateContent?key=${config.apiKey}`;
517
+ const response = await axios.post(url, payload, {
334
518
  headers: {
335
519
  'Content-Type': 'application/json'
336
520
  }
@@ -406,4 +590,5 @@ function displayAnalysisResults(result) {
406
590
  console.log();
407
591
  }
408
592
 
409
- program.parse();
593
+ // Make sure the final line uses parseAsync
594
+ program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeflow-hook",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "An interactive CI/CD simulator and lightweight pre-push code reviewer using Gemini AI",
5
5
  "type": "module",
6
6
  "main": "index.js",