devsplain 1.7.1 → 2.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/README.md +38 -8
- package/bin/cli.js +49 -26
- package/bin/post-commit.js +40 -18
- package/bin/setup-hook.js +38 -15
- package/lib/config.js +75 -25
- package/lib/llm.js +29 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,12 @@ An agent-agnostic CLI tool that adds JSDoc and inline comments using state-of-th
|
|
|
5
5
|
|
|
6
6
|
devsplain never rewrites executable code.
|
|
7
7
|
If the original source cannot be reproduced exactly after comment insertion, the operation aborts.
|
|
8
|
+
|
|
9
|
+
Unlike interactive AI editors, `devsplain` is a single-shot CLI designed for batch documentation passes, CI pipelines, and git hook automation—no agent overhead, no per-file confirmation loops.
|
|
10
|
+
|
|
11
|
+
> [!TIP]
|
|
12
|
+
> **Built with devsplain**
|
|
13
|
+
> This entire repository is heavily "dogfooded". Every single AI-generated `[ds]` comment you see in the source code of this tool was automatically generated and committed by `devsplain` itself using its native git hooks!
|
|
8
14
|
---
|
|
9
15
|
|
|
10
16
|
## Key Features
|
|
@@ -81,7 +87,10 @@ To force re-run the configuration wizard at any time, execute:
|
|
|
81
87
|
devsplain --config
|
|
82
88
|
```
|
|
83
89
|
|
|
84
|
-
Your settings are stored securely in `~/.devsplainrc` (configured with `chmod 600` on POSIX systems to restrict read access).
|
|
90
|
+
Your settings are stored securely in `~/.devsplainrc` (configured with `chmod 600` on POSIX systems to restrict read access, and keystrokes are masked during input).
|
|
91
|
+
|
|
92
|
+
> [!CAUTION]
|
|
93
|
+
> **Security Note:** Prefer configuring the `DEVSPLAIN_API_KEY` environment variable over using the `--api-key` CLI flag for recurring use. CLI flags may be exposed in your shell history (`~/.bash_history`) and process lists.
|
|
85
94
|
|
|
86
95
|
---
|
|
87
96
|
|
|
@@ -130,13 +139,10 @@ devsplain lib/ --prune
|
|
|
130
139
|
devsplain src/utils.ts --provider gemini --model gemini-2.0-flash --api-key YOUR_KEY
|
|
131
140
|
```
|
|
132
141
|
|
|
133
|
-
> [!
|
|
134
|
-
> **Directory Traversal
|
|
135
|
-
>
|
|
136
|
-
>
|
|
137
|
-
> To prevent this, it is highly recommended to either:
|
|
138
|
-
> 1. Use the **Automated Git Hooks** to comment only on files modified in your commits.
|
|
139
|
-
> 2. Pass specific files or selective subfolders manually (e.g., `devsplain src/utils.ts`) instead of targeting broad directories.
|
|
142
|
+
> [!TIP]
|
|
143
|
+
> **Directory Traversal Protection**
|
|
144
|
+
> When you run `devsplain` on a directory, it automatically ignores common build/dependency folders (`node_modules`, `.git`, `dist`, etc.).
|
|
145
|
+
> To ignore custom directories, simply create a `.devsplainignore` file in your project root using the standard `.gitignore` syntax. (A default `.devsplainignore` is automatically generated when you run `--setup-hook`).
|
|
140
146
|
|
|
141
147
|
---
|
|
142
148
|
|
|
@@ -165,6 +171,23 @@ Run the hook setup command inside your Git repository:
|
|
|
165
171
|
devsplain --setup-hook
|
|
166
172
|
```
|
|
167
173
|
|
|
174
|
+
### Bypassing the Hook (Manual Override)
|
|
175
|
+
If you ever want to commit code without triggering the AI (for example, if you just ran `devsplain index.js --full` manually and want to freeze those specific comments without the background hook overwriting them), you can bypass the hook entirely:
|
|
176
|
+
```bash
|
|
177
|
+
SKIP_DEVSPLAIN=1 git commit -m "my commit message"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Forcing or Skipping Auto-Prune
|
|
181
|
+
If you have configured `autoPrune` (aggressive overwrite) in your `~/.devsplainrc` but want to change the behavior for a specific commit:
|
|
182
|
+
- To **force an overwrite** (destroying all human/AI comments before generating new ones) regardless of your config:
|
|
183
|
+
```bash
|
|
184
|
+
DS_OVER=1 git commit -m "overwrite docs"
|
|
185
|
+
```
|
|
186
|
+
- To **force preservation** of existing comments (overriding a global prune preference):
|
|
187
|
+
```bash
|
|
188
|
+
DS_KEEP=1 git commit -m "keep docs"
|
|
189
|
+
```
|
|
190
|
+
|
|
168
191
|
### Pipeline Architecture
|
|
169
192
|
1. **Pre-commit Hook**: Runs your project test suite (`npm test`). If the tests fail, the commit is blocked.
|
|
170
193
|
2. **Post-commit Hook**:
|
|
@@ -176,6 +199,13 @@ devsplain --setup-hook
|
|
|
176
199
|
|
|
177
200
|
---
|
|
178
201
|
|
|
202
|
+
## Known Limitations & Contributing
|
|
203
|
+
|
|
204
|
+
> [!NOTE]
|
|
205
|
+
> **API Testing Notice:** `devsplain` natively supports OpenAI, Anthropic, Ollama, Groq, and Gemini endpoints, but the E2E test suite has currently only been aggressively verified against the **Groq** and **Gemini** APIs. If you encounter any unexpected parsing issues or edge-case errors with other providers, please open an issue or submit a PR!
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
179
209
|
## License
|
|
180
210
|
|
|
181
211
|
This project is licensed under the MIT License.
|
package/bin/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ const { execSync } = require('child_process');
|
|
|
10
10
|
let rl;
|
|
11
11
|
let askQuestion;
|
|
12
12
|
|
|
13
|
-
/** Checks if the
|
|
13
|
+
/** Checks if the Git working tree is dirty [ds] */
|
|
14
14
|
function isGitDirty() {
|
|
15
15
|
try {
|
|
16
16
|
const gitDir = execSync('git rev-parse --is-inside-work-tree', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
|
|
@@ -23,7 +23,7 @@ function isGitDirty() {
|
|
|
23
23
|
return false;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** Checks if a
|
|
26
|
+
/** Checks if a line is inside a string literal [ds] */
|
|
27
27
|
function isLineInsideString(lines, targetLineIndex, ext = '') {
|
|
28
28
|
const isPython = ext.toLowerCase() === '.py';
|
|
29
29
|
let inBacktick = false;
|
|
@@ -97,7 +97,7 @@ function isLineInsideString(lines, targetLineIndex, ext = '') {
|
|
|
97
97
|
return inBacktick || inTripleDouble || inTripleSingle || inSingle || inDouble;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/** Analyzes comments in a list of
|
|
100
|
+
/** Analyzes comments in a list of lines [ds] */
|
|
101
101
|
function analyzeComments(lines, ext = '') {
|
|
102
102
|
const isPython = ext.toLowerCase() === '.py';
|
|
103
103
|
const isHTML = ['.html', '.vue', '.svelte'].includes(ext.toLowerCase());
|
|
@@ -133,7 +133,6 @@ function analyzeComments(lines, ext = '') {
|
|
|
133
133
|
j++;
|
|
134
134
|
continue;
|
|
135
135
|
}
|
|
136
|
-
// Check for comment start index in non-Python files [ds]
|
|
137
136
|
if (!inSingle && !inDouble && !inBacktick && !inTripleSingle && !inTripleDouble) {
|
|
138
137
|
if (isPython) {
|
|
139
138
|
if (line[j] === '#') {
|
|
@@ -199,7 +198,6 @@ function analyzeComments(lines, ext = '') {
|
|
|
199
198
|
continue;
|
|
200
199
|
}
|
|
201
200
|
}
|
|
202
|
-
// Check for string literals in non-Python files [ds]
|
|
203
201
|
} else {
|
|
204
202
|
if (!inSingle && !inDouble) {
|
|
205
203
|
if (line[j] === '`') {
|
|
@@ -239,7 +237,6 @@ function analyzeComments(lines, ext = '') {
|
|
|
239
237
|
}
|
|
240
238
|
}
|
|
241
239
|
}
|
|
242
|
-
// Increment character index [ds]
|
|
243
240
|
j++;
|
|
244
241
|
}
|
|
245
242
|
if (!isPython) {
|
|
@@ -267,7 +264,7 @@ function analyzeComments(lines, ext = '') {
|
|
|
267
264
|
return analysis;
|
|
268
265
|
}
|
|
269
266
|
|
|
270
|
-
/** Splices comments into a list of
|
|
267
|
+
/** Splices comments into a list of lines [ds] */
|
|
271
268
|
function spliceComments(data, comments, mode = 'default', ext = '') {
|
|
272
269
|
const hasCRLF = data.includes('\r\n');
|
|
273
270
|
const lineEnding = hasCRLF ? '\r\n' : '\n';
|
|
@@ -459,7 +456,7 @@ function spliceComments(data, comments, mode = 'default', ext = '') {
|
|
|
459
456
|
return annotated.map(line => line.text).join(lineEnding);
|
|
460
457
|
}
|
|
461
458
|
|
|
462
|
-
/** Runs the CLI
|
|
459
|
+
/** Runs the CLI application [ds] */
|
|
463
460
|
async function runCLI() {
|
|
464
461
|
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
465
462
|
askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
@@ -549,6 +546,8 @@ Options:
|
|
|
549
546
|
if (args.includes('--prune')) mode = 'prune';
|
|
550
547
|
const isDryRun = args.includes('--dry-run');
|
|
551
548
|
const isForce = args.includes('--force');
|
|
549
|
+
const hasOverwriteFlag = args.includes('--overwrite');
|
|
550
|
+
const hasKeepFlag = args.includes('--keep');
|
|
552
551
|
|
|
553
552
|
if (process.env.NODE_ENV !== 'test' && isGitDirty() && !isForce) {
|
|
554
553
|
console.error("Error: Git working tree is dirty. Please commit or stash your changes, or use --force to bypass this check.");
|
|
@@ -566,10 +565,10 @@ Options:
|
|
|
566
565
|
if (cliProvider) {
|
|
567
566
|
config.provider = cliProvider;
|
|
568
567
|
if (!cliModel) {
|
|
569
|
-
config.model = cliProvider === 'gemini' ? 'gemini-2.0-flash' : 'llama-3.3-70b-versatile';
|
|
568
|
+
config.model = cliProvider === 'gemini' ? 'gemini-2.0-flash' : (cliProvider === 'claude' ? 'claude-3-5-sonnet-20240620' : 'llama-3.3-70b-versatile');
|
|
570
569
|
}
|
|
571
570
|
if (!cliBaseUrl) {
|
|
572
|
-
config.baseUrl = cliProvider === 'gemini' ? null : (cliProvider === 'groq' ? 'https://api.groq.com/openai' : (cliProvider === 'openai' ? 'https://api.openai.com' : ''));
|
|
571
|
+
config.baseUrl = cliProvider === 'gemini' ? null : (cliProvider === 'groq' ? 'https://api.groq.com/openai' : (cliProvider === 'openai' ? 'https://api.openai.com' : (cliProvider === 'claude' ? 'https://api.anthropic.com' : '')));
|
|
573
572
|
}
|
|
574
573
|
}
|
|
575
574
|
if (cliModel) config.model = cliModel;
|
|
@@ -579,24 +578,48 @@ Options:
|
|
|
579
578
|
let successCount = 0;
|
|
580
579
|
let failCount = 0;
|
|
581
580
|
|
|
581
|
+
const isOverwrite = (hasOverwriteFlag || config.autoPrune) && !hasKeepFlag;
|
|
582
|
+
|
|
583
|
+
let userIgnorePatterns = [];
|
|
584
|
+
try {
|
|
585
|
+
const ignorePath = path.join(process.cwd(), '.devsplainignore');
|
|
586
|
+
if (fs.existsSync(ignorePath)) {
|
|
587
|
+
const ignoreContent = fs.readFileSync(ignorePath, 'utf8');
|
|
588
|
+
userIgnorePatterns = ignoreContent.split(/\r?\n/)
|
|
589
|
+
.map(line => line.trim())
|
|
590
|
+
.filter(line => line && !line.startsWith('#'));
|
|
591
|
+
}
|
|
592
|
+
} catch(e) {}
|
|
593
|
+
|
|
594
|
+
const defaultIgnoredFolders = [
|
|
595
|
+
'node_modules', '.git', 'dist', 'build', 'out',
|
|
596
|
+
'.next', '.nuxt', '.svelte-kit',
|
|
597
|
+
'venv', 'env', '.venv',
|
|
598
|
+
'.vscode', '.idea', 'coverage',
|
|
599
|
+
'tests', '__tests__', 'fixtures'
|
|
600
|
+
];
|
|
601
|
+
const allIgnored = [...defaultIgnoredFolders, ...userIgnorePatterns];
|
|
602
|
+
|
|
603
|
+
function isPathIgnored(targetPath) {
|
|
604
|
+
// Checks if a path is ignored [ds]
|
|
605
|
+
const filename = path.basename(targetPath);
|
|
606
|
+
for (const pattern of allIgnored) {
|
|
607
|
+
const cleanPattern = pattern.replace(/\/$/, '').replace(/\\$/, '');
|
|
608
|
+
if (filename === cleanPattern) return true;
|
|
609
|
+
if (pattern.startsWith('*') && filename.endsWith(pattern.slice(1))) return true;
|
|
610
|
+
}
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Processes a path, scanning directories or analyzing files [ds] */
|
|
582
615
|
async function processPath(targetPath) {
|
|
583
|
-
// Process a directory or file path [ds]
|
|
584
616
|
const stats = fs.statSync(targetPath);
|
|
585
617
|
|
|
586
|
-
if (
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
'node_modules', '.git', 'dist', 'build', 'out',
|
|
590
|
-
'.next', '.nuxt', '.svelte-kit',
|
|
591
|
-
'venv', 'env', '.venv',
|
|
592
|
-
'.vscode', '.idea', 'coverage',
|
|
593
|
-
'tests', '__tests__', 'fixtures'
|
|
594
|
-
];
|
|
595
|
-
|
|
596
|
-
if (ignoredFolders.includes(folderName)) {
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
618
|
+
if (isPathIgnored(targetPath)) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
599
621
|
|
|
622
|
+
if (stats.isDirectory()) {
|
|
600
623
|
console.log(`\n Scanning directory: ${targetPath}`);
|
|
601
624
|
const items = fs.readdirSync(targetPath);
|
|
602
625
|
for (const item of items) {
|
|
@@ -628,7 +651,8 @@ Options:
|
|
|
628
651
|
let comments = [];
|
|
629
652
|
let commentedCode;
|
|
630
653
|
if (mode !== 'clean' && mode !== 'prune') {
|
|
631
|
-
const
|
|
654
|
+
const preProcessMode = isOverwrite ? 'prune' : 'clean';
|
|
655
|
+
const cleanData = spliceComments(data, [], preProcessMode, ext);
|
|
632
656
|
comments = await getComments(cleanData, filename, config, mode);
|
|
633
657
|
commentedCode = spliceComments(cleanData, comments, mode, ext);
|
|
634
658
|
} else {
|
|
@@ -677,7 +701,6 @@ Options:
|
|
|
677
701
|
rl.close();
|
|
678
702
|
}
|
|
679
703
|
|
|
680
|
-
// Run the CLI interface if this script is the main module [ds]
|
|
681
704
|
if (require.main === module) {
|
|
682
705
|
runCLI().catch(err => {
|
|
683
706
|
console.error(err);
|
package/bin/post-commit.js
CHANGED
|
@@ -3,63 +3,72 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { spliceComments } = require('./cli');
|
|
5
5
|
|
|
6
|
-
/** Main execution block [ds] */
|
|
6
|
+
/** Main execution block for post-commit hook [ds] */
|
|
7
7
|
try {
|
|
8
|
-
//
|
|
8
|
+
// Check if SKIP_DEVSPLAIN environment variable is set [ds]
|
|
9
|
+
if (process.env.SKIP_DEVSPLAIN) {
|
|
10
|
+
console.log('[devsplain] SKIP_DEVSPLAIN is set. Bypassing AI generation.');
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Determine if Git is in a rebasing, merging, or cherry-picking state [ds]
|
|
9
15
|
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
10
16
|
const isRebasing = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
|
|
11
17
|
const isMerging = fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
|
|
12
18
|
const isCherryPicking = fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'));
|
|
13
19
|
|
|
20
|
+
// Exit if Git is in a rebasing, merging, or cherry-picking state to avoid history conflicts [ds]
|
|
14
21
|
if (isRebasing || isMerging || isCherryPicking) {
|
|
15
22
|
console.log('[devsplain] Skipping AI comment generation during git rebase/merge/cherry-pick to avoid history conflicts.');
|
|
16
23
|
process.exit(0);
|
|
17
24
|
}
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
// Retrieve the last commit message [ds]
|
|
20
27
|
const lastCommitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim();
|
|
21
28
|
if (lastCommitMsg === 'docs: auto-generated comments by devsplain') {
|
|
22
29
|
process.exit(0);
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
// Get the last commit
|
|
32
|
+
// Get a list of changed files in the last commit [ds]
|
|
26
33
|
const changedFilesStr = execSync('git diff-tree --no-commit-id --name-only -r HEAD', { encoding: 'utf8' }).trim();
|
|
27
34
|
if (!changedFilesStr) {
|
|
28
35
|
process.exit(0);
|
|
29
36
|
}
|
|
30
37
|
const changedFiles = changedFilesStr.split(/\r?\n/);
|
|
31
38
|
|
|
32
|
-
/** List of valid file extensions for commenting [ds] */
|
|
39
|
+
/** List of valid file extensions for auto-commenting [ds] */
|
|
33
40
|
const validExtensions = [
|
|
34
41
|
'.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
|
|
35
42
|
'.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
|
|
36
43
|
'.swift', '.kt', '.dart', '.sh'
|
|
37
44
|
];
|
|
38
45
|
|
|
39
|
-
/** Filter files to
|
|
46
|
+
/** Filter function to determine which files to auto-comment [ds] */
|
|
40
47
|
const filesToComment = changedFiles.filter(file => {
|
|
41
48
|
const ext = path.extname(file).toLowerCase();
|
|
42
49
|
const isIgnored = file.includes('node_modules/') || file.includes('tests/') || file.includes('__tests__/') || file.includes('fixtures/');
|
|
43
50
|
return validExtensions.includes(ext) && fs.existsSync(file) && !isIgnored;
|
|
44
51
|
});
|
|
45
52
|
|
|
46
|
-
// Check if there are any files to comment [ds]
|
|
47
53
|
if (filesToComment.length === 0) {
|
|
48
54
|
process.exit(0);
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
// Log the number of files found for auto-commenting [ds]
|
|
51
58
|
console.log(`[devsplain] Found ${filesToComment.length} file(s) in the last commit to auto-comment.`);
|
|
52
59
|
|
|
53
|
-
// Parse command
|
|
60
|
+
// Parse command-line arguments for mode flags [ds]
|
|
54
61
|
const args = process.argv.slice(2);
|
|
55
62
|
let modeFlag = '';
|
|
56
63
|
if (args.includes('--light')) modeFlag = ' --light';
|
|
57
64
|
if (args.includes('--full')) modeFlag = ' --full';
|
|
58
65
|
|
|
59
66
|
let commentedAny = false;
|
|
67
|
+
const successfullyCommentedFiles = [];
|
|
60
68
|
|
|
61
|
-
/**
|
|
69
|
+
/** Loop through each file to auto-comment [ds] */
|
|
62
70
|
for (const file of filesToComment) {
|
|
71
|
+
// Attempt to read file contents and previous version [ds]
|
|
63
72
|
try {
|
|
64
73
|
const ext = path.extname(file).toLowerCase();
|
|
65
74
|
const contentHead = fs.readFileSync(file, 'utf8');
|
|
@@ -72,11 +81,12 @@ try {
|
|
|
72
81
|
} catch (prevErr) {
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
//
|
|
84
|
+
// Check if file contents have changed (ignoring comments) [ds]
|
|
76
85
|
if (contentPrev) {
|
|
77
86
|
const cleanHead = spliceComments(contentHead, [], 'prune', ext);
|
|
78
87
|
const cleanPrev = spliceComments(contentPrev, [], 'prune', ext);
|
|
79
|
-
|
|
88
|
+
const isExplicitOverwrite = !!process.env.DS_OVER;
|
|
89
|
+
if (cleanHead === cleanPrev && !isExplicitOverwrite) {
|
|
80
90
|
console.log(`[devsplain] Skipping ${file}: commit contains only comment changes.`);
|
|
81
91
|
continue;
|
|
82
92
|
}
|
|
@@ -84,27 +94,39 @@ try {
|
|
|
84
94
|
} catch (cleanErr) {
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
//
|
|
97
|
+
// Log and attempt to auto-comment the current file [ds]
|
|
88
98
|
console.log(`[devsplain] Automatically commenting file: ${file}`);
|
|
89
99
|
try {
|
|
100
|
+
let extraFlags = '';
|
|
101
|
+
if (process.env.DS_OVER) extraFlags += ' --overwrite';
|
|
102
|
+
if (process.env.DS_KEEP) extraFlags += ' --keep';
|
|
103
|
+
|
|
104
|
+
// Construct the command to run the auto-commenting CLI [ds]
|
|
90
105
|
const cliPath = path.join(__dirname, 'cli.js');
|
|
91
|
-
execSync(`node "${cliPath}" "${file}" --force${modeFlag}`, { stdio: 'inherit' });
|
|
106
|
+
execSync(`node "${cliPath}" "${file}" --force${modeFlag}${extraFlags}`, { stdio: 'inherit' });
|
|
92
107
|
commentedAny = true;
|
|
108
|
+
successfullyCommentedFiles.push(file);
|
|
93
109
|
} catch (err) {
|
|
94
110
|
console.warn(`[devsplain] Warning: Failed to comment ${file}: ${err.message}`);
|
|
95
111
|
}
|
|
96
112
|
}
|
|
97
113
|
|
|
98
|
-
/**
|
|
114
|
+
/** If any files were successfully commented, stage and commit changes [ds] */
|
|
99
115
|
if (commentedAny) {
|
|
100
|
-
const
|
|
101
|
-
|
|
116
|
+
for (const file of successfullyCommentedFiles) {
|
|
117
|
+
try {
|
|
118
|
+
execSync(`git add "${file}"`);
|
|
119
|
+
} catch (addErr) {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stagedChanges = execSync('git diff --cached --name-only', { encoding: 'utf8' }).trim();
|
|
123
|
+
if (stagedChanges.length > 0) {
|
|
102
124
|
console.log('[devsplain] Staging and committing auto-generated comments...');
|
|
103
|
-
execSync('git commit -
|
|
125
|
+
execSync('git commit -m "docs: auto-generated comments by devsplain" --no-verify', { stdio: 'inherit' });
|
|
104
126
|
console.log('[devsplain] Comments committed successfully! Rollback via: git reset --hard HEAD~1');
|
|
105
127
|
}
|
|
106
128
|
}
|
|
107
|
-
|
|
129
|
+
// Catch and log any errors that occur during execution [ds]
|
|
108
130
|
} catch (e) {
|
|
109
131
|
console.warn(`[devsplain] Warning: post-commit hook script failed: ${e.message}`);
|
|
110
132
|
}
|
package/bin/setup-hook.js
CHANGED
|
@@ -3,26 +3,26 @@ const path = require('path');
|
|
|
3
3
|
const { execSync } = require('child_process');
|
|
4
4
|
const readline = require('readline');
|
|
5
5
|
|
|
6
|
-
/** Installs Git hooks for
|
|
6
|
+
/** Installs Git hooks for auto-generating comments [ds] */
|
|
7
7
|
async function installHooks() {
|
|
8
8
|
try {
|
|
9
9
|
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
10
|
+
const gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
10
11
|
const hooksDir = path.join(gitDir, 'hooks');
|
|
11
12
|
if (!fs.existsSync(hooksDir)) {
|
|
12
13
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// Default commenting mode for Git commits [ds]
|
|
15
17
|
let modeChoice = '1';
|
|
16
|
-
// Check if
|
|
18
|
+
// Check if running in a TTY to prompt user for commenting mode [ds]
|
|
17
19
|
if (process.stdout.isTTY) {
|
|
18
20
|
const rl = readline.createInterface({
|
|
19
21
|
input: process.stdin,
|
|
20
22
|
output: process.stdout
|
|
21
23
|
});
|
|
22
|
-
// Create a readline interface for user input [ds]
|
|
23
24
|
const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
24
25
|
|
|
25
|
-
// Display a menu for the user to select the default commenting mode [ds]
|
|
26
26
|
console.log('\nSelect default commenting mode for Git commits:');
|
|
27
27
|
console.log('1. Balanced (mix of JSDoc and sparse inline comments)');
|
|
28
28
|
console.log('2. Light (JSDoc block comments above functions only)');
|
|
@@ -32,7 +32,6 @@ async function installHooks() {
|
|
|
32
32
|
rl.close();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// Determine the command line arguments based on the chosen mode [ds]
|
|
36
35
|
let modeArgs = '';
|
|
37
36
|
if (modeChoice === '2') {
|
|
38
37
|
modeArgs = ' --light';
|
|
@@ -40,7 +39,7 @@ async function installHooks() {
|
|
|
40
39
|
modeArgs = ' --full';
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
//
|
|
42
|
+
// Path to pre-commit hook script [ds]
|
|
44
43
|
const preCommitHookPath = path.join(hooksDir, 'pre-commit');
|
|
45
44
|
const preCommitContent = `#!/bin/sh
|
|
46
45
|
# devsplain native pre-commit hook
|
|
@@ -49,16 +48,16 @@ if [ -f package.json ] && grep -q '"test"' package.json 2>/dev/null; then
|
|
|
49
48
|
npm test || exit 1
|
|
50
49
|
fi
|
|
51
50
|
`;
|
|
51
|
+
// Write pre-commit hook content to file [ds]
|
|
52
52
|
fs.writeFileSync(preCommitHookPath, preCommitContent);
|
|
53
|
-
// Attempt to set the execute permissions for the pre-commit hook file [ds]
|
|
54
53
|
try {
|
|
55
54
|
fs.chmodSync(preCommitHookPath, 0o755);
|
|
56
55
|
} catch (err) {}
|
|
57
56
|
|
|
58
|
-
//
|
|
57
|
+
// Path to post-commit script [ds]
|
|
59
58
|
const postCommitScript = path.join(__dirname, 'post-commit.js').replace(/\\/g, '/');
|
|
60
59
|
|
|
61
|
-
//
|
|
60
|
+
// Path to post-commit hook script [ds]
|
|
62
61
|
const postCommitHookPath = path.join(hooksDir, 'post-commit');
|
|
63
62
|
const postCommitContent = `#!/bin/sh
|
|
64
63
|
# devsplain native post-commit hook
|
|
@@ -66,22 +65,46 @@ echo "Auto-generating comments for files in the last commit..."
|
|
|
66
65
|
node "${postCommitScript}"${modeArgs} || exit 1
|
|
67
66
|
`;
|
|
68
67
|
fs.writeFileSync(postCommitHookPath, postCommitContent);
|
|
69
|
-
// Attempt to set the execute permissions for the post-commit hook file [ds]
|
|
70
68
|
try {
|
|
71
69
|
fs.chmodSync(postCommitHookPath, 0o755);
|
|
72
70
|
} catch (err) {}
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
console.log(`[devsplain] Git post-commit hook successfully installed at: ${postCommitHookPath}`);
|
|
73
|
+
|
|
74
|
+
// Log successful installation of post-commit hook [ds]
|
|
75
|
+
const ignorePath = path.join(gitRoot, '.devsplainignore');
|
|
76
|
+
// Check if .devsplainignore file exists [ds]
|
|
77
|
+
if (!fs.existsSync(ignorePath)) {
|
|
78
|
+
const defaultIgnore = `node_modules/
|
|
79
|
+
.git/
|
|
80
|
+
dist/
|
|
81
|
+
build/
|
|
82
|
+
out/
|
|
83
|
+
.next/
|
|
84
|
+
.nuxt/
|
|
85
|
+
.svelte-kit/
|
|
86
|
+
venv/
|
|
87
|
+
env/
|
|
88
|
+
.venv/
|
|
89
|
+
.vscode/
|
|
90
|
+
.idea/
|
|
91
|
+
coverage/
|
|
92
|
+
tests/
|
|
93
|
+
__tests__/
|
|
94
|
+
fixtures/
|
|
95
|
+
`;
|
|
96
|
+
fs.writeFileSync(ignorePath, defaultIgnore);
|
|
97
|
+
console.log(`[devsplain] Created default .devsplainignore file at: ${ignorePath}`);
|
|
98
|
+
}
|
|
77
99
|
} catch (e) {
|
|
78
100
|
console.warn('Warning: Could not set up Git hooks (not inside a git repository or git command missing).');
|
|
101
|
+
console.warn(e.message);
|
|
79
102
|
}
|
|
80
103
|
}
|
|
81
104
|
|
|
82
|
-
//
|
|
105
|
+
// Run installHooks if this script is the main module [ds]
|
|
83
106
|
if (require.main === module) {
|
|
84
107
|
installHooks();
|
|
85
108
|
}
|
|
86
|
-
// Export
|
|
109
|
+
// Export installHooks function [ds]
|
|
87
110
|
module.exports = installHooks;
|
package/lib/config.js
CHANGED
|
@@ -4,12 +4,42 @@ const os = require('os');
|
|
|
4
4
|
const readline = require('readline');
|
|
5
5
|
const configPath = path.join(os.homedir(), '.devsplainrc');
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Asks the user for a secret input, such as an API key, without echoing the input to the console. [ds] */
|
|
8
|
+
function askSecret(query) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const rl = readline.createInterface({
|
|
11
|
+
input: process.stdin,
|
|
12
|
+
output: process.stdout
|
|
13
|
+
});
|
|
14
|
+
process.stdout.write(query);
|
|
15
|
+
process.stdin.setRawMode(true);
|
|
16
|
+
let key = '';
|
|
17
|
+
// Handle key presses, including enter, backspace, and Ctrl+C [ds]
|
|
18
|
+
const onData = (char) => {
|
|
19
|
+
char = char.toString();
|
|
20
|
+
if (char === '\n' || char === '\r') {
|
|
21
|
+
process.stdin.setRawMode(false);
|
|
22
|
+
process.stdin.removeListener('data', onData);
|
|
23
|
+
process.stdout.write('\n');
|
|
24
|
+
rl.close();
|
|
25
|
+
resolve(key);
|
|
26
|
+
} else if (char === '\u0003') { // Ctrl+C
|
|
27
|
+
process.exit();
|
|
28
|
+
} else {
|
|
29
|
+
key += char;
|
|
30
|
+
process.stdout.write('*');
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
process.stdin.on('data', onData);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Retrieves the configuration, either from environment variables or by prompting the user to set up a configuration file. [ds] */
|
|
8
38
|
async function getConfig(forceWizard = false) {
|
|
9
39
|
if (process.env.DEVSPLAIN_API_KEY || process.env.DEVSPLAIN_PROVIDER) {
|
|
10
40
|
const provider = process.env.DEVSPLAIN_PROVIDER || 'gemini';
|
|
11
|
-
const model = process.env.DEVSPLAIN_MODEL || (provider === 'gemini' ? 'gemini-2.0-flash' : 'llama-3.3-70b-versatile');
|
|
12
|
-
const baseUrl = process.env.DEVSPLAIN_BASE_URL || (provider === 'gemini' ? null : 'https://api.groq.com/openai');
|
|
41
|
+
const model = process.env.DEVSPLAIN_MODEL || (provider === 'gemini' ? 'gemini-2.0-flash' : (provider === 'claude' ? 'claude-3-5-sonnet-20240620' : 'llama-3.3-70b-versatile'));
|
|
42
|
+
const baseUrl = process.env.DEVSPLAIN_BASE_URL || (provider === 'gemini' ? null : (provider === 'claude' ? 'https://api.anthropic.com' : 'https://api.groq.com/openai'));
|
|
13
43
|
return {
|
|
14
44
|
provider,
|
|
15
45
|
apiKey: process.env.DEVSPLAIN_API_KEY || '',
|
|
@@ -18,34 +48,32 @@ async function getConfig(forceWizard = false) {
|
|
|
18
48
|
};
|
|
19
49
|
}
|
|
20
50
|
|
|
21
|
-
//
|
|
51
|
+
// If no configuration file exists or the forceWizard flag is set, prompt the user to set up a configuration [ds]
|
|
22
52
|
if (!fs.existsSync(configPath) || forceWizard) {
|
|
23
|
-
|
|
53
|
+
let rl = readline.createInterface({
|
|
24
54
|
input: process.stdin,
|
|
25
55
|
output: process.stdout
|
|
26
56
|
});
|
|
27
|
-
|
|
57
|
+
let askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
28
58
|
|
|
29
59
|
let config = null;
|
|
30
60
|
let confirmed = false;
|
|
31
61
|
|
|
32
|
-
// Continuously prompt user for configuration details until confirmation [ds]
|
|
33
62
|
while (!confirmed) {
|
|
34
63
|
let baseUrl = "";
|
|
35
64
|
let model = "";
|
|
36
65
|
let provider = "";
|
|
37
66
|
|
|
38
|
-
// Display available AI
|
|
67
|
+
// Display a menu of available AI providers [ds]
|
|
39
68
|
console.log("\nWhich AI Provider Do You want to use?");
|
|
40
69
|
console.log("1. Groq (Free, Fast, Llama-3)");
|
|
41
70
|
console.log("2. Gemini (Free Tier)");
|
|
42
71
|
console.log("3. OpenAI (Paid)");
|
|
43
72
|
console.log("4. Custom (Ollama, local, etc)");
|
|
73
|
+
console.log("5. Claude (Anthropic)");
|
|
44
74
|
|
|
45
|
-
|
|
46
|
-
const choice = await askQuestion("Select (1-4): ");
|
|
75
|
+
const choice = await askQuestion("Select (1-5): ");
|
|
47
76
|
|
|
48
|
-
// Handle selected AI provider option [ds]
|
|
49
77
|
if (choice === '1') {
|
|
50
78
|
provider = 'groq';
|
|
51
79
|
baseUrl = 'https://api.groq.com/openai';
|
|
@@ -68,30 +96,54 @@ async function getConfig(forceWizard = false) {
|
|
|
68
96
|
provider = 'custom';
|
|
69
97
|
model = await askQuestion("Model name (e.g., llama3): ");
|
|
70
98
|
baseUrl = await askQuestion("Base URL (e.g., http://localhost:11434): ");
|
|
99
|
+
} else if (choice === '5') {
|
|
100
|
+
provider = 'claude';
|
|
101
|
+
baseUrl = 'https://api.anthropic.com';
|
|
102
|
+
console.log("\nGet your Anthropic key here: https://console.anthropic.com/settings/keys");
|
|
103
|
+
const customModel = await askQuestion("Model name (press Enter for default 'claude-3-5-sonnet-20240620'): ");
|
|
104
|
+
model = customModel.trim() || 'claude-3-5-sonnet-20240620';
|
|
71
105
|
} else {
|
|
72
|
-
console.log("Invalid choice. Please select 1, 2, 3, or
|
|
106
|
+
console.log("Invalid choice. Please select 1, 2, 3, 4, or 5.");
|
|
73
107
|
continue;
|
|
74
108
|
}
|
|
75
109
|
|
|
76
|
-
|
|
77
|
-
|
|
110
|
+
let apiKey = '';
|
|
111
|
+
// If running in a TTY, use a secure method to prompt for the API key [ds]
|
|
112
|
+
if (process.stdin.isTTY) {
|
|
113
|
+
rl.close();
|
|
114
|
+
apiKey = await askSecret("Paste your API key (leave blank for local models): ");
|
|
115
|
+
|
|
116
|
+
rl = readline.createInterface({
|
|
117
|
+
input: process.stdin,
|
|
118
|
+
output: process.stdout
|
|
119
|
+
});
|
|
120
|
+
askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
121
|
+
} else {
|
|
122
|
+
apiKey = await askQuestion("Paste your API key (leave blank for local models): ");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Ask the user if they want to prune existing comments [ds]
|
|
126
|
+
const pruneAns = await askQuestion("Do you want devsplain to aggressively prune (overwrite) existing human/AI comments? (y/n, default: n): ");
|
|
127
|
+
const autoPrune = pruneAns.toLowerCase() === 'y';
|
|
78
128
|
|
|
79
|
-
// Display
|
|
129
|
+
// Display a summary of the configuration [ds]
|
|
80
130
|
console.log("\n--- Configuration Summary ---");
|
|
81
|
-
console.log(`Provider:
|
|
82
|
-
console.log(`Model:
|
|
83
|
-
console.log(`Base URL:
|
|
84
|
-
console.log(`API Key:
|
|
131
|
+
console.log(`Provider: ${provider}`);
|
|
132
|
+
console.log(`Model: ${model}`);
|
|
133
|
+
console.log(`Base URL: ${baseUrl || 'N/A'}`);
|
|
134
|
+
console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
|
|
135
|
+
console.log(`Auto-Prune: ${autoPrune ? 'Yes' : 'No'}`);
|
|
85
136
|
console.log("-----------------------------\n");
|
|
86
137
|
|
|
87
|
-
// Confirm configuration with user [ds]
|
|
138
|
+
// Confirm the configuration with the user [ds]
|
|
88
139
|
const confirm = await askQuestion("Does this look correct? (y/n, default: y): ");
|
|
89
140
|
if (confirm.toLowerCase() === 'y' || confirm.trim() === '') {
|
|
90
141
|
config = {
|
|
91
142
|
provider,
|
|
92
143
|
apiKey,
|
|
93
144
|
model,
|
|
94
|
-
baseUrl
|
|
145
|
+
baseUrl,
|
|
146
|
+
autoPrune
|
|
95
147
|
};
|
|
96
148
|
confirmed = true;
|
|
97
149
|
} else {
|
|
@@ -101,10 +153,10 @@ async function getConfig(forceWizard = false) {
|
|
|
101
153
|
|
|
102
154
|
rl.close();
|
|
103
155
|
|
|
104
|
-
// Write configuration to file [ds]
|
|
156
|
+
// Write the configuration to a file [ds]
|
|
105
157
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
106
158
|
try {
|
|
107
|
-
// Set file
|
|
159
|
+
// Set permissions on the configuration file to prevent other users from reading it [ds]
|
|
108
160
|
if (process.platform !== 'win32') {
|
|
109
161
|
fs.chmodSync(configPath, 0o600);
|
|
110
162
|
}
|
|
@@ -113,11 +165,9 @@ async function getConfig(forceWizard = false) {
|
|
|
113
165
|
|
|
114
166
|
return config;
|
|
115
167
|
} else {
|
|
116
|
-
// Read existing configuration from file [ds]
|
|
117
168
|
const rawData = fs.readFileSync(configPath, 'utf8');
|
|
118
169
|
return JSON.parse(rawData);
|
|
119
170
|
}
|
|
120
171
|
}
|
|
121
172
|
|
|
122
|
-
// Export the getConfig function [ds]
|
|
123
173
|
module.exports = { getConfig };
|
package/lib/llm.js
CHANGED
|
@@ -117,8 +117,36 @@ ${numberedCode}
|
|
|
117
117
|
throw new Error(`API Error: ${data.error.message}`);
|
|
118
118
|
}
|
|
119
119
|
textResponse = data.candidates[0].content.parts[0].text;
|
|
120
|
+
} else if (config.provider === 'claude') {
|
|
121
|
+
const url = `${config.baseUrl}/v1/messages`;
|
|
122
|
+
let data;
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetchWithRetry(url, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: {
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
'x-api-key': config.apiKey,
|
|
129
|
+
'anthropic-version': '2023-06-01'
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
"model": config.model,
|
|
133
|
+
"max_tokens": 8192,
|
|
134
|
+
"messages": [{
|
|
135
|
+
"role": "user",
|
|
136
|
+
"content": prompt
|
|
137
|
+
}]
|
|
138
|
+
})
|
|
139
|
+
});
|
|
140
|
+
data = await response.json();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Error("Network Error: Could not connect to the AI provider. Check your internet or API url.");
|
|
143
|
+
}
|
|
144
|
+
if (data.error) {
|
|
145
|
+
throw new Error(`API Error: ${data.error.message}`);
|
|
146
|
+
}
|
|
147
|
+
textResponse = data.content[0].text;
|
|
120
148
|
}
|
|
121
|
-
// Otherwise, use
|
|
149
|
+
// Otherwise, use an OpenAI-compatible provider [ds]
|
|
122
150
|
else {
|
|
123
151
|
const url = `${config.baseUrl}/v1/chat/completions`;
|
|
124
152
|
let data;
|
package/package.json
CHANGED