@rahul05ranjan/dhruv-cli 1.5.0 ā 1.6.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/CHANGELOG.md +12 -5
- package/__tests__/cli-contract.test.ts +66 -0
- package/__tests__/core.test.ts +72 -1
- package/__tests__/diagnostics.test.ts +24 -0
- package/__tests__/file-workflows.test.ts +39 -0
- package/__tests__/interactive.test.ts +15 -0
- package/dist/commands/generate.js +26 -7
- package/dist/commands/init.js +8 -6
- package/dist/commands/metrics.js +12 -2
- package/dist/commands/review.js +19 -2
- package/dist/commands/security-check.js +35 -2
- package/dist/commands/status.js +4 -2
- package/dist/core/command-runner.js +8 -2
- package/dist/core/metrics.js +3 -2
- package/dist/index.js +63 -11
- package/dist/utils/projectType.d.ts +6 -0
- package/dist/utils/projectType.js +76 -17
- package/package.json +1 -1
- package/src/commands/generate.ts +27 -7
- package/src/commands/init.ts +8 -6
- package/src/commands/metrics.ts +11 -2
- package/src/commands/review.ts +20 -2
- package/src/commands/security-check.ts +33 -2
- package/src/commands/status.ts +4 -2
- package/src/core/command-runner.ts +8 -2
- package/src/core/metrics.ts +3 -1
- package/src/index.ts +63 -11
- package/src/utils/projectType.ts +75 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
# [1.4.0](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.
|
|
1
|
+
# [1.4.0](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.5.0...v1.4.0) (2026-09-18)
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
### Bug Fixes
|
|
5
5
|
|
|
6
|
-
*
|
|
7
|
-
* drop unused menu loop flag ([51eeec6](https://github.com/rahul05ranjan/dhruv-cli/commit/51eeec6a607299120ce3e1f15af9f9dc62396ce7))
|
|
8
|
-
* stabilize health and CLI contract tests on CI ([3b3e650](https://github.com/rahul05ranjan/dhruv-cli/commit/3b3e6503591c8c33d36f1887e94358dafe6163ce))
|
|
6
|
+
* **test:** allow health --json contract test to inspect stdout on offline service exit code ([6231027](https://github.com/rahul05ranjan/dhruv-cli/commit/623102722d5c3ac2604dfffa276868a3b7eecdb8))
|
|
9
7
|
|
|
10
8
|
|
|
11
9
|
### Features
|
|
12
10
|
|
|
13
|
-
*
|
|
11
|
+
* **ai:** ensure complete and streamable responses across commands (closes [#70](https://github.com/rahul05ranjan/dhruv-cli/issues/70)) ([285655e](https://github.com/rahul05ranjan/dhruv-cli/commit/285655e49eab1cbe87492cba2602a27a4bfecdfe))
|
|
12
|
+
* **catalog:** unified command catalog, menu, and shell completions (closes [#79](https://github.com/rahul05ranjan/dhruv-cli/issues/79)) ([13a9c83](https://github.com/rahul05ranjan/dhruv-cli/commit/13a9c836a52933357b9ca187c787e3b9ae8f4948))
|
|
13
|
+
* **cli:** standardize exit codes, timeouts, and cancellation (closes [#72](https://github.com/rahul05ranjan/dhruv-cli/issues/72)) ([d075d16](https://github.com/rahul05ranjan/dhruv-cli/commit/d075d16d1cfb3c000d156bcd238e3c261f0fc66a))
|
|
14
|
+
* **cli:** standardize reliable json output contract (closes [#71](https://github.com/rahul05ranjan/dhruv-cli/issues/71)) ([e0de862](https://github.com/rahul05ranjan/dhruv-cli/commit/e0de86224b54dcf43652b5daf3f4b403d6aa7d8f))
|
|
15
|
+
* **detection:** expand project detection and diagnostics (closes [#77](https://github.com/rahul05ranjan/dhruv-cli/issues/77)) ([221414a](https://github.com/rahul05ranjan/dhruv-cli/commit/221414a7c1156a650c9c746b5345b871e5274c57))
|
|
16
|
+
* **diagnostics:** improve first-run model readiness and health diagnostics (closes [#78](https://github.com/rahul05ranjan/dhruv-cli/issues/78)) ([95cd3a8](https://github.com/rahul05ranjan/dhruv-cli/commit/95cd3a8d772024847a86ba53fb6391f8024ab962))
|
|
17
|
+
* **generate:** previewable generation and multi-language test support (closes [#75](https://github.com/rahul05ranjan/dhruv-cli/issues/75)) ([c7f3dbe](https://github.com/rahul05ranjan/dhruv-cli/commit/c7f3dbe3902ee903220a40a4fae051b3a03797ac))
|
|
18
|
+
* **metrics:** persistent invocation metrics with reset and export (closes [#76](https://github.com/rahul05ranjan/dhruv-cli/issues/76)) ([0b5c4db](https://github.com/rahul05ranjan/dhruv-cli/commit/0b5c4db69e85d10f6afeca1d26e8d9a089ce6e4c))
|
|
19
|
+
* **review:** project-aware review and diff scanning (closes [#73](https://github.com/rahul05ranjan/dhruv-cli/issues/73)) ([290e4a1](https://github.com/rahul05ranjan/dhruv-cli/commit/290e4a18f21dfa9ea5a1f5fb51e2f8d98d83df53))
|
|
20
|
+
* **security:** safe credential redaction and strict checks (closes [#74](https://github.com/rahul05ranjan/dhruv-cli/issues/74)) ([2467d37](https://github.com/rahul05ranjan/dhruv-cli/commit/2467d37f4404f7e16e2a10cfed860a76661d6c60))
|
|
14
21
|
# Changelog
|
|
15
22
|
|
|
16
23
|
## [1.1.1](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.1.0...v1.1.1) (2025-06-29)
|
|
@@ -37,6 +37,27 @@ describe('CLI output contract', () => {
|
|
|
37
37
|
|
|
38
38
|
expect(result.stdout).toContain('status');
|
|
39
39
|
expect(result.stdout).toContain('--json');
|
|
40
|
+
expect(result.stdout).toContain('tests documentation docs component');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('generates valid zsh and fish completion scripts with subcommands', async () => {
|
|
44
|
+
const zshResult = await execFileAsync(
|
|
45
|
+
process.execPath,
|
|
46
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'zsh'],
|
|
47
|
+
{ cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
|
|
48
|
+
);
|
|
49
|
+
expect(zshResult.stdout).toContain('#compdef dhruv');
|
|
50
|
+
expect(zshResult.stdout).toContain('generate');
|
|
51
|
+
expect(zshResult.stdout).not.toContain('\u001b[');
|
|
52
|
+
|
|
53
|
+
const fishResult = await execFileAsync(
|
|
54
|
+
process.execPath,
|
|
55
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'fish'],
|
|
56
|
+
{ cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
|
|
57
|
+
);
|
|
58
|
+
expect(fishResult.stdout).toContain('complete -c dhruv');
|
|
59
|
+
expect(fishResult.stdout).toContain('__fish_seen_subcommand_from generate');
|
|
60
|
+
expect(fishResult.stdout).not.toContain('\u001b[');
|
|
40
61
|
});
|
|
41
62
|
|
|
42
63
|
it('rejects unsupported completion shells', async () => {
|
|
@@ -101,4 +122,49 @@ describe('CLI output contract', () => {
|
|
|
101
122
|
|
|
102
123
|
expect(result.stdout).toContain('--details');
|
|
103
124
|
});
|
|
125
|
+
|
|
126
|
+
it('outputs exactly one valid JSON document on stdout with no ANSI or extra text', async () => {
|
|
127
|
+
const result = await execFileAsync(
|
|
128
|
+
process.execPath,
|
|
129
|
+
['--loader', loaderEntry, sourceEntry, 'metrics', '--json'],
|
|
130
|
+
{
|
|
131
|
+
cwd: repoRoot,
|
|
132
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(result.stdout).not.toContain('\u001b[');
|
|
137
|
+
const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
|
138
|
+
expect(parsed).toMatchObject({
|
|
139
|
+
ok: true,
|
|
140
|
+
command: 'metrics',
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('outputs valid JSON for health command with expected top-level schema', async () => {
|
|
145
|
+
let stdout = '';
|
|
146
|
+
try {
|
|
147
|
+
const result = await execFileAsync(
|
|
148
|
+
process.execPath,
|
|
149
|
+
['--loader', loaderEntry, sourceEntry, 'health', '--json'],
|
|
150
|
+
{
|
|
151
|
+
cwd: repoRoot,
|
|
152
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
stdout = result.stdout;
|
|
156
|
+
} catch (err: unknown) {
|
|
157
|
+
const execError = err as { stdout?: string; code?: number };
|
|
158
|
+
if (typeof execError?.stdout === 'string' && execError.stdout.trim().length > 0) {
|
|
159
|
+
stdout = execError.stdout;
|
|
160
|
+
} else {
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
expect(stdout).not.toContain('\u001b[');
|
|
166
|
+
const parsed = JSON.parse(stdout.trim()) as Record<string, unknown>;
|
|
167
|
+
expect(parsed).toHaveProperty('ok');
|
|
168
|
+
expect(parsed.command).toBe('health');
|
|
169
|
+
});
|
|
104
170
|
});
|
package/__tests__/core.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from '../src/core/ai';
|
|
8
8
|
import { loadConfig, saveConfig } from '../src/config/config';
|
|
9
9
|
import { createSpinner } from '../src/utils/ux';
|
|
10
|
-
import { detectProjectType } from '../src/utils/projectType';
|
|
10
|
+
import { detectProjectType, detectProjectDetails } from '../src/utils/projectType';
|
|
11
11
|
import { getSystemMessage } from '../src/core/prompts';
|
|
12
12
|
import { runCommand } from '../src/core/command-runner';
|
|
13
13
|
import fs from 'fs';
|
|
@@ -204,11 +204,56 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
204
204
|
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
205
205
|
mockReadFileSync.mockReturnValue('{ malformed');
|
|
206
206
|
|
|
207
|
+
const details = detectProjectDetails();
|
|
208
|
+
expect(details.type).toBe('unknown');
|
|
209
|
+
expect(details.diagnostic).toContain('Malformed package.json');
|
|
207
210
|
expect(detectProjectType()).toBe('unknown');
|
|
208
211
|
|
|
209
212
|
mockExistsSync.mockRestore();
|
|
210
213
|
mockReadFileSync.mockRestore();
|
|
211
214
|
});
|
|
215
|
+
|
|
216
|
+
it('should detect Python project with FastAPI framework', () => {
|
|
217
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
218
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'requirements.txt');
|
|
219
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
220
|
+
mockReadFileSync.mockReturnValue('fastapi>=0.100.0\nuvicorn>=0.20.0');
|
|
221
|
+
|
|
222
|
+
expect(detectProjectType()).toBe('python-fastapi');
|
|
223
|
+
|
|
224
|
+
mockExistsSync.mockRestore();
|
|
225
|
+
mockReadFileSync.mockRestore();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should detect Go project from go.mod', () => {
|
|
229
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
230
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'go.mod');
|
|
231
|
+
|
|
232
|
+
expect(detectProjectType()).toBe('go');
|
|
233
|
+
|
|
234
|
+
mockExistsSync.mockRestore();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should detect Rust project from Cargo.toml', () => {
|
|
238
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
239
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'Cargo.toml');
|
|
240
|
+
|
|
241
|
+
expect(detectProjectType()).toBe('rust');
|
|
242
|
+
|
|
243
|
+
mockExistsSync.mockRestore();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should detect Java Spring Boot project', () => {
|
|
247
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
248
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'pom.xml');
|
|
249
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
250
|
+
mockReadFileSync.mockReturnValue('<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>');
|
|
251
|
+
|
|
252
|
+
expect(detectProjectType()).toBe('java-spring-boot');
|
|
253
|
+
|
|
254
|
+
mockExistsSync.mockRestore();
|
|
255
|
+
mockReadFileSync.mockRestore();
|
|
256
|
+
});
|
|
212
257
|
});
|
|
213
258
|
|
|
214
259
|
describe('System Message Templates', () => {
|
|
@@ -366,6 +411,32 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
366
411
|
expect(jest.mocked(printError).mock.calls.length).toBeGreaterThan(0);
|
|
367
412
|
});
|
|
368
413
|
|
|
414
|
+
it('maps an untyped ECONNREFUSED error to the ollama-serve hint', async () => {
|
|
415
|
+
setAIClient({
|
|
416
|
+
ask: async () => { throw new Error('connect ECONNREFUSED 127.0.0.1:11434'); },
|
|
417
|
+
listModels: async () => [],
|
|
418
|
+
});
|
|
419
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
420
|
+
await runCommand(makeSpec());
|
|
421
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('ollama serve'));
|
|
422
|
+
consoleSpy.mockRestore();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('displays completed response when streaming is unavailable', async () => {
|
|
426
|
+
setAIClient({
|
|
427
|
+
ask: async () => 'static completed response',
|
|
428
|
+
listModels: async () => [],
|
|
429
|
+
});
|
|
430
|
+
const output: string[] = [];
|
|
431
|
+
const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
432
|
+
output.push(String(chunk));
|
|
433
|
+
return true;
|
|
434
|
+
});
|
|
435
|
+
await runCommand(makeSpec());
|
|
436
|
+
expect(output.join('')).toContain('static completed response');
|
|
437
|
+
writeSpy.mockRestore();
|
|
438
|
+
});
|
|
439
|
+
|
|
369
440
|
it('marks the process unsuccessful when an AI command fails', async () => {
|
|
370
441
|
const originalExitCode = process.exitCode;
|
|
371
442
|
process.exitCode = undefined;
|
|
@@ -152,4 +152,28 @@ describe('diagnostic commands', () => {
|
|
|
152
152
|
metricsCollector.resetPersistent();
|
|
153
153
|
}
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
it('tolerates filesystem storage failures without throwing during recording', () => {
|
|
157
|
+
const fs = require('node:fs');
|
|
158
|
+
const writeSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
|
|
159
|
+
const err = new Error('EACCES: permission denied');
|
|
160
|
+
(err as NodeJS.ErrnoException).code = 'EACCES';
|
|
161
|
+
throw err;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(() => {
|
|
165
|
+
metricsCollector.recordCommand('explain', 100, true);
|
|
166
|
+
}).not.toThrow();
|
|
167
|
+
|
|
168
|
+
writeSpy.mockRestore();
|
|
169
|
+
metricsCollector.resetPersistent();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('resets local metrics intentionally and clears persisted state', () => {
|
|
173
|
+
metricsCollector.recordCommand('explain', 200, true);
|
|
174
|
+
expect(metricsCollector.getSummary().commands.explain.runs).toBeGreaterThan(0);
|
|
175
|
+
|
|
176
|
+
metricsCollector.resetPersistent();
|
|
177
|
+
expect(metricsCollector.getSummary().commands.explain).toBeUndefined();
|
|
178
|
+
});
|
|
155
179
|
});
|
|
@@ -99,6 +99,22 @@ describe('file analysis commands', () => {
|
|
|
99
99
|
expect(client.requests[0].prompt).not.toContain('node_modules/library/ignored.ts');
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
it('excludes build and framework cache directories during recursive review', async () => {
|
|
103
|
+
fs.mkdirSync(path.join(root, '.next'), { recursive: true });
|
|
104
|
+
fs.mkdirSync(path.join(root, 'vendor'), { recursive: true });
|
|
105
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
106
|
+
fs.writeFileSync(path.join(root, '.next', 'bundle.js'), 'console.log("cached");');
|
|
107
|
+
fs.writeFileSync(path.join(root, 'vendor', 'lib.go'), 'package vendor');
|
|
108
|
+
fs.writeFileSync(path.join(root, 'src', 'main.ts'), 'export const main = 1;');
|
|
109
|
+
|
|
110
|
+
await review(root);
|
|
111
|
+
|
|
112
|
+
expect(client.requests).toHaveLength(1);
|
|
113
|
+
expect(client.requests[0].prompt).toContain('src/main.ts');
|
|
114
|
+
expect(client.requests[0].prompt).not.toContain('.next/bundle.js');
|
|
115
|
+
expect(client.requests[0].prompt).not.toContain('vendor/lib.go');
|
|
116
|
+
});
|
|
117
|
+
|
|
102
118
|
it('includes detected project context in review requests', async () => {
|
|
103
119
|
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ devDependencies: { typescript: '^5.0.0' } }));
|
|
104
120
|
fs.writeFileSync(path.join(root, 'index.ts'), 'export const value = 1;');
|
|
@@ -129,6 +145,18 @@ describe('file analysis commands', () => {
|
|
|
129
145
|
expect(client.requests[0].prompt).toContain('rotate the credential');
|
|
130
146
|
});
|
|
131
147
|
|
|
148
|
+
it('redacts GitHub tokens and AWS keys with remediation guidance', async () => {
|
|
149
|
+
const source = path.join(root, 'secrets.ts');
|
|
150
|
+
fs.writeFileSync(source, 'const GITHUB_TOKEN = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";\nconst AWS_KEY = "AKIA1234567890ABCDEF";');
|
|
151
|
+
|
|
152
|
+
await securityCheck(source);
|
|
153
|
+
|
|
154
|
+
expect(client.requests[0].prompt).not.toContain('ghp_1234567890abcdefghijklmnopqrstuvwxyz');
|
|
155
|
+
expect(client.requests[0].prompt).not.toContain('AKIA1234567890ABCDEF');
|
|
156
|
+
expect(client.requests[0].prompt).toContain('GitHub token detected');
|
|
157
|
+
expect(client.requests[0].prompt).toContain('AWS access key ID detected');
|
|
158
|
+
});
|
|
159
|
+
|
|
132
160
|
it('scans nested project files without sending dependency directories', async () => {
|
|
133
161
|
fs.mkdirSync(path.join(root, 'src', 'nested'), { recursive: true });
|
|
134
162
|
fs.mkdirSync(path.join(root, 'node_modules', 'library'), { recursive: true });
|
|
@@ -174,6 +202,17 @@ describe('file analysis commands', () => {
|
|
|
174
202
|
expect(fs.existsSync(path.join(root, 'sample.test.ts'))).toBe(true);
|
|
175
203
|
});
|
|
176
204
|
|
|
205
|
+
it('preserves the Python language and conventions when generating tests', async () => {
|
|
206
|
+
const source = path.join(root, 'math_utils.py');
|
|
207
|
+
fs.writeFileSync(source, 'def add(a, b):\n return a + b\n');
|
|
208
|
+
|
|
209
|
+
await generate('tests', source, { apply: true });
|
|
210
|
+
|
|
211
|
+
expect(client.requests[0].prompt).toContain('Python code');
|
|
212
|
+
expect(client.requests[0].prompt).toContain('pytest');
|
|
213
|
+
expect(fs.existsSync(path.join(root, 'math_utils.test.py'))).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
177
216
|
it('previews generated tests without writing by default', async () => {
|
|
178
217
|
const source = path.join(root, 'preview.ts');
|
|
179
218
|
fs.writeFileSync(source, 'export const value = 1;');
|
|
@@ -72,6 +72,21 @@ describe('interactive commands', () => {
|
|
|
72
72
|
}
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it('handles init non-interactive TTY failure with failure exit code', async () => {
|
|
76
|
+
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
77
|
+
const prompt = jest.mocked(inquirer.prompt);
|
|
78
|
+
prompt.mockRejectedValueOnce({ isTtyError: true, message: 'TTY required' });
|
|
79
|
+
process.exitCode = undefined;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await init();
|
|
83
|
+
expect(process.exitCode).toBe(1);
|
|
84
|
+
} finally {
|
|
85
|
+
prompt.mockReset();
|
|
86
|
+
process.exitCode = undefined;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
75
90
|
it('asks whether init should save project-local or user-global settings', async () => {
|
|
76
91
|
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
77
92
|
const prompt = jest.mocked(inquirer.prompt);
|
|
@@ -4,23 +4,42 @@ import { runCommand } from '../core/command-runner.js';
|
|
|
4
4
|
import { getSystemMessage } from '../core/prompts.js';
|
|
5
5
|
import { printError, printSuccess, printInfo } from '../utils/ux.js';
|
|
6
6
|
import { loadConfig } from '../config/config.js';
|
|
7
|
-
function
|
|
7
|
+
function getLanguageForFile(target) {
|
|
8
|
+
const ext = path.extname(target).toLowerCase();
|
|
9
|
+
switch (ext) {
|
|
10
|
+
case '.py':
|
|
11
|
+
return { name: 'Python', testFramework: 'pytest or unittest' };
|
|
12
|
+
case '.go':
|
|
13
|
+
return { name: 'Go', testFramework: 'standard testing package' };
|
|
14
|
+
case '.rs':
|
|
15
|
+
return { name: 'Rust', testFramework: 'standard Rust test framework' };
|
|
16
|
+
case '.ts':
|
|
17
|
+
case '.tsx':
|
|
18
|
+
return { name: 'TypeScript', testFramework: 'Jest or Vitest' };
|
|
19
|
+
case '.java':
|
|
20
|
+
return { name: 'Java', testFramework: 'JUnit 5' };
|
|
21
|
+
default:
|
|
22
|
+
return { name: 'JavaScript', testFramework: 'Jest or Mocha' };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function buildPrompt(type, content, target) {
|
|
26
|
+
const lang = getLanguageForFile(target);
|
|
8
27
|
if (type === 'tests' || type === 'test') {
|
|
9
|
-
return `Generate comprehensive unit tests for the following
|
|
28
|
+
return `Generate comprehensive unit tests for the following ${lang.name} code. Use ${lang.testFramework} syntax. Only return the test code without explanations:\n\n${content}`;
|
|
10
29
|
}
|
|
11
30
|
if (type === 'documentation' || type === 'docs') {
|
|
12
|
-
return `Generate JSDoc
|
|
31
|
+
return `Generate ${lang.name === 'Python' ? 'docstrings' : 'JSDoc/documentation'} for the following code:\n\n${content}`;
|
|
13
32
|
}
|
|
14
33
|
return `Generate ${type} for this code:\n\n${content}`;
|
|
15
34
|
}
|
|
16
35
|
/** Extracts test code from the response: a fenced block if present, else the raw response. */
|
|
17
36
|
function extractTestCode(response) {
|
|
18
|
-
const fenced = response.match(/```(?:javascript|js)?\s*\n([\s\S]*?)```/);
|
|
37
|
+
const fenced = response.match(/```(?:javascript|js|typescript|ts|python|py|go|rust|rs|java)?\s*\n([\s\S]*?)```/i);
|
|
19
38
|
if (fenced?.[1])
|
|
20
39
|
return fenced[1].trim();
|
|
21
40
|
return response
|
|
22
|
-
.replace(/^.*?(?=const|describe|test|it\s*\()/s, '')
|
|
23
|
-
.replace(/```[a-z]*\n?/
|
|
41
|
+
.replace(/^.*?(?=const|describe|test|it\s*\(|def test_|func Test|#\[test\])/s, '')
|
|
42
|
+
.replace(/```[a-z]*\n?/gi, '')
|
|
24
43
|
.trim();
|
|
25
44
|
}
|
|
26
45
|
export async function generate(type, target, options = {}) {
|
|
@@ -34,7 +53,7 @@ export async function generate(type, target, options = {}) {
|
|
|
34
53
|
input: { type, target },
|
|
35
54
|
header: `šØ Generating ${type}: `,
|
|
36
55
|
buildRequest: (input, model) => ({
|
|
37
|
-
prompt: buildPrompt(input.type, content),
|
|
56
|
+
prompt: buildPrompt(input.type, content, input.target),
|
|
38
57
|
systemMessage: getSystemMessage('generate'),
|
|
39
58
|
model,
|
|
40
59
|
}),
|
package/dist/commands/init.js
CHANGED
|
@@ -12,15 +12,16 @@ export async function init() {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
catch {
|
|
15
|
-
console.log(chalk.yellow('Warning: Could not
|
|
16
|
-
console.log(chalk.yellow('
|
|
15
|
+
console.log(chalk.yellow('Warning: Could not connect to Ollama.'));
|
|
16
|
+
console.log(chalk.yellow('š” Start Ollama with: ollama serve'));
|
|
17
|
+
console.log(chalk.yellow(`š” Install default model with: ollama pull ${current.model}\n`));
|
|
17
18
|
}
|
|
18
19
|
try {
|
|
19
20
|
const answers = await inquirer.prompt([
|
|
20
21
|
{
|
|
21
22
|
type: 'list',
|
|
22
23
|
name: 'model',
|
|
23
|
-
message:
|
|
24
|
+
message: `Which Ollama model do you want to use? (default: ${current.model})`,
|
|
24
25
|
choices: modelChoices,
|
|
25
26
|
default: current.model,
|
|
26
27
|
},
|
|
@@ -57,12 +58,13 @@ export async function init() {
|
|
|
57
58
|
console.log(chalk.green(`Configuration saved ${scope === 'global' ? 'for your user account' : 'in this project'}!`));
|
|
58
59
|
}
|
|
59
60
|
catch (error) {
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
const isTty = Boolean(error?.isTtyError);
|
|
62
|
+
process.exitCode = isTty ? 1 : 130;
|
|
63
|
+
if (isTty) {
|
|
62
64
|
console.log(chalk.red('This command requires an interactive terminal.'));
|
|
63
65
|
}
|
|
64
66
|
else {
|
|
65
|
-
console.log(chalk.red('Configuration cancelled
|
|
67
|
+
console.log(chalk.red('Configuration cancelled.'));
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
}
|
package/dist/commands/metrics.js
CHANGED
|
@@ -73,8 +73,18 @@ export async function metrics(options = {}) {
|
|
|
73
73
|
logger.info('Metrics displayed successfully', { metricsCount: metricsData.length });
|
|
74
74
|
}
|
|
75
75
|
catch (error) {
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
if (loadConfig().responseFormat === 'json') {
|
|
78
|
+
process.stdout.write(`${JSON.stringify({
|
|
79
|
+
ok: false,
|
|
80
|
+
command: 'metrics',
|
|
81
|
+
error: error.message,
|
|
82
|
+
})}\n`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
printError('Failed to retrieve metrics');
|
|
86
|
+
console.error(chalk.red(error.message));
|
|
87
|
+
}
|
|
78
88
|
logger.error('Metrics command failed', error);
|
|
79
89
|
}
|
|
80
90
|
}
|
package/dist/commands/review.js
CHANGED
|
@@ -3,10 +3,24 @@ import path from 'path';
|
|
|
3
3
|
import { execFileSync } from 'child_process';
|
|
4
4
|
import { runCommand } from '../core/command-runner.js';
|
|
5
5
|
import { getSystemMessage } from '../core/prompts.js';
|
|
6
|
-
import { printError } from '../utils/ux.js';
|
|
6
|
+
import { printError, printInfo } from '../utils/ux.js';
|
|
7
7
|
import { detectProjectType } from '../utils/projectType.js';
|
|
8
8
|
const CODE_FILE = /\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/;
|
|
9
|
-
const IGNORED_DIRECTORIES = new Set([
|
|
9
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
10
|
+
'.git',
|
|
11
|
+
'node_modules',
|
|
12
|
+
'dist',
|
|
13
|
+
'build',
|
|
14
|
+
'coverage',
|
|
15
|
+
'.dhruv-cache',
|
|
16
|
+
'logs',
|
|
17
|
+
'.next',
|
|
18
|
+
'.turbo',
|
|
19
|
+
'__pycache__',
|
|
20
|
+
'.pytest_cache',
|
|
21
|
+
'target',
|
|
22
|
+
'vendor',
|
|
23
|
+
]);
|
|
10
24
|
/** Reads a file or up to 10 code files from a directory tree. */
|
|
11
25
|
function readCode(fileOrDir) {
|
|
12
26
|
// Read first, branch on the error: no separate existence check to race against.
|
|
@@ -47,6 +61,9 @@ function readDirectory(dir) {
|
|
|
47
61
|
printError(`No code files found in directory "${dir}".`);
|
|
48
62
|
return undefined;
|
|
49
63
|
}
|
|
64
|
+
if (files.length >= 10) {
|
|
65
|
+
printInfo('Note: Directory review is capped at the first 10 source files.');
|
|
66
|
+
}
|
|
50
67
|
let code = '';
|
|
51
68
|
for (const f of files) {
|
|
52
69
|
try {
|
|
@@ -4,12 +4,29 @@ import { runCommand } from '../core/command-runner.js';
|
|
|
4
4
|
import { getSystemMessage } from '../core/prompts.js';
|
|
5
5
|
import { printError } from '../utils/ux.js';
|
|
6
6
|
const CODE_FILE = /\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/;
|
|
7
|
-
const IGNORED_DIRECTORIES = new Set([
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
8
|
+
'.git',
|
|
9
|
+
'node_modules',
|
|
10
|
+
'dist',
|
|
11
|
+
'build',
|
|
12
|
+
'coverage',
|
|
13
|
+
'.dhruv-cache',
|
|
14
|
+
'logs',
|
|
15
|
+
'.next',
|
|
16
|
+
'.turbo',
|
|
17
|
+
'__pycache__',
|
|
18
|
+
'.pytest_cache',
|
|
19
|
+
'target',
|
|
20
|
+
'vendor',
|
|
21
|
+
]);
|
|
8
22
|
function redactSensitiveContent(content) {
|
|
9
23
|
return content
|
|
10
24
|
.replace(/(\b(?:api[_-]?key|secret|token|password|authorization)\s*[:=]\s*["'`])[^"'`\r\n]+(["'`])/gi, '$1[REDACTED]$2')
|
|
11
25
|
.replace(/\b(?:sk|pk)-[a-z0-9_-]{8,}\b/gi, '[REDACTED]')
|
|
12
|
-
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
|
|
26
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
|
|
27
|
+
.replace(/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/g, '[REDACTED]')
|
|
28
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED]')
|
|
29
|
+
.replace(/-----BEGIN (?:RSA|OPENSSH|EC|PGP|DSA)? PRIVATE KEY-----[\s\S]*?-----END (?:RSA|OPENSSH|EC|PGP|DSA)? PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]');
|
|
13
30
|
}
|
|
14
31
|
function findHighConfidenceFindings(content) {
|
|
15
32
|
const findings = [];
|
|
@@ -39,6 +56,22 @@ function findHighConfidenceFindings(content) {
|
|
|
39
56
|
remediation: 'revoke the token and use a secure runtime secret store',
|
|
40
57
|
});
|
|
41
58
|
}
|
|
59
|
+
else if (/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/.test(line)) {
|
|
60
|
+
findings.push({
|
|
61
|
+
line: index + 1,
|
|
62
|
+
severity: 'high',
|
|
63
|
+
description: 'GitHub token detected',
|
|
64
|
+
remediation: 'revoke the GitHub token and store it in GitHub Secrets or environment variables',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else if (/\bAKIA[0-9A-Z]{16}\b/.test(line)) {
|
|
68
|
+
findings.push({
|
|
69
|
+
line: index + 1,
|
|
70
|
+
severity: 'high',
|
|
71
|
+
description: 'AWS access key ID detected',
|
|
72
|
+
remediation: 'rotate the AWS access key and use IAM roles or AWS Secrets Manager',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
42
75
|
});
|
|
43
76
|
return findings;
|
|
44
77
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -69,15 +69,17 @@ export async function status() {
|
|
|
69
69
|
else {
|
|
70
70
|
process.exitCode = 1;
|
|
71
71
|
printError(`ā Configured model '${config.model}' is not available`);
|
|
72
|
+
console.log(chalk.yellow(`š” Install the model: ollama pull ${config.model}`));
|
|
72
73
|
if (models.length > 0) {
|
|
73
74
|
console.log(chalk.yellow(`Available models: ${models.join(', ')}`));
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
catch (error) {
|
|
79
|
+
process.exitCode = 1;
|
|
78
80
|
printError('ā Ollama connection failed');
|
|
79
81
|
console.log(chalk.red(error.message));
|
|
80
|
-
console.log(chalk.yellow('\
|
|
81
|
-
console.log(chalk.yellow(
|
|
82
|
+
console.log(chalk.yellow('\nš” To start Ollama, run: ollama serve'));
|
|
83
|
+
console.log(chalk.yellow(`š” To install the configured model, run: ollama pull ${config.model}`));
|
|
82
84
|
}
|
|
83
85
|
}
|
|
@@ -16,7 +16,14 @@ import { securityManager } from '../core/security.js';
|
|
|
16
16
|
/** Maps typed AI errors to user-facing hints ā once, not per command. */
|
|
17
17
|
function describeAIError(error, model) {
|
|
18
18
|
if (!error || typeof error !== 'object' || !('kind' in error)) {
|
|
19
|
-
|
|
19
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
20
|
+
if (/econnrefused|failed to connect|fetch failed/i.test(msg)) {
|
|
21
|
+
return `š” Make sure Ollama is running: ollama serve`;
|
|
22
|
+
}
|
|
23
|
+
if (/model.*not found/i.test(msg)) {
|
|
24
|
+
return `š” Install the model: ollama pull ${model}`;
|
|
25
|
+
}
|
|
26
|
+
return msg;
|
|
20
27
|
}
|
|
21
28
|
const typedError = error;
|
|
22
29
|
switch (typedError.kind) {
|
|
@@ -119,7 +126,6 @@ export async function runCommand(spec) {
|
|
|
119
126
|
if (!streamed)
|
|
120
127
|
process.stdout.write(response);
|
|
121
128
|
process.stdout.write('\n');
|
|
122
|
-
console.log('\n');
|
|
123
129
|
if (spec.footer)
|
|
124
130
|
console.log(chalk.dim(spec.footer));
|
|
125
131
|
}
|
package/dist/core/metrics.js
CHANGED
|
@@ -239,8 +239,9 @@ export class MetricsCollector {
|
|
|
239
239
|
fs.unlinkSync(this.persistentPath());
|
|
240
240
|
}
|
|
241
241
|
catch (error) {
|
|
242
|
-
if (error.code !== 'ENOENT')
|
|
243
|
-
|
|
242
|
+
if (error.code !== 'ENOENT') {
|
|
243
|
+
logger.debug('Failed to unlink persistent metrics', { error: error.message });
|
|
244
|
+
}
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
247
|
persistentPath() {
|