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 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
- > [!WARNING]
134
- > **Directory Traversal Caution**
135
- > The built-in list of ignored folders (like `node_modules`, `.git`, `dist`, etc.) is not exhaustive. If you run `devsplain` on a broad directory that contains unignored directories (such as local caches or build directories), it may start commenting unwanted files in random folders.
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 current Git repository has uncommitted changes [ds] */
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 given line index is inside a string in a list of code lines [ds] */
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 code lines [ds] */
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 code lines [ds] */
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 interface for the commenting tool [ds] */
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 (stats.isDirectory()) {
587
- const folderName = path.basename(targetPath);
588
- const ignoredFolders = [
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 cleanData = spliceComments(data, [], 'clean', ext);
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);
@@ -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
- // Get the current git directory [ds]
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
- /** Check if the current operation is a rebase, merge or cherry-pick [ds] */
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 message [ds]
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 be commented [ds] */
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 line arguments for mode flag [ds]
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
- /** Iterate over files to comment [ds] */
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
- // Compare file content before and after the last commit [ds]
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
- if (cleanHead === cleanPrev) {
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
- // Comment the file using the CLI [ds]
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
- /** Stage and commit auto-generated comments if any [ds] */
114
+ /** If any files were successfully commented, stage and commit changes [ds] */
99
115
  if (commentedAny) {
100
- const status = execSync('git diff --name-only', { encoding: 'utf8' }).trim();
101
- if (status.length > 0) {
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 -am "docs: auto-generated comments by devsplain" --no-verify', { stdio: 'inherit' });
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
- /** Catch and log any errors [ds] */
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 the repository. [ds] */
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 process is running in a TTY to prompt for user input [ds]
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
- // Define the path to the pre-commit hook file [ds]
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
- // Define the path to the post-commit script [ds]
57
+ // Path to post-commit script [ds]
59
58
  const postCommitScript = path.join(__dirname, 'post-commit.js').replace(/\\/g, '/');
60
59
 
61
- // Define the path to the post-commit hook file [ds]
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
- // Display a success message if the hooks are installed successfully [ds]
75
- console.log('Success: Native Git pre-commit and post-commit hooks installed successfully!');
76
- // Catch and handle any exceptions that occur during the installation process [ds]
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
- // Check if this script is being run directly (not being required as a module) [ds]
105
+ // Run installHooks if this script is the main module [ds]
83
106
  if (require.main === module) {
84
107
  installHooks();
85
108
  }
86
- // Export the installHooks function as a module [ds]
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
- /** Retrieves the configuration for the AI provider. If a configuration file exists and no forceWizard flag is provided, the existing configuration will be returned. Otherwise, the function will prompt the user to input their configuration details. [ds] */
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
- // Check if configuration file exists or if forceWizard flag is set [ds]
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
- const rl = readline.createInterface({
53
+ let rl = readline.createInterface({
24
54
  input: process.stdin,
25
55
  output: process.stdout
26
56
  });
27
- const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
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 provider options [ds]
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
- // Get user's selected AI provider option [ds]
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 4.");
106
+ console.log("Invalid choice. Please select 1, 2, 3, 4, or 5.");
73
107
  continue;
74
108
  }
75
109
 
76
- // Get API key from user [ds]
77
- const apiKey = await askQuestion("Paste your API key (leave blank for local models): ");
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 configuration summary [ds]
129
+ // Display a summary of the configuration [ds]
80
130
  console.log("\n--- Configuration Summary ---");
81
- console.log(`Provider: ${provider}`);
82
- console.log(`Model: ${model}`);
83
- console.log(`Base URL: ${baseUrl || 'N/A'}`);
84
- console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
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 permissions to prevent unauthorized access [ds]
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 a different provider [ds]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devsplain",
3
- "version": "1.7.1",
3
+ "version": "2.0.0",
4
4
  "description": "An agent-agnostic CLI tool that automatically adds JSDoc and inline comments to your code using free LLMs.",
5
5
  "author": "mwahaj36",
6
6
  "license": "MIT",