@rahul05ranjan/dhruv-cli 1.4.6 → 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.
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 +16 -4
  7. package/README.md +145 -40
  8. package/__tests__/cli-contract.test.ts +170 -0
  9. package/__tests__/core.test.ts +193 -1
  10. package/__tests__/diagnostics.test.ts +179 -0
  11. package/__tests__/file-workflows.test.ts +234 -0
  12. package/__tests__/interactive.test.ts +134 -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 +44 -11
  17. package/dist/commands/health.d.ts +4 -1
  18. package/dist/commands/health.js +59 -16
  19. package/dist/commands/init.js +18 -7
  20. package/dist/commands/menu.js +125 -100
  21. package/dist/commands/metrics.d.ts +5 -1
  22. package/dist/commands/metrics.js +49 -10
  23. package/dist/commands/optimize.js +1 -1
  24. package/dist/commands/review.d.ts +4 -1
  25. package/dist/commands/review.js +66 -9
  26. package/dist/commands/security-check.d.ts +4 -1
  27. package/dist/commands/security-check.js +100 -6
  28. package/dist/commands/status.js +44 -4
  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 +106 -21
  36. package/dist/core/logger.js +1 -0
  37. package/dist/core/metrics.d.ts +28 -0
  38. package/dist/core/metrics.js +79 -0
  39. package/dist/index.js +98 -24
  40. package/dist/utils/projectType.d.ts +7 -1
  41. package/dist/utils/projectType.js +91 -13
  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 +50 -11
  53. package/src/commands/health.ts +62 -17
  54. package/src/commands/init.ts +18 -7
  55. package/src/commands/menu.ts +54 -30
  56. package/src/commands/metrics.ts +53 -9
  57. package/src/commands/optimize.ts +1 -1
  58. package/src/commands/review.ts +72 -9
  59. package/src/commands/security-check.ts +111 -6
  60. package/src/commands/status.ts +43 -5
  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 +108 -22
  65. package/src/core/logger.ts +1 -0
  66. package/src/core/metrics.ts +105 -0
  67. package/src/index.ts +97 -24
  68. package/src/utils/projectType.ts +85 -9
  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
@@ -4,6 +4,90 @@ 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
 
