@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.
- package/.github/workflows/ci.yml +18 -238
- package/.github/workflows/contribution.yml +6 -141
- package/.github/workflows/dependabot-auto-merge.yml +1 -0
- package/.github/workflows/labeler.yml +1 -0
- package/.github/workflows/security.yml +3 -0
- package/CHANGELOG.md +9 -4
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +104 -0
- package/__tests__/core.test.ts +121 -0
- package/__tests__/diagnostics.test.ts +155 -0
- package/__tests__/file-workflows.test.ts +195 -0
- package/__tests__/interactive.test.ts +119 -0
- package/__tests__/setup.ts +1 -0
- package/__tests__/workflows.test.ts +27 -4
- package/dist/commands/generate.d.ts +6 -1
- package/dist/commands/generate.js +18 -4
- package/dist/commands/health.d.ts +4 -1
- package/dist/commands/health.js +59 -16
- package/dist/commands/init.js +11 -2
- package/dist/commands/menu.js +125 -100
- package/dist/commands/metrics.d.ts +5 -1
- package/dist/commands/metrics.js +37 -8
- package/dist/commands/optimize.js +1 -1
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +48 -8
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +67 -6
- package/dist/commands/status.js +40 -2
- package/dist/config/config.d.ts +5 -1
- package/dist/config/config.js +24 -7
- package/dist/core/ai.d.ts +11 -0
- package/dist/core/ai.js +33 -9
- package/dist/core/command-catalog.d.ts +10 -0
- package/dist/core/command-catalog.js +27 -0
- package/dist/core/command-runner.js +100 -21
- package/dist/core/logger.js +1 -0
- package/dist/core/metrics.d.ts +28 -0
- package/dist/core/metrics.js +78 -0
- package/dist/index.js +46 -24
- package/dist/utils/projectType.d.ts +1 -1
- package/dist/utils/projectType.js +26 -7
- package/docs/api/assets/highlight.css +4 -4
- package/docs/api/index.html +161 -39
- package/docs/api/media/CONTRIBUTING.md +60 -0
- package/docs/api/media/SECURITY.md +8 -0
- package/docs/api/media/dhruv-cli-preview.svg +42 -0
- package/docs/api/media/publishing-fix.md +34 -0
- package/docs/dhruv-cli-preview.svg +42 -0
- package/docs/index.html +631 -533
- package/docs/publishing-fix.md +34 -0
- package/package.json +1 -1
- package/src/commands/generate.ts +23 -4
- package/src/commands/health.ts +62 -17
- package/src/commands/init.ts +11 -2
- package/src/commands/menu.ts +54 -30
- package/src/commands/metrics.ts +42 -7
- package/src/commands/optimize.ts +1 -1
- package/src/commands/review.ts +53 -8
- package/src/commands/security-check.ts +80 -6
- package/src/commands/status.ts +39 -3
- package/src/config/config.ts +26 -7
- package/src/core/ai.ts +36 -8
- package/src/core/command-catalog.ts +37 -0
- package/src/core/command-runner.ts +102 -22
- package/src/core/logger.ts +1 -0
- package/src/core/metrics.ts +103 -0
- package/src/index.ts +45 -24
- package/src/utils/projectType.ts +22 -7
- package/tsconfig.json +1 -1
- package/.github/workflows/auto-assign.yml +0 -14
- package/.github/workflows/build-publish.yml +0 -154
- package/.github/workflows/deploy.yml +0 -336
- package/.github/workflows/monitoring.yml +0 -270
- package/PUBLISHING_FIX.md +0 -92
- package/logs/.8a99b6cf655346317fdbf29f4fffcf91131432f3-audit.json +0 -15
- package/logs/.eee104bf8fff5ecd38a6a2842df260de6470a7c3-audit.json +0 -15
package/src/commands/status.ts
CHANGED
|
@@ -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(', ')}`));
|
package/src/config/config.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
21
|
-
if (fs.existsSync(
|
|
26
|
+
function readConfigFile(file: string): DhruvConfig {
|
|
27
|
+
if (fs.existsSync(file)) {
|
|
22
28
|
try {
|
|
23
|
-
const fileContent = fs.readFileSync(
|
|
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
|
|
62
|
-
|
|
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('
|
|
114
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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:
|
|
35
|
-
|
|
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 ${
|
|
54
|
+
return `💡 Install the model: ollama pull ${typedError.model || model}`;
|
|
40
55
|
case 'empty-response':
|
|
41
|
-
return `💡 Model returned nothing. Install it: ollama pull ${
|
|
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
|
|
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
|
-
|
|
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
|
|
101
|
+
spinner?.stop();
|
|
74
102
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
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
|
-
|
|
158
|
+
metricsCollector.recordAIRequest(config.model, name, Date.now() - aiStartTime, true);
|
|
89
159
|
logCommand(name, startTime, true, { model: config.model });
|
|
90
|
-
logPerformance(name,
|
|
91
|
-
metricsCollector.recordCommand(name,
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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 });
|
package/src/core/logger.ts
CHANGED
|
@@ -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(),
|
package/src/core/metrics.ts
CHANGED
|
@@ -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
|
}
|