devsplain 1.8.0 → 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,10 +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).
85
91
 
86
- > [!NOTE]
87
- > **API Testing Notice:** `devsplain` 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!
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.
88
94
 
89
95
  ---
90
96
 
@@ -133,13 +139,10 @@ devsplain lib/ --prune
133
139
  devsplain src/utils.ts --provider gemini --model gemini-2.0-flash --api-key YOUR_KEY
134
140
  ```
135
141
 
136
- > [!WARNING]
137
- > **Directory Traversal Caution**
138
- > 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.
139
- >
140
- > To prevent this, it is highly recommended to either:
141
- > 1. Use the **Automated Git Hooks** to comment only on files modified in your commits.
142
- > 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`).
143
146
 
144
147
  ---
145
148
 
@@ -168,6 +171,23 @@ Run the hook setup command inside your Git repository:
168
171
  devsplain --setup-hook
169
172
  ```
170
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
+
171
191
  ### Pipeline Architecture
172
192
  1. **Pre-commit Hook**: Runs your project test suite (`npm test`). If the tests fail, the commit is blocked.
173
193
  2. **Post-commit Hook**:
@@ -179,6 +199,13 @@ devsplain --setup-hook
179
199
 
180
200
  ---
181
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
+
182
209
  ## License
183
210
 
184
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.");
@@ -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,60 +3,61 @@ 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
- // Allow users to completely bypass the AI hook using an environment variable [ds]
8
+ // Check if SKIP_DEVSPLAIN environment variable is set [ds]
9
9
  if (process.env.SKIP_DEVSPLAIN) {
10
10
  console.log('[devsplain] SKIP_DEVSPLAIN is set. Bypassing AI generation.');
11
11
  process.exit(0);
12
12
  }
13
13
 
14
- // Prevent the hook from firing during rebases, merges, or cherry-picks [ds]
14
+ // Determine if Git is in a rebasing, merging, or cherry-picking state [ds]
15
15
  const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
16
16
  const isRebasing = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
17
17
  const isMerging = fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
18
18
  const isCherryPicking = fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'));
19
19
 
20
+ // Exit if Git is in a rebasing, merging, or cherry-picking state to avoid history conflicts [ds]
20
21
  if (isRebasing || isMerging || isCherryPicking) {
21
22
  console.log('[devsplain] Skipping AI comment generation during git rebase/merge/cherry-pick to avoid history conflicts.');
22
23
  process.exit(0);
23
24
  }
24
25
 
25
- /** Check if the current operation is a rebase, merge or cherry-pick [ds] */
26
+ // Retrieve the last commit message [ds]
26
27
  const lastCommitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim();
27
28
  if (lastCommitMsg === 'docs: auto-generated comments by devsplain') {
28
29
  process.exit(0);
29
30
  }
30
31
 
31
- // Get the last commit message [ds]
32
+ // Get a list of changed files in the last commit [ds]
32
33
  const changedFilesStr = execSync('git diff-tree --no-commit-id --name-only -r HEAD', { encoding: 'utf8' }).trim();
33
34
  if (!changedFilesStr) {
34
35
  process.exit(0);
35
36
  }
36
37
  const changedFiles = changedFilesStr.split(/\r?\n/);
37
38
 
38
- /** List of valid file extensions for commenting [ds] */
39
+ /** List of valid file extensions for auto-commenting [ds] */
39
40
  const validExtensions = [
40
41
  '.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
41
42
  '.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
42
43
  '.swift', '.kt', '.dart', '.sh'
43
44
  ];
44
45
 
45
- /** Filter files to be commented [ds] */
46
+ /** Filter function to determine which files to auto-comment [ds] */
46
47
  const filesToComment = changedFiles.filter(file => {
47
48
  const ext = path.extname(file).toLowerCase();
48
49
  const isIgnored = file.includes('node_modules/') || file.includes('tests/') || file.includes('__tests__/') || file.includes('fixtures/');
49
50
  return validExtensions.includes(ext) && fs.existsSync(file) && !isIgnored;
50
51
  });
51
52
 
52
- // Check if there are any files to comment [ds]
53
53
  if (filesToComment.length === 0) {
54
54
  process.exit(0);
55
55
  }
56
56
 
57
+ // Log the number of files found for auto-commenting [ds]
57
58
  console.log(`[devsplain] Found ${filesToComment.length} file(s) in the last commit to auto-comment.`);
58
59
 
59
- // Parse command line arguments for mode flag [ds]
60
+ // Parse command-line arguments for mode flags [ds]
60
61
  const args = process.argv.slice(2);
61
62
  let modeFlag = '';
62
63
  if (args.includes('--light')) modeFlag = ' --light';
@@ -65,8 +66,9 @@ try {
65
66
  let commentedAny = false;
66
67
  const successfullyCommentedFiles = [];
67
68
 
68
- /** Iterate over files to comment [ds] */
69
+ /** Loop through each file to auto-comment [ds] */
69
70
  for (const file of filesToComment) {
71
+ // Attempt to read file contents and previous version [ds]
70
72
  try {
71
73
  const ext = path.extname(file).toLowerCase();
72
74
  const contentHead = fs.readFileSync(file, 'utf8');
@@ -79,11 +81,12 @@ try {
79
81
  } catch (prevErr) {
80
82
  }
81
83
 
82
- // Compare file content before and after the last commit [ds]
84
+ // Check if file contents have changed (ignoring comments) [ds]
83
85
  if (contentPrev) {
84
86
  const cleanHead = spliceComments(contentHead, [], 'prune', ext);
85
87
  const cleanPrev = spliceComments(contentPrev, [], 'prune', ext);
86
- if (cleanHead === cleanPrev) {
88
+ const isExplicitOverwrite = !!process.env.DS_OVER;
89
+ if (cleanHead === cleanPrev && !isExplicitOverwrite) {
87
90
  console.log(`[devsplain] Skipping ${file}: commit contains only comment changes.`);
88
91
  continue;
89
92
  }
@@ -91,11 +94,16 @@ try {
91
94
  } catch (cleanErr) {
92
95
  }
93
96
 
94
- // Comment the file using the CLI [ds]
97
+ // Log and attempt to auto-comment the current file [ds]
95
98
  console.log(`[devsplain] Automatically commenting file: ${file}`);
96
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]
97
105
  const cliPath = path.join(__dirname, 'cli.js');
98
- execSync(`node "${cliPath}" "${file}" --force${modeFlag}`, { stdio: 'inherit' });
106
+ execSync(`node "${cliPath}" "${file}" --force${modeFlag}${extraFlags}`, { stdio: 'inherit' });
99
107
  commentedAny = true;
100
108
  successfullyCommentedFiles.push(file);
101
109
  } catch (err) {
@@ -103,16 +111,14 @@ try {
103
111
  }
104
112
  }
105
113
 
106
- /** Stage and commit auto-generated comments if any [ds] */
114
+ /** If any files were successfully commented, stage and commit changes [ds] */
107
115
  if (commentedAny) {
108
- // Only stage the exact files that the AI touched to avoid accidentally committing unstaged work [ds]
109
116
  for (const file of successfullyCommentedFiles) {
110
117
  try {
111
118
  execSync(`git add "${file}"`);
112
119
  } catch (addErr) {}
113
120
  }
114
121
 
115
- // Check if there are actually staged changes now [ds]
116
122
  const stagedChanges = execSync('git diff --cached --name-only', { encoding: 'utf8' }).trim();
117
123
  if (stagedChanges.length > 0) {
118
124
  console.log('[devsplain] Staging and committing auto-generated comments...');
@@ -120,7 +126,7 @@ try {
120
126
  console.log('[devsplain] Comments committed successfully! Rollback via: git reset --hard HEAD~1');
121
127
  }
122
128
  }
123
- /** Catch and log any errors [ds] */
129
+ // Catch and log any errors that occur during execution [ds]
124
130
  } catch (e) {
125
131
  console.warn(`[devsplain] Warning: post-commit hook script failed: ${e.message}`);
126
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,7 +4,37 @@ 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';
@@ -18,24 +48,23 @@ 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)");
@@ -43,10 +72,8 @@ async function getConfig(forceWizard = false) {
43
72
  console.log("4. Custom (Ollama, local, etc)");
44
73
  console.log("5. Claude (Anthropic)");
45
74
 
46
- // Get user's selected AI provider option [ds]
47
75
  const choice = await askQuestion("Select (1-5): ");
48
76
 
49
- // Handle selected AI provider option [ds]
50
77
  if (choice === '1') {
51
78
  provider = 'groq';
52
79
  baseUrl = 'https://api.groq.com/openai';
@@ -80,25 +107,43 @@ async function getConfig(forceWizard = false) {
80
107
  continue;
81
108
  }
82
109
 
83
- // Get API key from user [ds]
84
- 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';
85
128
 
86
- // Display configuration summary [ds]
129
+ // Display a summary of the configuration [ds]
87
130
  console.log("\n--- Configuration Summary ---");
88
- console.log(`Provider: ${provider}`);
89
- console.log(`Model: ${model}`);
90
- console.log(`Base URL: ${baseUrl || 'N/A'}`);
91
- 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'}`);
92
136
  console.log("-----------------------------\n");
