eckra 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.
Files changed (43) hide show
  1. package/CONTRIBUTING.md +85 -0
  2. package/LICENSE +21 -0
  3. package/README.md +109 -0
  4. package/package.json +47 -0
  5. package/screenshot.jpg +0 -0
  6. package/src/helpers/ai.js +240 -0
  7. package/src/helpers/config.js +122 -0
  8. package/src/helpers/git.js +655 -0
  9. package/src/helpers/lmstudio.js +11 -0
  10. package/src/helpers/patch.js +91 -0
  11. package/src/index.js +73 -0
  12. package/src/ui/app.js +177 -0
  13. package/src/ui/branch.js +295 -0
  14. package/src/ui/commit.js +250 -0
  15. package/src/ui/common.js +106 -0
  16. package/src/ui/config.js +269 -0
  17. package/src/ui/log.js +146 -0
  18. package/src/ui/menu.js +393 -0
  19. package/src/ui/modules/amend.js +43 -0
  20. package/src/ui/modules/blame.js +56 -0
  21. package/src/ui/modules/branch.js +223 -0
  22. package/src/ui/modules/commit.js +232 -0
  23. package/src/ui/modules/conflict.js +93 -0
  24. package/src/ui/modules/diff.js +68 -0
  25. package/src/ui/modules/log.js +52 -0
  26. package/src/ui/modules/more.js +94 -0
  27. package/src/ui/modules/rebase.js +72 -0
  28. package/src/ui/modules/remote.js +74 -0
  29. package/src/ui/modules/search.js +46 -0
  30. package/src/ui/modules/settings.js +123 -0
  31. package/src/ui/modules/stage.js +174 -0
  32. package/src/ui/modules/stash.js +96 -0
  33. package/src/ui/modules/stats.js +57 -0
  34. package/src/ui/modules/status.js +86 -0
  35. package/src/ui/modules/sync.js +73 -0
  36. package/src/ui/modules/tag.js +85 -0
  37. package/src/ui/modules/undo.js +49 -0
  38. package/src/ui/modules/worktree.js +131 -0
  39. package/src/ui/push.js +184 -0
  40. package/src/ui/status.js +156 -0
  41. package/tests/ai.test.js +112 -0
  42. package/tests/config.test.js +123 -0
  43. package/tests/patch.test.js +44 -0
