@rahul05ranjan/dhruv-cli 1.5.0 → 1.6.1

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,9 @@
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.6.0...v1.4.0) (2026-09-21)
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))
9
-
10
-
11
- ### Features
12
-
13
- * improve cli experience across commands ([ce41913](https://github.com/rahul05ranjan/dhruv-cli/commit/ce4191350ca3d1c5f49b371508e66de9421f6d20))
6
+ * **config:** keep one-off global flags in-memory and avoid writing local config (closes [#92](https://github.com/rahul05ranjan/dhruv-cli/issues/92)) ([3dd3f99](https://github.com/rahul05ranjan/dhruv-cli/commit/3dd3f99babe42d3804bfa438f38a3ddd6e1f1d72))
14
7
  # Changelog
15
8
 
16
9
  ## [1.1.1](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.1.0...v1.1.1) (2025-06-29)
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, it } from '@jest/globals';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
- import { resolve } from 'node:path';
4
+ import { resolve, join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
5
8
 
6
9
  const execFileAsync = promisify(execFile);
7
10
  const repoRoot = resolve(__dirname, '..');
@@ -37,6 +40,27 @@ describe('CLI output contract', () => {
37
40
 
38
41
  expect(result.stdout).toContain('status');
39
42
  expect(result.stdout).toContain('--json');
43
+ expect(result.stdout).toContain('tests documentation docs component');
44
+ });
45
+
46
+ it('generates valid zsh and fish completion scripts with subcommands', async () => {
47
+ const zshResult = await execFileAsync(
48
+ process.execPath,
49
+ ['--loader', loaderEntry, sourceEntry, 'completion', 'zsh'],
50
+ { cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
51
+ );
52
+ expect(zshResult.stdout).toContain('#compdef dhruv');
53
+ expect(zshResult.stdout).toContain('generate');
54
+ expect(zshResult.stdout).not.toContain('\u001b[');
55
+
56
+ const fishResult = await execFileAsync(
57
+ process.execPath,
58
+ ['--loader', loaderEntry, sourceEntry, 'completion', 'fish'],
59
+ { cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
60
+ );
61
+ expect(fishResult.stdout).toContain('complete -c dhruv');
62
+ expect(fishResult.stdout).toContain('__fish_seen_subcommand_from generate');
63
+ expect(fishResult.stdout).not.toContain('\u001b[');
40
64
  });
41
65
 
42
66
  it('rejects unsupported completion shells', async () => {
@@ -101,4 +125,79 @@ describe('CLI output contract', () => {
101
125
 
102
126
  expect(result.stdout).toContain('--details');
103
127
  });
128
+
129
+ it('outputs exactly one valid JSON document on stdout with no ANSI or extra text', async () => {
130
+ const result = await execFileAsync(
131
+ process.execPath,
132
+ ['--loader', loaderEntry, sourceEntry, 'metrics', '--json'],
133
+ {
134
+ cwd: repoRoot,
135
+ env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
136
+ },
137
+ );
138
+
139
+ expect(result.stdout).not.toContain('\u001b[');
140
+ const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
141
+ expect(parsed).toMatchObject({
142
+ ok: true,
143
+ command: 'metrics',
144
+ });
145
+ });
146
+
147
+ it('outputs valid JSON for health command with expected top-level schema', async () => {
148
+ let stdout = '';
149
+ try {
150
+ const result = await execFileAsync(
151
+ process.execPath,
152
+ ['--loader', loaderEntry, sourceEntry, 'health', '--json'],
153
+ {
154
+ cwd: repoRoot,
155
+ env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
156
+ },
157
+ );
158
+ stdout = result.stdout;
159
+ } catch (err: unknown) {
160
+ const execError = err as { stdout?: string; code?: number };
161
+ if (typeof execError?.stdout === 'string' && execError.stdout.trim().length > 0) {
162
+ stdout = execError.stdout;
163
+ } else {
164
+ throw err;
165
+ }
166
+ }
167
+
168
+ expect(stdout).not.toContain('\u001b[');
169
+ const parsed = JSON.parse(stdout.trim()) as Record<string, unknown>;
170
+ expect(parsed).toHaveProperty('ok');
171
+ expect(parsed.command).toBe('health');
172
+ });
173
+
174
+ it('does not persist global flags (--json, --model, --verbose, --timeout) to .dhruv-config.json', async () => {
175
+ const tempDir = mkdtempSync(join(tmpdir(), 'dhruv-session-flags-'));
176
+ const tsNodeLoader = pathToFileURL(resolve(repoRoot, 'node_modules/ts-node/esm.mjs')).href;
177
+ try {
178
+ const result = await execFileAsync(
179
+ process.execPath,
180
+ ['--loader', tsNodeLoader, resolve(repoRoot, sourceEntry), 'metrics', '--json'],
181
+ {
182
+ cwd: tempDir,
183
+ env: {
184
+ ...process.env,
185
+ TS_NODE_PROJECT: resolve(repoRoot, 'tsconfig.json'),
186
+ DHRUV_METRICS_ENABLED: 'false',
187
+ },
188
+ },
189
+ );
190
+
191
+ const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
192
+ expect(parsed).toMatchObject({
193
+ ok: true,
194
+ command: 'metrics',
195
+ });
196
+
197
+ const localConfigFile = join(tempDir, '.dhruv-config.json');
198
+ expect(existsSync(localConfigFile)).toBe(false);
199
+ } finally {
200
+ rmSync(tempDir, { recursive: true, force: true });
201
+ }
202
+ });
104
203
  });
@@ -5,9 +5,15 @@ import {
5
5
  setAIClient,
6
6
  InMemoryAIClient,
7
7
  } from '../src/core/ai';
8
- import { loadConfig, saveConfig } from '../src/config/config';
8
+ import {
9
+ loadConfig,
10
+ saveConfig,
11
+ setSessionConfig,
12
+ resetSessionConfig,
13
+ getSessionConfig,
14
+ } from '../src/config/config';
9
15
  import { createSpinner } from '../src/utils/ux';
10
- import { detectProjectType } from '../src/utils/projectType';
16
+ import { detectProjectType, detectProjectDetails } from '../src/utils/projectType';
11
17
  import { getSystemMessage } from '../src/core/prompts';
12
18
  import { runCommand } from '../src/core/command-runner';
13
19
  import fs from 'fs';
@@ -134,6 +140,58 @@ describe('Dhruv CLI Core Systems', () => {
134
140
  fs.unlinkSync(configPath);
135
141
  }
136
142
  });
143
+
144
+ it('should apply in-memory session overrides without writing to disk', () => {
145
+ resetSessionConfig();
146
+ const configPath = path.join(process.cwd(), '.dhruv-config.json');
147
+ const existedBefore = fs.existsSync(configPath);
148
+
149
+ setSessionConfig({
150
+ model: 'session-model',
151
+ responseFormat: 'json',
152
+ verbose: true,
153
+ timeoutMs: 12345,
154
+ });
155
+
156
+ expect(getSessionConfig()).toEqual({
157
+ model: 'session-model',
158
+ responseFormat: 'json',
159
+ verbose: true,
160
+ timeoutMs: 12345,
161
+ });
162
+
163
+ const loaded = loadConfig();
164
+ expect(loaded.model).toBe('session-model');
165
+ expect(loaded.responseFormat).toBe('json');
166
+ expect(loaded.verbose).toBe(true);
167
+ expect(loaded.timeoutMs).toBe(12345);
168
+
169
+ if (!existedBefore) {
170
+ expect(fs.existsSync(configPath)).toBe(false);
171
+ }
172
+
173
+ resetSessionConfig();
174
+ expect(getSessionConfig()).toEqual({});
175
+ const afterReset = loadConfig();
176
+ expect(afterReset.model).not.toBe('session-model');
177
+ });
178
+
179
+ it('should not persist session overrides when saving configuration', () => {
180
+ resetSessionConfig();
181
+ const configPath = path.join(process.cwd(), '.dhruv-config.json');
182
+ if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
183
+
184
+ setSessionConfig({ responseFormat: 'json' });
185
+ saveConfig({ model: 'persistent-model' });
186
+
187
+ const fileContent = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
188
+ expect(fileContent.model).toBe('persistent-model');
189
+ // Must not snapshot the in-memory responseFormat into the saved file
190
+ expect(fileContent.responseFormat).not.toBe('json');
191
+
192
+ if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
193
+ resetSessionConfig();
194
+ });
137
195
  });
138
196
 
139
197
  describe('Project Type Detection', () => {
@@ -204,11 +262,56 @@ describe('Dhruv CLI Core Systems', () => {
204
262
  const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
205
263
  mockReadFileSync.mockReturnValue('{ malformed');
206
264
 
265
+ const details = detectProjectDetails();
266
+ expect(details.type).toBe('unknown');
267
+ expect(details.diagnostic).toContain('Malformed package.json');
207
268
  expect(detectProjectType()).toBe('unknown');
208
269
 
209
270
  mockExistsSync.mockRestore();
210
271
  mockReadFileSync.mockRestore();
211
272
  });
273
+
274
+ it('should detect Python project with FastAPI framework', () => {
275
+ const mockExistsSync = jest.spyOn(fs, 'existsSync');
276
+ mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'requirements.txt');
277
+ const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
278
+ mockReadFileSync.mockReturnValue('fastapi>=0.100.0\nuvicorn>=0.20.0');
279
+
280
+ expect(detectProjectType()).toBe('python-fastapi');
281
+
282
+ mockExistsSync.mockRestore();
283
+ mockReadFileSync.mockRestore();
284
+ });
285
+
286
+ it('should detect Go project from go.mod', () => {
287
+ const mockExistsSync = jest.spyOn(fs, 'existsSync');
288
+ mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'go.mod');
289
+
290
+ expect(detectProjectType()).toBe('go');
291
+
292
+ mockExistsSync.mockRestore();
293
+ });
294
+
295
+ it('should detect Rust project from Cargo.toml', () => {
296
+ const mockExistsSync = jest.spyOn(fs, 'existsSync');
297
+ mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'Cargo.toml');
298
+
299
+ expect(detectProjectType()).toBe('rust');
300
+
301
+ mockExistsSync.mockRestore();
302
+ });
303
+
304
+ it('should detect Java Spring Boot project', () => {
305
+ const mockExistsSync = jest.spyOn(fs, 'existsSync');
306
+ mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'pom.xml');
307
+ const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
308
+ mockReadFileSync.mockReturnValue('<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>');
309
+
310
+ expect(detectProjectType()).toBe('java-spring-boot');
311
+
312
+ mockExistsSync.mockRestore();
313
+ mockReadFileSync.mockRestore();
314
+ });
212
315
  });
