@rahul05ranjan/dhruv-cli 1.4.6 → 1.5.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.
Files changed (76) hide show
  1. package/.github/workflows/ci.yml +18 -238
  2. package/.github/workflows/contribution.yml +6 -141
  3. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  4. package/.github/workflows/labeler.yml +1 -0
  5. package/.github/workflows/security.yml +3 -0
  6. package/CHANGELOG.md +9 -4
  7. package/README.md +145 -40
  8. package/__tests__/cli-contract.test.ts +104 -0
  9. package/__tests__/core.test.ts +121 -0
  10. package/__tests__/diagnostics.test.ts +155 -0
  11. package/__tests__/file-workflows.test.ts +195 -0
  12. package/__tests__/interactive.test.ts +119 -0
  13. package/__tests__/setup.ts +1 -0
  14. package/__tests__/workflows.test.ts +27 -4
  15. package/dist/commands/generate.d.ts +6 -1
  16. package/dist/commands/generate.js +18 -4
  17. package/dist/commands/health.d.ts +4 -1
  18. package/dist/commands/health.js +59 -16
  19. package/dist/commands/init.js +11 -2
  20. package/dist/commands/menu.js +125 -100
  21. package/dist/commands/metrics.d.ts +5 -1
  22. package/dist/commands/metrics.js +37 -8
  23. package/dist/commands/optimize.js +1 -1
  24. package/dist/commands/review.d.ts +4 -1
  25. package/dist/commands/review.js +48 -8
  26. package/dist/commands/security-check.d.ts +4 -1
  27. package/dist/commands/security-check.js +67 -6
  28. package/dist/commands/status.js +40 -2
  29. package/dist/config/config.d.ts +5 -1
  30. package/dist/config/config.js +24 -7
  31. package/dist/core/ai.d.ts +11 -0
  32. package/dist/core/ai.js +33 -9
  33. package/dist/core/command-catalog.d.ts +10 -0
  34. package/dist/core/command-catalog.js +27 -0
  35. package/dist/core/command-runner.js +100 -21
  36. package/dist/core/logger.js +1 -0
  37. package/dist/core/metrics.d.ts +28 -0
  38. package/dist/core/metrics.js +78 -0
  39. package/dist/index.js +46 -24
  40. package/dist/utils/projectType.d.ts +1 -1
  41. package/dist/utils/projectType.js +26 -7
  42. package/docs/api/assets/highlight.css +4 -4
  43. package/docs/api/index.html +161 -39
  44. package/docs/api/media/CONTRIBUTING.md +60 -0
  45. package/docs/api/media/SECURITY.md +8 -0
  46. package/docs/api/media/dhruv-cli-preview.svg +42 -0
  47. package/docs/api/media/publishing-fix.md +34 -0
  48. package/docs/dhruv-cli-preview.svg +42 -0
  49. package/docs/index.html +631 -533
  50. package/docs/publishing-fix.md +34 -0
  51. package/package.json +1 -1
  52. package/src/commands/generate.ts +23 -4
  53. package/src/commands/health.ts +62 -17
  54. package/src/commands/init.ts +11 -2
  55. package/src/commands/menu.ts +54 -30
  56. package/src/commands/metrics.ts +42 -7
  57. package/src/commands/optimize.ts +1 -1
  58. package/src/commands/review.ts +53 -8
  59. package/src/commands/security-check.ts +80 -6
  60. package/src/commands/status.ts +39 -3
  61. package/src/config/config.ts +26 -7
  62. package/src/core/ai.ts +36 -8
  63. package/src/core/command-catalog.ts +37 -0
  64. package/src/core/command-runner.ts +102 -22
  65. package/src/core/logger.ts +1 -0
  66. package/src/core/metrics.ts +103 -0
  67. package/src/index.ts +45 -24
  68. package/src/utils/projectType.ts +22 -7
  69. package/tsconfig.json +1 -1
  70. package/.github/workflows/auto-assign.yml +0 -14
  71. package/.github/workflows/build-publish.yml +0 -154
  72. package/.github/workflows/deploy.yml +0 -336
  73. package/.github/workflows/monitoring.yml +0 -270
  74. package/PUBLISHING_FIX.md +0 -92
  75. package/logs/.8a99b6cf655346317fdbf29f4fffcf91131432f3-audit.json +0 -15
  76. package/logs/.eee104bf8fff5ecd38a6a2842df260de6470a7c3-audit.json +0 -15