@@ -0,0 +1,156 @@
1
+ const chalk = require("chalk");
2
+ const Table = require("cli-table3");
3
+ const boxen = require("boxen");
4
+ const {
5
+ getGitStatus,
6
+ getCurrentBranch,
7
+ getRemotes,
8
+ } = require("../helpers/git");
9
+
10
+ async function showStatus() {
11
+ const status = await getGitStatus();
12
+ const remotes = await getRemotes();
13
+
14
+ console.log("\n");
15
+
16
+ // Branch info
17
+ console.log(
18
+ boxen(
19
+ chalk.cyan("🌿 Branch: ") +
20
+ chalk.yellow.bold(status.current) +
21
+ (status.tracking ? chalk.gray(` → ${status.tracking}`) : "") +
22
+ (status.ahead ? chalk.green(` ↑${status.ahead}`) : "") +
23
+ (status.behind ? chalk.red(` ↓${status.behind}`) : ""),
24
+ {
25
+ padding: { left: 2, right: 2, top: 0, bottom: 0 },
26
+ borderStyle: "round",
27
+ borderColor: "cyan",
28
+ },
29
+ ),
30
+ );
31
+
32
+ // Staged files
33
+ if (status.staged.length > 0) {
34
+ console.log(
35
+ chalk.green.bold("\nāœ“ Staged Files (ready to commit):"),
36
+ );
37
+ const stagedTable = new Table({
38
+ head: [chalk.green("File"), chalk.green("Status")],
39
+ colWidths: [50, 15],
40
+ style: { head: [], border: ["gray"] },
41
+ });
42
+
43
+ status.staged.forEach((file) => {
44
+ let fileStatus = "modified";
45
+ if (status.created.includes(file)) fileStatus = "new file";
46
+ if (status.deleted.includes(file)) fileStatus = "deleted";
47
+ if (status.renamed.includes(file)) fileStatus = "renamed";
48
+
49
+ stagedTable.push([chalk.green(file), chalk.green(fileStatus)]);
50
+ });
51
+
52
+ console.log(stagedTable.toString());
53
+ }
54
+
55
+ // Modified files (not staged)
56
+ if (status.modified.length > 0 || status.deleted.length > 0) {
57
+ console.log(
58
+ chalk.red.bold("\nā— Modified Files (not staged):"),
59
+ );
60
+ const modifiedTable = new Table({
61
+ head: [chalk.red("File"), chalk.red("Status")],
62
+ colWidths: [50, 15],
63
+ style: { head: [], border: ["gray"] },
64
+ });
65
+
66
+ status.modified.forEach((file) => {
67
+ if (!status.staged.includes(file)) {
68
+ modifiedTable.push([chalk.red(file), chalk.red("modified")]);
69
+ }
70
+ });
71
+
72
+ status.deleted.forEach((file) => {
73
+ if (!status.staged.includes(file)) {
74
+ modifiedTable.push([chalk.red(file), chalk.red("deleted")]);
75
+ }
76
+ });
77
+
78
+ if (modifiedTable.length > 0) {
79
+ console.log(modifiedTable.toString());
80
+ }
81
+ }
82
+
83
+ // Untracked files
84
+ if (status.not_added.length > 0) {
85
+ console.log(chalk.blue.bold("\n? Untracked Files:"));
86
+ const untrackedTable = new Table({
87
+ head: [chalk.blue("File")],
88
+ colWidths: [65],
89
+ style: { head: [], border: ["gray"] },
90
+ });
91
+
92
+ status.not_added.forEach((file) => {
93
+ untrackedTable.push([chalk.blue(file)]);
94
+ });
95
+
96
+ console.log(untrackedTable.toString());
97
+ }
98
+
99
+ // Conflicted files
100
+ if (status.conflicted.length > 0) {
101
+ console.log(chalk.yellow.bold("\nāš ļø Conflicted Files:"));
102
+ const conflictTable = new Table({
103
+ head: [chalk.yellow("File")],
104
+ colWidths: [65],
105
+ style: { head: [], border: ["gray"] },
106
+ });
107
+
108
+ status.conflicted.forEach((file) => {
109
+ conflictTable.push([chalk.yellow(file)]);
110
+ });
111
+
112
+ console.log(conflictTable.toString());
113
+ }
114
+
115
+ // Summary
116
+ const totalChanges =
117
+ status.staged.length + status.modified.length + status.not_added.length;
118
+
119
+ if (totalChanges === 0) {
120
+ console.log(
121
+ boxen(
122
+ chalk.green(
123
+ "✨ Working directory clean - nothing to commit.",
124
+ ),
125
+ { padding: 1, borderStyle: "round", borderColor: "green" },
126
+ ),
127
+ );
128
+ } else {
129
+ console.log(chalk.gray("\n─".repeat(40)));
130
+ console.log(
131
+ chalk.white("Summary: ") +
132
+ chalk.green(`${status.staged.length} staged`) +
133
+ chalk.gray(" | ") +
134
+ chalk.red(`${status.modified.length} modified`) +
135
+ chalk.gray(" | ") +
136
+ chalk.blue(`${status.not_added.length} untracked`),
137
+ );
138
+ }
139
+
140
+ // Remote info
141
+ if (remotes.length > 0) {
142
+ console.log(chalk.gray("\nšŸ“” Remotes:"));
143
+ remotes.forEach((remote) => {
144
+ console.log(
145
+ chalk.gray(` ${remote.name}: `) +
146
+ chalk.white(remote.refs.fetch || remote.refs.push),
147
+ );
148
+ });
149
+ }
150
+
151
+ console.log("\n");
152
+ }
153
+
154
+ module.exports = {
155
+ showStatus,
156
+ };
@@ -0,0 +1,112 @@
1
+ const axios = require('axios');
2
+ const { generateCommitMessage } = require('../src/helpers/ai');
3
+ const configHelper = require('../src/helpers/config');
4
+
5
+ jest.mock('axios');
6
+ jest.mock('../src/helpers/config');
7
+
8
+ describe('AI Helper', () => {
9
+ const mockDiff = 'diff content';
10
+ const mockFiles = ['file1.js'];
11
+
12
+ beforeEach(() => {
13
+ jest.clearAllMocks();
14
+ });
15
+
16
+ test('should call OpenAI API correctly', async () => {
17
+ configHelper.getConfig.mockReturnValue({
18
+ aiProvider: 'openai',
19
+ openaiApiKey: 'sk-test',
20
+ openaiModel: 'gpt-4o'
21
+ });
22
+
23
+ axios.post.mockResolvedValue({
24
+ data: {
25
+ choices: [{ message: { content: 'feat: openai commit' } }]
26
+ }
27
+ });
28
+
29
+ const message = await generateCommitMessage(mockDiff, mockFiles);
30
+
31
+ expect(message).toBe('feat: openai commit');
32
+ expect(axios.post).toHaveBeenCalledWith(
33
+ 'https://api.openai.com/v1/chat/completions',
34
+ expect.objectContaining({
35
+ model: 'gpt-4o',
36
+ messages: expect.any(Array)
37
+ }),
38
+ expect.objectContaining({
39
+ headers: expect.objectContaining({
40
+ 'Authorization': 'Bearer sk-test'
41
+ })
42
+ })
43
+ );
44
+ });
45
+
46
+ test('should call Anthropic API correctly', async () => {
47
+ configHelper.getConfig.mockReturnValue({
48
+ aiProvider: 'anthropic',
49
+ anthropicApiKey: 'sk-ant-test',
50
+ anthropicModel: 'claude-3'
51
+ });
52
+
53
+ axios.post.mockResolvedValue({
54
+ data: {
55
+ content: [{ text: 'feat: anthropic commit' }]
56
+ }
57
+ });
58
+
59
+ const message = await generateCommitMessage(mockDiff, mockFiles);
60
+
61
+ expect(message).toBe('feat: anthropic commit');
62
+ expect(axios.post).toHaveBeenCalledWith(
63
+ 'https://api.anthropic.com/v1/messages',
64
+ expect.objectContaining({
65
+ model: 'claude-3',
66
+ system: expect.any(String) // System prompt is separate in Anthropic
67
+ }),
68
+ expect.objectContaining({
69
+ headers: expect.objectContaining({
70
+ 'x-api-key': 'sk-ant-test'
71
+ })
72
+ })
73
+ );
74
+ });
75
+
76
+ test('should call Ollama API correctly', async () => {
77
+ configHelper.getConfig.mockReturnValue({
78
+ aiProvider: 'ollama',
79
+ ollamaUrl: 'http://localhost:11434',
80
+ ollamaModel: 'llama3'
81
+ });
82
+
83
+ axios.post.mockResolvedValue({
84
+ data: {
85
+ message: { content: 'feat: ollama commit' }
86
+ }
87
+ });
88
+
89
+ const message = await generateCommitMessage(mockDiff, mockFiles);
90
+
91
+ expect(message).toBe('feat: ollama commit');
92
+ expect(axios.post).toHaveBeenCalledWith(
93
+ 'http://localhost:11434/api/chat',
94
+ expect.objectContaining({
95
+ model: 'llama3',
96
+ stream: false
97
+ }),
98
+ expect.any(Object)
99
+ );
100
+ });
101
+
102
+ test('should handle API errors gracefully', async () => {
103
+ configHelper.getConfig.mockReturnValue({ aiProvider: 'openai' });
104
+
105
+ axios.post.mockRejectedValue({
106
+ response: { status: 401, data: { error: 'Unauthorized' } }
107
+ });
108
+
109
+ await expect(generateCommitMessage(mockDiff, mockFiles))
110
+ .rejects.toThrow('AI Provider Error (openai): 401');
111
+ });
112
+ });
@@ -0,0 +1,123 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { getConfig, DEFAULT_CONFIG } = require('../src/helpers/config');
5
+
6
+ // Mock fs to avoid touching real files
7
+ jest.mock('fs');
8
+
9
+ describe('Config Helper', () => {
10
+ const MOCK_HOMEDIR = '/mock/home';
11
+ const MOCK_CWD = '/mock/project';
12
+
13
+ beforeEach(() => {
14
+ jest.resetModules();
15
+ jest.clearAllMocks();
16
+
17
+ // Mock os.homedir
18
+ jest.spyOn(os, 'homedir').mockReturnValue(MOCK_HOMEDIR);
19
+
20
+ // Mock process.cwd
21
+ jest.spyOn(process, 'cwd').mockReturnValue(MOCK_CWD);
22
+
23
+ // Default fs behavior
24
+ fs.existsSync.mockReturnValue(false);
25
+ fs.mkdirSync.mockImplementation(() => {});
26
+ fs.readFileSync.mockImplementation(() => '');
27
+ });
28
+
29
+ test('should return default config when no config files exist', () => {
30
+ const config = getConfig();
31
+ expect(config).toEqual(DEFAULT_CONFIG);
32
+ });
33
+
34
+ test('should merge global config correctly', () => {
35
+ const globalConfig = {
36
+ model: 'global-model-v1',
37
+ lmStudioUrl: 'http://global-url:1234'
38
+ };
39
+
40
+ // Setup mocks for global config presence
41
+ fs.existsSync.mockImplementation((filePath) => {
42
+ if (filePath.includes('.eckra') && filePath.includes('config.json')) return true;
43
+ return false;
44
+ });
45
+
46
+ fs.readFileSync.mockImplementation((filePath) => {
47
+ if (filePath.includes('.eckra') && filePath.includes('config.json')) {
48
+ return JSON.stringify(globalConfig);
49
+ }
50
+ return '';
51
+ });
52
+
53
+ const config = getConfig();
54
+
55
+ expect(config.model).toBe(globalConfig.model);
56
+ expect(config.lmStudioUrl).toBe(globalConfig.lmStudioUrl);
57
+ // Should verify other defaults remain
58
+ expect(config.language).toBe(DEFAULT_CONFIG.language);
59
+ });
60
+
61
+ test('should prioritize local .eckrarc over global config', () => {
62
+ const globalConfig = {
63
+ model: 'global-model',
64
+ aiProvider: 'lmstudio'
65
+ };
66
+
67
+ const localConfig = {
68
+ model: 'local-model',
69
+ aiProvider: 'openai'
70
+ };
71
+
72
+ // Setup mocks for both files
73
+ fs.existsSync.mockImplementation((filePath) => {
74
+ // Global config
75
+ if (filePath.includes('.eckra') && filePath.includes('config.json')) return true;
76
+ // Local config in CWD
77
+ if (filePath === path.join(MOCK_CWD, '.eckrarc')) return true;
78
+ return false;
79
+ });
80
+
81
+ fs.readFileSync.mockImplementation((filePath) => {
82
+ if (filePath.includes('config.json')) return JSON.stringify(globalConfig);
83
+ if (filePath.includes('.eckrarc')) return JSON.stringify(localConfig);
84
+ return '';
85
+ });
86
+
87
+ const config = getConfig();
88
+
89
+ expect(config.model).toBe(localConfig.model);
90
+ expect(config.aiProvider).toBe(localConfig.aiProvider);
91
+ expect(config.language).toBe(DEFAULT_CONFIG.language);
92
+ });
93
+
94
+ test('should look for .eckrarc in parent directories', () => {
95
+ const parentDir = path.dirname(MOCK_CWD);
96
+ const localConfig = { aiInstruction: 'parent instruction' };
97
+
98
+ // Update cwd mock to be a subdir
99
+ jest.spyOn(process, 'cwd').mockReturnValue(MOCK_CWD);
100
+
101
+ // Setup mocks
102
+ fs.existsSync.mockImplementation((filePath) => {
103
+ // Not in CWD
104
+ if (filePath === path.join(MOCK_CWD, '.eckrarc')) return false;
105
+ // Found in Parent
106
+ if (filePath === path.join(parentDir, '.eckrarc')) return true;
107
+ return false;
108
+ });
109
+
110
+ fs.readFileSync.mockImplementation((filePath) => {
111
+ if (filePath === path.join(parentDir, '.eckrarc')) return JSON.stringify(localConfig);
112
+ return '';
113
+ });
114
+
115
+ // We need to mock path.dirname to actually traverse up in our mocked environment logic if needed,
116
+ // but getConfig uses real path module. Ideally we rely on real path behavior or mock carefully.
117
+ // The getConfig implementation uses a while loop with path.dirname.
118
+ // Since we are mocking fs, we just need to ensure the loop calls fs.existsSync with the parent path.
119
+
120
+ const config = getConfig();
121
+ expect(config.aiInstruction).toBe(localConfig.aiInstruction);
122
+ });
123
+ });
@@ -0,0 +1,44 @@
1
+ const { parseDiff, generatePatch } = require('../src/helpers/patch');
2
+
3
+ describe('Patch Helper', () => {
4
+ const mockDiff = `diff --git a/test.js b/test.js
5
+ index 1234567..89abcdef 100644
6
+ --- a/test.js
7
+ +++ b/test.js
8
+ @@ -1,3 +1,4 @@
9
+ line 1
10
+ +added line
11
+ line 2
12
+ line 3
13
+ @@ -10,3 +11,3 @@
14
+ old line
15
+ -removed line
16
+ +new line
17
+ other line`;
18
+
19
+ test('should parse diff into hunks correctly', () => {
20
+ const files = parseDiff(mockDiff);
21
+
22
+ expect(files).toHaveLength(1);
23
+ expect(files[0].name).toBe('test.js');
24
+ expect(files[0].hunks).toHaveLength(2);
25
+
26
+ expect(files[0].hunks[0].header).toContain('@@ -1,3 +1,4 @@');
27
+ expect(files[0].hunks[1].header).toContain('@@ -10,3 +11,3 @@');
28
+ });
29
+
30
+ test('should generate patch for selected hunks', () => {
31
+ const files = parseDiff(mockDiff);
32
+ const patch = generatePatch(files[0], [0]); // Only first hunk
33
+
34
+ expect(patch).toContain('@@ -1,3 +1,4 @@');
35
+ expect(patch).not.toContain('@@ -10,3 +11,3 @@');
36
+ expect(patch).toContain('+added line');
37
+ expect(patch).not.toContain('-removed line');
38
+ });
39
+
40
+ test('should handle empty diffs', () => {
41
+ const files = parseDiff('');
42
+ expect(files).toHaveLength(0);
43
+ });
44
+ });