@misaelabanto/commita 0.1.2

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/install.sh ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ REPO="misaelabanto/commita"
6
+ BINARY_NAME="commita"
7
+ INSTALL_DIR="/usr/local/bin"
8
+
9
+ # Colors
10
+ RED='\033[0;31m'
11
+ GREEN='\033[0;32m'
12
+ YELLOW='\033[1;33m'
13
+ CYAN='\033[0;36m'
14
+ NC='\033[0m' # No Color
15
+
16
+ info() { echo -e "${CYAN}[commita]${NC} $*"; }
17
+ success() { echo -e "${GREEN}[commita]${NC} $*"; }
18
+ warn() { echo -e "${YELLOW}[commita]${NC} $*"; }
19
+ error() { echo -e "${RED}[commita]${NC} $*" >&2; exit 1; }
20
+
21
+ # Detect OS
22
+ detect_os() {
23
+ local os
24
+ os=$(uname -s | tr '[:upper:]' '[:lower:]')
25
+ case "$os" in
26
+ darwin) echo "darwin" ;;
27
+ linux) echo "linux" ;;
28
+ msys*|mingw*|cygwin*) echo "windows" ;;
29
+ *) error "Unsupported OS: $os" ;;
30
+ esac
31
+ }
32
+
33
+ # Detect architecture
34
+ detect_arch() {
35
+ local arch
36
+ arch=$(uname -m)
37
+ case "$arch" in
38
+ x86_64|amd64) echo "amd64" ;;
39
+ arm64|aarch64) echo "arm64" ;;
40
+ *) error "Unsupported architecture: $arch" ;;
41
+ esac
42
+ }
43
+
44
+ # Check for required tools
45
+ check_deps() {
46
+ for cmd in curl; do
47
+ if ! command -v "$cmd" &>/dev/null; then
48
+ error "'$cmd' is required but not installed."
49
+ fi
50
+ done
51
+ }
52
+
53
+ # Get latest release tag from GitHub API
54
+ get_latest_tag() {
55
+ local tag
56
+ tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
57
+ | grep '"tag_name"' \
58
+ | sed -E 's/.*"([^"]+)".*/\1/')
59
+
60
+ if [ -z "$tag" ]; then
61
+ error "Failed to fetch the latest release tag. Check your internet connection or visit https://github.com/${REPO}/releases."
62
+ fi
63
+
64
+ echo "$tag"
65
+ }
66
+
67
+ main() {
68
+ check_deps
69
+
70
+ local os arch
71
+ os=$(detect_os)
72
+ arch=$(detect_arch)
73
+
74
+ # Windows is not supported via this script — direct users to releases
75
+ if [ "$os" = "windows" ]; then
76
+ error "Windows is not supported by this install script. Please download the binary manually from: https://github.com/${REPO}/releases"
77
+ fi
78
+
79
+ local tag
80
+ tag=$(get_latest_tag)
81
+
82
+ local binary_file="${BINARY_NAME}-${os}-${arch}"
83
+ local download_url="https://github.com/${REPO}/releases/download/${tag}/${binary_file}"
84
+ local tmp_file
85
+ tmp_file=$(mktemp)
86
+
87
+ info "Detected platform: ${os}/${arch}"
88
+ info "Latest version: ${tag}"
89
+ info "Downloading ${binary_file}..."
90
+
91
+ if ! curl -fsSL --progress-bar "$download_url" -o "$tmp_file"; then
92
+ rm -f "$tmp_file"
93
+ error "Download failed. Please check: ${download_url}"
94
+ fi
95
+
96
+ chmod +x "$tmp_file"
97
+
98
+ # Install to INSTALL_DIR, using sudo if needed
99
+ if [ -w "$INSTALL_DIR" ]; then
100
+ mv "$tmp_file" "${INSTALL_DIR}/${BINARY_NAME}"
101
+ else
102
+ warn "Installing to ${INSTALL_DIR} requires elevated privileges."
103
+ sudo mv "$tmp_file" "${INSTALL_DIR}/${BINARY_NAME}"
104
+ fi
105
+
106
+ success "${BINARY_NAME} ${tag} installed to ${INSTALL_DIR}/${BINARY_NAME}"
107
+ echo ""
108
+ echo " Get started: commita --help"
109
+ echo " Docs: https://github.com/${REPO}#readme"
110
+ echo ""
111
+ }
112
+
113
+ main "$@"
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@misaelabanto/commita",
3
+ "version": "0.1.2",
4
+ "description": "AI-powered git auto-commit tool that intelligently groups your changes and generates meaningful commit messages",
5
+ "module": "index.ts",
6
+ "type": "module",
7
+ "private": false,
8
+ "bin": {
9
+ "commita": "./index.ts"
10
+ },
11
+ "scripts": {
12
+ "dev": "bun run index.ts",
13
+ "build": "bun build index.ts --outdir dist --target bun",
14
+ "commita": "bun run index.ts"
15
+ },
16
+ "author": "Misael Abanto",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/misaelabanto/commita.git"
21
+ },
22
+ "homepage": "https://github.com/misaelabanto/commita#readme",
23
+ "dependencies": {
24
+ "@ai-sdk/google": "^2.0.28",
25
+ "@ai-sdk/openai": "^2.0.62",
26
+ "ai": "^5.0.87",
27
+ "chalk": "^5.3.0",
28
+ "commander": "^12.1.0",
29
+ "dotenv": "^16.4.7",
30
+ "minimatch": "^10.0.1",
31
+ "simple-git": "^3.27.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "latest",
35
+ "@types/minimatch": "^5.1.2"
36
+ },
37
+ "peerDependencies": {
38
+ "typescript": "^5"
39
+ }
40
+ }
@@ -0,0 +1,124 @@
1
+ import { CommitTypeAnalyzer } from '@/ai/commit-type-analyzer.ts';
2
+ import { EmojiMapper } from '@/ai/emoji-mapper.ts';
3
+ import type { CommitaConfig } from '@/config/config.types.ts';
4
+ import { PROMPT_TEMPLATES } from '@/config/prompt-templates.ts';
5
+ import { openai, createOpenAI } from '@ai-sdk/openai';
6
+ import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
7
+ import { generateText } from 'ai';
8
+
9
+ export class AIService {
10
+ private config: CommitaConfig;
11
+ private typeAnalyzer: CommitTypeAnalyzer;
12
+ private emojiMapper: EmojiMapper;
13
+
14
+ constructor(config: CommitaConfig) {
15
+ this.config = config;
16
+ this.typeAnalyzer = new CommitTypeAnalyzer();
17
+ this.emojiMapper = new EmojiMapper();
18
+
19
+ this.validateConfig();
20
+ }
21
+
22
+ private validateConfig(): void {
23
+ if (this.config.provider === 'openai' && !this.config.openaiApiKey) {
24
+ throw new Error('OpenAI API key is required. Set it in .commita file or OPENAI_API_KEY env var.');
25
+ }
26
+
27
+ if (this.config.provider === 'gemini' && !this.config.geminiApiKey) {
28
+ throw new Error('Gemini API key is required. Set it in .commita file or GEMINI_API_KEY env var.');
29
+ }
30
+ }
31
+
32
+ async generateCommitMessage(diff: string, files: string[], scope: string): Promise<string> {
33
+ const prompt = this.buildPrompt(diff);
34
+
35
+ try {
36
+ const provider = this.getProvider();
37
+ const model = provider(this.config.model);
38
+
39
+ const { text } = await generateText({
40
+ model,
41
+ system: 'You are a helpful assistant that generates concise git commit messages. The response should be plain text without any markdown formatting.',
42
+ prompt,
43
+ temperature: 0.7,
44
+ maxTokens: 500,
45
+ });
46
+
47
+ let message = text.replace(/^```\n*((.*\n*)+)```$/, '$1').trim() || '';
48
+
49
+ if (!message) {
50
+ const commitType = this.typeAnalyzer.analyzeFromDiff(diff, files);
51
+ message = `${commitType}(${scope}): update files`;
52
+ }
53
+
54
+ if (this.config.commitStyle === 'emoji') {
55
+ message = this.emojiMapper.replaceTypeWithEmoji(message);
56
+ }
57
+
58
+ return message;
59
+ } catch (error) {
60
+ console.error('Error generating commit message:', error);
61
+ const commitType = this.typeAnalyzer.analyzeFromDiff(diff, files);
62
+ let fallbackMessage = `${commitType}(${scope}): update files`;
63
+
64
+ if (this.config.commitStyle === 'emoji') {
65
+ fallbackMessage = this.emojiMapper.replaceTypeWithEmoji(fallbackMessage);
66
+ }
67
+
68
+ return fallbackMessage;
69
+ }
70
+ }
71
+
72
+ private getProvider() {
73
+ if (this.config.provider === 'gemini') {
74
+ if (this.config.geminiApiKey) {
75
+ return createGoogleGenerativeAI({
76
+ apiKey: this.config.geminiApiKey,
77
+ });
78
+ }
79
+ return google;
80
+ }
81
+
82
+ if (this.config.openaiApiKey) {
83
+ return createOpenAI({
84
+ apiKey: this.config.openaiApiKey,
85
+ });
86
+ }
87
+
88
+ return openai;
89
+ }
90
+
91
+ private buildPrompt(diff: string): string {
92
+ let template: string;
93
+
94
+ if (this.config.promptStyle === 'custom') {
95
+ template = this.config.customPrompt || this.config.promptTemplate || PROMPT_TEMPLATES.default;
96
+ } else {
97
+ template = PROMPT_TEMPLATES[this.config.promptStyle];
98
+ }
99
+
100
+ return template.replace('{diff}', this.truncateDiff(diff));
101
+ }
102
+
103
+ private truncateDiff(diff: string, maxLength: number = 8000): string {
104
+ if (diff.length <= maxLength) {
105
+ return diff;
106
+ }
107
+
108
+ const lines = diff.split('\n');
109
+ const truncated: string[] = [];
110
+ let currentLength = 0;
111
+
112
+ for (const line of lines) {
113
+ if (currentLength + line.length > maxLength) {
114
+ truncated.push('\n... (diff truncated for brevity) ...');
115
+ break;
116
+ }
117
+ truncated.push(line);
118
+ currentLength += line.length + 1;
119
+ }
120
+
121
+ return truncated.join('\n');
122
+ }
123
+ }
124
+
@@ -0,0 +1,103 @@
1
+ export type CommitType = 'feat' | 'fix' | 'refactor' | 'chore' | 'docs' | 'style' | 'test' | 'perf';
2
+
3
+ export class CommitTypeAnalyzer {
4
+ analyzeFromDiff(diff: string, files: string[]): CommitType {
5
+ const lowerDiff = diff.toLowerCase();
6
+ const filePaths = files.map(f => f.toLowerCase());
7
+
8
+ if (this.isTest(filePaths, lowerDiff)) return 'test';
9
+ if (this.isDocs(filePaths, lowerDiff)) return 'docs';
10
+ if (this.isChore(filePaths, lowerDiff)) return 'chore';
11
+ if (this.isStyle(lowerDiff)) return 'style';
12
+ if (this.isPerf(lowerDiff)) return 'perf';
13
+ if (this.isFix(lowerDiff)) return 'fix';
14
+ if (this.isFeat(diff, lowerDiff)) return 'feat';
15
+
16
+ return 'refactor';
17
+ }
18
+
19
+ private isTest(files: string[], diff: string): boolean {
20
+ const testPatterns = ['.test.', '.spec.', '__tests__', '/tests/', '/test/'];
21
+ return files.some(f => testPatterns.some(p => f.includes(p))) ||
22
+ diff.includes('test(') || diff.includes('describe(') || diff.includes('it(');
23
+ }
24
+
25
+ private isDocs(files: string[], diff: string): boolean {
26
+ const docPatterns = ['readme', '.md', 'documentation', '/docs/'];
27
+ return files.some(f => docPatterns.some(p => f.includes(p)));
28
+ }
29
+
30
+ private isChore(files: string[], diff: string): boolean {
31
+ const chorePatterns = [
32
+ 'package.json',
33
+ 'package-lock.json',
34
+ 'yarn.lock',
35
+ 'bun.lock',
36
+ '.gitignore',
37
+ 'tsconfig',
38
+ 'webpack',
39
+ 'vite.config',
40
+ '.eslint',
41
+ '.prettier',
42
+ ];
43
+ return files.some(f => chorePatterns.some(p => f.includes(p)));
44
+ }
45
+
46
+ private isStyle(diff: string): boolean {
47
+ const styleKeywords = ['formatting', 'whitespace', 'indent', 'prettier', 'eslint'];
48
+ const hasStyleKeywords = styleKeywords.some(k => diff.includes(k));
49
+
50
+ const hasCodeChanges = diff.includes('function') ||
51
+ diff.includes('class') ||
52
+ diff.includes('const') ||
53
+ diff.includes('import');
54
+
55
+ return hasStyleKeywords && !hasCodeChanges;
56
+ }
57
+
58
+ private isPerf(diff: string): boolean {
59
+ const perfKeywords = [
60
+ 'performance',
61
+ 'optimize',
62
+ 'cache',
63
+ 'memoize',
64
+ 'debounce',
65
+ 'throttle',
66
+ 'lazy',
67
+ 'async',
68
+ 'usememo',
69
+ 'usecallback',
70
+ ];
71
+ return perfKeywords.some(k => diff.includes(k));
72
+ }
73
+
74
+ private isFix(diff: string): boolean {
75
+ const fixKeywords = [
76
+ 'fix',
77
+ 'bug',
78
+ 'issue',
79
+ 'error',
80
+ 'crash',
81
+ 'problem',
82
+ 'resolve',
83
+ 'correct',
84
+ 'patch',
85
+ 'hotfix',
86
+ ];
87
+ return fixKeywords.some(k => diff.includes(k));
88
+ }
89
+
90
+ private isFeat(diff: string, lowerDiff: string): boolean {
91
+ const hasNewFiles = diff.includes('new file mode');
92
+ const hasNewFunctions = lowerDiff.includes('+function') ||
93
+ lowerDiff.includes('+export') ||
94
+ lowerDiff.includes('+const') ||
95
+ lowerDiff.includes('+class');
96
+
97
+ const featKeywords = ['add', 'create', 'implement', 'feature', 'new'];
98
+ const hasFeatKeywords = featKeywords.some(k => lowerDiff.includes(k));
99
+
100
+ return hasNewFiles || (hasNewFunctions && hasFeatKeywords);
101
+ }
102
+ }
103
+
@@ -0,0 +1,29 @@
1
+ import type { CommitType } from '@/ai/commit-type-analyzer.ts';
2
+
3
+ export class EmojiMapper {
4
+ private emojiMap: Record<CommitType, string> = {
5
+ feat: '✨',
6
+ fix: '🐛',
7
+ refactor: '♻️',
8
+ chore: '🔧',
9
+ docs: '📝',
10
+ style: '💄',
11
+ test: '✅',
12
+ perf: '⚡',
13
+ };
14
+
15
+ getEmoji(type: CommitType): string {
16
+ return this.emojiMap[type];
17
+ }
18
+
19
+ replaceTypeWithEmoji(commitMessage: string): string {
20
+ for (const [type, emoji] of Object.entries(this.emojiMap)) {
21
+ const pattern = new RegExp(`^${type}`, 'i');
22
+ if (pattern.test(commitMessage)) {
23
+ return commitMessage.replace(pattern, emoji);
24
+ }
25
+ }
26
+ return commitMessage;
27
+ }
28
+ }
29
+
@@ -0,0 +1,185 @@
1
+ import { AIService } from '@/ai/ai.service.ts';
2
+ import type { CommitaConfig } from '@/config/config.types.ts';
3
+ import { FileGrouper } from '@/git/file-grouper.ts';
4
+ import type { FileChange } from '@/git/git.service.ts';
5
+ import { GitService } from '@/git/git.service.ts';
6
+ import { ProjectDetector } from '@/git/project-detector.ts';
7
+ import { PatternMatcher } from '@/utils/pattern-matcher.ts';
8
+ import chalk from 'chalk';
9
+
10
+ export interface CommitOptions {
11
+ all: boolean;
12
+ ignore: string;
13
+ push: boolean;
14
+ config?: string;
15
+ }
16
+
17
+ export class CommitHandler {
18
+ private gitService: GitService;
19
+ private fileGrouper: FileGrouper;
20
+ private aiService: AIService;
21
+ private config: CommitaConfig;
22
+
23
+ constructor(config: CommitaConfig) {
24
+ this.config = config;
25
+ this.gitService = new GitService();
26
+ this.fileGrouper = new FileGrouper();
27
+ this.aiService = new AIService(config);
28
+ }
29
+
30
+ async execute(options: CommitOptions): Promise<void> {
31
+ console.log(chalk.blue('🤖 Commita - AI-powered auto-commit\n'));
32
+
33
+ await this.gitService.init();
34
+
35
+ const boundaries = ProjectDetector.detect(this.gitService.getRootDir());
36
+ this.fileGrouper = new FileGrouper(boundaries);
37
+
38
+ const patternMatcher = new PatternMatcher(
39
+ PatternMatcher.parsePatterns(options.ignore)
40
+ );
41
+
42
+ try {
43
+ const stagedChanges = await this.gitService.getStagedChanges();
44
+
45
+ if (!options.all && stagedChanges.length === 0) {
46
+ console.error(chalk.red('❌ Error: No staged changes found.\n'));
47
+ console.log(chalk.yellow('Either stage some changes or use the --all flag to process all changes.\n'));
48
+ console.log(chalk.gray('Examples:'));
49
+ console.log(chalk.gray(' git add <files> # Stage specific files'));
50
+ console.log(chalk.gray(' commita --all # Process all changes\n'));
51
+ process.exit(1);
52
+ }
53
+
54
+ if (stagedChanges.length > 0 && !options.all) {
55
+ console.log(chalk.yellow('📦 Found staged changes. Grouping and processing them...\n'));
56
+ await this.processGroupedStagedChanges(stagedChanges);
57
+ } else if (stagedChanges.length > 0 && options.all) {
58
+ console.log(chalk.yellow('⚠️ Ignoring staged changes due to --all flag\n'));
59
+ }
60
+
61
+ if (options.all) {
62
+ await this.processAllChanges(patternMatcher);
63
+ }
64
+
65
+ if (options.push) {
66
+ await this.pushChanges();
67
+ }
68
+
69
+ console.log(chalk.green('\n✨ Done!\n'));
70
+ } catch (error) {
71
+ if (error instanceof Error) {
72
+ console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
73
+ } else {
74
+ console.error(chalk.red('\n❌ An unknown error occurred\n'));
75
+ }
76
+ process.exit(1);
77
+ }
78
+ }
79
+
80
+ private async _processChangesInGroups(changes: FileChange[], isStaged: boolean): Promise<void> {
81
+ if (changes.length === 0) {
82
+ console.log(chalk.yellow(`No ${isStaged ? 'staged' : 'unstaged'} changes found to group. Skipping...`));
83
+ return;
84
+ }
85
+
86
+ if (isStaged) {
87
+ const allFiles = changes.map(f => f.path);
88
+ await this.gitService.unstageFiles(allFiles);
89
+ }
90
+
91
+ const groups = this.fileGrouper.groupByPath(changes);
92
+ const optimizedGroups = this.fileGrouper.optimizeGroups(groups);
93
+
94
+ console.log(chalk.blue(`Found ${optimizedGroups.length} group(s) of ${isStaged ? 'staged' : 'unstaged'} changes:\n`));
95
+
96
+ for (const [index, group] of optimizedGroups.entries()) {
97
+ console.log(chalk.cyan(`\n[${index + 1}/${optimizedGroups.length}] Processing ${isStaged ? 'staged' : 'unstaged'}: ${group.scope}`));
98
+ console.log(chalk.gray(`Files: ${group.files.map(f => f.path).join(', ')}\n`));
99
+
100
+ const files = group.files.map(f => f.path);
101
+ const diff = await this.gitService.getDiff(files, false);
102
+
103
+ if (!diff) {
104
+ console.log(chalk.yellow(` No diff found for this ${isStaged ? 'staged' : 'unstaged'} group. Skipping...`));
105
+ continue;
106
+ }
107
+
108
+ console.log(chalk.cyan(' Generating commit message...'));
109
+ const message = await this.aiService.generateCommitMessage(diff, files, group.scope);
110
+
111
+ console.log(chalk.gray(' Commit message:'));
112
+ console.log(chalk.white(` ${message.replace(/\n/g, '\n ')}`));
113
+ console.log();
114
+
115
+ await this.gitService.stageFiles(files);
116
+ await this.gitService.commit(message);
117
+ console.log(chalk.green(` \u2713 Committed ${files.length} ${isStaged ? 'staged' : 'unstaged'} file(s)`));
118
+ }
119
+ }
120
+
121
+ private async processGroupedStagedChanges(stagedChanges: FileChange[]): Promise<void> {
122
+ await this._processChangesInGroups(stagedChanges, true);
123
+ }
124
+
125
+ private async processAllChanges(patternMatcher: PatternMatcher): Promise<void> {
126
+ const unstagedChanges = await this.gitService.getUnstagedChanges();
127
+
128
+ if (unstagedChanges.length === 0) {
129
+ console.log(chalk.yellow('No unstaged changes found.'));
130
+ return;
131
+ }
132
+
133
+ const filteredChanges = patternMatcher.filterFiles(unstagedChanges);
134
+
135
+ if (filteredChanges.length === 0) {
136
+ console.log(chalk.yellow('All files were filtered out by ignore patterns.'));
137
+ return;
138
+ }
139
+
140
+ await this._processChangesInGroups(filteredChanges, false);
141
+ }
142
+
143
+ private async pushChanges(): Promise<void> {
144
+ const hasRemote = await this.gitService.hasRemote();
145
+
146
+ if (!hasRemote) {
147
+ console.log(chalk.yellow('\n⚠️ No remote repository configured. Skipping push.'));
148
+ return;
149
+ }
150
+
151
+ console.log(chalk.blue('\n📤 Pushing changes...'));
152
+
153
+ try {
154
+ await this.gitService.push();
155
+ console.log(chalk.green('✓ Changes pushed successfully'));
156
+ } catch (error) {
157
+ console.log(chalk.yellow('⚠️ Failed to push changes. You may need to push manually.'));
158
+ if (error instanceof Error) {
159
+ console.log(chalk.gray(` Reason: ${error.message}`));
160
+ }
161
+ }
162
+ }
163
+
164
+ private extractScopeFromFiles(files: string[]): string {
165
+ if (files.length === 0) return 'root';
166
+
167
+ const firstFile = files[0];
168
+ if (!firstFile) return 'root';
169
+
170
+ const parts = firstFile.split('/');
171
+
172
+ if (parts.length === 1) return 'root';
173
+ if (parts.length === 2) return parts[0] || 'root';
174
+
175
+ const commonDirs = ['src', 'lib', 'app'];
176
+ const srcIndex = parts.findIndex(p => commonDirs.includes(p));
177
+
178
+ if (srcIndex !== -1 && srcIndex + 1 < parts.length) {
179
+ return parts[srcIndex + 1] || 'root';
180
+ }
181
+
182
+ return parts[0] || 'root';
183
+ }
184
+ }
185
+
@@ -0,0 +1,58 @@
1
+ import type { CommitOptions } from '@/cli/commit-handler.ts';
2
+ import { CommitHandler } from '@/cli/commit-handler.ts';
3
+ import type { SetOptions } from '@/cli/set-handler.ts';
4
+ import { SetHandler } from '@/cli/set-handler.ts';
5
+ import { ConfigLoader } from '@/config/config.loader.ts';
6
+ import chalk from 'chalk';
7
+ import { Command } from 'commander';
8
+ import packageJson from '../../package.json' with { type: 'json' };
9
+
10
+ export async function runCLI() {
11
+ const program = new Command();
12
+
13
+ program
14
+ .name('commita')
15
+ .description('AI-powered git auto-commit tool')
16
+ .version(packageJson.version, '-v, --version', 'Show version number')
17
+ .option('-a, --all', 'Process all changes grouped by folders', false)
18
+ .option('-i, --ignore <patterns>', 'Comma-separated patterns to exclude', '')
19
+ .option('--no-push', 'Skip pushing after commit')
20
+ .option('-c, --config <path>', 'Path to custom config file')
21
+ .action(async (options: CommitOptions) => {
22
+ try {
23
+ const configLoader = new ConfigLoader();
24
+ const config = await configLoader.load(options.config);
25
+
26
+ const handler = new CommitHandler(config);
27
+ await handler.execute(options);
28
+ } catch (error) {
29
+ if (error instanceof Error) {
30
+ console.error(chalk.red(`\n❌ Fatal error: ${error.message}\n`));
31
+ } else {
32
+ console.error(chalk.red('\n❌ An unknown fatal error occurred\n'));
33
+ }
34
+ process.exit(1);
35
+ }
36
+ });
37
+
38
+ program
39
+ .command('set <key-value>')
40
+ .description('Set configuration value (format: KEY=value or KEY to prompt)')
41
+ .option('-l, --local', 'Set in project .commita file instead of global ~/.commita')
42
+ .action(async (keyValue: string, options: SetOptions) => {
43
+ try {
44
+ const handler = new SetHandler();
45
+ await handler.execute(keyValue, options);
46
+ } catch (error) {
47
+ if (error instanceof Error) {
48
+ console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
49
+ } else {
50
+ console.error(chalk.red('\n❌ An unknown error occurred\n'));
51
+ }
52
+ process.exit(1);
53
+ }
54
+ });
55
+
56
+ await program.parseAsync(process.argv);
57
+ }
58
+