@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
package/dist/commands/review.js
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
3
4
|
import { runCommand } from '../core/command-runner.js';
|
|
4
5
|
import { getSystemMessage } from '../core/prompts.js';
|
|
5
|
-
import { printError } from '../utils/ux.js';
|
|
6
|
-
|
|
6
|
+
import { printError, printInfo } from '../utils/ux.js';
|
|
7
|
+
import { detectProjectType } from '../utils/projectType.js';
|
|
8
|
+
const CODE_FILE = /\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/;
|
|
9
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
10
|
+
'.git',
|
|
11
|
+
'node_modules',
|
|
12
|
+
'dist',
|
|
13
|
+
'build',
|
|
14
|
+
'coverage',
|
|
15
|
+
'.dhruv-cache',
|
|
16
|
+
'logs',
|
|
17
|
+
'.next',
|
|
18
|
+
'.turbo',
|
|
19
|
+
'__pycache__',
|
|
20
|
+
'.pytest_cache',
|
|
21
|
+
'target',
|
|
22
|
+
'vendor',
|
|
23
|
+
]);
|
|
24
|
+
/** Reads a file or up to 10 code files from a directory tree. */
|
|
7
25
|
function readCode(fileOrDir) {
|
|
8
26
|
// Read first, branch on the error: no separate existence check to race against.
|
|
9
27
|
let content;
|
|
@@ -21,14 +39,31 @@ function readCode(fileOrDir) {
|
|
|
21
39
|
return content;
|
|
22
40
|
}
|
|
23
41
|
function readDirectory(dir) {
|
|
24
|
-
const files =
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
42
|
+
const files = [];
|
|
43
|
+
function collect(current) {
|
|
44
|
+
if (files.length >= 10)
|
|
45
|
+
return;
|
|
46
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
47
|
+
if (files.length >= 10)
|
|
48
|
+
return;
|
|
49
|
+
const absolute = path.join(current, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
if (!IGNORED_DIRECTORIES.has(entry.name))
|
|
52
|
+
collect(absolute);
|
|
53
|
+
}
|
|
54
|
+
else if (entry.isFile() && CODE_FILE.test(entry.name)) {
|
|
55
|
+
files.push(path.relative(dir, absolute).split(path.sep).join('/'));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
collect(dir);
|
|
28
60
|
if (files.length === 0) {
|
|
29
61
|
printError(`No code files found in directory "${dir}".`);
|
|
30
62
|
return undefined;
|
|
31
63
|
}
|
|
64
|
+
if (files.length >= 10) {
|
|
65
|
+
printInfo('Note: Directory review is capped at the first 10 source files.');
|
|
66
|
+
}
|
|
32
67
|
let code = '';
|
|
33
68
|
for (const f of files) {
|
|
34
69
|
try {
|
|
@@ -40,16 +75,38 @@ function readDirectory(dir) {
|
|
|
40
75
|
}
|
|
41
76
|
return code;
|
|
42
77
|
}
|
|
43
|
-
|
|
44
|
-
const
|
|
78
|
+
function readGitDiff(fileOrDir) {
|
|
79
|
+
const root = fs.existsSync(fileOrDir) && fs.statSync(fileOrDir).isDirectory() ? fileOrDir : path.dirname(fileOrDir);
|
|
80
|
+
try {
|
|
81
|
+
const diff = execFileSync('git', ['diff', '--no-ext-diff', '--unified=80', '--'], {
|
|
82
|
+
cwd: root,
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
85
|
+
});
|
|
86
|
+
if (!diff.trim()) {
|
|
87
|
+
printError(`No uncommitted changes found in "${fileOrDir}".`);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return diff;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
printError(`Could not read a git diff for "${fileOrDir}".`);
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function review(fileOrDir, options = {}) {
|
|
98
|
+
const code = options.diff ? readGitDiff(fileOrDir) : readCode(fileOrDir);
|
|
45
99
|
if (code === undefined)
|
|
46
100
|
return;
|
|
101
|
+
const projectRoot = fs.existsSync(fileOrDir) && fs.statSync(fileOrDir).isDirectory() ? fileOrDir : path.dirname(fileOrDir);
|
|
102
|
+
const projectType = detectProjectType(projectRoot);
|
|
103
|
+
const scope = options.diff ? 'the current uncommitted git diff' : 'the supplied source files';
|
|
47
104
|
await runCommand({
|
|
48
105
|
name: 'review',
|
|
49
106
|
input: { fileOrDir },
|
|
50
107
|
header: '🔍 Code Review: ',
|
|
51
108
|
buildRequest: (input, model) => ({
|
|
52
|
-
prompt: `Please review
|
|
109
|
+
prompt: `Please review ${scope} for a ${projectType} project. Provide feedback on code quality, best practices, potential issues, and suggestions for improvement. For every finding, include the file, line or region, severity, explanation, and an actionable recommendation. Here is the code to review:\n\nCODE_START\n${code}\nCODE_END`,
|
|
53
110
|
systemMessage: getSystemMessage('review'),
|
|
54
111
|
model,
|
|
55
112
|
}),
|
|
@@ -3,6 +3,78 @@ import path from 'path';
|
|
|
3
3
|
import { runCommand } from '../core/command-runner.js';
|
|
4
4
|
import { getSystemMessage } from '../core/prompts.js';
|
|
5
5
|
import { printError } from '../utils/ux.js';
|
|
6
|
+
const CODE_FILE = /\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/;
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
8
|
+
'.git',
|
|
9
|
+
'node_modules',
|
|
10
|
+
'dist',
|
|
11
|
+
'build',
|
|
12
|
+
'coverage',
|
|
13
|
+
'.dhruv-cache',
|
|
14
|
+
'logs',
|
|
15
|
+
'.next',
|
|
16
|
+
'.turbo',
|
|
17
|
+
'__pycache__',
|
|
18
|
+
'.pytest_cache',
|
|
19
|
+
'target',
|
|
20
|
+
'vendor',
|
|
21
|
+
]);
|
|
22
|
+
function redactSensitiveContent(content) {
|
|
23
|
+
return content
|
|
24
|
+
.replace(/(\b(?:api[_-]?key|secret|token|password|authorization)\s*[:=]\s*["'`])[^"'`\r\n]+(["'`])/gi, '$1[REDACTED]$2')
|
|
25
|
+
.replace(/\b(?:sk|pk)-[a-z0-9_-]{8,}\b/gi, '[REDACTED]')
|
|
26
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
|
|
27
|
+
.replace(/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/g, '[REDACTED]')
|
|
28
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED]')
|
|
29
|
+
.replace(/-----BEGIN (?:RSA|OPENSSH|EC|PGP|DSA)? PRIVATE KEY-----[\s\S]*?-----END (?:RSA|OPENSSH|EC|PGP|DSA)? PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]');
|
|
30
|
+
}
|
|
31
|
+
function findHighConfidenceFindings(content) {
|
|
32
|
+
const findings = [];
|
|
33
|
+
const lines = content.split(/\r?\n/);
|
|
34
|
+
lines.forEach((line, index) => {
|
|
35
|
+
if (/\b(?:api[_-]?key|secret|token|password|authorization)\s*[:=]\s*["'`][^"'`\r\n]+["'`]/i.test(line)) {
|
|
36
|
+
findings.push({
|
|
37
|
+
line: index + 1,
|
|
38
|
+
severity: 'high',
|
|
39
|
+
description: 'credential-like value assigned in source',
|
|
40
|
+
remediation: 'rotate the credential and load it from a secret manager or environment variable',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
else if (/\b(?:sk|pk)-[a-z0-9_-]{8,}\b/i.test(line)) {
|
|
44
|
+
findings.push({
|
|
45
|
+
line: index + 1,
|
|
46
|
+
severity: 'high',
|
|
47
|
+
description: 'credential-like API key detected',
|
|
48
|
+
remediation: 'rotate the credential and remove it from source control',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
else if (/\bBearer\s+[A-Za-z0-9._~+/=-]+/i.test(line)) {
|
|
52
|
+
findings.push({
|
|
53
|
+
line: index + 1,
|
|
54
|
+
severity: 'high',
|
|
55
|
+
description: 'bearer token detected',
|
|
56
|
+
remediation: 'revoke the token and use a secure runtime secret store',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
else if (/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/.test(line)) {
|
|
60
|
+
findings.push({
|
|
61
|
+
line: index + 1,
|
|
62
|
+
severity: 'high',
|
|
63
|
+
description: 'GitHub token detected',
|
|
64
|
+
remediation: 'revoke the GitHub token and store it in GitHub Secrets or environment variables',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else if (/\bAKIA[0-9A-Z]{16}\b/.test(line)) {
|
|
68
|
+
findings.push({
|
|
69
|
+
line: index + 1,
|
|
70
|
+
severity: 'high',
|
|
71
|
+
description: 'AWS access key ID detected',
|
|
72
|
+
remediation: 'rotate the AWS access key and use IAM roles or AWS Secrets Manager',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return findings;
|
|
77
|
+
}
|
|
6
78
|
/** Reads a file or the code files of a directory (up to 10), concatenated. */
|
|
7
79
|
function readCode(fileOrDir) {
|
|
8
80
|
// Read first, branch on the error: no separate existence check to race against.
|
|
@@ -21,10 +93,24 @@ function readCode(fileOrDir) {
|
|
|
21
93
|
return content;
|
|
22
94
|
}
|
|
23
95
|
function readDirectory(dir) {
|
|
24
|
-
const files =
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
96
|
+
const files = [];
|
|
97
|
+
function collect(current) {
|
|
98
|
+
if (files.length >= 10)
|
|
99
|
+
return;
|
|
100
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
101
|
+
if (files.length >= 10)
|
|
102
|
+
return;
|
|
103
|
+
const absolute = path.join(current, entry.name);
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
if (!IGNORED_DIRECTORIES.has(entry.name))
|
|
106
|
+
collect(absolute);
|
|
107
|
+
}
|
|
108
|
+
else if (entry.isFile() && CODE_FILE.test(entry.name)) {
|
|
109
|
+
files.push(path.relative(dir, absolute).split(path.sep).join('/'));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
collect(dir);
|
|
28
114
|
if (files.length === 0) {
|
|
29
115
|
printError(`No code files found in directory "${dir}".`);
|
|
30
116
|
return undefined;
|
|
@@ -40,16 +126,24 @@ function readDirectory(dir) {
|
|
|
40
126
|
}
|
|
41
127
|
return code;
|
|
42
128
|
}
|
|
43
|
-
export async function securityCheck(fileOrDir = '.') {
|
|
129
|
+
export async function securityCheck(fileOrDir = '.', options = {}) {
|
|
44
130
|
const code = readCode(fileOrDir);
|
|
45
131
|
if (code === undefined)
|
|
46
132
|
return;
|
|
133
|
+
const findings = findHighConfidenceFindings(code);
|
|
134
|
+
const safeCode = redactSensitiveContent(code);
|
|
135
|
+
const findingSummary = findings.length === 0
|
|
136
|
+
? 'none'
|
|
137
|
+
: findings.map((finding) => `- ${finding.severity} at line ${finding.line}: ${finding.description}; remediation: ${finding.remediation}`).join('\n');
|
|
138
|
+
if (options.strict && findings.length > 0) {
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
}
|
|
47
141
|
await runCommand({
|
|
48
142
|
name: 'security-check',
|
|
49
143
|
input: { fileOrDir },
|
|
50
144
|
header: '🛡️ Security Analysis: ',
|
|
51
145
|
buildRequest: (input, model) => ({
|
|
52
|
-
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${
|
|
146
|
+
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}`,
|
|
53
147
|
systemMessage: getSystemMessage('security'),
|
|
54
148
|
model,
|
|
55
149
|
}),
|
package/dist/commands/status.js
CHANGED
|
@@ -1,10 +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
|
export async function status() {
|
|
6
|
-
console.log(chalk.blue('🔍 Dhruv CLI Status Check\n'));
|
|
7
6
|
const config = loadConfig();
|
|
7
|
+
if (config.responseFormat === 'json') {
|
|
8
|
+
try {
|
|
9
|
+
const models = await listModels();
|
|
10
|
+
const server = await getOllamaStatus();
|
|
11
|
+
const configuredModelAvailable = models.includes(config.model);
|
|
12
|
+
process.stdout.write(`${JSON.stringify({
|
|
13
|
+
ok: configuredModelAvailable,
|
|
14
|
+
command: 'status',
|
|
15
|
+
model: config.model,
|
|
16
|
+
responseFormat: config.responseFormat,
|
|
17
|
+
verbose: config.verbose,
|
|
18
|
+
theme: config.theme,
|
|
19
|
+
availableModels: models,
|
|
20
|
+
configuredModelAvailable,
|
|
21
|
+
endpoint: server.endpoint,
|
|
22
|
+
version: server.version ?? null,
|
|
23
|
+
ollama: 'connected',
|
|
24
|
+
nextSteps: configuredModelAvailable ? [] : [`ollama pull ${config.model}`],
|
|
25
|
+
})}\n`);
|
|
26
|
+
if (!configuredModelAvailable)
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
process.stdout.write(`${JSON.stringify({
|
|
32
|
+
ok: false,
|
|
33
|
+
command: 'status',
|
|
34
|
+
model: config.model,
|
|
35
|
+
ollama: 'unavailable',
|
|
36
|
+
error: error.message,
|
|
37
|
+
})}\n`);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
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`);
|
|
8
45
|
printInfo(`Current configuration:`);
|
|
9
46
|
console.log(` Model: ${config.model}`);
|
|
10
47
|
console.log(` Response Format: ${config.responseFormat}`);
|
|
@@ -30,16 +67,19 @@ export async function status() {
|
|
|
30
67
|
printSuccess(`✓ Configured model '${config.model}' is available`);
|
|
31
68
|
}
|
|
32
69
|
else {
|
|
70
|
+
process.exitCode = 1;
|
|
33
71
|
printError(`✗ Configured model '${config.model}' is not available`);
|
|
72
|
+
console.log(chalk.yellow(`💡 Install the model: ollama pull ${config.model}`));
|
|
34
73
|
if (models.length > 0) {
|
|
35
74
|
console.log(chalk.yellow(`Available models: ${models.join(', ')}`));
|
|
36
75
|
}
|
|
37
76
|
}
|
|
38
77
|
}
|
|
39
78
|
catch (error) {
|
|
79
|
+
process.exitCode = 1;
|
|
40
80
|
printError('✗ Ollama connection failed');
|
|
41
81
|
console.log(chalk.red(error.message));
|
|
42
|
-
console.log(chalk.yellow('\
|
|
43
|
-
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}`));
|
|
44
84
|
}
|
|
45
85
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
export type ConfigScope = 'local' | 'global';
|
|
1
2
|
export interface DhruvConfig {
|
|
2
3
|
model: string;
|
|
3
4
|
verbose: boolean;
|
|
4
5
|
responseFormat: 'text' | 'json' | 'markdown';
|
|
6
|
+
timeoutMs: number;
|
|
5
7
|
theme?: 'default' | 'dark' | 'light' | 'mono';
|
|
6
8
|
}
|
|
7
9
|
export declare function loadConfig(): DhruvConfig;
|
|
8
|
-
export declare function saveConfig(config: Partial<DhruvConfig
|
|
10
|
+
export declare function saveConfig(config: Partial<DhruvConfig>, options?: {
|
|
11
|
+
scope?: ConfigScope;
|
|
12
|
+
}): void;
|
package/dist/config/config.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
|
|
3
|
+
import os from 'os';
|
|
4
|
+
const LOCAL_CONFIG_FILE = path.join(process.cwd(), '.dhruv-config.json');
|
|
5
|
+
const GLOBAL_CONFIG_FILE = path.join(os.homedir(), '.config', 'dhruv', 'config.json');
|
|
4
6
|
const defaultConfig = {
|
|
5
7
|
model: 'gemma3:270m',
|
|
6
8
|
verbose: false,
|
|
7
9
|
responseFormat: 'text',
|
|
10
|
+
timeoutMs: 45000,
|
|
8
11
|
theme: 'default',
|
|
9
12
|
};
|
|
10
|
-
|
|
11
|
-
if (fs.existsSync(
|
|
13
|
+
function readConfigFile(file) {
|
|
14
|
+
if (fs.existsSync(file)) {
|
|
12
15
|
try {
|
|
13
|
-
const fileContent = fs.readFileSync(
|
|
16
|
+
const fileContent = fs.readFileSync(file, 'utf-8');
|
|
14
17
|
const parsedConfig = JSON.parse(fileContent);
|
|
15
18
|
return validateAndMergeConfig(parsedConfig);
|
|
16
19
|
}
|
|
@@ -21,6 +24,13 @@ export function loadConfig() {
|
|
|
21
24
|
}
|
|
22
25
|
return defaultConfig;
|
|
23
26
|
}
|
|
27
|
+
export function loadConfig() {
|
|
28
|
+
if (fs.existsSync(LOCAL_CONFIG_FILE))
|
|
29
|
+
return readConfigFile(LOCAL_CONFIG_FILE);
|
|
30
|
+
if (fs.existsSync(GLOBAL_CONFIG_FILE))
|
|
31
|
+
return readConfigFile(GLOBAL_CONFIG_FILE);
|
|
32
|
+
return defaultConfig;
|
|
33
|
+
}
|
|
24
34
|
function validateAndMergeConfig(config) {
|
|
25
35
|
const validatedConfig = { ...defaultConfig };
|
|
26
36
|
// Validate model
|
|
@@ -35,14 +45,21 @@ function validateAndMergeConfig(config) {
|
|
|
35
45
|
if (config.responseFormat && ['text', 'json', 'markdown'].includes(config.responseFormat)) {
|
|
36
46
|
validatedConfig.responseFormat = config.responseFormat;
|
|
37
47
|
}
|
|
48
|
+
if (typeof config.timeoutMs === 'number' && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0) {
|
|
49
|
+
validatedConfig.timeoutMs = Math.round(config.timeoutMs);
|
|
50
|
+
}
|
|
38
51
|
// Validate theme
|
|
39
52
|
if (config.theme && ['default', 'dark', 'light', 'mono'].includes(config.theme)) {
|
|
40
53
|
validatedConfig.theme = config.theme;
|
|
41
54
|
}
|
|
42
55
|
return validatedConfig;
|
|
43
56
|
}
|
|
44
|
-
export function saveConfig(config) {
|
|
45
|
-
const
|
|
46
|
-
|
|
57
|
+
export function saveConfig(config, options = {}) {
|
|
58
|
+
const scope = options.scope ?? 'local';
|
|
59
|
+
const file = scope === 'global' ? GLOBAL_CONFIG_FILE : LOCAL_CONFIG_FILE;
|
|
60
|
+
const current = scope === 'global' ? readConfigFile(GLOBAL_CONFIG_FILE) : loadConfig();
|
|
61
|
+
if (scope === 'global')
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
fs.writeFileSync(file, JSON.stringify({ ...current, ...config }, null, 2));
|
|
47
64
|
}
|
|
48
65
|
// Use .js extension for ESM compatibility if imported elsewhere
|
package/dist/core/ai.d.ts
CHANGED
|
@@ -21,6 +21,11 @@ export type AIError = {
|
|
|
21
21
|
} | {
|
|
22
22
|
kind: 'empty-response';
|
|
23
23
|
model: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'timeout';
|
|
26
|
+
timeoutMs: number;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'cancelled';
|
|
24
29
|
} | {
|
|
25
30
|
kind: 'request';
|
|
26
31
|
cause: string;
|
|
@@ -31,6 +36,7 @@ export interface AIRequest {
|
|
|
31
36
|
context?: string;
|
|
32
37
|
model?: string;
|
|
33
38
|
onToken?: (token: string) => void;
|
|
39
|
+
signal?: AbortSignal;
|
|
34
40
|
}
|
|
35
41
|
/** The seam. Both adapters implement this; commands and tests depend on it, never on Ollama. */
|
|
36
42
|
export interface AIClient {
|
|
@@ -76,5 +82,10 @@ export declare function setAIClient(client: AIClient): void;
|
|
|
76
82
|
*/
|
|
77
83
|
export declare function ask(request: AIRequest): Promise<string>;
|
|
78
84
|
export declare function listModels(): Promise<string[]>;
|
|
85
|
+
export interface OllamaStatus {
|
|
86
|
+
endpoint: string;
|
|
87
|
+
version?: string;
|
|
88
|
+
}
|
|
89
|
+
export declare function getOllamaStatus(): Promise<OllamaStatus>;
|
|
79
90
|
/** Default model, from configuration — one source of truth. */
|
|
80
91
|
export declare function defaultModel(): string;
|
package/dist/core/ai.js
CHANGED
|
@@ -15,6 +15,7 @@ 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
|
const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
19
20
|
const MAX_CACHE_FILES = 100;
|
|
20
21
|
function cacheDir() {
|
|
@@ -88,9 +89,10 @@ function toAIError(err, model) {
|
|
|
88
89
|
if (message.includes('ECONNREFUSED') || message.includes('fetch failed') || message.includes('ENOTFOUND')) {
|
|
89
90
|
return { kind: 'connection', cause: message };
|
|
90
91
|
}
|
|
91
|
-
if (message.includes('
|
|
92
|
+
if (message.includes('returned empty response'))
|
|
93
|
+
return { kind: 'empty-response', model };
|
|
94
|
+
if (message.includes('not found'))
|
|
92
95
|
return { kind: 'model-not-found', model };
|
|
93
|
-
}
|
|
94
96
|
return { kind: 'request', cause: message };
|
|
95
97
|
}
|
|
96
98
|
/**
|
|
@@ -109,22 +111,31 @@ export class OllamaAIClient {
|
|
|
109
111
|
: request.prompt;
|
|
110
112
|
const cached = readCache(request, model);
|
|
111
113
|
if (cached !== undefined) {
|
|
114
|
+
metricsCollector.recordCacheHit('ai-response');
|
|
112
115
|
if (request.onToken)
|
|
113
116
|
request.onToken(cached);
|
|
114
117
|
return cached;
|
|
115
118
|
}
|
|
119
|
+
metricsCollector.recordCacheMiss('ai-response');
|
|
116
120
|
try {
|
|
117
121
|
const streaming = Boolean(request.onToken);
|
|
118
122
|
let result = '';
|
|
119
123
|
if (streaming) {
|
|
120
124
|
const stream = await this.client.generate({ model, prompt: fullPrompt, stream: true });
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
const abort = () => stream.abort();
|
|
126
|
+
request.signal?.addEventListener('abort', abort, { once: true });
|
|
127
|
+
try {
|
|
128
|
+
for await (const chunk of stream) {
|
|
129
|
+
const token = typeof chunk === 'object' && chunk !== null && 'response' in chunk ? chunk.response : '';
|
|
130
|
+
if (!token)
|
|
131
|
+
continue;
|
|
132
|
+
result += token;
|
|
133
|
+
if (request.onToken)
|
|
134
|
+
request.onToken(token);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
request.signal?.removeEventListener('abort', abort);
|
|
128
139
|
}
|
|
129
140
|
}
|
|
130
141
|
else {
|
|
@@ -214,6 +225,19 @@ export async function ask(request) {
|
|
|
214
225
|
export async function listModels() {
|
|
215
226
|
return getAIClient().listModels();
|
|
216
227
|
}
|
|
228
|
+
export async function getOllamaStatus() {
|
|
229
|
+
const endpoint = (process.env.OLLAMA_HOST ?? 'http://127.0.0.1:11434').replace(/\/$/, '');
|
|
230
|
+
try {
|
|
231
|
+
const response = await fetch(`${endpoint}/api/version`, { signal: AbortSignal.timeout(1000) });
|
|
232
|
+
if (!response.ok)
|
|
233
|
+
return { endpoint };
|
|
234
|
+
const body = await response.json();
|
|
235
|
+
return { endpoint, version: typeof body.version === 'string' ? body.version : undefined };
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return { endpoint };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
217
241
|
/** Default model, from configuration — one source of truth. */
|
|
218
242
|
export function defaultModel() {
|
|
219
243
|
return loadConfig().model;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CommandCatalogEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
menuLabel: string;
|
|
5
|
+
options?: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare const commandCatalog: CommandCatalogEntry[];
|
|
8
|
+
export declare function commandDescription(name: string): string;
|
|
9
|
+
export declare function completionCommands(): string;
|
|
10
|
+
export declare function completionOptions(): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const commandCatalog = [
|
|
2
|
+
{ name: 'explain', description: 'Explain a concept or command', menuLabel: 'Explain' },
|
|
3
|
+
{ name: 'suggest', description: 'Get AI-powered suggestions', menuLabel: 'Suggest' },
|
|
4
|
+
{ name: 'fix', description: 'Get a fix for a coding issue or error', menuLabel: 'Fix' },
|
|
5
|
+
{ name: 'review', description: 'Review code in a file or directory', menuLabel: 'Review', options: ['--diff'] },
|
|
6
|
+
{ name: 'optimize', description: 'Optimize a file (e.g., package.json)', menuLabel: 'Optimize' },
|
|
7
|
+
{ name: 'security-check', description: 'Run a security check on code', menuLabel: 'Security Check', options: ['--strict'] },
|
|
8
|
+
{ name: 'generate', description: 'Generate code/tests for a file', menuLabel: 'Generate', options: ['--apply', '--output', '--overwrite'] },
|
|
9
|
+
{ name: 'init', description: 'Interactive setup/configuration wizard', menuLabel: 'Init (Setup)' },
|
|
10
|
+
{ name: 'status', description: 'Check Ollama connection and available models', menuLabel: 'Status' },
|
|
11
|
+
{ name: 'health', description: 'Run comprehensive health check', menuLabel: 'Health Check', options: ['--details'] },
|
|
12
|
+
{ name: 'metrics', description: 'Display CLI usage metrics', menuLabel: 'Metrics', options: ['--raw', '--reset'] },
|
|
13
|
+
{ name: 'project-type', description: 'Detect and print the current project type', menuLabel: 'Project Type' },
|
|
14
|
+
{ name: 'menu', description: 'Interactive command palette', menuLabel: 'Menu' },
|
|
15
|
+
{ name: 'completion', description: 'Generate shell completion script', menuLabel: 'Shell Completion' },
|
|
16
|
+
];
|
|
17
|
+
export function commandDescription(name) {
|
|
18
|
+
return commandCatalog.find((command) => command.name === name)?.description ?? name;
|
|
19
|
+
}
|
|
20
|
+
export function completionCommands() {
|
|
21
|
+
return commandCatalog.map((command) => command.name).join(' ');
|
|
22
|
+
}
|
|
23
|
+
export function completionOptions() {
|
|
24
|
+
const options = new Set(['--help', '--version', '--model', '--verbose', '--json', '--timeout']);
|
|
25
|
+
commandCatalog.forEach((command) => command.options?.forEach((option) => options.add(option)));
|
|
26
|
+
return [...options].join(' ');
|
|
27
|
+
}
|