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 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
+ ![Node.js](https://img.shields.io/badge/Node.js-18+-339933?logo=node.js&logoColor=white)
4
+ ![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?logo=typescript&logoColor=white)
5
+ ![Gemini](https://img.shields.io/badge/Gemini-AI-8E75B2?logo=google&logoColor=white)
6
+ ![License](https://img.shields.io/badge/License-MIT-green)
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,2 @@
1
+ export { program } from './program.js';
2
+ export { analyzeAction } from './analyze.js';
@@ -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,2 @@
1
+ export { ConfigService } from './service.js';
2
+ export { getConfigPath } from './constants.js';
@@ -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,4 @@
1
+ #!/usr/bin/env node
2
+ import { program, analyzeAction } from './cli/index.js';
3
+ program.action(analyzeAction);
4
+ program.parse(process.argv);
@@ -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,2 @@
1
+ export * from './gemini.js';
2
+ export * from './models.js';
@@ -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,2 @@
1
+ export * from './input.js';
2
+ export * from './ui.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
+ }
@@ -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
+ }