scai 0.1.9 → 0.1.11

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/README.md CHANGED
@@ -5,23 +5,37 @@
5
5
  **scai** (Smart Commit AI) is a lightweight, privacy-focused CLI tool that uses local AI models (via [Ollama](https://ollama.com)) to help developers work faster and cleaner:
6
6
 
7
7
  - šŸ¤– Suggest high-quality Git commit messages
8
- - ✨ Refactor messy code files
9
- - 🧠 Check Git status and improve workflows
8
+ - ✨ Comments your code automatically
9
+ - 🧠 Generate README updates based on your diff
10
10
  - šŸ”’ 100% local — no API keys, no cloud, no telemetry
11
11
 
12
12
  ---
13
13
 
14
+ **scai** follows the Unix philosophy — small, composable tools with powerful output.
15
+
14
16
  ## šŸš€ Features
15
17
 
16
18
  - šŸ’¬ Generate commit messages from staged Git changes
17
- - ✨ Refactor a single JavaScript file for improved readability
18
- - šŸ” Check Git status with one command
19
- - āš”ļø Powered by Ollama + local models like `llama3` and `mistral`
19
+ - ✨ Add comments to your code
20
+ - āš”ļø Powered by open local Ollama models like `mistral`
20
21
  - šŸ› ļø CLI built with Node.js + TypeScript
21
22
  - šŸ”’ No external services, full privacy by design
22
23
 
23
24
  ---
24
25
 
26
+ ## ā¤ļø Philosophy: Why Local AI?
27
+
28
+ We believe your code — and your workflow — should stay **yours**. scai runs entirely on your machine using open-source models and tools.
29
+
30
+ No internet connection. No vendor lock-in. Just local, private, AI-enhanced developer experience.
31
+
32
+ āœ… Works entirely offline
33
+ āœ… No API keys, cloud accounts, or telemetry
34
+ āœ… Backed by open-source models
35
+ āœ… Designed for CLI-first devs and scriptable workflows
36
+
37
+ ---
38
+
25
39
  ## šŸ“¦ Installation
26
40
 
27
41
  1. **Install [Ollama](https://ollama.com)**
@@ -61,7 +75,7 @@ This will:
61
75
  git add .
62
76
 
63
77
  # Let scai suggest a commit message
64
- scai commit
78
+ scai suggest
65
79
  ```
66
80
 
67
81
  > Example output:
@@ -72,7 +86,7 @@ feat(api): add error handling to user service
72
86
  To automatically commit with the suggested message:
73
87
 
74
88
  ```bash
75
- scai commit --commit
89
+ scai suggest --commit
76
90
  ```
77
91
 
78
92
  ---
@@ -91,6 +105,16 @@ path/to/refactored/file.refactored.js
91
105
 
92
106
  ---
93
107
 
108
+ ### 🧩 Generate Tests
109
+
110
+ ```bash
111
+ scai generate-tests <file>
112
+ ```
113
+
114
+ Automatically generates Jest test files for specified JavaScript/TypeScript modules.
115
+
116
+ ---
117
+
94
118
  ### šŸ” Check Git status
95
119
 
96
120
  ```bash
@@ -118,10 +142,3 @@ However:
118
142
  For full terms, see the [LICENSE](./LICENSE) file.
119
143
 
120
144
  ---
121
-
122
- ## ā¤ļø Why Local-First?
123
-
124
- We believe your code — and your workflow — should stay **yours**. scai runs entirely on your machine using open-source models and tools.
125
-
126
- No internet connection. No vendor lock-in. Just local, private, AI-enhanced developer experience.
127
-
@@ -1,60 +1,116 @@
1
- // Prompt function asks user to choose a commit message from given suggestions
2
- function askUserToChoose(suggestions) {
3
- // Create and close readline interface after getting user input
1
+ // Import required modules
2
+ import { execSync } from 'child_process';
3
+ import readline from 'readline';
4
+ // Function to ask the user to choose a commit message suggestion
5
+ async function askUserToChoose(suggestions) {
6
+ // Create an interface for reading and writing to the console
4
7
  const rl = readline.createInterface({
5
8
  input: process.stdin,
6
9
  output: process.stdout,
7
10
  });
8
- rl.question(...);
9
- rl.close();
11
+ // Show AI-suggested commit messages to the user and ask for their choice
12
+ return new Promise((resolve) => {
13
+ console.log('\nšŸ’” AI-suggested commit messages:\n');
14
+ suggestions.forEach((msg, i) => {
15
+ console.log(`${i + 1}) ${msg}`);
16
+ });
17
+ console.log(`${suggestions.length + 1}) šŸ” Regenerate suggestions`);
18
+ console.log(`${suggestions.length + 2}) āœļø Write your own commit message`);
19
+ rl.question(`\nšŸ‘‰ Choose a commit message [1-${suggestions.length + 2}]: `, (answer) => {
20
+ // Close the readline interface and resolve the promise with user choice
21
+ rl.close();
22
+ const choice = parseInt(answer, 10);
23
+ if (isNaN(choice) || choice < 1 || choice > suggestions.length + 2) {
24
+ console.log('āš ļø Invalid selection. Using the first suggestion by default.');
25
+ resolve(0);
26
+ }
27
+ else if (choice === suggestions.length + 2) {
28
+ resolve('custom');
29
+ }
30
+ else {
31
+ resolve(choice - 1); // Return 0-based index (0 to 3)
32
+ }
33
+ });
34
+ });
10
35
  }
11
- // Fetches suggestions from the API and processes the response
36
+ // Async function to generate commit message suggestions using an API call
12
37
  async function generateSuggestions(prompt) {
13
- // Fetch JSON data from the given API endpoint
14
- const res = await fetch("http://localhost:11434/api/generate", { ... });
15
- // Filter out any invalid lines from the response and map them to a list of commit messages
16
- const messages = lines.map(...);
38
+ // Fetch the suggestions from an API and return them as an array of strings
39
+ const res = await fetch("http://localhost:11434/api/generate", {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({
43
+ model: "llama3",
44
+ prompt,
45
+ stream: false,
46
+ }),
47
+ });
48
+ const { response } = await res.json();
49
+ // Check for validity of the LLM response and return an array of commit messages
50
+ if (!response || typeof response !== 'string') {
51
+ throw new Error('Invalid LLM response');
52
+ }
53
+ const lines = response.trim().split('\n').filter(line => /^\d+\.\s+/.test(line));
54
+ return lines.map(line => line.replace(/^\d+\.\s+/, '').replace(/^"(.*)"$/, '$1').trim());
55
+ }
56
+ // Function to prompt the user for a custom commit message
57
+ async function promptCustomMessage() {
58
+ // Read the user's input for their custom commit message
59
+ return new Promise((resolve) => {
60
+ const rl = readline.createInterface({
61
+ input: process.stdin,
62
+ output: process.stdout,
63
+ });
64
+ rl.question('\nšŸ“ Enter your custom commit message:\n> ', (input) => {
65
+ rl.close();
66
+ resolve(input.trim());
67
+ });
68
+ });
17
69
  }
18
- // Export function for suggesting and committing a commit message if needed
70
+ // Export the main function to suggest a commit message based on user input and generated suggestions
19
71
  export async function suggestCommitMessage(options) {
20
72
  try {
21
- // Get the diff between local changes and staged changes
22
73
  let diff = execSync("git diff", { encoding: "utf-8" }).trim();
23
74
  if (!diff) {
24
75
  diff = execSync("git diff --cached", { encoding: "utf-8" }).trim();
25
76
  }
26
- // Check if there are any changes to suggest a message for
27
77
  if (!diff) {
28
78
  console.log('āš ļø No staged changes to suggest a message for.');
29
79
  return;
30
80
  }
31
- // Construct the prompt based on the diff and generate suggestions using the 'generateSuggestions' function
32
- const prompt = `...`;
81
+ const prompt = `Suggest 3 concise, conventional Git commit message options for this diff. Return ONLY the commit messages, numbered 1 to 3, like so:
82
+ 1. ...
83
+ 2. ...
84
+ 3. ...
85
+
86
+ Here is the diff:
87
+ ${diff}`;
33
88
  let message = null;
34
- // Continuously ask user for their choice until a valid commit message is selected
35
89
  while (message === null) {
36
90
  const suggestions = await generateSuggestions(prompt);
37
- // Ask user to choose from the given suggestions and handle 'Regenerate' option
38
- const choiceIndex = await askUserToChoose(suggestions);
39
- if (choiceIndex === suggestions.length) {
40
- console.log('šŸ”„ Regenerating suggestions...\n');
91
+ const choice = await askUserToChoose(suggestions);
92
+ if (choice === suggestions.length) {
93
+ // User chose "Regenerate"
94
+ console.log('\nšŸ”„ Regenerating suggestions...\n');
41
95
  continue;
42
96
  }
43
- message = suggestions[choiceIndex];
97
+ if (choice === 'custom') {
98
+ message = await promptCustomMessage();
99
+ break;
100
+ }
101
+ message = suggestions[choice];
44
102
  }
45
- // Print the selected commit message and commit it if needed
46
- console.log(...);
103
+ console.log(`\nāœ… Selected commit message:\n${message}\n`);
47
104
  if (options.commit) {
48
105
  const commitDiff = execSync("git diff", { encoding: "utf-8" }).trim();
49
106
  if (commitDiff) {
50
107
  execSync("git add .", { encoding: "utf-8" });
51
108
  }
52
109
  execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { stdio: 'inherit' });
53
- console.log('āœ… Committed with AI-suggested message.');
110
+ console.log('āœ… Committed with selected message.');
54
111
  }
55
112
  }
56
113
  catch (err) {
57
- // Print error message if an error occurs during the commit message suggestion process
58
- console.error(...);
114
+ console.error('āŒ Error in commit message suggestion:', err.message);
59
115
  }
60
116
  }
@@ -3,9 +3,12 @@ import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
  export async function updateReadmeIfNeeded() {
5
5
  try {
6
- const diff = execSync("git diff", { encoding: "utf-8" }).trim();
6
+ let diff = execSync("git diff", { encoding: "utf-8" }).trim();
7
7
  if (!diff) {
8
- console.log("āš ļø No changes to analyze in the working directory.");
8
+ diff = execSync("git diff --cached", { encoding: "utf-8" }).trim();
9
+ }
10
+ if (!diff) {
11
+ console.log('āš ļø No staged changes to suggest a message for.');
9
12
  return;
10
13
  }
11
14
  const readmePath = path.resolve("README.md");
@@ -28,10 +28,10 @@ export async function handleRefactor(filepath, options = {}) {
28
28
  const originalCode = await fs.readFile(filepath, 'utf-8');
29
29
  // Run through pipeline modules
30
30
  const refactored = await runPromptPipeline([addCommentsModule, cleanupModule], { code: originalCode });
31
- if (!refactored.trim())
31
+ if (!refactored.code.trim())
32
32
  throw new Error('āš ļø Model returned empty result');
33
33
  // Save refactored output
34
- await fs.writeFile(refactoredPath, refactored, 'utf-8');
34
+ await fs.writeFile(refactoredPath, refactored.code, 'utf-8');
35
35
  console.log(`āœ… Refactored code saved to: ${refactoredPath}`);
36
36
  console.log(`ā„¹ļø Run again with '--apply' to overwrite the original.`);
37
37
  }
@@ -0,0 +1,14 @@
1
+ import fs from 'fs/promises';
2
+ import { generateTestsModule } from '../pipeline/modules/generateTestsModule.js';
3
+ import { cleanupModule } from '../pipeline/modules/cleanupModule.js';
4
+ import { runPromptPipeline } from '../pipeline/runPipeline.js';
5
+ export async function generateTests(filepath) {
6
+ try {
7
+ const code = await fs.readFile(filepath, 'utf-8');
8
+ const result = await runPromptPipeline([generateTestsModule, cleanupModule], { code, filepath });
9
+ console.log('āœ… Test generated and cleaned up.');
10
+ }
11
+ catch (err) {
12
+ console.error('āŒ Error generating tests:', err.message);
13
+ }
14
+ }
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { checkGit } from "./commands/GitCmd.js";
5
5
  import { suggestCommitMessage } from "./commands/CommitSuggesterCmd.js";
6
6
  import { handleRefactor } from "./commands/RefactorCmd.js";
7
7
  import { updateReadmeIfNeeded } from "./commands/ReadmeCmd.js";
8
+ import { generateTests } from "./commands/TestGenCmd.js";
8
9
  // Import the model check and initialization logic
9
10
  import { bootstrap } from './modelSetup.js';
10
11
  // Create the CLI instance
@@ -27,7 +28,7 @@ cmd
27
28
  .description('Check Git status')
28
29
  .action(checkGit);
29
30
  cmd
30
- .command('commit')
31
+ .command('suggest')
31
32
  .description('Suggest a commit message from staged changes')
32
33
  .option('-c, --commit', 'Automatically commit with suggested message')
33
34
  .action(suggestCommitMessage);
@@ -40,5 +41,9 @@ cmd
40
41
  .command('readme')
41
42
  .description('Update README.md if relevant changes were made')
42
43
  .action(updateReadmeIfNeeded);
44
+ cmd
45
+ .command('generate-tests <file>')
46
+ .description('Generate a Jest test file for the specified JS/TS module')
47
+ .action((file) => generateTests(file));
43
48
  // Parse CLI arguments
44
49
  cmd.parse(process.argv);
@@ -0,0 +1,11 @@
1
+ const config = {
2
+ preset: 'ts-jest',
3
+ testEnvironment: 'node',
4
+ setupFilesAfterEnv: ['./jest.setup.ts'],
5
+ transform: {
6
+ '^.+\\.ts$': 'ts-jest',
7
+ },
8
+ moduleFileExtensions: ['ts', 'js', 'json', 'node'],
9
+ testPathIgnorePatterns: ['/node_modules/', '/dist/'],
10
+ };
11
+ export default config;
@@ -0,0 +1,14 @@
1
+ import { jest } from '@jest/globals';
2
+ // jest.setup.ts
3
+ // Mock the global fetch function in Jest for Node.js 18+
4
+ global.fetch = jest.fn(() => Promise.resolve({
5
+ json: () => Promise.resolve({ response: 'Mocked Commit Message' }),
6
+ ok: true,
7
+ status: 200,
8
+ statusText: 'OK',
9
+ headers: new Headers(),
10
+ redirected: false,
11
+ type: 'default',
12
+ url: 'https://mocked-url.com',
13
+ }) // Type assertion for `Response` object
14
+ );
@@ -0,0 +1,42 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ export const generateTestsModule = {
4
+ name: 'generateTests',
5
+ description: 'Generate a Jest test file for the class/module',
6
+ async run({ code, filepath }) {
7
+ const prompt = `
8
+ You're a senior TypeScript developer. Given the following class or module, generate a Jest test file.
9
+
10
+ Guidelines:
11
+ - Use the 'jest' test framework
12
+ - Cover public methods and one edge case
13
+ - Name the file <original>.test.ts
14
+ - Only return valid TypeScript code
15
+
16
+ --- CODE START ---
17
+ ${code}
18
+ --- CODE END ---
19
+ `.trim();
20
+ const res = await fetch('http://localhost:11434/api/generate', {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({
24
+ model: 'mistral',
25
+ prompt,
26
+ stream: false
27
+ })
28
+ });
29
+ const data = await res.json();
30
+ const testCode = data.response?.trim();
31
+ if (!testCode)
32
+ throw new Error('āš ļø No test code returned from model');
33
+ // Determine test file path next to refactored file
34
+ if (!filepath)
35
+ throw new Error('Missing filepath in pipeline context');
36
+ const { dir, name } = path.parse(filepath);
37
+ const testPath = path.join(dir, `${name}.test.ts`);
38
+ await fs.writeFile(testPath, testCode, 'utf-8');
39
+ console.log(`āœ… Test file saved to: ${testPath}`);
40
+ return { code, filepath }; // unchanged input
41
+ }
42
+ };
@@ -6,8 +6,9 @@ export const refactorModule = {
6
6
  You are a senior JavaScript/TypeScript engineer.
7
7
 
8
8
  Refactor the following code:
9
- - Refactor all functions longer than 20 lines by extracting logical parts into smaller helper functions.
10
- - Each resulting function should aim for clarity and ideally be under 20 lines, unless splitting would harm readability or functionality.
9
+ - Refactor only long and complex functions
10
+ - Keep original names and semantics.
11
+ - Do NOT insert comments
11
12
 
12
13
  --- CODE START ---
13
14
  ${code}
@@ -1,10 +1,23 @@
1
1
  export async function runPromptPipeline(modules, input) {
2
2
  let current = input;
3
- console.log('Input: ', input);
3
+ // Add flag or condition for logging (optional)
4
+ const isDebug = true;
5
+ if (isDebug) {
6
+ console.log('Input: ', input);
7
+ }
4
8
  for (const mod of modules) {
5
- current = await mod.run(current);
6
- console.log(`āš™ļø Running: ${mod.name}`);
7
- console.log("Current: ", current);
9
+ try {
10
+ current = await mod.run(current);
11
+ if (isDebug) {
12
+ console.log(`āš™ļø Running: ${mod.name}`);
13
+ console.log("Current: ", current);
14
+ }
15
+ }
16
+ catch (error) {
17
+ console.error(`āŒ Error in ${mod.name}:`, error instanceof Error ? error.message : error);
18
+ throw new Error(`Pipeline failed at module ${mod.name}`);
19
+ }
8
20
  }
9
- return current.code;
21
+ // Return the output, assuming 'code' holds the relevant transformed content
22
+ return { code: current.code }; // Ensure the return type matches PromptOutput
10
23
  }
@@ -0,0 +1,11 @@
1
+ import { execSync } from 'child_process';
2
+ export function runJestTest(testFile) {
3
+ try {
4
+ execSync(`npx jest ${testFile} --silent`, { stdio: 'inherit' });
5
+ return true;
6
+ }
7
+ catch (err) {
8
+ console.error(`āŒ Tests failed for ${testFile}`);
9
+ return false;
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scai",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "scai": "./dist/index.js"
@@ -11,7 +11,14 @@
11
11
  },
12
12
  "author": "Rasmus Uhd Norgaard",
13
13
  "license": "SEE LICENSE IN LICENSE",
14
- "keywords": ["cli", "ai", "refactor", "devtools", "local", "typescript"],
14
+ "keywords": [
15
+ "cli",
16
+ "ai",
17
+ "refactor",
18
+ "devtools",
19
+ "local",
20
+ "typescript"
21
+ ],
15
22
  "scripts": {
16
23
  "build": "tsc",
17
24
  "start": "node dist/index.js"
@@ -20,11 +27,14 @@
20
27
  "commander": "^11.0.0"
21
28
  },
22
29
  "devDependencies": {
30
+ "@types/jest": "^30.0.0",
23
31
  "@types/node": "^24.0.1",
32
+ "jest": "^30.0.2",
33
+ "ts-jest": "^29.4.0",
24
34
  "typescript": "^5.8.3"
25
35
  },
26
36
  "files": [
27
37
  "dist/",
28
- "README.md"
38
+ "README.md"
29
39
  ]
30
- }
40
+ }
@@ -1,12 +0,0 @@
1
- "use strict";
2
- // Import the 'child_process' module from 'node:child_process'.
3
- // Export a function called 'checkGit', which checks the Git status.
4
- // Try to execute the command "git rev-parse --is-inside-work-tree" with ignored stdio, ensuring we're in a Git repository.
5
- // If the Git working directory is clean (no uncommitted changes), log a message indicating this.
6
- // Otherwise, if there are uncommitted changes, log a warning message.
7
- // Execute "git status --porcelain" to get a compact representation of the current Git state and store its output as a string.
8
- // Trim any leading/trailing whitespace from the string.
9
- // Compare the trimmed string's length to zero. If it is equal to zero, the working directory is clean (length > 0 indicates uncommitted changes).
10
- // If the Git branch matches the head commit hash of 'origin/main', log a message indicating that the current branch is up-to-date with 'origin/main'.
11
- // Otherwise, log a message indicating that the current branch is not up-to-date with 'origin/main'.
12
- // Catch any errors that may occur during Git execution and log an error message.
@@ -1,8 +0,0 @@
1
- "use strict";
2
- // Summary of refactor:
3
- // - Normalized file paths by adding './' if not present
4
- // - Parsed normalized file paths into their directory, name, and extension
5
- // - Generated a refactored file path using the original file's directory, name, and extension along with the '.refactored' suffix
6
- // - Handled the refactor operation for a given file path and options (apply flag to decide whether to overwrite the original file or not)
7
- // - If apply is set, attempted to read the refactored code from the refactored file path and overwrite the original file if it exists.
8
- // - If not, read the original code from the file, passed it through a pipeline for refactoring, saved the refactored code to a new file path, and provided an option to run again with '--apply' to overwrite the original.
@@ -1,15 +0,0 @@
1
- export const stripMarkdownModule = {
2
- name: 'stripMarkdown',
3
- description: 'Remove surrounding markdown and explanations from LLM output',
4
- async run({ code }) {
5
- let cleaned = code.trim();
6
- // Remove common intro text like "Here is the refactored code:"
7
- cleaned = cleaned.replace(/^.*?(?=\n|```|import|function|const|let)/is, '');
8
- // Remove triple backtick blocks and optional language tags
9
- cleaned = cleaned.replace(/^```[a-z]*\n?/i, '');
10
- cleaned = cleaned.replace(/```$/, '');
11
- // Remove any leftover empty lines at top and bottom
12
- cleaned = cleaned.trim();
13
- return { code: cleaned };
14
- }
15
- };