7
+ const CODE_FILE = /\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/;
8
+ const IGNORED_DIRECTORIES = new Set([
9
+ '.git',
10
+ 'node_modules',
11
+ 'dist',
12
+ 'build',
13
+ 'coverage',
14
+ '.dhruv-cache',
15
+ 'logs',
16
+ '.next',
17
+ '.turbo',
18
+ '__pycache__',
19
+ '.pytest_cache',
20
+ 'target',
21
+ 'vendor',
22
+ ]);
23
+
24
+ function redactSensitiveContent(content: string): string {
25
+ return content
26
+ .replace(/(\b(?:api[_-]?key|secret|token|password|authorization)\s*[:=]\s*["'`])[^"'`\r\n]+(["'`])/gi, '$1[REDACTED]$2')
27
+ .replace(/\b(?:sk|pk)-[a-z0-9_-]{8,}\b/gi, '[REDACTED]')
28
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
29
+ .replace(/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/g, '[REDACTED]')
30
+ .replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED]')
31
+ .replace(/-----BEGIN (?:RSA|OPENSSH|EC|PGP|DSA)? PRIVATE KEY-----[\s\S]*?-----END (?:RSA|OPENSSH|EC|PGP|DSA)? PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]');
32
+ }
33
+
34
+ interface SecurityFinding {
35
+ line: number;
36
+ severity: 'high';
37
+ description: string;
38
+ remediation: string;
39
+ }
40
+
41
+ function findHighConfidenceFindings(content: string): SecurityFinding[] {
42
+ const findings: SecurityFinding[] = [];
43
+ const lines = content.split(/\r?\n/);
44
+
45
+ lines.forEach((line, index) => {
46
+ if (/\b(?:api[_-]?key|secret|token|password|authorization)\s*[:=]\s*["'`][^"'`\r\n]+["'`]/i.test(line)) {
47
+ findings.push({
48
+ line: index + 1,
49
+ severity: 'high',
50
+ description: 'credential-like value assigned in source',
51
+ remediation: 'rotate the credential and load it from a secret manager or environment variable',
52
+ });
53
+ } else if (/\b(?:sk|pk)-[a-z0-9_-]{8,}\b/i.test(line)) {
54
+ findings.push({
55
+ line: index + 1,
56
+ severity: 'high',
57
+ description: 'credential-like API key detected',
58
+ remediation: 'rotate the credential and remove it from source control',
59
+ });
60
+ } else if (/\bBearer\s+[A-Za-z0-9._~+/=-]+/i.test(line)) {
61
+ findings.push({
62
+ line: index + 1,
63
+ severity: 'high',
64
+ description: 'bearer token detected',
65
+ remediation: 'revoke the token and use a secure runtime secret store',
66
+ });
67
+ } else if (/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/.test(line)) {
68
+ findings.push({
69
+ line: index + 1,
70
+ severity: 'high',
71
+ description: 'GitHub token detected',
72
+ remediation: 'revoke the GitHub token and store it in GitHub Secrets or environment variables',
73
+ });
74
+ } else if (/\bAKIA[0-9A-Z]{16}\b/.test(line)) {
75
+ findings.push({
76
+ line: index + 1,
77
+ severity: 'high',
78
+ description: 'AWS access key ID detected',
79
+ remediation: 'rotate the AWS access key and use IAM roles or AWS Secrets Manager',
80
+ });
81
+ }
82
+ });
83
+
84
+ return findings;
85
+ }
86
+
87
+ export interface SecurityCheckOptions {
88
+ strict?: boolean;
89
+ }
90
+
7
91
  /** Reads a file or the code files of a directory (up to 10), concatenated. */
