@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 CHANGED
@@ -1,16 +1,23 @@
1
- # [1.4.0](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.4.6...v1.4.0) (2026-09-18)
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
- * change moduleResolution from Node to Bundler in tsconfig ([b8339bb](https://github.com/rahul05ranjan/dhruv-cli/commit/b8339bb89781490d5678f43052b606138f375d21))
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
- * improve cli experience across commands ([ce41913](https://github.com/rahul05ranjan/dhruv-cli/commit/ce4191350ca3d1c5f49b371508e66de9421f6d20))
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
  });
@@ -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 buildPrompt(type, content) {
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 JavaScript code. Use Jest or Mocha syntax. Only return the test code without explanations:\n\n${content}`;
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 documentation for the following code:\n\n${content}`;
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?/g, '')
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
  }),
@@ -12,15 +12,16 @@ export async function init() {
12
12
  }
13
13
  }
14
14
  catch {
15
- console.log(chalk.yellow('Warning: Could not fetch available models from Ollama.'));
16
- console.log(chalk.yellow('Using default model choices.'));
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: 'Which Ollama model do you want to use?',
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
- process.exitCode = 130;
61
- if (error?.isTtyError) {
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 or failed.'));
67
+ console.log(chalk.red('Configuration cancelled.'));
66
68
  }
67
69
  }
68
70
  }
@@ -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
- printError('Failed to retrieve metrics');
77
- console.error(chalk.red(error.message));
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
  }
@@ -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(['.git', 'node_modules', 'dist', 'build', 'coverage', '.dhruv-cache', 'logs']);
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(['.git', 'node_modules', 'dist', 'build', 'coverage', '.dhruv-cache', 'logs']);
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
  }
@@ -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('\nTo start Ollama, run: ollama serve'));
81
- console.log(chalk.yellow('To install a model, run: ollama pull llama2'));
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
- return error instanceof Error ? error.message : String(error);
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
  }
@@ -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
- throw error;
242
+ if (error.code !== 'ENOENT') {
243
+ logger.debug('Failed to unlink persistent metrics', { error: error.message });
244
+ }
244
245
  }
245
246
  }
246
247
  persistentPath() {