husky-ai 1.0.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/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # 🐶 Husky AI
2
+
3
+ **Zero-config AI Code Reviewer for your git commits.**
4
+
5
+ Husky AI automatically reviews your staged changes before you commit, catching bugs, security issues, and sloppy code using your favorite local AI engine (OpenCode, Claude, etc.).
6
+
7
+ ![License](https://img.shields.io/npm/l/husky-ai)
8
+ ![Version](https://img.shields.io/npm/v/husky-ai)
9
+
10
+ ## ✨ Features
11
+
12
+ - **Zero Config**: Auto-detects your installed AI tools.
13
+ - **Detailed Reviews**: "GitHub-style" comments with code snippets and line numbers.
14
+ - **Smart Gatekeeping**: Blocks commits with critical bugs or security risks, but allows suggestions.
15
+ - **Privacy First**: Runs locally using your existing CLI tools. Your code goes only where your AI tool sends it.
16
+
17
+ ## šŸš€ Quick Start
18
+
19
+ ### 1. Install & Init
20
+ Run this in your project root (we automatically install `husky` if you don't have it):
21
+
22
+ ```bash
23
+ npx husky-ai init
24
+ ```
25
+
26
+ Follow the prompts to select your AI engine (e.g., OpenCode).
27
+
28
+ ### 2. Commit
29
+ Just commit as usual!
30
+
31
+ ```bash
32
+ git add .
33
+ git commit -m "feat: my new feature"
34
+ ```
35
+
36
+ Husky AI will intercept the commit, review the changes, and approve or reject it.
37
+
38
+ ## āš™ļø Configuration
39
+
40
+ Your configuration is stored in `package.json`:
41
+
42
+ ```json
43
+ {
44
+ "huskyAi": {
45
+ "engine": "opencode"
46
+ }
47
+ }
48
+ ```
49
+
50
+ Supported engines:
51
+ - `opencode` (Recommended)
52
+ - `claude` (Coming soon)
53
+ - `codex` (Coming soon)
54
+
55
+ ## šŸ¤ Contributing
56
+
57
+ Pull requests are welcome!
58
+
59
+ ## šŸ“„ License
60
+
61
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/index.js';
@@ -0,0 +1,95 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import execa from 'execa';
4
+ import ora from 'ora';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { setConfig, getPackageJson } from '../utils/config.js';
8
+ async function checkCommand(cmd) {
9
+ try {
10
+ await execa.command(`command -v ${cmd}`);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export async function initCommand() {
18
+ console.log(chalk.bold.cyan('\n🐶 Husky AI Setup\n'));
19
+ // 1. Detect Engines
20
+ const spinner = ora('Detecting AI engines...').start();
21
+ const hasOpenCode = await checkCommand('opencode');
22
+ const hasClaude = await checkCommand('claude');
23
+ const hasCodex = await checkCommand('codex');
24
+ spinner.stop();
25
+ const choices = [];
26
+ if (hasOpenCode)
27
+ choices.push({ name: 'OpenCode (Recommended)', value: 'opencode' });
28
+ if (hasClaude)
29
+ choices.push({ name: 'Claude Code', value: 'claude' });
30
+ if (hasCodex)
31
+ choices.push({ name: 'OpenAI Codex', value: 'codex' });
32
+ choices.push(new inquirer.Separator());
33
+ choices.push({ name: 'Install OpenCode', value: 'install-opencode' });
34
+ // 2. Select Engine
35
+ const { engine } = await inquirer.prompt([
36
+ {
37
+ type: 'list',
38
+ name: 'engine',
39
+ message: 'Which AI engine do you want to use?',
40
+ choices: choices,
41
+ },
42
+ ]);
43
+ // Handle Installation
44
+ if (engine === 'install-opencode') {
45
+ console.log(chalk.yellow('\nPlease install OpenCode manually: https://opencode.dev'));
46
+ return;
47
+ }
48
+ // 3. Verify Auth (Stub for now)
49
+ // TODO: Add actual auth check command here
50
+ // 4. Install Husky
51
+ const pkg = getPackageJson();
52
+ const hasHusky = pkg?.devDependencies?.husky || pkg?.dependencies?.husky;
53
+ if (!hasHusky) {
54
+ const installSpinner = ora('Installing Husky...').start();
55
+ try {
56
+ await execa('npm', ['install', 'husky', '--save-dev']);
57
+ await execa('npx', ['husky', 'init']);
58
+ installSpinner.succeed('Husky installed');
59
+ }
60
+ catch (e) {
61
+ installSpinner.fail('Failed to install Husky');
62
+ console.error(e.message);
63
+ return;
64
+ }
65
+ }
66
+ // 5. Create Hook
67
+ const hookPath = path.join(process.cwd(), '.husky', 'pre-commit');
68
+ const hookCmd = 'npx husky-ai review';
69
+ try {
70
+ // Ensure directory exists
71
+ if (!fs.existsSync(path.dirname(hookPath))) {
72
+ await execa('npx', ['husky', 'init']);
73
+ }
74
+ // Read existing hook
75
+ let currentHook = '';
76
+ if (fs.existsSync(hookPath)) {
77
+ currentHook = fs.readFileSync(hookPath, 'utf-8');
78
+ }
79
+ if (!currentHook.includes('husky-ai review')) {
80
+ fs.appendFileSync(hookPath, `\n# Run AI Code Review\n${hookCmd}\n`);
81
+ console.log(chalk.green('āœ… Added pre-commit hook'));
82
+ }
83
+ else {
84
+ console.log(chalk.gray('ā„¹ļø Pre-commit hook already exists'));
85
+ }
86
+ // Make executable
87
+ await execa('chmod', ['+x', hookPath]);
88
+ }
89
+ catch (e) {
90
+ console.error(chalk.red('Failed to setup hook:'), e.message);
91
+ }
92
+ // 6. Save Config
93
+ setConfig({ engine });
94
+ console.log(chalk.green(`\nšŸŽ‰ Setup complete! Using ${engine}.\n`));
95
+ }
@@ -0,0 +1,97 @@
1
+ import execa from 'execa';
2
+ import chalk from 'chalk';
3
+ import { getConfig } from '../utils/config.js';
4
+ import { renderReview } from '../utils/renderer.js';
5
+ const PROMPT_TEMPLATE = (diff) => `
6
+ I have saved the staged changes below.
7
+ Act as a senior software engineer doing a code review.
8
+ Analyze the changes for:
9
+ - Critical bugs (logic errors, crashes)
10
+ - Security vulnerabilities
11
+ - Best practices & Clean code
12
+ - Performance issues
13
+
14
+ Output your review strictly in this JSON format (no markdown code blocks, just raw JSON if possible, or inside a JSON block):
15
+
16
+ {
17
+ "summary": "Brief bulleted summary of changes",
18
+ "comments": [
19
+ {
20
+ "file": "path/to/file",
21
+ "line_snippet": "quoted code line (or empty)",
22
+ "line_number": number (approximate line number of the issue),
23
+ "type": "issue" | "suggestion" | "praise",
24
+ "message": "Explanation of the finding",
25
+ "critical": boolean
26
+ }
27
+ ],
28
+ "verdict": "PASS" | "FAIL"
29
+ }
30
+
31
+ Rules:
32
+ - Mark 'critical': true ONLY for bugs, security risks, or breaking changes.
33
+ - Use type 'suggestion' for refactoring/style.
34
+ - Use type 'praise' for particularly good code.
35
+ - If everything is good, return verdict PASS and empty/praise comments.
36
+
37
+ CHANGES:
38
+ ${diff}
39
+ `;
40
+ export async function reviewCommand() {
41
+ const config = getConfig();
42
+ // Default to opencode if not configured (or prompt user?)
43
+ const engine = config?.engine || 'opencode';
44
+ // 1. Get Diff
45
+ try {
46
+ const { stdout: diff } = await execa('git', ['diff', '--cached']);
47
+ if (!diff.trim()) {
48
+ // No changes
49
+ process.exit(0);
50
+ }
51
+ console.log(chalk.cyan('šŸ¤– AI Agent is reviewing your changes...'));
52
+ let jsonResponse;
53
+ // 2. Call Engine
54
+ if (engine === 'opencode') {
55
+ // We use the 'run' command and pipe stdin or pass argument
56
+ // Ideally we use a temporary file for large diffs to avoid arg length limits
57
+ const prompt = PROMPT_TEMPLATE(diff);
58
+ // Limit diff size?
59
+ if (diff.length > 50000) {
60
+ console.warn(chalk.yellow('āš ļø Diff is too large, truncating...'));
61
+ }
62
+ // console.log("Sending prompt to OpenCode...");
63
+ const { stdout } = await execa('opencode', ['run', prompt], {
64
+ input: '',
65
+ env: { ...process.env, CI: 'true', NO_COLOR: 'true' }
66
+ });
67
+ // console.log("Got response length:", stdout.length);
68
+ jsonResponse = stdout;
69
+ }
70
+ else {
71
+ console.error(chalk.red(`Engine ${engine} not yet implemented`));
72
+ process.exit(1);
73
+ }
74
+ // 3. Parse JSON
75
+ let review;
76
+ try {
77
+ // Clean up markdown code blocks if present
78
+ const cleanJson = jsonResponse.replace(/```json\n|\n```/g, '').trim();
79
+ // Find JSON object
80
+ const match = cleanJson.match(/\{[\s\S]*\}/);
81
+ if (!match)
82
+ throw new Error('No JSON found');
83
+ review = JSON.parse(match[0]);
84
+ }
85
+ catch (e) {
86
+ console.log(chalk.yellow('āš ļø Could not parse AI response. Raw output:'));
87
+ console.log(jsonResponse);
88
+ process.exit(0); // Fail open?
89
+ }
90
+ // 4. Render
91
+ renderReview(review);
92
+ }
93
+ catch (e) {
94
+ console.error(chalk.red('Error running review:'), e.message);
95
+ process.exit(1);
96
+ }
97
+ }
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import { Command } from 'commander';
2
+ import { initCommand } from './commands/init.js';
3
+ import { reviewCommand } from './commands/review.js';
4
+ const program = new Command();
5
+ program
6
+ .name('husky-ai')
7
+ .description('Zero-config AI code review hook for Git')
8
+ .version('1.0.0');
9
+ program
10
+ .command('init')
11
+ .description('Initialize husky-ai in the current project')
12
+ .action(initCommand);
13
+ program
14
+ .command('review')
15
+ .description('Run AI code review on staged files')
16
+ .action(reviewCommand);
17
+ program.parse(process.argv);
@@ -0,0 +1,22 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ const PKG_PATH = path.join(process.cwd(), 'package.json');
4
+ export function getConfig() {
5
+ if (!fs.existsSync(PKG_PATH))
6
+ return null;
7
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
8
+ return pkg.huskyAi || null;
9
+ }
10
+ export function setConfig(config) {
11
+ if (!fs.existsSync(PKG_PATH)) {
12
+ throw new Error('package.json not found in current directory');
13
+ }
14
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
15
+ pkg.huskyAi = config;
16
+ fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2));
17
+ }
18
+ export function getPackageJson() {
19
+ if (!fs.existsSync(PKG_PATH))
20
+ return null;
21
+ return JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
22
+ }
@@ -0,0 +1,48 @@
1
+ import chalk from 'chalk';
2
+ export function renderReview(review) {
3
+ console.log('\n' + chalk.bold.cyan('šŸ¤– AI Code Review'));
4
+ console.log(chalk.gray('──────────────────────────────────────────────────'));
5
+ // Summary
6
+ if (review.summary) {
7
+ console.log('\n' + chalk.bold('šŸ“ Summary:'));
8
+ review.summary.split('\n').forEach(line => {
9
+ console.log(` ${line.replace(/^- /, '• ')}`);
10
+ });
11
+ }
12
+ // Group comments
13
+ const commentsByFile = {};
14
+ review.comments.forEach(c => {
15
+ if (!commentsByFile[c.file])
16
+ commentsByFile[c.file] = [];
17
+ commentsByFile[c.file].push(c);
18
+ });
19
+ let hasCritical = false;
20
+ Object.keys(commentsByFile).forEach(file => {
21
+ console.log('\n' + chalk.bold.blue(`šŸ“„ ${file}`));
22
+ commentsByFile[file].forEach(comment => {
23
+ const isCritical = comment.critical || comment.type === 'issue';
24
+ if (isCritical)
25
+ hasCritical = true;
26
+ const icon = isCritical ? 'āŒ' : (comment.type === 'suggestion' ? 'šŸ’”' : 'āœ…');
27
+ const colorFn = isCritical ? chalk.red : (comment.type === 'suggestion' ? chalk.yellow : chalk.green);
28
+ const loc = comment.line_number ? `:${comment.line_number}` : '';
29
+ console.log(` ${icon} ${colorFn(chalk.bold(comment.type.toUpperCase()))} ${chalk.gray(`(at line ${comment.line_number || '?'})`)}: ${comment.message}`);
30
+ if (comment.line_snippet) {
31
+ console.log(` ${chalk.gray(`ā”Œā”€ā”€${file}${loc}`)}`);
32
+ console.log(` ${chalk.gray('│')} ${comment.line_snippet.trim()}`);
33
+ console.log(` ${chalk.gray('└──')}`);
34
+ }
35
+ });
36
+ });
37
+ console.log(chalk.gray('\n──────────────────────────────────────────────────'));
38
+ if (review.verdict === 'FAIL' || hasCritical) {
39
+ console.log(chalk.bgRed.bold(' ā›” COMMIT REJECTED ') + chalk.red(' Critical issues found.\n'));
40
+ process.exit(1);
41
+ }
42
+ else {
43
+ const hasSuggestions = review.comments.some(c => c.type === 'suggestion');
44
+ const warning = hasSuggestions ? chalk.yellow('(with suggestions)') : chalk.green('(clean)');
45
+ console.log(chalk.green.bold(' āœ… COMMIT ACCEPTED ') + warning + '\n');
46
+ process.exit(0);
47
+ }
48
+ }
Binary file
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "husky-ai",
3
+ "version": "1.0.0",
4
+ "description": "Zero-config AI code review hook for git commits",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "husky-ai": "./bin/husky-ai.js"
9
+ },
10
+ "scripts": {
11
+ "test": "echo \"Error: no test specified\" && exit 1",
12
+ "build": "tsc",
13
+ "prepare": "npm run build"
14
+ },
15
+ "keywords": [
16
+ "git",
17
+ "hook",
18
+ "husky",
19
+ "ai",
20
+ "code-review",
21
+ "opencode",
22
+ "claude"
23
+ ],
24
+ "author": "Saurav",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/saurav/husky-ai.git"
29
+ },
30
+ "devDependencies": {
31
+ "@types/chalk": "^2.2.4",
32
+ "@types/inquirer": "^9.0.9",
33
+ "@types/node": "^25.0.9",
34
+ "boxen": "^5.1.2",
35
+ "chalk": "^4.1.2",
36
+ "commander": "^14.0.2",
37
+ "execa": "^5.1.1",
38
+ "inquirer": "^9.2.12",
39
+ "ora": "^5.4.1",
40
+ "ts-node": "^10.9.2",
41
+ "typescript": "^5.9.3",
42
+ "zod": "^4.3.5"
43
+ }
44
+ }