93
137
 
94
- // Confirm configuration with user [ds]
138
+ // Confirm the configuration with the user [ds]
95
139
  const confirm = await askQuestion("Does this look correct? (y/n, default: y): ");
96
140
  if (confirm.toLowerCase() === 'y' || confirm.trim() === '') {
97
141
  config = {
98
142
  provider,
99
143
  apiKey,
100
144
  model,
101
- baseUrl
145
+ baseUrl,
146
+ autoPrune
102
147
  };
103
148
  confirmed = true;
104
149
  } else {
@@ -108,10 +153,10 @@ async function getConfig(forceWizard = false) {
108
153
 
109
154
  rl.close();
110
155
 
111
- // Write configuration to file [ds]
156
+ // Write the configuration to a file [ds]
112
157
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
113
158
  try {
114
- // Set file permissions to prevent unauthorized access [ds]
159
+ // Set permissions on the configuration file to prevent other users from reading it [ds]
115
160
  if (process.platform !== 'win32') {
116
161
  fs.chmodSync(configPath, 0o600);
117
162
  }
@@ -120,11 +165,9 @@ async function getConfig(forceWizard = false) {
120
165
 
121
166
  return config;
122
167
  } else {
123
- // Read existing configuration from file [ds]
124
168
  const rawData = fs.readFileSync(configPath, 'utf8');
125
169
  return JSON.parse(rawData);
126
170
  }
127
171
  }
128
172
 
129
- // Export the getConfig function [ds]
130
173
  module.exports = { getConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devsplain",
3
- "version": "1.8.0",
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",