@@ -0,0 +1,195 @@
1
+ import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { review } from '../src/commands/review';
6
+ import { securityCheck } from '../src/commands/security-check';
7
+ import { generate } from '../src/commands/generate';
8
+ import { optimize } from '../src/commands/optimize';
9
+ import { setAIClient } from '../src/core/ai';
10
+ import type { AIClient, AIRequest } from '../src/core/ai';
11
+
12
+ jest.mock('chalk', () => {
13
+ const identity = (value: unknown) => String(value);
14
+ const makeChalk = (): unknown => new Proxy(identity, {
15
+ get: (_target, property: string | symbol) => property === 'level' ? 0 : makeChalk(),
16
+ apply: (_target, _thisArg, args: unknown[]) => String(args[0]),
17
+ });
18
+ const chalk = makeChalk() as Record<string, unknown>;
19
+ return { __esModule: true, default: chalk, ...chalk };
20
+ });
21
+
22
+ jest.mock('ora', () => ({
23
+ __esModule: true,
24
+ default: jest.fn(() => ({
25
+ start: jest.fn().mockReturnThis(),
26
+ stop: jest.fn().mockReturnThis(),
27
+ })),
28
+ }));
29
+
30
+ jest.mock('../src/utils/ux', () => ({
31
+ printError: jest.fn(),
32
+ printSuccess: jest.fn(),
33
+ printWarning: jest.fn(),
34
+ printInfo: jest.fn(),
35
+ createSpinner: jest.fn(),
36
+ themed: jest.fn((value: string) => value),
37
+ highlightCode: jest.fn((value: string) => value),
38
+ createProgressBar: jest.fn(),
39
+ }));
40
+
41
+ jest.mock('../src/core/logger', () => ({
42
+ logger: {
43
+ info: jest.fn(),
44
+ warn: jest.fn(),
45
+ error: jest.fn(),
46
+ debug: jest.fn(),
47
+ command: jest.fn(),
48
+ performance: jest.fn(),
49
+ security: jest.fn(),
50
+ getSessionId: jest.fn(() => 'test-session'),
51
+ flush: jest.fn(),
52
+ },
53
+ logCommand: jest.fn(),
54
+ logPerformance: jest.fn(),
55
+ logSecurity: jest.fn(),
56
+ logError: jest.fn(),
57
+ logInfo: jest.fn(),
58
+ logWarn: jest.fn(),
59
+ logDebug: jest.fn(),
60
+ }));
61
+
62
+ class RecordingClient implements AIClient {
63
+ requests: AIRequest[] = [];
64
+
65
+ async ask(request: AIRequest): Promise<string> {
66
+ this.requests.push(request);
67
+ return 'review complete';
68
+ }
69
+
70
+ async listModels(): Promise<string[]> {
71
+ return ['test-model'];
72
+ }
73
+ }
74
+
75
+ describe('file analysis commands', () => {
76
+ let root: string;
77
+ let client: RecordingClient;
78
+
79
+ beforeEach(() => {
80
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'dhruv-review-'));
81
+ client = new RecordingClient();
82
+ setAIClient(client);
83
+ });
84
+
85
+ afterEach(() => {
86
+ fs.rmSync(root, { recursive: true, force: true });
87
+ });
88
+
89
+ it('reviews nested source files without sending dependency directories', async () => {
90
+ fs.mkdirSync(path.join(root, 'src', 'nested'), { recursive: true });
91
+ fs.mkdirSync(path.join(root, 'node_modules', 'library'), { recursive: true });
92
+ fs.writeFileSync(path.join(root, 'src', 'nested', 'feature.ts'), 'export const feature = true;');
93
+ fs.writeFileSync(path.join(root, 'node_modules', 'library', 'ignored.ts'), 'export const ignored = true;');
94
+
95
+ await review(root);
96
+
97
+ expect(client.requests).toHaveLength(1);
98
+ expect(client.requests[0].prompt).toContain('src/nested/feature.ts');
99
+ expect(client.requests[0].prompt).not.toContain('node_modules/library/ignored.ts');
100
+ });
101
+
102
+ it('includes detected project context in review requests', async () => {
103
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ devDependencies: { typescript: '^5.0.0' } }));
104
+ fs.writeFileSync(path.join(root, 'index.ts'), 'export const value = 1;');
105
+
106
+ await review(root);
107
+
108
+ expect(client.requests[0].prompt).toContain('node-typescript');
109
+ });
110
+
111
+ it('redacts credential-like values before security analysis', async () => {
112
+ const source = path.join(root, 'config.ts');
113
+ fs.writeFileSync(source, 'const API_KEY = "sk-live-super-secret";');
114
+
115
+ await securityCheck(source);
116
+
117
+ expect(client.requests).toHaveLength(1);
118
+ expect(client.requests[0].prompt).not.toContain('sk-live-super-secret');
119
+ expect(client.requests[0].prompt).toContain('[REDACTED]');
120
+ });
121
+
122
+ it('includes location and remediation for deterministic security findings', async () => {
123
+ const source = path.join(root, 'config.ts');
124
+ fs.writeFileSync(source, 'const API_KEY = "sk-live-super-secret";');
125
+
126
+ await securityCheck(source);
127
+
128
+ expect(client.requests[0].prompt).toContain('line 1');
129
+ expect(client.requests[0].prompt).toContain('rotate the credential');
130
+ });
131
+
132
+ it('scans nested project files without sending dependency directories', async () => {
133
+ fs.mkdirSync(path.join(root, 'src', 'nested'), { recursive: true });
134
+ fs.mkdirSync(path.join(root, 'node_modules', 'library'), { recursive: true });
135
+ fs.writeFileSync(path.join(root, 'src', 'nested', 'config.ts'), 'const API_KEY = "sk-live-super-secret";');
136
+ fs.writeFileSync(path.join(root, 'node_modules', 'library', 'ignored.ts'), 'const API_KEY = "sk-dependency-secret";');
137
+
138
+ await securityCheck(root);
139
+
140
+ expect(client.requests[0].prompt).toContain('src/nested/config.ts');
141
+ expect(client.requests[0].prompt).not.toContain('sk-dependency-secret');
142
+ });
143
+
144
+ it('sets a failing exit code in strict mode for high-confidence findings', async () => {
145
+ const source = path.join(root, 'unsafe.ts');
146
+ fs.writeFileSync(source, 'const API_KEY = "sk-live-super-secret";');
147
+ process.exitCode = undefined;
148
+
149
+ try {
150
+ await securityCheck(source, { strict: true });
151
+ expect(process.exitCode).toBe(1);
152
+ } finally {
153
+ process.exitCode = undefined;
154
+ }
155
+ });
156
+
157
+ it('does not overwrite an existing generated file', async () => {
158
+ const source = path.join(root, 'sample.ts');
159
+ const generated = path.join(root, 'sample.test.ts');
160
+ fs.writeFileSync(source, 'export const value = 1;');
161
+ fs.writeFileSync(generated, 'keep this work');
162
+
163
+ await generate('tests', source, { apply: true });
164
+
165
+ expect(fs.readFileSync(generated, 'utf8')).toBe('keep this work');
166
+ });
167
+
168
+ it('preserves the target language when generating tests', async () => {
169
+ const source = path.join(root, 'sample.ts');
170
+ fs.writeFileSync(source, 'export const value = 1;');
171
+
172
+ await generate('tests', source, { apply: true });
173
+
174
+ expect(fs.existsSync(path.join(root, 'sample.test.ts'))).toBe(true);
175
+ });
176
+
177
+ it('previews generated tests without writing by default', async () => {
178
+ const source = path.join(root, 'preview.ts');
179
+ fs.writeFileSync(source, 'export const value = 1;');
180
+
181
+ await generate('tests', source);
182
+
183
+ expect(fs.existsSync(path.join(root, 'preview.test.ts'))).toBe(false);
184
+ });
185
+
186
+ it('asks optimization responses to explain impact and trade-offs', async () => {
187
+ const source = path.join(root, 'sample.ts');
188
+ fs.writeFileSync(source, 'export const value = 1;');
189
+
190
+ await optimize(source);
191
+
192
+ expect(client.requests[0].prompt).toContain('expected impact');
193
+ expect(client.requests[0].prompt).toContain('trade-offs');
194
+ });
195
+ });
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import inquirer from 'inquirer';
3
+ import { menu } from '../src/commands/menu';
4
+ import { init } from '../src/commands/init';
5
+ import { listModels } from '../src/core/ai';
6
+
7
+ jest.mock('chalk', () => {
8
+ const identity = (value: unknown) => String(value);
9
+ const makeChalk = (): unknown => new Proxy(identity, {
10
+ get: (_target, property: string | symbol) => property === 'level' ? 0 : makeChalk(),
11
+ apply: (_target, _thisArg, args: unknown[]) => String(args[0]),
12
+ });
13
+ const chalk = makeChalk() as Record<string, unknown>;
14
+ return { __esModule: true, default: chalk, ...chalk };
15
+ });
16
+
17
+ jest.mock('ora', () => ({
18
+ __esModule: true,
19
+ default: jest.fn(() => ({
20
+ start: jest.fn().mockReturnThis(),
21
+ stop: jest.fn().mockReturnThis(),
22
+ })),
23
+ }));
24
+
25
+ jest.mock('../src/utils/ux', () => ({
26
+ printError: jest.fn(),
27
+ printSuccess: jest.fn(),
28
+ printWarning: jest.fn(),
29
+ printInfo: jest.fn(),
30
+ createSpinner: jest.fn(),
31
+ themed: jest.fn((value: string) => value),
32
+ highlightCode: jest.fn((value: string) => value),
33
+ createProgressBar: jest.fn(),
34
+ }));
35
+
36
+ jest.mock('../src/core/ai', () => ({
37
+ listModels: jest.fn(),
38
+ }));
39
+
40
+ jest.mock('inquirer', () => ({
41
+ __esModule: true,
42
+ default: { prompt: jest.fn() },
43
+ }));
44
+
45
+ describe('interactive commands', () => {
46
+ it('handles menu cancellation without an unhandled rejection', async () => {
47
+ const prompt = jest.mocked(inquirer.prompt);
48
+ prompt.mockRejectedValueOnce(new Error('User force closed the prompt with 0 null'));
49
+ process.exitCode = undefined;
50
+
51
+ try {
52
+ await menu();
53
+ expect(process.exitCode).toBe(130);
54
+ } finally {
55
+ prompt.mockReset();
56
+ process.exitCode = undefined;
57
+ }
58
+ });
59
+
60
+ it('handles init cancellation with a cancellation exit code', async () => {
61
+ jest.mocked(listModels).mockResolvedValue(['test-model']);
62
+ const prompt = jest.mocked(inquirer.prompt);
63
+ prompt.mockRejectedValueOnce(new Error('User force closed the prompt with 0 null'));
64
+ process.exitCode = undefined;
65
+
66
+ try {
67
+ await init();
68
+ expect(process.exitCode).toBe(130);
69
+ } finally {
70
+ prompt.mockReset();
71
+ process.exitCode = undefined;
72
+ }
73
+ });
74
+
75
+ it('asks whether init should save project-local or user-global settings', async () => {
76
+ jest.mocked(listModels).mockResolvedValue(['test-model']);
77
+ const prompt = jest.mocked(inquirer.prompt);
78
+ prompt.mockResolvedValueOnce({
79
+ model: 'test-model',
80
+ responseFormat: 'text',
81
+ verbose: false,
82
+ theme: 'default',
83
+ scope: 'local',
84
+ });
85
+
86
+ await init();
87
+
88
+ const questions = prompt.mock.calls[0][0] as unknown as Array<{ name: string; choices?: string[] }>;
89
+ expect(questions.find((question) => question.name === 'scope')?.choices).toEqual(['local', 'global']);
90
+ prompt.mockReset();
91
+ });
92
+
93
+ it('offers diagnostic commands from the interactive menu', async () => {
94
+ const prompt = jest.mocked(inquirer.prompt);
95
+ prompt.mockResolvedValueOnce({ filter: '' }).mockResolvedValueOnce({ cmd: 'exit' });
96
+
97
+ await menu();
98
+
99
+ const choices = (prompt.mock.calls[1][0] as unknown as Array<{ choices: Array<{ value: string }> }>)[0].choices;
100
+ expect(choices.map(choice => choice.value)).toEqual(expect.arrayContaining([
101
+ 'status',
102
+ 'health',
103
+ 'metrics',
104
+ 'completion',
105
+ ]));
106
+ });
107
+
108
+ it('offers command filtering before opening the menu', async () => {
109
+ const prompt = jest.mocked(inquirer.prompt);
110
+ prompt.mockRejectedValueOnce(new Error('User force closed the prompt with 0 null'));
111
+
112
+ await menu();
113
+
114
+ expect((prompt.mock.calls[0][0] as unknown as Array<{ name: string; type: string }>)[0]).toMatchObject({
115
+ name: 'filter',
116
+ type: 'input',
117
+ });
118
+ });
119
+ });
@@ -26,6 +26,7 @@ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
26
26
 
