devsplain 1.7.0 → 1.8.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 +12 -1
- package/bin/cli.js +2 -2
- package/bin/post-commit.js +42 -14
- package/lib/config.js +11 -4
- package/lib/llm.js +29 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# devsplain
|
|
2
|
+
An agent-agnostic CLI tool that adds JSDoc and inline comments using state-of-the-art LLMs while preserving non-comment source lines byte-for-byte through deterministic verification.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+

|
|
4
5
|
|
|
6
|
+
devsplain never rewrites executable code.
|
|
7
|
+
If the original source cannot be reproduced exactly after comment insertion, the operation aborts.
|
|
5
8
|
---
|
|
6
9
|
|
|
7
10
|
## Key Features
|
|
@@ -35,6 +38,11 @@ Many AI code formatters rewrite your code entirely, exposing you to logic regres
|
|
|
35
38
|
### String Literal Guardrails
|
|
36
39
|
The engine tracks lexical state across template strings, single quotes, double quotes, and multi-line docstrings (such as Python triple-quotes). Comment insertion is blocked if the target line resides within a string literal, preventing broken syntax.
|
|
37
40
|
|
|
41
|
+
### The `[ds]` AI Tag Guarantee
|
|
42
|
+
Every single comment generated by the LLM is forcibly prefixed with a `[ds]` tag (e.g., `// [ds] This function handles...`).
|
|
43
|
+
This guarantees that the local `devsplain` lexer can mathematically differentiate between your human-written manual comments and the AI-generated comments.
|
|
44
|
+
When you run the `--clean` command, the lexer looks specifically for the `[ds]` prefix and surgically removes only the AI-generated comments, safely preserving 100% of your manual documentation.
|
|
45
|
+
|
|
38
46
|
### Why Not AST Verification?
|
|
39
47
|
|
|
40
48
|
AST verification would require language-specific parser dependencies for every supported language.
|
|
@@ -75,6 +83,9 @@ devsplain --config
|
|
|
75
83
|
|
|
76
84
|
Your settings are stored securely in `~/.devsplainrc` (configured with `chmod 600` on POSIX systems to restrict read access).
|
|
77
85
|
|
|
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!
|
|
88
|
+
|
|
78
89
|
---
|
|
79
90
|
|
|
80
91
|
## CLI Usage & Options
|
package/bin/cli.js
CHANGED
|
@@ -566,10 +566,10 @@ Options:
|
|
|
566
566
|
if (cliProvider) {
|
|
567
567
|
config.provider = cliProvider;
|
|
568
568
|
if (!cliModel) {
|
|
569
|
-
config.model = cliProvider === 'gemini' ? 'gemini-2.0-flash' : 'llama-3.3-70b-versatile';
|
|
569
|
+
config.model = cliProvider === 'gemini' ? 'gemini-2.0-flash' : (cliProvider === 'claude' ? 'claude-3-5-sonnet-20240620' : 'llama-3.3-70b-versatile');
|
|
570
570
|
}
|
|
571
571
|
if (!cliBaseUrl) {
|
|
572
|
-
config.baseUrl = cliProvider === 'gemini' ? null : (cliProvider === 'groq' ? 'https://api.groq.com/openai' : (cliProvider === 'openai' ? 'https://api.openai.com' : ''));
|
|
572
|
+
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
573
|
}
|
|
574
574
|
}
|
|
575
575
|
if (cliModel) config.model = cliModel;
|
package/bin/post-commit.js
CHANGED
|
@@ -3,51 +3,69 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { spliceComments } = require('./cli');
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/** Main execution block [ds] */
|
|
7
7
|
try {
|
|
8
|
-
//
|
|
8
|
+
// Allow users to completely bypass the AI hook using an environment variable [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
|
+
// Prevent the hook from firing during rebases, merges, or cherry-picks [ds]
|
|
15
|
+
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
16
|
+
const isRebasing = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
|
|
17
|
+
const isMerging = fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
|
|
18
|
+
const isCherryPicking = fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'));
|
|
19
|
+
|
|
20
|
+
if (isRebasing || isMerging || isCherryPicking) {
|
|
21
|
+
console.log('[devsplain] Skipping AI comment generation during git rebase/merge/cherry-pick to avoid history conflicts.');
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Check if the current operation is a rebase, merge or cherry-pick [ds] */
|
|
9
26
|
const lastCommitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim();
|
|
10
27
|
if (lastCommitMsg === 'docs: auto-generated comments by devsplain') {
|
|
11
28
|
process.exit(0);
|
|
12
29
|
}
|
|
13
30
|
|
|
14
|
-
// Get the
|
|
31
|
+
// Get the last commit message [ds]
|
|
15
32
|
const changedFilesStr = execSync('git diff-tree --no-commit-id --name-only -r HEAD', { encoding: 'utf8' }).trim();
|
|
16
33
|
if (!changedFilesStr) {
|
|
17
34
|
process.exit(0);
|
|
18
35
|
}
|
|
19
36
|
const changedFiles = changedFilesStr.split(/\r?\n/);
|
|
20
37
|
|
|
21
|
-
/**
|
|
38
|
+
/** List of valid file extensions for commenting [ds] */
|
|
22
39
|
const validExtensions = [
|
|
23
40
|
'.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.scss', '.vue', '.svelte',
|
|
24
41
|
'.py', '.java', '.c', '.cpp', '.cs', '.go', '.rb', '.php', '.rs',
|
|
25
42
|
'.swift', '.kt', '.dart', '.sh'
|
|
26
43
|
];
|
|
27
44
|
|
|
28
|
-
/**
|
|
45
|
+
/** Filter files to be commented [ds] */
|
|
29
46
|
const filesToComment = changedFiles.filter(file => {
|
|
30
47
|
const ext = path.extname(file).toLowerCase();
|
|
31
48
|
const isIgnored = file.includes('node_modules/') || file.includes('tests/') || file.includes('__tests__/') || file.includes('fixtures/');
|
|
32
49
|
return validExtensions.includes(ext) && fs.existsSync(file) && !isIgnored;
|
|
33
50
|
});
|
|
34
51
|
|
|
52
|
+
// Check if there are any files to comment [ds]
|
|
35
53
|
if (filesToComment.length === 0) {
|
|
36
54
|
process.exit(0);
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
// Log the number of files to be commented [ds]
|
|
40
57
|
console.log(`[devsplain] Found ${filesToComment.length} file(s) in the last commit to auto-comment.`);
|
|
41
58
|
|
|
42
|
-
// Parse command
|
|
59
|
+
// Parse command line arguments for mode flag [ds]
|
|
43
60
|
const args = process.argv.slice(2);
|
|
44
61
|
let modeFlag = '';
|
|
45
62
|
if (args.includes('--light')) modeFlag = ' --light';
|
|
46
63
|
if (args.includes('--full')) modeFlag = ' --full';
|
|
47
64
|
|
|
48
65
|
let commentedAny = false;
|
|
66
|
+
const successfullyCommentedFiles = [];
|
|
49
67
|
|
|
50
|
-
/**
|
|
68
|
+
/** Iterate over files to comment [ds] */
|
|
51
69
|
for (const file of filesToComment) {
|
|
52
70
|
try {
|
|
53
71
|
const ext = path.extname(file).toLowerCase();
|
|
@@ -61,7 +79,7 @@ try {
|
|
|
61
79
|
} catch (prevErr) {
|
|
62
80
|
}
|
|
63
81
|
|
|
64
|
-
//
|
|
82
|
+
// Compare file content before and after the last commit [ds]
|
|
65
83
|
if (contentPrev) {
|
|
66
84
|
const cleanHead = spliceComments(contentHead, [], 'prune', ext);
|
|
67
85
|
const cleanPrev = spliceComments(contentPrev, [], 'prune', ext);
|
|
@@ -73,26 +91,36 @@ try {
|
|
|
73
91
|
} catch (cleanErr) {
|
|
74
92
|
}
|
|
75
93
|
|
|
76
|
-
//
|
|
94
|
+
// Comment the file using the CLI [ds]
|
|
77
95
|
console.log(`[devsplain] Automatically commenting file: ${file}`);
|
|
78
96
|
try {
|
|
79
97
|
const cliPath = path.join(__dirname, 'cli.js');
|
|
80
98
|
execSync(`node "${cliPath}" "${file}" --force${modeFlag}`, { stdio: 'inherit' });
|
|
81
99
|
commentedAny = true;
|
|
100
|
+
successfullyCommentedFiles.push(file);
|
|
82
101
|
} catch (err) {
|
|
83
102
|
console.warn(`[devsplain] Warning: Failed to comment ${file}: ${err.message}`);
|
|
84
103
|
}
|
|
85
104
|
}
|
|
86
105
|
|
|
87
|
-
/**
|
|
106
|
+
/** Stage and commit auto-generated comments if any [ds] */
|
|
88
107
|
if (commentedAny) {
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
// Only stage the exact files that the AI touched to avoid accidentally committing unstaged work [ds]
|
|
109
|
+
for (const file of successfullyCommentedFiles) {
|
|
110
|
+
try {
|
|
111
|
+
execSync(`git add "${file}"`);
|
|
112
|
+
} catch (addErr) {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check if there are actually staged changes now [ds]
|
|
116
|
+
const stagedChanges = execSync('git diff --cached --name-only', { encoding: 'utf8' }).trim();
|
|
117
|
+
if (stagedChanges.length > 0) {
|
|
91
118
|
console.log('[devsplain] Staging and committing auto-generated comments...');
|
|
92
|
-
execSync('git commit -
|
|
119
|
+
execSync('git commit -m "docs: auto-generated comments by devsplain" --no-verify', { stdio: 'inherit' });
|
|
93
120
|
console.log('[devsplain] Comments committed successfully! Rollback via: git reset --hard HEAD~1');
|
|
94
121
|
}
|
|
95
122
|
}
|
|
123
|
+
/** Catch and log any errors [ds] */
|
|
96
124
|
} catch (e) {
|
|
97
125
|
console.warn(`[devsplain] Warning: post-commit hook script failed: ${e.message}`);
|
|
98
126
|
}
|
package/lib/config.js
CHANGED
|
@@ -8,8 +8,8 @@ const configPath = path.join(os.homedir(), '.devsplainrc');
|
|
|
8
8
|
async function getConfig(forceWizard = false) {
|
|
9
9
|
if (process.env.DEVSPLAIN_API_KEY || process.env.DEVSPLAIN_PROVIDER) {
|
|
10
10
|
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');
|
|
11
|
+
const model = process.env.DEVSPLAIN_MODEL || (provider === 'gemini' ? 'gemini-2.0-flash' : (provider === 'claude' ? 'claude-3-5-sonnet-20240620' : 'llama-3.3-70b-versatile'));
|
|
12
|
+
const baseUrl = process.env.DEVSPLAIN_BASE_URL || (provider === 'gemini' ? null : (provider === 'claude' ? 'https://api.anthropic.com' : 'https://api.groq.com/openai'));
|
|
13
13
|
return {
|
|
14
14
|
provider,
|
|
15
15
|
apiKey: process.env.DEVSPLAIN_API_KEY || '',
|
|
@@ -41,9 +41,10 @@ async function getConfig(forceWizard = false) {
|
|
|
41
41
|
console.log("2. Gemini (Free Tier)");
|
|
42
42
|
console.log("3. OpenAI (Paid)");
|
|
43
43
|
console.log("4. Custom (Ollama, local, etc)");
|
|
44
|
+
console.log("5. Claude (Anthropic)");
|
|
44
45
|
|
|
45
46
|
// Get user's selected AI provider option [ds]
|
|
46
|
-
const choice = await askQuestion("Select (1-
|
|
47
|
+
const choice = await askQuestion("Select (1-5): ");
|
|
47
48
|
|
|
48
49
|
// Handle selected AI provider option [ds]
|
|
49
50
|
if (choice === '1') {
|
|
@@ -68,8 +69,14 @@ async function getConfig(forceWizard = false) {
|
|
|
68
69
|
provider = 'custom';
|
|
69
70
|
model = await askQuestion("Model name (e.g., llama3): ");
|
|
70
71
|
baseUrl = await askQuestion("Base URL (e.g., http://localhost:11434): ");
|
|
72
|
+
} else if (choice === '5') {
|
|
73
|
+
provider = 'claude';
|
|
74
|
+
baseUrl = 'https://api.anthropic.com';
|
|
75
|
+
console.log("\nGet your Anthropic key here: https://console.anthropic.com/settings/keys");
|
|
76
|
+
const customModel = await askQuestion("Model name (press Enter for default 'claude-3-5-sonnet-20240620'): ");
|
|
77
|
+
model = customModel.trim() || 'claude-3-5-sonnet-20240620';
|
|
71
78
|
} else {
|
|
72
|
-
console.log("Invalid choice. Please select 1, 2, 3, or
|
|
79
|
+
console.log("Invalid choice. Please select 1, 2, 3, 4, or 5.");
|
|
73
80
|
continue;
|
|
74
81
|
}
|
|
75
82
|
|
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