8
92
  function readCode(fileOrDir: string): string | undefined {
9
93
  // Read first, branch on the error: no separate existence check to race against.
@@ -22,10 +106,22 @@ function readCode(fileOrDir: string): string | undefined {
22
106
  }
23
107
 
24
108
  function readDirectory(dir: string): string | undefined {
25
- const files = fs
26
- .readdirSync(dir)
27
- .filter((f) => f.match(/\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/))
28
- .slice(0, 10);
109
+ const files: string[] = [];
110
+
111
+ function collect(current: string): void {
112
+ if (files.length >= 10) return;
113
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
114
+ if (files.length >= 10) return;
115
+ const absolute = path.join(current, entry.name);
116
+ if (entry.isDirectory()) {
117
+ if (!IGNORED_DIRECTORIES.has(entry.name)) collect(absolute);
118
+ } else if (entry.isFile() && CODE_FILE.test(entry.name)) {
119
+ files.push(path.relative(dir, absolute).split(path.sep).join('/'));
120
+ }
121
+ }
122
+ }
123
+
124
+ collect(dir);
29
125
 
30
126
  if (files.length === 0) {
31
127
  printError(`No code files found in directory "${dir}".`);
@@ -43,16 +139,25 @@ function readDirectory(dir: string): string | undefined {
43
139
  return code;
44
140
  }
45
141
 
46
- export async function securityCheck(fileOrDir: string = '.') {
142
+ export async function securityCheck(fileOrDir: string = '.', options: SecurityCheckOptions = {}) {
47
143
  const code = readCode(fileOrDir);
48
144
  if (code === undefined) return;
145
+ const findings = findHighConfidenceFindings(code);
146
+ const safeCode = redactSensitiveContent(code);
147
+ const findingSummary = findings.length === 0
148
+ ? 'none'
149
+ : findings.map((finding) => `- ${finding.severity} at line ${finding.line}: ${finding.description}; remediation: ${finding.remediation}`).join('\n');
150
+
151
+ if (options.strict && findings.length > 0) {
152
+ process.exitCode = 1;
153
+ }
49
154
 
50
155
  await runCommand({
51
156
  name: 'security-check',
52
157
  input: { fileOrDir },
53
158
  header: '🛡️ Security Analysis: ',
54
159
  buildRequest: (input, model) => ({
55
- prompt: `Perform a security analysis on this code. Look for common security vulnerabilities, unsafe practices, potential injection attacks, and provide recommendations for improvement:\n\n${code}`,
160
+ prompt: `Perform a security analysis on this code. Look for common security vulnerabilities, unsafe practices, potential injection attacks, and provide recommendations for improvement. High-confidence pre-scan findings:\n${findingSummary}\n\n${safeCode}`,
56
161
  systemMessage: getSystemMessage('security'),
57
162
  model,
58
163
  }),
@@ -1,12 +1,47 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig } from '../config/config.js';
3
3
  import { printSuccess, printError, printInfo } from '../utils/ux.js';
4
- import { listModels } from '../core/ai.js';
4
+ import { getOllamaStatus, listModels } from '../core/ai.js';
5
5
 
6
6
  export async function status() {
7
- console.log(chalk.blue('🔍 Dhruv CLI Status Check\n'));
8
-
9
7
  const config = loadConfig();
8
+ if (config.responseFormat === 'json') {
9
+ try {
10
+ const models = await listModels();
11
+ const server = await getOllamaStatus();
12
+ const configuredModelAvailable = models.includes(config.model);
13
+ process.stdout.write(`${JSON.stringify({
14
+ ok: configuredModelAvailable,
15
+ command: 'status',
16
+ model: config.model,
17
+ responseFormat: config.responseFormat,
18
+ verbose: config.verbose,
19
+ theme: config.theme,
20
+ availableModels: models,
21
+ configuredModelAvailable,
22
+ endpoint: server.endpoint,
23
+ version: server.version ?? null,
24
+ ollama: 'connected',
25
+ nextSteps: configuredModelAvailable ? [] : [`ollama pull ${config.model}`],
26
+ })}\n`);
27
+ if (!configuredModelAvailable) process.exitCode = 1;
28
+ } catch (error) {
29
+ process.exitCode = 1;
30
+ process.stdout.write(`${JSON.stringify({
31
+ ok: false,
32
+ command: 'status',
33
+ model: config.model,
34
+ ollama: 'unavailable',
35
+ error: (error as Error).message,
36
+ })}\n`);
37
+ }
38
+ return;
39
+ }
40
+
41
+ console.log(chalk.blue('🔍 Dhruv CLI Status Check\n'));
42
+ const server = await getOllamaStatus();
43
+ printInfo(`Ollama endpoint: ${server.endpoint}`);
44
+ printInfo(`Ollama version: ${server.version ?? 'unavailable'}\n`);
10
45
  printInfo(`Current configuration:`);
11
46
  console.log(` Model: ${config.model}`);
12
47
  console.log(` Response Format: ${config.responseFormat}`);
@@ -33,15 +68,18 @@ export async function status() {
33
68
  if (models.includes(config.model)) {
34
69
  printSuccess(`✓ Configured model '${config.model}' is available`);
35
70
  } else {
71
+ process.exitCode = 1;
36
72
  printError(`✗ Configured model '${config.model}' is not available`);
73
+ console.log(chalk.yellow(`💡 Install the model: ollama pull ${config.model}`));
37
74
  if (models.length > 0) {
38
75
  console.log(chalk.yellow(`Available models: ${models.join(', ')}`));
39
76
  }
40
77
  }
41
78
  } catch (error) {
79
+ process.exitCode = 1;
42
80
  printError('✗ Ollama connection failed');
43
81
  console.log(chalk.red((error as Error).message));
44
- console.log(chalk.yellow('\nTo start Ollama, run: ollama serve'));
45
- 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}`));
46
84
  }
47
85
  }
@@ -1,12 +1,17 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import os from 'os';
3
4
 
4
- const CONFIG_FILE = path.join(process.cwd(), '.dhruv-config.json');
5
+ const LOCAL_CONFIG_FILE = path.join(process.cwd(), '.dhruv-config.json');
6
+ const GLOBAL_CONFIG_FILE = path.join(os.homedir(), '.config', 'dhruv', 'config.json');
7
+
8
+ export type ConfigScope = 'local' | 'global';
5
9
 
6
10
  export interface DhruvConfig {
7
11
  model: string;
8
12
  verbose: boolean;
9
13
  responseFormat: 'text' | 'json' | 'markdown';
14
+ timeoutMs: number;
10
15
  theme?: 'default' | 'dark' | 'light' | 'mono';
11
16
  }
12
17
 
@@ -14,13 +19,14 @@ const defaultConfig: DhruvConfig = {
14
19
  model: 'gemma3:270m',
15
20
  verbose: false,
16
21
  responseFormat: 'text',
22
+ timeoutMs: 45000,
17
23
  theme: 'default',
18
24
  };
19
25
 
20
- export function loadConfig(): DhruvConfig {
21
- if (fs.existsSync(CONFIG_FILE)) {
26
+ function readConfigFile(file: string): DhruvConfig {
27
+ if (fs.existsSync(file)) {
22
28
  try {
23
- const fileContent = fs.readFileSync(CONFIG_FILE, 'utf-8');
29
+ const fileContent = fs.readFileSync(file, 'utf-8');
24
30
  const parsedConfig = JSON.parse(fileContent);
25
31
  return validateAndMergeConfig(parsedConfig);
26
32
  } catch (error) {
@@ -31,6 +37,12 @@ export function loadConfig(): DhruvConfig {
31
37
  return defaultConfig;
32
38
  }
33
39
 
40
+ export function loadConfig(): DhruvConfig {
41
+ if (fs.existsSync(LOCAL_CONFIG_FILE)) return readConfigFile(LOCAL_CONFIG_FILE);
42
+ if (fs.existsSync(GLOBAL_CONFIG_FILE)) return readConfigFile(GLOBAL_CONFIG_FILE);
43
+ return defaultConfig;
44
+ }
45
+
34
46
  function validateAndMergeConfig(config: Partial<DhruvConfig>): DhruvConfig {
35
47
  const validatedConfig = { ...defaultConfig };
36
48
 
@@ -48,6 +60,10 @@ function validateAndMergeConfig(config: Partial<DhruvConfig>): DhruvConfig {
48
60
  if (config.responseFormat && ['text', 'json', 'markdown'].includes(config.responseFormat)) {
49
61
  validatedConfig.responseFormat = config.responseFormat as 'text' | 'json' | 'markdown';
50
62
  }
63
+
64
+ if (typeof config.timeoutMs === 'number' && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0) {
65
+ validatedConfig.timeoutMs = Math.round(config.timeoutMs);
66
+ }
51
67
 
52
68
  // Validate theme
53
69
  if (config.theme && ['default', 'dark', 'light', 'mono'].includes(config.theme)) {
@@ -57,9 +73,12 @@ function validateAndMergeConfig(config: Partial<DhruvConfig>): DhruvConfig {
57
73
  return validatedConfig;
58
74
  }
59
75
 
60
- export function saveConfig(config: Partial<DhruvConfig>) {
61
- const current = loadConfig();
62
- fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2));
76
+ export function saveConfig(config: Partial<DhruvConfig>, options: { scope?: ConfigScope } = {}) {
77
+ const scope = options.scope ?? 'local';
78
+ const file = scope === 'global' ? GLOBAL_CONFIG_FILE : LOCAL_CONFIG_FILE;
79
+ const current = scope === 'global' ? readConfigFile(GLOBAL_CONFIG_FILE) : loadConfig();
80
+ if (scope === 'global') fs.mkdirSync(path.dirname(file), { recursive: true });
81
+ fs.writeFileSync(file, JSON.stringify({ ...current, ...config }, null, 2));
63
82
  }
64
83
 
65
84
  // Use .js extension for ESM compatibility if imported elsewhere
package/src/core/ai.ts CHANGED
@@ -15,12 +15,15 @@ import fs from 'fs';
15
15
  import path from 'path';
16
16
  import crypto from 'crypto';
17
17
  import { loadConfig } from '../config/config.js';
18
+ import { metricsCollector } from './metrics.js';
18
19
 
19
20
  /** Typed errors: the runner maps these to user-facing hints, never by string matching. */
20
21
  export type AIError =
21
22
  | { kind: 'connection'; cause: string }
22
23
  | { kind: 'model-not-found'; model: string }
23
24
  | { kind: 'empty-response'; model: string }
25
+ | { kind: 'timeout'; timeoutMs: number }
26
+ | { kind: 'cancelled' }
24
27
  | { kind: 'request'; cause: string };
25
28
 
26
29
  export interface AIRequest {
@@ -29,6 +32,7 @@ export interface AIRequest {
29
32
  context?: string;
30
33
  model?: string;
31
34
  onToken?: (token: string) => void;
35
+ signal?: AbortSignal;
32
36
  }
33
37
 
34
38
  /** The seam. Both adapters implement this; commands and tests depend on it, never on Ollama. */
@@ -110,9 +114,8 @@ function toAIError(err: unknown, model: string): AIError {
110
114
  if (message.includes('ECONNREFUSED') || message.includes('fetch failed') || message.includes('ENOTFOUND')) {
111
115
  return { kind: 'connection', cause: message };
112
116
  }
113
- if (message.includes('not found')) {
114
- return { kind: 'model-not-found', model };
115
- }
117
+ if (message.includes('returned empty response')) return { kind: 'empty-response', model };
118
+ if (message.includes('not found')) return { kind: 'model-not-found', model };
116
119
  return { kind: 'request', cause: message };
117
120
  }
118
121
 
@@ -136,9 +139,11 @@ export class OllamaAIClient implements AIClient {
136
139
 
137
140
  const cached = readCache(request, model);
138
141
  if (cached !== undefined) {
142
+ metricsCollector.recordCacheHit('ai-response');
139
143
  if (request.onToken) request.onToken(cached);
140
144
  return cached;
141
145
  }
146
+ metricsCollector.recordCacheMiss('ai-response');
142
147
 
143
148
  try {
144
149
  const streaming = Boolean(request.onToken);
@@ -146,11 +151,17 @@ export class OllamaAIClient implements AIClient {
146
151
 
147
152
  if (streaming) {
148
153
  const stream = await this.client.generate({ model, prompt: fullPrompt, stream: true });
149
- for await (const chunk of stream) {
150
- const token = typeof chunk === 'object' && chunk !== null && 'response' in chunk ? chunk.response : '';
151
- if (!token) continue;
152
- result += token;
153
- if (request.onToken) request.onToken(token);
154
+ const abort = () => stream.abort();
155
+ request.signal?.addEventListener('abort', abort, { once: true });
156
+ try {
157
+ for await (const chunk of stream) {
158
+ const token = typeof chunk === 'object' && chunk !== null && 'response' in chunk ? chunk.response : '';
159
+ if (!token) continue;
160
+ result += token;
161
+ if (request.onToken) request.onToken(token);
162
+ }
163
+ } finally {
164
+ request.signal?.removeEventListener('abort', abort);
154
165
  }
155
166
  } else {
156
167
  const response = await this.client.generate({ model, prompt: fullPrompt, stream: false });
@@ -246,6 +257,23 @@ export async function listModels(): Promise<string[]> {
246
257
  return getAIClient().listModels();
247
258
  }
248
259
 
260
+ export interface OllamaStatus {
261
+ endpoint: string;
262
+ version?: string;
263
+ }
264
+
265
+ export async function getOllamaStatus(): Promise<OllamaStatus> {
266
+ const endpoint = (process.env.OLLAMA_HOST ?? 'http://127.0.0.1:11434').replace(/\/$/, '');
267
+ try {
268
+ const response = await fetch(`${endpoint}/api/version`, { signal: AbortSignal.timeout(1000) });
269
+ if (!response.ok) return { endpoint };
270
+ const body = await response.json() as { version?: unknown };
271
+ return { endpoint, version: typeof body.version === 'string' ? body.version : undefined };
272
+ } catch {
273
+ return { endpoint };
274
+ }
275
+ }
276
+
249
277
  /** Default model, from configuration — one source of truth. */
250
278
  export function defaultModel(): string {
251
279
  return loadConfig().model;
@@ -0,0 +1,37 @@
1
+ export interface CommandCatalogEntry {
2
+ name: string;
3
+ description: string;
4
+ menuLabel: string;
5
+ options?: string[];
6
+ }
7
+
8
+ export const commandCatalog: CommandCatalogEntry[] = [
9
+ { name: 'explain', description: 'Explain a concept or command', menuLabel: 'Explain' },
10
+ { name: 'suggest', description: 'Get AI-powered suggestions', menuLabel: 'Suggest' },
11
+ { name: 'fix', description: 'Get a fix for a coding issue or error', menuLabel: 'Fix' },
12
+ { name: 'review', description: 'Review code in a file or directory', menuLabel: 'Review', options: ['--diff'] },
13
+ { name: 'optimize', description: 'Optimize a file (e.g., package.json)', menuLabel: 'Optimize' },
14
+ { name: 'security-check', description: 'Run a security check on code', menuLabel: 'Security Check', options: ['--strict'] },
15
+ { name: 'generate', description: 'Generate code/tests for a file', menuLabel: 'Generate', options: ['--apply', '--output', '--overwrite'] },
16
+ { name: 'init', description: 'Interactive setup/configuration wizard', menuLabel: 'Init (Setup)' },
17
+ { name: 'status', description: 'Check Ollama connection and available models', menuLabel: 'Status' },
18
+ { name: 'health', description: 'Run comprehensive health check', menuLabel: 'Health Check', options: ['--details'] },
19
+ { name: 'metrics', description: 'Display CLI usage metrics', menuLabel: 'Metrics', options: ['--raw', '--reset'] },
20
+ { name: 'project-type', description: 'Detect and print the current project type', menuLabel: 'Project Type' },
21
+ { name: 'menu', description: 'Interactive command palette', menuLabel: 'Menu' },
22
+ { name: 'completion', description: 'Generate shell completion script', menuLabel: 'Shell Completion' },
23
+ ];
24
+
25
+ export function commandDescription(name: string): string {
26
+ return commandCatalog.find((command) => command.name === name)?.description ?? name;
27
+ }
28
+
29
+ export function completionCommands(): string {
30
+ return commandCatalog.map((command) => command.name).join(' ');
31
+ }
32
+
33
+ export function completionOptions(): string {
34
+ const options = new Set(['--help', '--version', '--model', '--verbose', '--json', '--timeout']);
35
+ commandCatalog.forEach((command) => command.options?.forEach((option) => options.add(option)));
36
+ return [...options].join(' ');
37
+ }
@@ -14,6 +14,16 @@ import { logger, logCommand, logPerformance, logError } from '../core/logger.js'
14
14
  import { metricsCollector } from '../core/metrics.js';
15
15
  import { securityManager } from '../core/security.js';
16
16
 
17
+ interface CommandResult {
18
+ ok: boolean;
19
+ command: string;
20
+ model?: string;
21
+ response?: string;
22
+ error?: string;
23
+ hint?: string;
24
+ durationMs?: number;
25
+ }
26
+
17
27
  /** What makes a command distinct. The runner owns everything else. */
18
28
  export interface CommandSpec {
19
29
  /** Commander command name, for validation, logging, and metrics. */
@@ -31,16 +41,32 @@ export interface CommandSpec {
31
41
  }
32
42
 
33
43
  /** Maps typed AI errors to user-facing hints — once, not per command. */
34
- function describeAIError(error: AIError, model: string): string {
35
- switch (error.kind) {
44
+ function describeAIError(error: unknown, model: string): string {
45
+ if (!error || typeof error !== 'object' || !('kind' in error)) {
46
+ const msg = error instanceof Error ? error.message : String(error);
47
+ if (/econnrefused|failed to connect|fetch failed/i.test(msg)) {
48
+ return `💡 Make sure Ollama is running: ollama serve`;
49
+ }
50
+ if (/model.*not found/i.test(msg)) {
51
+ return `💡 Install the model: ollama pull ${model}`;
52
+ }
53
+ return msg;
54
+ }
55
+
56
+ const typedError = error as AIError;
57
+ switch (typedError.kind) {
36
58
  case 'connection':
37
59
  return `💡 Make sure Ollama is running: ollama serve`;
38
60
  case 'model-not-found':
39
- return `💡 Install the model: ollama pull ${error.model || model}`;
61
+ return `💡 Install the model: ollama pull ${typedError.model || model}`;
40
62
  case 'empty-response':
41
- return `💡 Model returned nothing. Install it: ollama pull ${error.model || model}`;
63
+ return `💡 Model returned nothing. Install it: ollama pull ${typedError.model || model}`;
64
+ case 'timeout':
65
+ return `💡 The request timed out after ${typedError.timeoutMs}ms. Try again, use a smaller prompt, or increase --timeout.`;
66
+ case 'cancelled':
67
+ return '💡 Request cancelled. Run the command again when ready.';
42
68
  default:
43
- return error.cause;
69
+ return typedError.cause;
44
70
  }
45
71
  }
46
72
 
@@ -48,9 +74,16 @@ export async function runCommand(spec: CommandSpec): Promise<void> {
48
74
  const startTime = Date.now();
49
75
  const { name, input } = spec;
50
76
  const config = loadConfig();
77
+ const jsonOutput = config.responseFormat === 'json';
78
+
79
+ const writeJson = (result: CommandResult): void => {
80
+ process.stdout.write(`${JSON.stringify(result)}\n`);
81
+ };
51
82
 
52
83
  const fail = (error: string): void => {
53
- printError(error);
84
+ process.exitCode = 2;
85
+ if (jsonOutput) writeJson({ ok: false, command: name, error, model: config.model, durationMs: Date.now() - startTime });
86
+ else printError(error);
54
87
  logCommand(name, startTime, false, { error });
55
88
  metricsCollector.recordCommand(name, Date.now() - startTime, false);
56
89
  };
@@ -68,34 +101,87 @@ export async function runCommand(spec: CommandSpec): Promise<void> {
68
101
  return;
69
102
  }
70
103
 
71
- const spinner = ora('Thinking...').start();
104
+ const spinner = jsonOutput ? undefined : ora('Thinking...').start();
105
+ let requestTimeout: ReturnType<typeof setTimeout> | undefined;
106
+ let sigintHandler: (() => void) | undefined;
72
107
  try {
73
- spinner.stop();
108
+ spinner?.stop();
74
109
 
75
- console.log(chalk.yellowBright('🤖 Dhruv CLI: AI-powered developer assistant'));
76
- console.log(chalk.green.bold(spec.header));
77
- console.log();
110
+ if (!jsonOutput) {
111
+ console.log(chalk.yellowBright('🤖 Dhruv CLI: AI-powered developer assistant'));
112
+ console.log(chalk.green.bold(spec.header));
113
+ console.log();
114
+ }
78
115
 
79
- const response = await ask(spec.buildRequest(input, config.model));
116
+ let streamed = false;
117
+ const controller = new AbortController();
118
+ const request = {
119
+ ...spec.buildRequest(input, config.model),
120
+ signal: controller.signal,
121
+ onToken: (token: string) => {
122
+ streamed = true;
123
+ if (!jsonOutput) process.stdout.write(token);
124
+ },
125
+ };
126
+ const aiStartTime = Date.now();
127
+ const responsePromise = ask(request);
128
+ const cancellationPromise = new Promise<string>((_, reject) => {
129
+ sigintHandler = () => {
130
+ controller.abort();
131
+ reject({ kind: 'cancelled' } satisfies AIError);
132
+ };
133
+ process.once('SIGINT', sigintHandler);
134
+ });
135
+ const response = config.timeoutMs > 0
136
+ ? await Promise.race([
137
+ responsePromise,
138
+ cancellationPromise,
139
+ new Promise<string>((_, reject) => {
140
+ requestTimeout = setTimeout(() => {
141
+ controller.abort();
142
+ reject({ kind: 'timeout', timeoutMs: config.timeoutMs } satisfies AIError);
143
+ }, config.timeoutMs);
144
+ }),
145
+ ])
146
+ : await Promise.race([responsePromise, cancellationPromise]);
147
+ if (requestTimeout) clearTimeout(requestTimeout);
148
+ if (sigintHandler) process.removeListener('SIGINT', sigintHandler);
149
+ if (!response.trim()) {
150
+ throw { kind: 'empty-response', model: config.model } satisfies AIError;
151
+ }
80
152
 
81
- console.log('\n');
82
- if (spec.footer) {
83
- console.log(chalk.dim(spec.footer));
153
+ const durationMs = Date.now() - startTime;
154
+ if (jsonOutput) {
155
+ writeJson({ ok: true, command: name, model: config.model, response, durationMs });
156
+ } else {
157
+ if (!streamed) process.stdout.write(response);
158
+ process.stdout.write('\n');
159
+ if (spec.footer) console.log(chalk.dim(spec.footer));
84
160
  }
85
161
 
86
162
  if (spec.onComplete) spec.onComplete(response, input);
87
163
 
88
- const duration = Date.now() - startTime;
164
+ metricsCollector.recordAIRequest(config.model, name, Date.now() - aiStartTime, true);
89
165
  logCommand(name, startTime, true, { model: config.model });
90
- logPerformance(name, duration);
91
- metricsCollector.recordCommand(name, duration, true);
92
- logger.info(`${name} command completed successfully`, { duration });
166
+ logPerformance(name, durationMs);
167
+ metricsCollector.recordCommand(name, durationMs, true);
168
+ logger.info(`${name} command completed successfully`, { duration: durationMs });
93
169
  } catch (err) {
94
- spinner.stop();
170
+ spinner?.stop();
171
+ if (requestTimeout) clearTimeout(requestTimeout);
172
+ if (sigintHandler) process.removeListener('SIGINT', sigintHandler);
173
+ const cancelled = typeof err === 'object' && err !== null && 'kind' in err && (err as { kind?: string }).kind === 'cancelled';
174
+ process.exitCode = cancelled ? 130 : 1;
95
175
  const duration = Date.now() - startTime;
176
+ const hint = describeAIError(err as AIError, config.model);
177
+ metricsCollector.recordAIRequest(config.model, name, duration, false);
96
178
 
97
- printError(`Command failed.`);
98
- console.log(chalk.yellow(describeAIError(err as AIError, config.model)));
179
+ if (jsonOutput) {
180
+ writeJson({ ok: false, command: name, model: config.model, error: 'Command failed.', hint, durationMs: duration });
181
+ } else {
182
+ printError(`Command failed.`);
183
+ console.log(chalk.yellow(hint));
184
+ }
99
185
 
100
186
  logError(`${name} command failed`, err as Error, { command: name });
101
187
  logCommand(name, startTime, false, { error: (err as Error).message });
@@ -61,6 +61,7 @@ class Logger {
61
61
  // Console transport for development
62
62
  new winston.transports.Console({
63
63
  level: config.verbose ? 'debug' : 'info',
64
+ stderrLevels: ['error', 'warn', 'info', 'debug'],
64
65
  format: winston.format.combine(
65
66
  winston.format.colorize(),
66
67
  winston.format.simple(),