213
316
 
214
317
  describe('System Message Templates', () => {
@@ -366,6 +469,32 @@ describe('Dhruv CLI Core Systems', () => {
366
469
  expect(jest.mocked(printError).mock.calls.length).toBeGreaterThan(0);
367
470
  });
368
471
 
472
+ it('maps an untyped ECONNREFUSED error to the ollama-serve hint', async () => {
473
+ setAIClient({
474
+ ask: async () => { throw new Error('connect ECONNREFUSED 127.0.0.1:11434'); },
475
+ listModels: async () => [],
476
+ });
477
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
478
+ await runCommand(makeSpec());
479
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('ollama serve'));
480
+ consoleSpy.mockRestore();
481
+ });
482
+
483
+ it('displays completed response when streaming is unavailable', async () => {
484
+ setAIClient({
485
+ ask: async () => 'static completed response',
486
+ listModels: async () => [],
487
+ });
488
+ const output: string[] = [];
489
+ const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
490
+ output.push(String(chunk));
491
+ return true;
492
+ });
493
+ await runCommand(makeSpec());
494
+ expect(output.join('')).toContain('static completed response');
495
+ writeSpy.mockRestore();
496
+ });
497
+
369
498
  it('marks the process unsuccessful when an AI command fails', async () => {
370
499
  const originalExitCode = process.exitCode;
371
500
  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
  }
@@ -6,6 +6,9 @@ export interface DhruvConfig {
6
6
  timeoutMs: number;
7
7
  theme?: 'default' | 'dark' | 'light' | 'mono';
8
8
  }
9
+ export declare function setSessionConfig(config: Partial<DhruvConfig>): void;
10
+ export declare function resetSessionConfig(): void;
11
+ export declare function getSessionConfig(): Partial<DhruvConfig>;
9
12
  export declare function loadConfig(): DhruvConfig;
10
13
  export declare function saveConfig(config: Partial<DhruvConfig>, options?: {
11
14
  scope?: ConfigScope;