xoegit 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/LICENSE.md +9 -0
- package/README.md +165 -0
- package/dist/cli/analyze.js +97 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/program.js +8 -0
- package/dist/config/constants.js +16 -0
- package/dist/config/index.js +2 -0
- package/dist/config/service.js +47 -0
- package/dist/git/index.js +1 -0
- package/dist/git/service.js +40 -0
- package/dist/index.js +4 -0
- package/dist/prompts/index.js +1 -0
- package/dist/prompts/service.js +38 -0
- package/dist/prompts/templates/RULES.md +126 -0
- package/dist/providers/gemini.js +87 -0
- package/dist/providers/index.js +2 -0
- package/dist/providers/models.js +39 -0
- package/dist/services/config.service.js +124 -0
- package/dist/services/gemini.service.js +50 -0
- package/dist/services/git.service.js +45 -0
- package/dist/services/prompt.service.js +39 -0
- package/dist/types/config.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/input.js +67 -0
- package/dist/utils/ui.js +86 -0
- package/package.json +59 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ilham alfath
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# xoegit
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
**xoegit** is an AI-powered CLI tool that generates concise, semantic, and atomic git commit messages and PR descriptions. It analyzes your `git diff`, `git status`, and `git log` to provide context-aware suggestions powered by Google's Gemini models.
|
|
9
|
+
|
|
10
|
+
> **Philosophy:** "Craft, Don't Code" — `xoegit` suggests commands; YOU execute them. You stay in control.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Atomic Commits** — Automatically suggests splitting large changes into multiple logical commits
|
|
15
|
+
- **Context Aware** — Provide context with `--context` for more accurate commit messages
|
|
16
|
+
- **Smart Fallback** — Automatically switches between Gemini models when rate limits are hit
|
|
17
|
+
- **Semantic Commits** — Strictly follows [Conventional Commits](https://www.conventionalcommits.org/)
|
|
18
|
+
- **PR Ready** — Generates ready-to-use PR title and description
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
### Prerequisites
|
|
23
|
+
|
|
24
|
+
- **Node.js**: Version 18 or higher
|
|
25
|
+
- **Git**: Must be installed and available in your PATH
|
|
26
|
+
- **API Key**: A Google Gemini API key ([get one here](https://aistudio.google.com/))
|
|
27
|
+
|
|
28
|
+
### Quick Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone git@github.com:ujangdoubleday/xoegit.git
|
|
32
|
+
cd xoegit
|
|
33
|
+
make
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> **Note:** If you encounter permission errors, run `sudo make global`
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
### First Run
|
|
41
|
+
|
|
42
|
+
Simply run `xoegit` for the first time. It will prompt you for your API Key securely and save it.
|
|
43
|
+
|
|
44
|
+
### Manual Configuration
|
|
45
|
+
|
|
46
|
+
**Option 1: Environment Variable**
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export XOEGIT_GEMINI_API_KEY="your-key-here"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Option 2: Config File**
|
|
53
|
+
|
|
54
|
+
- Linux: `~/.config/xoegit/config.json`
|
|
55
|
+
- macOS: `~/Library/Application Support/xoegit/config.json`
|
|
56
|
+
- Windows: `%APPDATA%\xoegit\config.json`
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"XOEGIT_GEMINI_API_KEY": "your-key-here"
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
xoegit
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Options
|
|
71
|
+
|
|
72
|
+
| Option | Description |
|
|
73
|
+
| ---------------------- | --------------------------------------------- |
|
|
74
|
+
| `-k, --api-key <key>` | Use specific API key for this session |
|
|
75
|
+
| `-c, --context <text>` | Provide context for more accurate suggestions |
|
|
76
|
+
| `-V, --version` | Show version |
|
|
77
|
+
| `-h, --help` | Show help |
|
|
78
|
+
|
|
79
|
+
### Examples
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Basic usage
|
|
83
|
+
xoegit
|
|
84
|
+
|
|
85
|
+
# With context for better commit type detection
|
|
86
|
+
xoegit --context "refactoring folder structure"
|
|
87
|
+
xoegit -c "fixing authentication bug"
|
|
88
|
+
xoegit -c "adding new payment feature"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Sample Output
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
xoegit — AI-powered commit generator
|
|
95
|
+
|
|
96
|
+
Suggestion generated!
|
|
97
|
+
|
|
98
|
+
commit 1
|
|
99
|
+
git add src/auth/login.ts
|
|
100
|
+
git commit -m "feat(auth): add login validation"
|
|
101
|
+
|
|
102
|
+
commit 2
|
|
103
|
+
git add src/utils/logger.ts
|
|
104
|
+
git commit -m "refactor(utils): improve error logging"
|
|
105
|
+
|
|
106
|
+
pr title: feat(auth): implement secure login
|
|
107
|
+
pr description: feat(auth): implement secure login
|
|
108
|
+
- feat(auth): add login validation
|
|
109
|
+
- refactor(utils): improve error logging
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Smart Model Fallback
|
|
113
|
+
|
|
114
|
+
xoegit uses multiple Gemini models with automatic fallback:
|
|
115
|
+
|
|
116
|
+
| Model | Priority |
|
|
117
|
+
| ----------------------- | ------------- |
|
|
118
|
+
| `gemini-2.5-flash-lite` | 1st (default) |
|
|
119
|
+
| `gemini-2.5-flash` | 2nd |
|
|
120
|
+
| `gemini-3-flash` | 3rd |
|
|
121
|
+
|
|
122
|
+
When one model hits its rate limit, xoegit automatically tries the next one.
|
|
123
|
+
|
|
124
|
+
## Troubleshooting
|
|
125
|
+
|
|
126
|
+
### "Current directory is not a git repository"
|
|
127
|
+
|
|
128
|
+
- Ensure you're inside a valid git repo (`git init`)
|
|
129
|
+
|
|
130
|
+
### "No changes detected"
|
|
131
|
+
|
|
132
|
+
- Make sure you have modified, staged, or untracked files
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# Install dependencies
|
|
138
|
+
npm install
|
|
139
|
+
|
|
140
|
+
# Build
|
|
141
|
+
npm run build
|
|
142
|
+
|
|
143
|
+
# Run tests
|
|
144
|
+
npm test
|
|
145
|
+
|
|
146
|
+
# Watch mode for tests
|
|
147
|
+
npm run test:watch
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Project Structure
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
src/
|
|
154
|
+
├── cli/ # CLI program and actions
|
|
155
|
+
├── config/ # Configuration management
|
|
156
|
+
├── git/ # Git operations
|
|
157
|
+
├── prompts/ # AI prompt templates
|
|
158
|
+
├── providers/ # Gemini AI integration
|
|
159
|
+
├── types/ # TypeScript types
|
|
160
|
+
└── utils/ # Utilities (input, UI)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
[MIT](LICENSE.md)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import ora from 'ora';
|
|
2
|
+
import { program } from './program.js';
|
|
3
|
+
import { ConfigService } from '../config/index.js';
|
|
4
|
+
import { isValidApiKey, promptApiKey, showBanner, showSuggestion, showSuccess, showError, showWarning, showInfo, showTip, spinnerText } from '../utils/index.js';
|
|
5
|
+
import { getGitDiff, getGitLog, getGitStatus, isGitRepository } from '../git/index.js';
|
|
6
|
+
import { generateSystemPrompt } from '../prompts/index.js';
|
|
7
|
+
import { generateCommitSuggestion } from '../providers/index.js';
|
|
8
|
+
/**
|
|
9
|
+
* Main analyze action - orchestrates the commit suggestion flow
|
|
10
|
+
*/
|
|
11
|
+
export async function analyzeAction() {
|
|
12
|
+
// Show beautiful banner
|
|
13
|
+
showBanner();
|
|
14
|
+
try {
|
|
15
|
+
// 0. Check API Key Config
|
|
16
|
+
const options = program.opts();
|
|
17
|
+
let apiKey = options.apiKey;
|
|
18
|
+
const configService = new ConfigService();
|
|
19
|
+
if (!apiKey) {
|
|
20
|
+
apiKey = await configService.getApiKey();
|
|
21
|
+
}
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
showWarning('Gemini API Key not found.');
|
|
24
|
+
showInfo('Get one at https://aistudio.google.com/');
|
|
25
|
+
try {
|
|
26
|
+
apiKey = await promptApiKey();
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
showError('Input Error', 'Failed to read input.');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
if (!apiKey || !isValidApiKey(apiKey)) {
|
|
33
|
+
showError('Configuration Error', 'API Key is required to use xoegit.');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
await configService.saveApiKey(apiKey);
|
|
37
|
+
showSuccess('API Key saved successfully!');
|
|
38
|
+
}
|
|
39
|
+
// 1. Check if git repo
|
|
40
|
+
const isRepo = await isGitRepository();
|
|
41
|
+
if (!isRepo) {
|
|
42
|
+
showError('Git Error', 'Current directory is not a git repository.');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
// 2. Fetch Git Info
|
|
46
|
+
const spinner = ora({
|
|
47
|
+
text: spinnerText.analyzing,
|
|
48
|
+
spinner: 'dots12'
|
|
49
|
+
}).start();
|
|
50
|
+
const [diff, status, log] = await Promise.all([
|
|
51
|
+
getGitDiff(),
|
|
52
|
+
getGitStatus(),
|
|
53
|
+
getGitLog()
|
|
54
|
+
]);
|
|
55
|
+
// Check if there are any changes
|
|
56
|
+
const hasDiff = diff && diff.trim() !== 'Unstaged Changes:\n\n\nStaged Changes:';
|
|
57
|
+
let hasUntracked = false;
|
|
58
|
+
try {
|
|
59
|
+
const statusObj = JSON.parse(status);
|
|
60
|
+
hasUntracked = statusObj.not_added && statusObj.not_added.length > 0;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
// failed to parse
|
|
64
|
+
}
|
|
65
|
+
if (!hasDiff && !hasUntracked) {
|
|
66
|
+
spinner.stop();
|
|
67
|
+
showWarning('No changes detected (staged, unstaged, or untracked).');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// 3. Generate Prompt
|
|
71
|
+
const systemPrompt = await generateSystemPrompt();
|
|
72
|
+
// Get user context if provided
|
|
73
|
+
const userContext = options.context || '';
|
|
74
|
+
if (userContext) {
|
|
75
|
+
spinner.text = spinnerText.generatingWithContext(userContext);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
spinner.text = spinnerText.generating;
|
|
79
|
+
}
|
|
80
|
+
// 4. Call AI (automatic model fallback on rate limit)
|
|
81
|
+
try {
|
|
82
|
+
const suggestion = await generateCommitSuggestion(apiKey, systemPrompt, diff, status, log, userContext);
|
|
83
|
+
spinner.stop();
|
|
84
|
+
showSuccess('Suggestion generated!');
|
|
85
|
+
showSuggestion(suggestion);
|
|
86
|
+
showTip('Copy and execute the commands above. xoegit never runs commands automatically.');
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
spinner.stop();
|
|
90
|
+
showError('Generation Failed', error.message);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
showError('Unexpected Error', error.message);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
export const program = new Command();
|
|
3
|
+
program
|
|
4
|
+
.name('xoegit')
|
|
5
|
+
.description('AI-powered git commit generator')
|
|
6
|
+
.version('0.1.0')
|
|
7
|
+
.option('-k, --api-key <key>', 'Gemini API Key')
|
|
8
|
+
.option('-c, --context <context>', 'Context for the changes (e.g., "refactoring folder structure")');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
/**
|
|
4
|
+
* Gets the platform-specific config file path
|
|
5
|
+
*/
|
|
6
|
+
export function getConfigPath() {
|
|
7
|
+
const homeDir = os.homedir();
|
|
8
|
+
switch (process.platform) {
|
|
9
|
+
case 'win32':
|
|
10
|
+
return path.join(process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), 'xoegit', 'config.json');
|
|
11
|
+
case 'darwin':
|
|
12
|
+
return path.join(homeDir, 'Library', 'Application Support', 'xoegit', 'config.json');
|
|
13
|
+
default: // Linux and others
|
|
14
|
+
return path.join(homeDir, '.config', 'xoegit', 'config.json');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getConfigPath } from './constants.js';
|
|
4
|
+
import { isValidApiKey } from '../utils/index.js';
|
|
5
|
+
export class ConfigService {
|
|
6
|
+
configPath;
|
|
7
|
+
constructor() {
|
|
8
|
+
this.configPath = getConfigPath();
|
|
9
|
+
}
|
|
10
|
+
async getApiKey() {
|
|
11
|
+
// 1. Check environment variable
|
|
12
|
+
if (process.env.XOEGIT_GEMINI_API_KEY) {
|
|
13
|
+
return process.env.XOEGIT_GEMINI_API_KEY;
|
|
14
|
+
}
|
|
15
|
+
// 2. Check config file
|
|
16
|
+
try {
|
|
17
|
+
const configStr = await fs.readFile(this.configPath, 'utf-8');
|
|
18
|
+
const config = JSON.parse(configStr);
|
|
19
|
+
if (config.XOEGIT_GEMINI_API_KEY && isValidApiKey(config.XOEGIT_GEMINI_API_KEY)) {
|
|
20
|
+
return config.XOEGIT_GEMINI_API_KEY;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
// Config file doesn't exist or is invalid, ignore
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
async saveApiKey(apiKey) {
|
|
29
|
+
try {
|
|
30
|
+
const dir = path.dirname(this.configPath);
|
|
31
|
+
await fs.mkdir(dir, { recursive: true });
|
|
32
|
+
let config = {};
|
|
33
|
+
try {
|
|
34
|
+
const existing = await fs.readFile(this.configPath, 'utf-8');
|
|
35
|
+
config = JSON.parse(existing);
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
config.XOEGIT_GEMINI_API_KEY = apiKey;
|
|
41
|
+
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw new Error(`Failed to save configuration: ${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './service.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { simpleGit } from 'simple-git';
|
|
2
|
+
const git = simpleGit();
|
|
3
|
+
/**
|
|
4
|
+
* Checks if the current directory is a git repository
|
|
5
|
+
*/
|
|
6
|
+
export async function isGitRepository() {
|
|
7
|
+
try {
|
|
8
|
+
return await git.checkIsRepo();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Gets the current git status
|
|
16
|
+
*/
|
|
17
|
+
export async function getGitStatus() {
|
|
18
|
+
const status = await git.status();
|
|
19
|
+
return JSON.stringify(status, null, 2);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Gets the diff of staged and unstaged changes
|
|
23
|
+
*/
|
|
24
|
+
export async function getGitDiff() {
|
|
25
|
+
const diff = await git.diff();
|
|
26
|
+
const diffCached = await git.diff(['--cached']);
|
|
27
|
+
return `Unstaged Changes:\n${diff}\n\nStaged Changes:\n${diffCached}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Gets the git log (recent commits)
|
|
31
|
+
*/
|
|
32
|
+
export async function getGitLog(maxCount = 5) {
|
|
33
|
+
try {
|
|
34
|
+
const log = await git.log({ maxCount });
|
|
35
|
+
return JSON.stringify(log.all, null, 2);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return "No commits yet.";
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './service.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import url from 'url';
|
|
4
|
+
/**
|
|
5
|
+
* Generates the system prompt for the AI
|
|
6
|
+
*/
|
|
7
|
+
export async function generateSystemPrompt() {
|
|
8
|
+
let rulesContent = '';
|
|
9
|
+
try {
|
|
10
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
11
|
+
const rulesPath = path.resolve(__dirname, './templates/RULES.md');
|
|
12
|
+
rulesContent = await fs.readFile(rulesPath, 'utf-8');
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
console.warn('Could not read RULES.md, using default rules.');
|
|
16
|
+
rulesContent = 'Follow conventional commits.';
|
|
17
|
+
}
|
|
18
|
+
return `
|
|
19
|
+
You are a Git Commit Assistant for the 'xoegit' CLI.
|
|
20
|
+
Your goal is to suggest git commands and commit messages based on the provided changes.
|
|
21
|
+
|
|
22
|
+
1. Analyze the provided "Git Diff", "Git Status", and "Git Log".
|
|
23
|
+
2. Generate a valid 'git add' command.
|
|
24
|
+
3. Generate a valid 'git commit' command following Conventional Commits.
|
|
25
|
+
4. Strictly follow the rules defined below.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
RULES FROM USER:
|
|
29
|
+
${rulesContent}
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
IMPORTANT:
|
|
33
|
+
- You definitely MUST NOT execute commands. You only suggest them.
|
|
34
|
+
- Output MUST be strictly valid shell commands or clear instructions as per the examples in RULES.md.
|
|
35
|
+
- If the changes are huge, suggest splitting them if instructed by the Rules, or just one big commit if not specified.
|
|
36
|
+
- The user is using a CLI. Return the response in a way that is easy to read.
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Git Workflow Agent
|
|
2
|
+
|
|
3
|
+
An agent that helps generate git commit messages, PR titles, and squash messages following team conventions. You execute all commands manually.
|
|
4
|
+
|
|
5
|
+
## 1. Agent Purpose
|
|
6
|
+
|
|
7
|
+
The agent will **only suggest/generate**:
|
|
8
|
+
|
|
9
|
+
- Atomic commits (splitting changes into logical units)
|
|
10
|
+
- PR title in proper format
|
|
11
|
+
- Squash message for PR merge
|
|
12
|
+
|
|
13
|
+
**Agent will NOT**:
|
|
14
|
+
|
|
15
|
+
- Suggest single large commits for unrelated changes
|
|
16
|
+
- Auto-commit your changes
|
|
17
|
+
|
|
18
|
+
## 2. REQUIRED Output Format
|
|
19
|
+
|
|
20
|
+
You must ALWAYS output in this exact format. Do not use markdown code blocks for the commands themselves, but separate commits clearly.
|
|
21
|
+
|
|
22
|
+
**CRITICAL:** Split the changes into **AS MANY ATOMIC COMMITS AS NEEDED**. Do not limit yourself to 1 or 2 commits if the changes cover multiple distinct scopes or purposes.
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
commit 1
|
|
26
|
+
git add <files for commit 1>
|
|
27
|
+
git commit -m "<type>(<scope>): <subject>"
|
|
28
|
+
|
|
29
|
+
commit 2
|
|
30
|
+
git add <files for commit 2>
|
|
31
|
+
git commit -m "<type>(<scope>): <subject>"
|
|
32
|
+
|
|
33
|
+
... (add more commits as needed) ...
|
|
34
|
+
|
|
35
|
+
commit N
|
|
36
|
+
git add <files for commit N>
|
|
37
|
+
git commit -m "<type>(<scope>): <subject>"
|
|
38
|
+
|
|
39
|
+
pr title: <type>(<scope>): <summary>
|
|
40
|
+
pr description: <type>(<scope>): <summary>
|
|
41
|
+
- <commit 1 message>
|
|
42
|
+
- <commit 2 message>
|
|
43
|
+
- ... (list all commits)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Example Output (showing 3 commits, but could be more):**
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
commit 1
|
|
50
|
+
git add src/auth/login.ts
|
|
51
|
+
git commit -m "feat(auth): add login validation"
|
|
52
|
+
|
|
53
|
+
commit 2
|
|
54
|
+
git add src/utils/logger.ts
|
|
55
|
+
git commit -m "refactor(utils): improve error logging"
|
|
56
|
+
|
|
57
|
+
commit 3
|
|
58
|
+
git add package.json
|
|
59
|
+
git commit -m "chore: update dependencies"
|
|
60
|
+
|
|
61
|
+
pr title: feat(auth): implement secure login and maintenance
|
|
62
|
+
pr description: feat(auth): implement secure login and maintenance
|
|
63
|
+
- feat(auth): add login validation
|
|
64
|
+
- refactor(utils): improve error logging
|
|
65
|
+
- chore: update dependencies
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 3. How to Use the Agent
|
|
69
|
+
|
|
70
|
+
### Step 1: Analyze Your Changes
|
|
71
|
+
|
|
72
|
+
Run these commands to see your changes:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git status
|
|
76
|
+
git diff
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Step 2: Agent Response
|
|
80
|
+
|
|
81
|
+
Agent will analyze the diff and `git status` and automatically split changes into atomic commits.
|
|
82
|
+
|
|
83
|
+
## 4. Commit Message Convention
|
|
84
|
+
|
|
85
|
+
### Prefix Types
|
|
86
|
+
|
|
87
|
+
- **feat:** new feature
|
|
88
|
+
- **fix:** bug fix
|
|
89
|
+
- **chore:** maintenance (dependencies, configs)
|
|
90
|
+
- **refactor:** code restructuring
|
|
91
|
+
- **docs:** documentation only
|
|
92
|
+
- **style:** formatting, whitespace
|
|
93
|
+
- **perf:** performance improvement
|
|
94
|
+
- **test:** add or update tests
|
|
95
|
+
- **build:** build system changes
|
|
96
|
+
- **ci:** CI/CD changes
|
|
97
|
+
- **revert:** revert previous commit
|
|
98
|
+
|
|
99
|
+
### Format
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
<type>(<scope>): <description>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Rules
|
|
106
|
+
|
|
107
|
+
- All lowercase
|
|
108
|
+
- Description under 72 characters
|
|
109
|
+
- Use imperative mood ("add" not "added")
|
|
110
|
+
- No period at the end
|
|
111
|
+
|
|
112
|
+
## 5. PR Title & PR Description Convention
|
|
113
|
+
|
|
114
|
+
- **PR Title**: summaries the entire set of changes.
|
|
115
|
+
- **PR Description**: matches the PR title and includes a detailed list of changes.
|
|
116
|
+
|
|
117
|
+
## 6. Logic for Splitting Commits
|
|
118
|
+
|
|
119
|
+
- Group changes by **scope** (e.g., auth, ui, api).
|
|
120
|
+
- separating **refactors** from **features**.
|
|
121
|
+
- separating **tests** from **implementation** (unless TDD implies otherwise, but often usually better to keep atomic).
|
|
122
|
+
- separating **config/chore** changes (package.json, .gitignore) from **code** changes.
|
|
123
|
+
|
|
124
|
+
## 7. User Instructions
|
|
125
|
+
|
|
126
|
+
If the user provides specific instructions like "split into 3 commits", follow them. Otherwise, determine the best split logically.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
import { getModelList } from './models.js';
|
|
3
|
+
/**
|
|
4
|
+
* Check if error is a rate limit error (429)
|
|
5
|
+
*/
|
|
6
|
+
function isRateLimitError(error) {
|
|
7
|
+
const message = error?.message || '';
|
|
8
|
+
return message.includes('429') || message.includes('Too Many Requests') || message.includes('quota');
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Generate content with a specific model
|
|
12
|
+
*/
|
|
13
|
+
async function tryGenerateWithModel(genAI, modelName, systemPrompt, userMessage) {
|
|
14
|
+
const model = genAI.getGenerativeModel({ model: modelName });
|
|
15
|
+
const result = await model.generateContent([
|
|
16
|
+
{ text: systemPrompt },
|
|
17
|
+
{ text: userMessage }
|
|
18
|
+
]);
|
|
19
|
+
const response = await result.response;
|
|
20
|
+
return response.text();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Generates a commit suggestion using the Google Generative AI SDK (Gemini)
|
|
24
|
+
* Automatically falls back to other models when rate limit is hit
|
|
25
|
+
*/
|
|
26
|
+
export async function generateCommitSuggestion(apiKey, systemPrompt, diff, status, log, context = '') {
|
|
27
|
+
const genAI = new GoogleGenerativeAI(apiKey);
|
|
28
|
+
// Parse status to find untracked files
|
|
29
|
+
let untrackedMsg = '';
|
|
30
|
+
try {
|
|
31
|
+
const statusObj = JSON.parse(status);
|
|
32
|
+
if (statusObj.not_added && statusObj.not_added.length > 0) {
|
|
33
|
+
untrackedMsg = `
|
|
34
|
+
Untracked Files (New Files):
|
|
35
|
+
${statusObj.not_added.join('\n')}
|
|
36
|
+
|
|
37
|
+
IMPORTANT: The above files are NEW and untracked. You MUST suggest 'git add' for them and include them in commits based on their names/purpose.
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
// metadata parsing failed, just ignore
|
|
43
|
+
}
|
|
44
|
+
// Build context section if provided
|
|
45
|
+
const contextSection = context ? `
|
|
46
|
+
USER CONTEXT (IMPORTANT - This describes the overall purpose of these changes):
|
|
47
|
+
"${context}"
|
|
48
|
+
|
|
49
|
+
Use this context to determine the appropriate commit type (feat, fix, refactor, chore, etc.) and to write more accurate commit messages.
|
|
50
|
+
` : '';
|
|
51
|
+
const userMessage = `
|
|
52
|
+
${contextSection}
|
|
53
|
+
Git Status:
|
|
54
|
+
${status}
|
|
55
|
+
|
|
56
|
+
${untrackedMsg}
|
|
57
|
+
|
|
58
|
+
Git Log (Last 5 commits):
|
|
59
|
+
${log}
|
|
60
|
+
|
|
61
|
+
Git Diff:
|
|
62
|
+
${diff}
|
|
63
|
+
|
|
64
|
+
Please suggest the git add command and the git commit message.
|
|
65
|
+
`;
|
|
66
|
+
// Get ordered list of models to try
|
|
67
|
+
const modelsToTry = getModelList();
|
|
68
|
+
const errors = [];
|
|
69
|
+
// Try each model in order, fallback on rate limit
|
|
70
|
+
for (const modelName of modelsToTry) {
|
|
71
|
+
try {
|
|
72
|
+
const result = await tryGenerateWithModel(genAI, modelName, systemPrompt, userMessage);
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (isRateLimitError(error)) {
|
|
77
|
+
errors.push(`${modelName}: rate limited`);
|
|
78
|
+
// Continue to next model
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// Non-rate-limit error, throw immediately
|
|
82
|
+
throw new Error(`Gemini Provider Error: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// All models exhausted
|
|
86
|
+
throw new Error(`All models rate limited. Tried: ${errors.join(', ')}. Please try again later.`);
|
|
87
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Available Gemini models for free tier
|
|
3
|
+
*/
|
|
4
|
+
export const GEMINI_MODELS = {
|
|
5
|
+
'flash-lite': 'gemini-2.5-flash-lite',
|
|
6
|
+
'flash': 'gemini-2.5-flash',
|
|
7
|
+
'flash-3': 'gemini-3-flash',
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Default model to use
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_MODEL = 'flash-lite';
|
|
13
|
+
/**
|
|
14
|
+
* Get model name from key, returns default if invalid
|
|
15
|
+
*/
|
|
16
|
+
export function getModelName(key) {
|
|
17
|
+
if (key && key in GEMINI_MODELS) {
|
|
18
|
+
return GEMINI_MODELS[key];
|
|
19
|
+
}
|
|
20
|
+
return GEMINI_MODELS[DEFAULT_MODEL];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Get list of available model keys for CLI help
|
|
24
|
+
*/
|
|
25
|
+
export function getAvailableModels() {
|
|
26
|
+
return Object.keys(GEMINI_MODELS);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get ordered list of model names for fallback (default first)
|
|
30
|
+
*/
|
|
31
|
+
export function getModelList() {
|
|
32
|
+
const defaultModelName = GEMINI_MODELS[DEFAULT_MODEL];
|
|
33
|
+
const allModels = Object.values(GEMINI_MODELS);
|
|
34
|
+
// Put default model first, then the rest
|
|
35
|
+
return [
|
|
36
|
+
defaultModelName,
|
|
37
|
+
...allModels.filter(m => m !== defaultModelName)
|
|
38
|
+
];
|
|
39
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import readline from 'readline';
|
|
5
|
+
export class ConfigService {
|
|
6
|
+
configPath;
|
|
7
|
+
constructor() {
|
|
8
|
+
this.configPath = this.getConfigPath();
|
|
9
|
+
}
|
|
10
|
+
getConfigPath() {
|
|
11
|
+
const homeDir = os.homedir();
|
|
12
|
+
switch (process.platform) {
|
|
13
|
+
case 'win32':
|
|
14
|
+
return path.join(process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), 'xoegit', 'config.json');
|
|
15
|
+
case 'darwin':
|
|
16
|
+
return path.join(homeDir, 'Library', 'Application Support', 'xoegit', 'config.json');
|
|
17
|
+
default: // Linux and others
|
|
18
|
+
return path.join(homeDir, '.config', 'xoegit', 'config.json');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async getApiKey() {
|
|
22
|
+
// 1. Check environment variable
|
|
23
|
+
if (process.env.XOEGIT_GEMINI_API_KEY) {
|
|
24
|
+
return process.env.XOEGIT_GEMINI_API_KEY;
|
|
25
|
+
}
|
|
26
|
+
// 2. Check config file
|
|
27
|
+
try {
|
|
28
|
+
const configStr = await fs.readFile(this.configPath, 'utf-8');
|
|
29
|
+
const config = JSON.parse(configStr);
|
|
30
|
+
if (config.XOEGIT_GEMINI_API_KEY && this.isValidApiKey(config.XOEGIT_GEMINI_API_KEY)) {
|
|
31
|
+
return config.XOEGIT_GEMINI_API_KEY;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
// Config file doesn't exist or is invalid, ignore
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
isValidApiKey(key) {
|
|
40
|
+
// Basic validation: length > 10, no spaces, allowed chars
|
|
41
|
+
// Gemini keys are typically AIza... (39 chars)
|
|
42
|
+
// We allow A-Z, a-z, 0-9, -, _
|
|
43
|
+
if (!key || key.length < 10)
|
|
44
|
+
return false;
|
|
45
|
+
// Check for non-ASCII characters which cause the ByteString error in headers
|
|
46
|
+
// 9881 is '⚙', checking for ASCII range 33-126
|
|
47
|
+
const asciiRegex = /^[\x21-\x7E]+$/;
|
|
48
|
+
return asciiRegex.test(key);
|
|
49
|
+
}
|
|
50
|
+
async saveApiKey(apiKey) {
|
|
51
|
+
try {
|
|
52
|
+
const dir = path.dirname(this.configPath);
|
|
53
|
+
await fs.mkdir(dir, { recursive: true });
|
|
54
|
+
let config = {};
|
|
55
|
+
try {
|
|
56
|
+
const existing = await fs.readFile(this.configPath, 'utf-8');
|
|
57
|
+
config = JSON.parse(existing);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
// ignore
|
|
61
|
+
}
|
|
62
|
+
config.XOEGIT_GEMINI_API_KEY = apiKey;
|
|
63
|
+
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); // Secure permissions
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new Error(`Failed to save configuration: ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async promptApiKey() {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
process.stdout.write('Please enter your Google Gemini API Key: ');
|
|
72
|
+
// Use raw mode for hidden input if available
|
|
73
|
+
if (process.stdin.setRawMode && process.stdout.isTTY) {
|
|
74
|
+
process.stdin.setRawMode(true);
|
|
75
|
+
process.stdin.resume();
|
|
76
|
+
let input = '';
|
|
77
|
+
const onData = (char) => {
|
|
78
|
+
const charStr = char.toString('utf-8');
|
|
79
|
+
// Enter key
|
|
80
|
+
if (charStr === '\r' || charStr === '\n' || charStr === '\r\n') {
|
|
81
|
+
process.stdin.setRawMode(false);
|
|
82
|
+
process.stdin.removeListener('data', onData);
|
|
83
|
+
process.stdin.pause(); // Pause stdin to allow program to exit naturally if needed
|
|
84
|
+
process.stdout.write('\n');
|
|
85
|
+
const trimmedInput = input.trim();
|
|
86
|
+
if (this.isValidApiKey(trimmedInput)) {
|
|
87
|
+
resolve(trimmedInput);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.error('Invalid API Key format. Please try again.');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Ctrl+C
|
|
96
|
+
if (charStr === '\u0003') {
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
// Backspace
|
|
100
|
+
if (charStr === '\u007f' || charStr === '\b') {
|
|
101
|
+
if (input.length > 0) {
|
|
102
|
+
input = input.slice(0, -1);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
input += charStr;
|
|
107
|
+
};
|
|
108
|
+
process.stdin.on('data', onData);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Fallback for non-TTY environments (or if hidden input not supported)
|
|
112
|
+
const rl = readline.createInterface({
|
|
113
|
+
input: process.stdin,
|
|
114
|
+
output: process.stdout,
|
|
115
|
+
terminal: false // Treat as non-terminal to avoid echoing if possible, though rl.question usually echoes.
|
|
116
|
+
});
|
|
117
|
+
rl.question('', (answer) => {
|
|
118
|
+
rl.close();
|
|
119
|
+
resolve(answer.trim());
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
/**
|
|
3
|
+
* generates a commit suggestion using the Google Generative AI SDK (Gemini)
|
|
4
|
+
*/
|
|
5
|
+
export async function generateCommitSuggestion(apiKey, systemPrompt, diff, status, log) {
|
|
6
|
+
const genAI = new GoogleGenerativeAI(apiKey);
|
|
7
|
+
// use the user-requested model.
|
|
8
|
+
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash-lite' });
|
|
9
|
+
// parse status to find untracked files and make them explicit to the AI
|
|
10
|
+
let untrackedMsg = '';
|
|
11
|
+
try {
|
|
12
|
+
const statusObj = JSON.parse(status);
|
|
13
|
+
if (statusObj.not_added && statusObj.not_added.length > 0) {
|
|
14
|
+
untrackedMsg = `
|
|
15
|
+
Untracked Files (New Files):
|
|
16
|
+
${statusObj.not_added.join('\n')}
|
|
17
|
+
|
|
18
|
+
IMPORTANT: The above files are NEW and untracked. You MUST suggest 'git add' for them and include them in commits based on their names/purpose.
|
|
19
|
+
`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
// metadata parsing failed, just ignore
|
|
24
|
+
}
|
|
25
|
+
const userMessage = `
|
|
26
|
+
Git Status:
|
|
27
|
+
${status}
|
|
28
|
+
|
|
29
|
+
${untrackedMsg}
|
|
30
|
+
|
|
31
|
+
Git Log (Last 5 commits):
|
|
32
|
+
${log}
|
|
33
|
+
|
|
34
|
+
Git Diff:
|
|
35
|
+
${diff}
|
|
36
|
+
|
|
37
|
+
Please suggest the git add command and the git commit message.
|
|
38
|
+
`;
|
|
39
|
+
try {
|
|
40
|
+
const result = await model.generateContent([
|
|
41
|
+
{ text: systemPrompt },
|
|
42
|
+
{ text: userMessage }
|
|
43
|
+
]);
|
|
44
|
+
const response = await result.response;
|
|
45
|
+
return response.text();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new Error(`Gemini Provider Error: ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { simpleGit } from 'simple-git';
|
|
2
|
+
const git = simpleGit();
|
|
3
|
+
/**
|
|
4
|
+
* Checks if the current directory is a git repository
|
|
5
|
+
*/
|
|
6
|
+
export async function isGitRepository() {
|
|
7
|
+
try {
|
|
8
|
+
return await git.checkIsRepo();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Gets the current git status
|
|
16
|
+
*/
|
|
17
|
+
export async function getGitStatus() {
|
|
18
|
+
const status = await git.status();
|
|
19
|
+
return JSON.stringify(status, null, 2);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Gets the diff of staged and unstaged changes
|
|
23
|
+
*/
|
|
24
|
+
export async function getGitDiff() {
|
|
25
|
+
// Get diff of everything (staged and unstaged)
|
|
26
|
+
const diff = await git.diff();
|
|
27
|
+
// Also get staged diff to be thorough, if needed, but 'git diff' usually shows unstaged.
|
|
28
|
+
// 'git diff --cached' shows staged.
|
|
29
|
+
// For the AI to know full context, sending both might be useful, or a combined view.
|
|
30
|
+
// Let's allow the caller to decide or just fetch both.
|
|
31
|
+
const diffCached = await git.diff(['--cached']);
|
|
32
|
+
return `Unstaged Changes:\n${diff}\n\nStaged Changes:\n${diffCached}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Gets the git log (recent commits)
|
|
36
|
+
*/
|
|
37
|
+
export async function getGitLog(maxCount = 5) {
|
|
38
|
+
try {
|
|
39
|
+
const log = await git.log({ maxCount });
|
|
40
|
+
return JSON.stringify(log.all, null, 2);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return "No commits yet.";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import url from 'url';
|
|
4
|
+
/**
|
|
5
|
+
* Generates the system prompt for the AI
|
|
6
|
+
*/
|
|
7
|
+
export async function generateSystemPrompt() {
|
|
8
|
+
let rulesContent = '';
|
|
9
|
+
try {
|
|
10
|
+
// Find rules relative to this file (dist/services/prompt.service.js -> dist/rules/RULES.md)
|
|
11
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
12
|
+
const rulesPath = path.resolve(__dirname, '../rules/RULES.md');
|
|
13
|
+
rulesContent = await fs.readFile(rulesPath, 'utf-8');
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
console.warn('Could not read RULES/RULES.md, using default rules.');
|
|
17
|
+
rulesContent = 'Follow conventional commits.';
|
|
18
|
+
}
|
|
19
|
+
return `
|
|
20
|
+
You are a Git Commit Assistant for the 'xoegit' CLI.
|
|
21
|
+
Your goal is to suggest git commands and commit messages based on the provided changes.
|
|
22
|
+
|
|
23
|
+
1. Analyze the provided "Git Diff", "Git Status", and "Git Log".
|
|
24
|
+
2. Generate a valid 'git add' command.
|
|
25
|
+
3. Generate a valid 'git commit' command following Conventional Commits.
|
|
26
|
+
4. Strictly follow the rules defined below.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
RULES FROM USER:
|
|
30
|
+
${rulesContent}
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
IMPORTANT:
|
|
34
|
+
- You definitely MUST NOT execute commands. You only suggest them.
|
|
35
|
+
- Output MUST be strictly valid shell commands or clear instructions as per the examples in RULES.md.
|
|
36
|
+
- If the changes are huge, suggest splitting them if instructed by the Rules, or just one big commit if not specified.
|
|
37
|
+
- The user is using a CLI. Return the response in a way that is easy to read.
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './config.js';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
/**
|
|
3
|
+
* Validates if a string is a valid API key format
|
|
4
|
+
*/
|
|
5
|
+
export function isValidApiKey(key) {
|
|
6
|
+
if (!key || key.length < 10)
|
|
7
|
+
return false;
|
|
8
|
+
const asciiRegex = /^[\x21-\x7E]+$/;
|
|
9
|
+
return asciiRegex.test(key);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Prompts user for API key input via stdin with hidden input
|
|
13
|
+
*/
|
|
14
|
+
export async function promptApiKey() {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
process.stdout.write('Please enter your Google Gemini API Key: ');
|
|
17
|
+
if (process.stdin.setRawMode && process.stdout.isTTY) {
|
|
18
|
+
process.stdin.setRawMode(true);
|
|
19
|
+
process.stdin.resume();
|
|
20
|
+
let input = '';
|
|
21
|
+
const onData = (char) => {
|
|
22
|
+
const charStr = char.toString('utf-8');
|
|
23
|
+
// Enter key
|
|
24
|
+
if (charStr === '\r' || charStr === '\n' || charStr === '\r\n') {
|
|
25
|
+
process.stdin.setRawMode(false);
|
|
26
|
+
process.stdin.removeListener('data', onData);
|
|
27
|
+
process.stdin.pause();
|
|
28
|
+
process.stdout.write('\n');
|
|
29
|
+
const trimmedInput = input.trim();
|
|
30
|
+
if (isValidApiKey(trimmedInput)) {
|
|
31
|
+
resolve(trimmedInput);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.error('Invalid API Key format. Please try again.');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Ctrl+C
|
|
40
|
+
if (charStr === '\u0003') {
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
// Backspace
|
|
44
|
+
if (charStr === '\u007f' || charStr === '\b') {
|
|
45
|
+
if (input.length > 0) {
|
|
46
|
+
input = input.slice(0, -1);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
input += charStr;
|
|
51
|
+
};
|
|
52
|
+
process.stdin.on('data', onData);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// Fallback for non-TTY environments
|
|
56
|
+
const rl = readline.createInterface({
|
|
57
|
+
input: process.stdin,
|
|
58
|
+
output: process.stdout,
|
|
59
|
+
terminal: false
|
|
60
|
+
});
|
|
61
|
+
rl.question('', (answer) => {
|
|
62
|
+
rl.close();
|
|
63
|
+
resolve(answer.trim());
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
package/dist/utils/ui.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
/**
|
|
3
|
+
* CLI UI Components
|
|
4
|
+
*/
|
|
5
|
+
// Brand colors
|
|
6
|
+
const brand = {
|
|
7
|
+
primary: chalk.hex('#6366F1'), // Indigo
|
|
8
|
+
secondary: chalk.hex('#8B5CF6'), // Violet
|
|
9
|
+
accent: chalk.hex('#06B6D4'), // Cyan
|
|
10
|
+
success: chalk.hex('#10B981'), // Emerald
|
|
11
|
+
warning: chalk.hex('#F59E0B'), // Amber
|
|
12
|
+
error: chalk.hex('#EF4444'), // Red
|
|
13
|
+
muted: chalk.hex('#6B7280'), // Gray
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* App banner
|
|
17
|
+
*/
|
|
18
|
+
export function showBanner() {
|
|
19
|
+
console.log(`\n${brand.primary('⚡')} ${brand.secondary.bold('xoegit')} ${brand.muted('— AI-powered commit generator')}\n`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Display the AI suggestion
|
|
23
|
+
*/
|
|
24
|
+
export function showSuggestion(suggestion) {
|
|
25
|
+
const lines = suggestion.split('\n');
|
|
26
|
+
console.log(brand.accent.bold('\n📝 Suggestion\n'));
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
console.log(formatSuggestionLine(line));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Format individual suggestion lines with subtle highlighting
|
|
33
|
+
*/
|
|
34
|
+
function formatSuggestionLine(line) {
|
|
35
|
+
if (line.startsWith('git add')) {
|
|
36
|
+
return chalk.cyan(line);
|
|
37
|
+
}
|
|
38
|
+
if (line.startsWith('git commit')) {
|
|
39
|
+
return chalk.green(line);
|
|
40
|
+
}
|
|
41
|
+
if (line.match(/^commit \d+$/i)) {
|
|
42
|
+
return brand.secondary.bold(line);
|
|
43
|
+
}
|
|
44
|
+
if (line.startsWith('pr title:') || line.startsWith('pr description:')) {
|
|
45
|
+
return chalk.yellow(line);
|
|
46
|
+
}
|
|
47
|
+
return line;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Show success message
|
|
51
|
+
*/
|
|
52
|
+
export function showSuccess(message) {
|
|
53
|
+
console.log(`${brand.success('✓')} ${message}`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Show error message
|
|
57
|
+
*/
|
|
58
|
+
export function showError(title, message) {
|
|
59
|
+
console.log(`${brand.error('✗')} ${brand.error.bold(title)}: ${message}`);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Show warning message
|
|
63
|
+
*/
|
|
64
|
+
export function showWarning(message) {
|
|
65
|
+
console.log(`${brand.warning('⚠')} ${message}`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Show info message
|
|
69
|
+
*/
|
|
70
|
+
export function showInfo(message) {
|
|
71
|
+
console.log(`${brand.accent('ℹ')} ${brand.muted(message)}`);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Show tip/reminder
|
|
75
|
+
*/
|
|
76
|
+
export function showTip(message) {
|
|
77
|
+
console.log(`\n${brand.muted('💡 ' + message)}`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Spinner text for different stages
|
|
81
|
+
*/
|
|
82
|
+
export const spinnerText = {
|
|
83
|
+
analyzing: brand.muted('Analyzing repository...'),
|
|
84
|
+
generating: brand.muted('Generating suggestion...'),
|
|
85
|
+
generatingWithContext: (ctx) => brand.muted(`Generating with context: "${ctx}"...`),
|
|
86
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xoegit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AI-powered CLI tool for generating semantic git commit messages and PR descriptions",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "ujangdoubleday",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ujangdoubleday/xoegit.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/ujangdoubleday/xoegit/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ujangdoubleday/xoegit#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"bin": {
|
|
18
|
+
"xoegit": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"git",
|
|
30
|
+
"commit",
|
|
31
|
+
"ai",
|
|
32
|
+
"gemini",
|
|
33
|
+
"cli",
|
|
34
|
+
"conventional-commits",
|
|
35
|
+
"commit-message",
|
|
36
|
+
"automation",
|
|
37
|
+
"productivity"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc && mkdir -p dist/prompts/templates && cp src/prompts/templates/RULES.md dist/prompts/templates/RULES.md",
|
|
41
|
+
"start": "node dist/index.js",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"prepublishOnly": "npm run build && npm test"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@google/generative-ai": "^0.24.1",
|
|
48
|
+
"chalk": "^5.6.2",
|
|
49
|
+
"commander": "^14.0.2",
|
|
50
|
+
"ora": "^8.1.0",
|
|
51
|
+
"simple-git": "^3.30.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^25.0.3",
|
|
55
|
+
"ts-node": "^10.9.2",
|
|
56
|
+
"typescript": "^5.7.0",
|
|
57
|
+
"vitest": "^4.0.16"
|
|
58
|
+
}
|
|
59
|
+
}
|