27
27
  afterEach(() => {
28
28
  mockExit.mockClear();
29
+ process.exitCode = undefined;
29
30
  });
30
31
 
31
32
  // Global test utilities
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from '@jest/globals';
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  import { parse } from 'yaml';
5
5
 
@@ -13,7 +13,7 @@ interface Workflow {
13
13
  on: Record<string, unknown>;
14
14
  env?: Record<string, string>;
15
15
  permissions?: Record<string, string>;
16
- jobs: Record<string, { steps: Step[] }>;
16
+ jobs: Record<string, { steps: Step[]; if?: string }>;
17
17
  }
18
18
 
19
19
  const root = resolve(__dirname, '..');
@@ -24,6 +24,12 @@ const atLeast = (version: string, minimum: string): boolean =>
24
24
  /^\d+\.\d+\.\d+$/.test(version) && version.localeCompare(minimum, 'en', { numeric: true }) >= 0;
25
25
 
26
26
  describe('release workflow requirements', () => {
27
+ it('does not retain redundant publishing or deployment workflows', () => {
28
+ for (const name of ['auto-assign.yml', 'build-publish.yml', 'deploy.yml', 'monitoring.yml']) {
29
+ expect(existsSync(resolve(root, '.github/workflows', name))).toBe(false);
30
+ }
31
+ });
32
+
27
33
  it('installs an OIDC-capable npm CLI where semantic-release looks for executables', () => {
28
34
  // The plugin uses preferLocal: true. A global npm upgrade cannot fix an
29
35
  // older CLI hoisted here by a conflicting @semantic-release/npm version.
@@ -31,7 +37,7 @@ describe('release workflow requirements', () => {
31
37
  expect(atLeast(npm.version, '11.5.1')).toBe(true);
32
38
  });
33
39
 
34
- it.each(['build-publish.yml', 'release.yml', 'deploy.yml'])(
40
+ it.each(['release.yml'])(
35
41
  '%s provisions a supported Node and npm before publishing',
36
42
  name => {
37
43
  const config = workflow(name);
@@ -52,7 +58,7 @@ describe('release workflow requirements', () => {
52
58
  });
53
59
 
54
60
  it('has one automatic publisher for main pushes', () => {
55
- const publishers = ['ci.yml', 'build-publish.yml', 'release.yml']
61
+ const publishers = ['ci.yml', 'release.yml']
56
62
  .filter(name => {
57
63
  const config = workflow(name);
58
64
  return config.on.push && Object.values(config.jobs).some(job =>
@@ -62,6 +68,23 @@ describe('release workflow requirements', () => {
62
68
  });
63
69
  });
64
70
 
71
+ describe('workflow trigger boundaries', () => {
72
+ it('runs branch and pull-request validation only for the default branch', () => {
73
+ for (const name of ['ci.yml', 'contribution.yml', 'labeler.yml', 'dependabot-auto-merge.yml']) {
74
+ const config = workflow(name);
75
+ const event = config.on[name === 'ci.yml' ? 'push' : 'pull_request'] as { branches?: string[] };
76
+ expect(event.branches).toEqual(['main']);
77
+ }
78
+ });
79
+
80
+ it('keeps expensive security jobs off pull-request runs', () => {
81
+ const config = workflow('security.yml');
82
+ for (const name of ['license-check', 'supply-chain', 'sbom-generation']) {
83
+ expect(config.jobs[name]?.if).toBe("github.event_name != 'pull_request'");
84
+ }
85
+ });
86
+ });
87
+
65
88
  describe('security workflow requirements', () => {
66
89
  it('lets TruffleHog select the commit range for push, PR, schedule and manual events', () => {
67
90
  const steps = workflow('security.yml').jobs['secret-scan'].steps;
@@ -1 +1,6 @@
1
- export declare function generate(type: string, target: string): Promise<void>;
1
+ export interface GenerateOptions {
2
+ apply?: boolean;
3
+ output?: string;
4
+ overwrite?: boolean;
5
+ }
6
+ export declare function generate(type: string, target: string, options?: GenerateOptions): Promise<void>;
@@ -1,7 +1,9 @@
1
1
  import fs from 'fs';
2
+ import path from 'path';
2
3
  import { runCommand } from '../core/command-runner.js';
3
4
  import { getSystemMessage } from '../core/prompts.js';
4
- import { printError, printSuccess } from '../utils/ux.js';
5
+ import { printError, printSuccess, printInfo } from '../utils/ux.js';
6
+ import { loadConfig } from '../config/config.js';
5
7
  function buildPrompt(type, content) {
6
8
  if (type === 'tests' || type === 'test') {
7
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}`;
@@ -21,7 +23,7 @@ function extractTestCode(response) {
21
23
  .replace(/```[a-z]*\n?/g, '')
22
24
  .trim();
23
25
  }
24
- export async function generate(type, target) {
26
+ export async function generate(type, target, options = {}) {
25
27
  if (!fs.existsSync(target)) {
26
28
  printError(`Target file "${target}" does not exist.`);
27
29
  return;
@@ -44,9 +46,21 @@ export async function generate(type, target) {
44
46
  printError('No valid test code generated.');
45
47
  return;
46
48
  }
47
- const testFile = target.replace(/\.[^.]+$/, '.test.js');
49
+ const extension = path.extname(target) || '.js';
50
+ const testFile = options.output ?? target.replace(/\.[^.]+$/, `.test${extension}`);
51
+ if (!options.apply && !options.output) {
52
+ if (loadConfig().responseFormat !== 'json') {
53
+ printInfo(`Preview only. Use --apply to write ${testFile}, or --output <path> to choose a destination.`);
54
+ }
55
+ return;
56
+ }
57
+ if (fs.existsSync(testFile) && !options.overwrite) {
58
+ printError(`Test file "${testFile}" already exists. Use --overwrite to replace it.`);
59
+ return;
60
+ }
48
61
  fs.writeFileSync(testFile, codeToSave);
49
- printSuccess(`Test file saved: ${testFile}`);
62
+ if (loadConfig().responseFormat !== 'json')
63
+ printSuccess(`Test file saved: ${testFile}`);
50
64
  },
51
65
  footer: `šŸ” Want a review? Try: dhruv review ${target}`,
52
66
  });
@@ -1 +1,4 @@
1
- export declare function health(): Promise<void>;
1
+ export interface HealthOptions {
2
+ details?: boolean;
3
+ }
4
+ export declare function health(options?: HealthOptions): Promise<void>;
@@ -7,18 +7,21 @@ import { securityManager } from '../core/security.js';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
- export async function health() {
11
- console.log(chalk.blue.bold('šŸ” Dhruv CLI Health Check\n'));
10
+ export async function health(options = {}) {
11
+ const jsonOutput = loadConfig().responseFormat === 'json';
12
12
  const results = [];
13
13
  const startTime = Date.now();
14
14
  try {
15
15
  // System Information
16
16
  const systemInfo = getSystemInfo();
17
- console.log(chalk.cyan('šŸ“Š System Information:'));
18
- Object.entries(systemInfo).forEach(([key, value]) => {
19
- console.log(` ${key}: ${chalk.yellow(value)}`);
20
- });
21
- console.log();
17
+ if (!jsonOutput) {
18
+ console.log(chalk.blue.bold('šŸ” Dhruv CLI Health Check\n'));
19
+ console.log(chalk.cyan('šŸ“Š System Information:'));
20
+ Object.entries(systemInfo).forEach(([key, value]) => {
21
+ console.log(` ${key}: ${chalk.yellow(value)}`);
22
+ });
23
+ console.log();
24
+ }
22
25
  // Configuration Check
23
26
  results.push(...await checkConfiguration());
24
27
  // Dependencies Check
@@ -33,14 +36,40 @@ export async function health() {
33
36
  results.push(...await checkFileSystem());
34
37
  // Plugin System Check
35
38
  results.push(...await checkPlugins());
36
- // Display Results
37
- displayResults(results);
38
39
  const duration = Date.now() - startTime;
40
+ const summary = summarizeResults(results);
41
+ if (jsonOutput) {
42
+ process.exitCode = summary.fail > 0 ? 1 : 0;
43
+ process.stdout.write(`${JSON.stringify({
44
+ ok: summary.fail === 0,
45
+ command: 'health',
46
+ system: systemInfo,
47
+ results,
48
+ summary,
49
+ durationMs: duration,
50
+ })}\n`);
51
+ }
52
+ else {
53
+ if (options.details)
54
+ displayResults(results);
55
+ else
56
+ displayConciseResults(results);
57
+ }
39
58
  logger.info('Health check completed', { duration, results: results.length });
40
59
  }
41
60
  catch (error) {
42
- printError('Health check failed');
43
- console.error(chalk.red(error.message));
61
+ process.exitCode = 1;
62
+ if (jsonOutput) {
63
+ process.stdout.write(`${JSON.stringify({
64
+ ok: false,
65
+ command: 'health',
66
+ error: error.message,
67
+ })}\n`);
68
+ }
69
+ else {
70
+ printError('Health check failed');
71
+ console.error(chalk.red(error.message));
72
+ }
44
73
  logger.error('Health check failed', error);
45
74
  }
46
75
  }
@@ -360,11 +389,7 @@ function displayResults(results) {
360
389
  }
361
390
  });
362
391
  // Summary
363
- const summary = {
364
- pass: results.filter(r => r.status === 'pass').length,
365
- warn: results.filter(r => r.status === 'warn').length,
366
- fail: results.filter(r => r.status === 'fail').length
367
- };
392
+ const summary = summarizeResults(results);
368
393
  console.log(chalk.blue.bold('\nšŸ“Š Summary:'));
369
394
  console.log(` āœ… Passed: ${chalk.green(summary.pass)}`);
370
395
  console.log(` āš ļø Warnings: ${chalk.yellow(summary.warn)}`);
@@ -374,3 +399,21 @@ function displayResults(results) {
374
399
  const statusColor = overallStatus === 'pass' ? chalk.green : overallStatus === 'warn' ? chalk.yellow : chalk.red;
375
400
  console.log(`\n${statusIcon} ${statusColor('Overall Status: ' + overallStatus.toUpperCase())}`);
376
401
  }
402
+ function displayConciseResults(results) {
403
+ const summary = summarizeResults(results);
404
+ const overallStatus = summary.fail > 0 ? 'fail' : summary.warn > 0 ? 'warn' : 'pass';
405
+ const icon = overallStatus === 'pass' ? 'āœ…' : overallStatus === 'warn' ? 'āš ļø' : 'āŒ';
406
+ console.log(chalk.blue.bold(`\n${icon} Health: ${overallStatus.toUpperCase()}`));
407
+ console.log(` ${chalk.green(`${summary.pass} passed`)}, ${chalk.yellow(`${summary.warn} warnings`)}, ${chalk.red(`${summary.fail} failed`)}`);
408
+ results
409
+ .filter((result) => result.status !== 'pass')
410
+ .forEach((result) => console.log(` ${result.category}: ${result.message}${result.recommendation ? ` — ${result.recommendation}` : ''}`));
411
+ console.log(chalk.dim('Run `dhruv health --details` for full diagnostics.'));
412
+ }
413
+ function summarizeResults(results) {
414
+ return {
415
+ pass: results.filter(r => r.status === 'pass').length,
416
+ warn: results.filter(r => r.status === 'warn').length,
417
+ fail: results.filter(r => r.status === 'fail').length,
418
+ };
419
+ }
@@ -24,6 +24,13 @@ export async function init() {
24
24
  choices: modelChoices,
25
25
  default: current.model,
26
26
  },
27
+ {
28
+ type: 'list',
29
+ name: 'scope',
30
+ message: 'Where should Dhruv save these settings?',
31
+ choices: ['local', 'global'],
32
+ default: 'local',
33
+ },
27
34
  {
28
35
  type: 'list',
29
36
  name: 'responseFormat',
@@ -45,10 +52,12 @@ export async function init() {
45
52
  default: current.theme || 'default',
46
53
  },
47
54
  ]);
48
- saveConfig(answers);
49
- console.log(chalk.green('Configuration saved!'));
55
+ const { scope, ...settings } = answers;
56
+ saveConfig(settings, { scope });
57
+ console.log(chalk.green(`Configuration saved ${scope === 'global' ? 'for your user account' : 'in this project'}!`));
50
58
  }
51
59
  catch (error) {
60
+ process.exitCode = 130;
52
61
  if (error?.isTtyError) {
53
62
  console.log(chalk.red('This command requires an interactive terminal.'));
54
63
  }