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 +2 -0
- package/README.md +61 -0
- package/bin/husky-ai.js +2 -0
- package/dist/commands/init.js +95 -0
- package/dist/commands/review.js +97 -0
- package/dist/index.js +17 -0
- package/dist/utils/config.js +22 -0
- package/dist/utils/renderer.js +48 -0
- package/husky-ai-1.0.0.tgz +0 -0
- package/package.json +44 -0
package/.gitattributes
ADDED
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
|
+

|
|
8
|
+

|
|
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
|
package/bin/husky-ai.js
ADDED
|
@@ -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
|
+
}
|