@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
@@ -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,6 +68,7 @@ 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`);
37
73
  if (models.length > 0) {
38
74
  console.log(chalk.yellow(`Available models: ${models.join(', ')}`));
@@ -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,25 @@ 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
+ return error instanceof Error ? error.message : String(error);
47
+ }
48
+
49
+ const typedError = error as AIError;
50
+ switch (typedError.kind) {
36
51
  case 'connection':
37
52
  return `💡 Make sure Ollama is running: ollama serve`;
38
53
  case 'model-not-found':
39
- return `💡 Install the model: ollama pull ${error.model || model}`;
54
+ return `💡 Install the model: ollama pull ${typedError.model || model}`;
40
55
  case 'empty-response':
41
- return `💡 Model returned nothing. Install it: ollama pull ${error.model || model}`;
56
+ return `💡 Model returned nothing. Install it: ollama pull ${typedError.model || model}`;
57
+ case 'timeout':
58
+ return `💡 The request timed out after ${typedError.timeoutMs}ms. Try again, use a smaller prompt, or increase --timeout.`;
59
+ case 'cancelled':
60
+ return '💡 Request cancelled. Run the command again when ready.';
42
61
  default:
43
- return error.cause;
62
+ return typedError.cause;
44
63
  }
45
64
  }
46
65
 
@@ -48,9 +67,16 @@ export async function runCommand(spec: CommandSpec): Promise<void> {
48
67
  const startTime = Date.now();
49
68
  const { name, input } = spec;
50
69
  const config = loadConfig();
70
+ const jsonOutput = config.responseFormat === 'json';
71
+
72
+ const writeJson = (result: CommandResult): void => {
73
+ process.stdout.write(`${JSON.stringify(result)}\n`);
74
+ };
51
75
 
52
76
  const fail = (error: string): void => {
53
- printError(error);
77
+ process.exitCode = 2;
78
+ if (jsonOutput) writeJson({ ok: false, command: name, error, model: config.model, durationMs: Date.now() - startTime });
79
+ else printError(error);
54
80
  logCommand(name, startTime, false, { error });
55
81
  metricsCollector.recordCommand(name, Date.now() - startTime, false);
56
82
  };
@@ -68,34 +94,88 @@ export async function runCommand(spec: CommandSpec): Promise<void> {
68
94
  return;
69
95
  }
70
96
 
71
- const spinner = ora('Thinking...').start();
97
+ const spinner = jsonOutput ? undefined : ora('Thinking...').start();
98
+ let requestTimeout: ReturnType<typeof setTimeout> | undefined;
99
+ let sigintHandler: (() => void) | undefined;
72
100
  try {
73
- spinner.stop();
101
+ spinner?.stop();
74
102
 
75
- console.log(chalk.yellowBright('🤖 Dhruv CLI: AI-powered developer assistant'));
76
- console.log(chalk.green.bold(spec.header));
77
- console.log();
103
+ if (!jsonOutput) {
104
+ console.log(chalk.yellowBright('🤖 Dhruv CLI: AI-powered developer assistant'));
105
+ console.log(chalk.green.bold(spec.header));
106
+ console.log();
107
+ }
78
108
 
79
- const response = await ask(spec.buildRequest(input, config.model));
109
+ let streamed = false;
110
+ const controller = new AbortController();
111
+ const request = {
112
+ ...spec.buildRequest(input, config.model),
113
+ signal: controller.signal,
114
+ onToken: (token: string) => {
115
+ streamed = true;
116
+ if (!jsonOutput) process.stdout.write(token);
117
+ },
118
+ };
119
+ const aiStartTime = Date.now();
120
+ const responsePromise = ask(request);
121
+ const cancellationPromise = new Promise<string>((_, reject) => {
122
+ sigintHandler = () => {
123
+ controller.abort();
124
+ reject({ kind: 'cancelled' } satisfies AIError);
125
+ };
126
+ process.once('SIGINT', sigintHandler);
127
+ });
128
+ const response = config.timeoutMs > 0
129
+ ? await Promise.race([
130
+ responsePromise,
131
+ cancellationPromise,
132
+ new Promise<string>((_, reject) => {
133
+ requestTimeout = setTimeout(() => {
134
+ controller.abort();
135
+ reject({ kind: 'timeout', timeoutMs: config.timeoutMs } satisfies AIError);
136
+ }, config.timeoutMs);
137
+ }),
138
+ ])
139
+ : await Promise.race([responsePromise, cancellationPromise]);
140
+ if (requestTimeout) clearTimeout(requestTimeout);
141
+ if (sigintHandler) process.removeListener('SIGINT', sigintHandler);
142
+ if (!response.trim()) {
143
+ throw { kind: 'empty-response', model: config.model } satisfies AIError;
144
+ }
80
145
 
81
- console.log('\n');
82
- if (spec.footer) {
83
- console.log(chalk.dim(spec.footer));
146
+ const durationMs = Date.now() - startTime;
147
+ if (jsonOutput) {
148
+ writeJson({ ok: true, command: name, model: config.model, response, durationMs });
149
+ } else {
150
+ if (!streamed) process.stdout.write(response);
151
+ process.stdout.write('\n');
152
+ console.log('\n');
153
+ if (spec.footer) console.log(chalk.dim(spec.footer));
84
154
  }
85
155
 
86
156
  if (spec.onComplete) spec.onComplete(response, input);
87
157
 
88
- const duration = Date.now() - startTime;
158
+ metricsCollector.recordAIRequest(config.model, name, Date.now() - aiStartTime, true);
89
159
  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 });
160
+ logPerformance(name, durationMs);
161
+ metricsCollector.recordCommand(name, durationMs, true);
162
+ logger.info(`${name} command completed successfully`, { duration: durationMs });
93
163
  } catch (err) {
94
- spinner.stop();
164
+ spinner?.stop();
165
+ if (requestTimeout) clearTimeout(requestTimeout);
166
+ if (sigintHandler) process.removeListener('SIGINT', sigintHandler);
167
+ const cancelled = typeof err === 'object' && err !== null && 'kind' in err && (err as { kind?: string }).kind === 'cancelled';
168
+ process.exitCode = cancelled ? 130 : 1;
95
169
  const duration = Date.now() - startTime;
170
+ const hint = describeAIError(err as AIError, config.model);
171
+ metricsCollector.recordAIRequest(config.model, name, duration, false);
96
172
 
97
- printError(`Command failed.`);
98
- console.log(chalk.yellow(describeAIError(err as AIError, config.model)));
173
+ if (jsonOutput) {
174
+ writeJson({ ok: false, command: name, model: config.model, error: 'Command failed.', hint, durationMs: duration });
175
+ } else {
176
+ printError(`Command failed.`);
177
+ console.log(chalk.yellow(hint));
178
+ }
99
179
 
100
180
  logError(`${name} command failed`, err as Error, { command: name });
101
181
  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(),
@@ -1,5 +1,38 @@
1
1
  import promClient from 'prom-client';
2
2
  import { logger } from './logger.js';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ export interface CommandSummary {
7
+ runs: number;
8
+ successes: number;
9
+ failures: number;
10
+ durationMs: number;
11
+ }
12
+
13
+ export interface ModelSummary {
14
+ requests: number;
15
+ successes: number;
16
+ failures: number;
17
+ durationMs: number;
18
+ }
19
+
20
+ export interface CacheSummary {
21
+ hits: number;
22
+ misses: number;
23
+ }
24
+
25
+ export interface MetricsSummary {
26
+ sessions: number;
27
+ commands: Record<string, CommandSummary>;
28
+ errors: Record<string, number>;
29
+ models: Record<string, ModelSummary>;
30
+ cache: CacheSummary;
31
+ }
32
+
33
+ function emptySummary(): MetricsSummary {
34
+ return { sessions: 0, commands: {}, errors: {}, models: {}, cache: { hits: 0, misses: 0 } };
35
+ }
3
36
 
4
37
  // Create a Registry which registers the metrics
5
38
  const register = new promClient.Registry();
@@ -122,6 +155,14 @@ export class MetricsCollector {
122
155
  try {
123
156
  metrics.commandDuration.observe({ command, success: success.toString() }, duration / 1000);
124
157
  metrics.commandCount.inc({ command, success: success.toString() });
158
+ this.updatePersistent((summary) => {
159
+ const current = summary.commands[command] ?? { runs: 0, successes: 0, failures: 0, durationMs: 0 };
160
+ current.runs += 1;
161
+ if (success) current.successes += 1;
162
+ else current.failures += 1;
163
+ current.durationMs += duration;
164
+ summary.commands[command] = current;
165
+ });
125
166
  } catch (error) {
126
167
  logger.error('Failed to record command metrics', error as Error);
127
168
  }
@@ -137,6 +178,14 @@ export class MetricsCollector {
137
178
  if (tokensUsed) {
138
179
  metrics.aiTokensUsed.inc({ model, command_type: commandType }, tokensUsed);
139
180
  }
181
+ this.updatePersistent((summary) => {
182
+ const current = summary.models[model] ?? { requests: 0, successes: 0, failures: 0, durationMs: 0 };
183
+ current.requests += 1;
184
+ if (success) current.successes += 1;
185
+ else current.failures += 1;
186
+ current.durationMs += duration;
187
+ summary.models[model] = current;
188
+ });
140
189
  } catch (error) {
141
190
  logger.error('Failed to record AI request metrics', error as Error);
142
191
  }
@@ -147,6 +196,9 @@ export class MetricsCollector {
147
196
 
148
197
  try {
149
198
  metrics.cacheHitCount.inc({ cache_type: cacheType });
199
+ this.updatePersistent((summary) => {
200
+ summary.cache.hits += 1;
201
+ });
150
202
  } catch (error) {
151
203
  logger.error('Failed to record cache hit metrics', error as Error);
152
204
  }
@@ -157,6 +209,9 @@ export class MetricsCollector {
157
209
 
158
210
  try {
159
211
  metrics.cacheMissCount.inc({ cache_type: cacheType });
212
+ this.updatePersistent((summary) => {
213
+ summary.cache.misses += 1;
214
+ });
160
215
  } catch (error) {
161
216
  logger.error('Failed to record cache miss metrics', error as Error);
162
217
  }
@@ -177,6 +232,10 @@ export class MetricsCollector {
177
232
 
178
233
  try {
179
234
  metrics.errorCount.inc({ error_type: errorType, command: command || 'unknown' });
235
+ this.updatePersistent((summary) => {
236
+ const key = command ? `${errorType}:${command}` : errorType;
237
+ summary.errors[key] = (summary.errors[key] ?? 0) + 1;
238
+ });
180
239
  } catch (error) {
181
240
  logger.error('Failed to record error metrics', error as Error);
182
241
  }
@@ -201,6 +260,9 @@ export class MetricsCollector {
201
260
 
202
261
  try {
203
262
  metrics.sessionCount.inc();
263
+ this.updatePersistent((summary) => {
264
+ summary.sessions += 1;
265
+ });
204
266
  } catch (error) {
205
267
  logger.error('Failed to record session metrics', error as Error);
206
268
  }
@@ -216,6 +278,47 @@ export class MetricsCollector {
216
278
  }
217
279
  }
218
280
 
281
+ public getSummary(): MetricsSummary {
282
+ return this.readPersistent();
283
+ }
284
+
285
+ public resetPersistent(): void {
286
+ try {
287
+ fs.unlinkSync(this.persistentPath());
288
+ } catch (error) {
289
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
290
+ }
291
+ }
292
+
293
+ private persistentPath(): string {
294
+ return path.join(process.cwd(), '.dhruv-metrics.json');
295
+ }
296
+
297
+ private readPersistent(): MetricsSummary {
298
+ try {
299
+ const parsed = JSON.parse(fs.readFileSync(this.persistentPath(), 'utf8')) as Partial<MetricsSummary>;
300
+ return {
301
+ sessions: typeof parsed.sessions === 'number' ? parsed.sessions : 0,
302
+ commands: parsed.commands ?? {},
303
+ errors: parsed.errors ?? {},
304
+ models: parsed.models ?? {},
305
+ cache: parsed.cache ?? { hits: 0, misses: 0 },
306
+ };
307
+ } catch {
308
+ return emptySummary();
309
+ }
310
+ }
311
+
312
+ private updatePersistent(update: (summary: MetricsSummary) => void): void {
313
+ try {
314
+ const summary = this.readPersistent();
315
+ update(summary);
316
+ fs.writeFileSync(this.persistentPath(), JSON.stringify(summary, null, 2));
317
+ } catch (error) {
318
+ logger.debug('Failed to persist metrics', { error: (error as Error).message });
319
+ }
320
+ }
321
+
219
322
  public async getMetrics(): Promise<string> {
220
323
  return register.metrics();
221
324
  }