@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.
- 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 +16 -4
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +170 -0
- package/__tests__/core.test.ts +193 -1
- package/__tests__/diagnostics.test.ts +179 -0
- package/__tests__/file-workflows.test.ts +234 -0
- package/__tests__/interactive.test.ts +134 -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 +44 -11
- package/dist/commands/health.d.ts +4 -1
- package/dist/commands/health.js +59 -16
- package/dist/commands/init.js +18 -7
- package/dist/commands/menu.js +125 -100
- package/dist/commands/metrics.d.ts +5 -1
- package/dist/commands/metrics.js +49 -10
- package/dist/commands/optimize.js +1 -1
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +66 -9
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +100 -6
- package/dist/commands/status.js +44 -4
- 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 +106 -21
- package/dist/core/logger.js +1 -0
- package/dist/core/metrics.d.ts +28 -0
- package/dist/core/metrics.js +79 -0
- package/dist/index.js +98 -24
- package/dist/utils/projectType.d.ts +7 -1
- package/dist/utils/projectType.js +91 -13
- 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 +50 -11
- package/src/commands/health.ts +62 -17
- package/src/commands/init.ts +18 -7
- package/src/commands/menu.ts +54 -30
- package/src/commands/metrics.ts +53 -9
- package/src/commands/optimize.ts +1 -1
- package/src/commands/review.ts +72 -9
- package/src/commands/security-check.ts +111 -6
- package/src/commands/status.ts +43 -5
- 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 +108 -22
- package/src/core/logger.ts +1 -0
- package/src/core/metrics.ts +105 -0
- package/src/index.ts +97 -24
- package/src/utils/projectType.ts +85 -9
- 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
|
@@ -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 =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.
|
|
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${
|
|
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
|
}),
|
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,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('\
|
|
45
|
-
console.log(chalk.yellow(
|
|
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
|
}
|
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,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:
|
|
35
|
-
|
|
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 ${
|
|
61
|
+
return `💡 Install the model: ollama pull ${typedError.model || model}`;
|
|
40
62
|
case 'empty-response':
|
|
41
|
-
return `💡 Model returned nothing. Install it: ollama pull ${
|
|
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
|
|
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
|
-
|
|
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
|
|
108
|
+
spinner?.stop();
|
|
74
109
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
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
|
-
|
|
164
|
+
metricsCollector.recordAIRequest(config.model, name, Date.now() - aiStartTime, true);
|
|
89
165
|
logCommand(name, startTime, true, { model: config.model });
|
|
90
|
-
logPerformance(name,
|
|
91
|
-
metricsCollector.recordCommand(name,
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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 });
|
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(),
|