@rahul05ranjan/dhruv-cli 1.3.0 → 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/ENTERPRISE.md +275 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +45 -17
- package/.github/ISSUE_TEMPLATE/documentation_issue.md +61 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +61 -9
- package/.github/ISSUE_TEMPLATE/security_vulnerability.md +74 -0
- package/.github/dependabot.yml +42 -2
- package/.github/pull_request_template.md +13 -0
- package/.github/workflows/ci.yml +51 -38
- package/.github/workflows/contribution.yml +41 -34
- package/.github/workflows/dependabot-auto-merge.yml +62 -2
- package/.github/workflows/labeler.yml +1 -0
- package/.github/workflows/release.yml +229 -0
- package/.github/workflows/security.yml +201 -0
- package/.releaserc.json +50 -0
- package/AGENTS.md +13 -0
- package/CHANGELOG.md +13 -0
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +104 -0
- package/__tests__/core.test.ts +439 -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 +62 -0
- package/__tests__/workflows.test.ts +118 -0
- package/dist/commands/explain.js +13 -44
- package/dist/commands/fix.js +13 -38
- package/dist/commands/generate.d.ts +6 -1
- package/dist/commands/generate.js +60 -55
- package/dist/commands/health.d.ts +4 -0
- package/dist/commands/health.js +419 -0
- package/dist/commands/init.js +56 -42
- package/dist/commands/menu.js +137 -24
- package/dist/commands/metrics.d.ts +5 -0
- package/dist/commands/metrics.js +80 -0
- package/dist/commands/optimize.js +42 -34
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +87 -43
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +109 -38
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +83 -0
- package/dist/commands/suggest.js +13 -39
- package/dist/config/config.d.ts +5 -1
- package/dist/config/config.js +53 -8
- package/dist/core/ai.d.ts +88 -2
- package/dist/core/ai.js +231 -30
- package/dist/core/command-catalog.d.ts +10 -0
- package/dist/core/command-catalog.js +27 -0
- package/dist/core/command-runner.d.ts +17 -0
- package/dist/core/command-runner.js +157 -0
- package/dist/core/logger.d.ts +40 -0
- package/dist/core/logger.js +139 -0
- package/dist/core/metrics.d.ts +62 -0
- package/dist/core/metrics.js +284 -0
- package/dist/core/prompts.d.ts +1 -0
- package/dist/core/prompts.js +121 -0
- package/dist/core/security.d.ts +34 -0
- package/dist/core/security.js +197 -0
- package/dist/index.js +84 -22
- package/dist/utils/projectType.d.ts +1 -1
- package/dist/utils/projectType.js +26 -7
- package/dist/utils/ux.d.ts +3 -0
- package/dist/utils/ux.js +15 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/api/.nojekyll +1 -0
- package/docs/api/assets/hierarchy.js +1 -0
- package/docs/api/assets/highlight.css +71 -0
- package/docs/api/assets/icons.js +18 -0
- package/docs/api/assets/icons.svg +1 -0
- package/docs/api/assets/main.js +60 -0
- package/docs/api/assets/navigation.js +1 -0
- package/docs/api/assets/search.js +1 -0
- package/docs/api/assets/style.css +1633 -0
- package/docs/api/hierarchy.html +1 -0
- package/docs/api/index.html +161 -0
- 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/api/modules.html +1 -0
- package/docs/dhruv-cli-preview.svg +42 -0
- package/docs/index.html +631 -533
- package/docs/publishing-fix.md +34 -0
- package/eslint.config.js +170 -0
- package/jest.config.json +37 -0
- package/lighthouserc.json +22 -0
- package/package.json +62 -8
- package/src/commands/explain.ts +13 -42
- package/src/commands/fix.ts +13 -31
- package/src/commands/generate.ts +68 -48
- package/src/commands/health.ts +485 -0
- package/src/commands/init.ts +57 -42
- package/src/commands/menu.ts +132 -24
- package/src/commands/metrics.ts +100 -0
- package/src/commands/optimize.ts +40 -28
- package/src/commands/review.ts +96 -37
- package/src/commands/security-check.ts +127 -33
- package/src/commands/status.ts +83 -0
- package/src/commands/suggest.ts +13 -32
- package/src/config/config.ts +60 -8
- package/src/core/ai.ts +265 -26
- package/src/core/command-catalog.ts +37 -0
- package/src/core/command-runner.ts +185 -0
- package/src/core/logger.ts +195 -0
- package/src/core/metrics.ts +335 -0
- package/src/core/prompts.ts +128 -0
- package/src/core/security.ts +243 -0
- package/src/index.ts +90 -22
- package/src/utils/projectType.ts +22 -7
- package/src/utils/ux.ts +18 -0
- package/test-suite.sh +147 -0
- package/tsconfig.json +4 -3
- package/typedoc.json +44 -0
- package/types/global.d.ts +13 -0
- package/validate-workflows.sh +270 -0
- package/.eslintignore +0 -1
- package/.eslintrc.cjs +0 -43
- package/.github/workflows/auto-assign.yml +0 -14
- package/src/core/ai.test.js +0 -40
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig } from '../config/config.js';
|
|
3
|
+
import { printSuccess, printError, printInfo } from '../utils/ux.js';
|
|
4
|
+
import { getOllamaStatus, listModels } from '../core/ai.js';
|
|
5
|
+
export async function status() {
|
|
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`);
|
|
45
|
+
printInfo(`Current configuration:`);
|
|
46
|
+
console.log(` Model: ${config.model}`);
|
|
47
|
+
console.log(` Response Format: ${config.responseFormat}`);
|
|
48
|
+
console.log(` Verbose: ${config.verbose}`);
|
|
49
|
+
console.log(` Theme: ${config.theme}\n`);
|
|
50
|
+
try {
|
|
51
|
+
printInfo('Testing Ollama connection...');
|
|
52
|
+
const models = await listModels();
|
|
53
|
+
printSuccess('✓ Ollama is running and accessible');
|
|
54
|
+
if (models.length > 0) {
|
|
55
|
+
printSuccess(`✓ Found ${models.length} available models:`);
|
|
56
|
+
models.forEach((name) => {
|
|
57
|
+
const isConfigured = name === config.model;
|
|
58
|
+
const status = isConfigured ? chalk.green('(configured)') : '';
|
|
59
|
+
console.log(` • ${name} ${status}`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
printError('✗ No models found');
|
|
64
|
+
console.log(chalk.yellow('Install a model using: ollama pull llama2'));
|
|
65
|
+
}
|
|
66
|
+
if (models.includes(config.model)) {
|
|
67
|
+
printSuccess(`✓ Configured model '${config.model}' is available`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
printError(`✗ Configured model '${config.model}' is not available`);
|
|
72
|
+
if (models.length > 0) {
|
|
73
|
+
console.log(chalk.yellow(`Available models: ${models.join(', ')}`));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
printError('✗ Ollama connection failed');
|
|
79
|
+
console.log(chalk.red(error.message));
|
|
80
|
+
console.log(chalk.yellow('\nTo start Ollama, run: ollama serve'));
|
|
81
|
+
console.log(chalk.yellow('To install a model, run: ollama pull llama2'));
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/commands/suggest.js
CHANGED
|
@@ -1,41 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
import { loadConfig } from '../config/config.js';
|
|
5
|
-
import { highlightCode, printError } from '../utils/ux.js';
|
|
1
|
+
import { runCommand } from '../core/command-runner.js';
|
|
2
|
+
import { getSystemMessage } from '../core/prompts.js';
|
|
6
3
|
export async function suggest(query) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
process.stdout.write(chalk.cyan(token));
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
process.stdout.write('\n');
|
|
22
|
-
if (typeof highlightCode === 'function' && streamed.match(/```[a-z]*[\s\S]*?```/)) {
|
|
23
|
-
// Highlight code blocks if present
|
|
24
|
-
const codeBlocks = streamed.match(/```([a-z]*)\n([\s\S]*?)```/g) || [];
|
|
25
|
-
for (const block of codeBlocks) {
|
|
26
|
-
const [, lang, code] = block.match(/```([a-z]*)\n([\s\S]*?)```/) || [];
|
|
27
|
-
if (code)
|
|
28
|
-
try {
|
|
29
|
-
console.log(highlightCode(code, lang || 'js'));
|
|
30
|
-
}
|
|
31
|
-
catch (err) {
|
|
32
|
-
console.error('Highlight error:', err);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
catch (err) {
|
|
38
|
-
printError('Failed to get suggestions.');
|
|
39
|
-
console.error(chalk.red(err.message));
|
|
40
|
-
}
|
|
4
|
+
await runCommand({
|
|
5
|
+
name: 'suggest',
|
|
6
|
+
input: { query },
|
|
7
|
+
header: '💡 Suggestions: ',
|
|
8
|
+
buildRequest: (input, model) => ({
|
|
9
|
+
prompt: input.query,
|
|
10
|
+
systemMessage: getSystemMessage('suggest'),
|
|
11
|
+
model,
|
|
12
|
+
}),
|
|
13
|
+
footer: `🔧 Need implementation help? Try: dhruv fix "${query}"`,
|
|
14
|
+
});
|
|
41
15
|
}
|
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,20 +1,65 @@
|
|
|
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
|
-
model: '
|
|
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(
|
|
12
|
-
|
|
13
|
+
function readConfigFile(file) {
|
|
14
|
+
if (fs.existsSync(file)) {
|
|
15
|
+
try {
|
|
16
|
+
const fileContent = fs.readFileSync(file, 'utf-8');
|
|
17
|
+
const parsedConfig = JSON.parse(fileContent);
|
|
18
|
+
return validateAndMergeConfig(parsedConfig);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
console.warn(`Warning: Invalid config file. Using defaults. Error: ${error.message}`);
|
|
22
|
+
return defaultConfig;
|
|
23
|
+
}
|
|
13
24
|
}
|
|
14
25
|
return defaultConfig;
|
|
15
26
|
}
|
|
16
|
-
export function
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
}
|
|
34
|
+
function validateAndMergeConfig(config) {
|
|
35
|
+
const validatedConfig = { ...defaultConfig };
|
|
36
|
+
// Validate model
|
|
37
|
+
if (config.model && typeof config.model === 'string') {
|
|
38
|
+
validatedConfig.model = config.model;
|
|
39
|
+
}
|
|
40
|
+
// Validate verbose
|
|
41
|
+
if (typeof config.verbose === 'boolean') {
|
|
42
|
+
validatedConfig.verbose = config.verbose;
|
|
43
|
+
}
|
|
44
|
+
// Validate responseFormat
|
|
45
|
+
if (config.responseFormat && ['text', 'json', 'markdown'].includes(config.responseFormat)) {
|
|
46
|
+
validatedConfig.responseFormat = config.responseFormat;
|
|
47
|
+
}
|
|
48
|
+
if (typeof config.timeoutMs === 'number' && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0) {
|
|
49
|
+
validatedConfig.timeoutMs = Math.round(config.timeoutMs);
|
|
50
|
+
}
|
|
51
|
+
// Validate theme
|
|
52
|
+
if (config.theme && ['default', 'dark', 'light', 'mono'].includes(config.theme)) {
|
|
53
|
+
validatedConfig.theme = config.theme;
|
|
54
|
+
}
|
|
55
|
+
return validatedConfig;
|
|
56
|
+
}
|
|
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));
|
|
19
64
|
}
|
|
20
65
|
// Use .js extension for ESM compatibility if imported elsewhere
|
package/dist/core/ai.d.ts
CHANGED
|
@@ -1,5 +1,91 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The AI module: everything about talking to a local model lives here.
|
|
3
|
+
*
|
|
4
|
+
* Interface (the only surface callers and tests cross):
|
|
5
|
+
* ask(request) -> full response, optionally streaming tokens via onToken
|
|
6
|
+
* listModels() -> available model names
|
|
7
|
+
*
|
|
8
|
+
* Everything else — connection handling, streaming, caching, error
|
|
9
|
+
* translation — is implementation. Two adapters satisfy the interface:
|
|
10
|
+
* the HTTP adapter (production, talks to the local Ollama server) and the
|
|
11
|
+
* in-memory adapter (tests). No third adapter exists.
|
|
12
|
+
*/
|
|
13
|
+
import { Ollama } from 'ollama';
|
|
14
|
+
/** Typed errors: the runner maps these to user-facing hints, never by string matching. */
|
|
15
|
+
export type AIError = {
|
|
16
|
+
kind: 'connection';
|
|
17
|
+
cause: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'model-not-found';
|
|
20
|
+
model: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'empty-response';
|
|
23
|
+
model: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'timeout';
|
|
26
|
+
timeoutMs: number;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'cancelled';
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'request';
|
|
31
|
+
cause: string;
|
|
32
|
+
};
|
|
33
|
+
export interface AIRequest {
|
|
2
34
|
prompt: string;
|
|
35
|
+
systemMessage?: string;
|
|
36
|
+
context?: string;
|
|
3
37
|
model?: string;
|
|
4
38
|
onToken?: (token: string) => void;
|
|
5
|
-
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}
|
|
41
|
+
/** The seam. Both adapters implement this; commands and tests depend on it, never on Ollama. */
|
|
42
|
+
export interface AIClient {
|
|
43
|
+
ask(request: AIRequest): Promise<string>;
|
|
44
|
+
listModels(): Promise<string[]>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* HTTP adapter: production. Standardizes on the Ollama client package —
|
|
48
|
+
* the raw-HTTP path existed only to work around a LangChain prompt-template
|
|
49
|
+
* issue, and that integration is gone.
|
|
50
|
+
*/
|
|
51
|
+
export declare class OllamaAIClient implements AIClient {
|
|
52
|
+
private client;
|
|
53
|
+
constructor(client?: Ollama);
|
|
54
|
+
ask(request: AIRequest): Promise<string>;
|
|
55
|
+
listModels(): Promise<string[]>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* In-memory adapter: tests. Satisfies the same interface with no network,
|
|
59
|
+
* which is what makes AI behavior testable without Ollama installed.
|
|
60
|
+
*/
|
|
61
|
+
export declare class InMemoryAIClient implements AIClient {
|
|
62
|
+
private responses;
|
|
63
|
+
private store;
|
|
64
|
+
/** Failures to simulate, keyed by prompt substring. */
|
|
65
|
+
failures: Map<string, AIError>;
|
|
66
|
+
/** Count of computations performed (not cache reads) — lets tests observe cache hits. */
|
|
67
|
+
computations: number;
|
|
68
|
+
/** Simulated clock for expiry tests. */
|
|
69
|
+
now: () => number;
|
|
70
|
+
/** TTL override for tests; defaults to the production expiry. */
|
|
71
|
+
ttlMs: number;
|
|
72
|
+
constructor(responses?: Map<string, string>);
|
|
73
|
+
ask(request: AIRequest): Promise<string>;
|
|
74
|
+
listModels(): Promise<string[]>;
|
|
75
|
+
}
|
|
76
|
+
export declare function getAIClient(): AIClient;
|
|
77
|
+
/** Test seam setter: swaps the adapter the module hands out. */
|
|
78
|
+
export declare function setAIClient(client: AIClient): void;
|
|
79
|
+
/**
|
|
80
|
+
* The interface commands call. Kept as module functions so callers don't
|
|
81
|
+
* reach for a client object; the client is resolved internally.
|
|
82
|
+
*/
|
|
83
|
+
export declare function ask(request: AIRequest): Promise<string>;
|
|
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>;
|
|
90
|
+
/** Default model, from configuration — one source of truth. */
|
|
91
|
+
export declare function defaultModel(): string;
|
package/dist/core/ai.js
CHANGED
|
@@ -1,43 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI module: everything about talking to a local model lives here.
|
|
3
|
+
*
|
|
4
|
+
* Interface (the only surface callers and tests cross):
|
|
5
|
+
* ask(request) -> full response, optionally streaming tokens via onToken
|
|
6
|
+
* listModels() -> available model names
|
|
7
|
+
*
|
|
8
|
+
* Everything else — connection handling, streaming, caching, error
|
|
9
|
+
* translation — is implementation. Two adapters satisfy the interface:
|
|
10
|
+
* the HTTP adapter (production, talks to the local Ollama server) and the
|
|
11
|
+
* in-memory adapter (tests). No third adapter exists.
|
|
12
|
+
*/
|
|
1
13
|
import { Ollama } from 'ollama';
|
|
2
14
|
import fs from 'fs';
|
|
3
15
|
import path from 'path';
|
|
4
16
|
import crypto from 'crypto';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return path.join(
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
import { loadConfig } from '../config/config.js';
|
|
18
|
+
import { metricsCollector } from './metrics.js';
|
|
19
|
+
const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
20
|
+
const MAX_CACHE_FILES = 100;
|
|
21
|
+
function cacheDir() {
|
|
22
|
+
return path.join(process.cwd(), '.dhruv-cache');
|
|
23
|
+
}
|
|
24
|
+
function cacheKey(request, model) {
|
|
25
|
+
const hash = crypto
|
|
26
|
+
.createHash('sha256')
|
|
27
|
+
.update(`${model}:${request.systemMessage ?? ''}:${request.context ?? ''}:${request.prompt}`)
|
|
28
|
+
.digest('hex');
|
|
29
|
+
return path.join(cacheDir(), hash);
|
|
30
|
+
}
|
|
31
|
+
function readCache(request, model) {
|
|
32
|
+
const file = cacheKey(request, model);
|
|
33
|
+
try {
|
|
34
|
+
const cached = fs.readFileSync(file, 'utf-8');
|
|
35
|
+
const stats = fs.statSync(file);
|
|
36
|
+
if (Date.now() - stats.mtimeMs > CACHE_EXPIRY_MS) {
|
|
37
|
+
fs.unlinkSync(file);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
18
40
|
return cached;
|
|
19
41
|
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function writeCache(request, model, response) {
|
|
47
|
+
try {
|
|
48
|
+
const dir = cacheDir();
|
|
49
|
+
if (!fs.existsSync(dir))
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(cacheKey(request, model), response);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Cache write failures never fail the request.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Wired up (the old cleanupCache was never called); runs opportunistically after a cache write. */
|
|
58
|
+
function cleanupCache() {
|
|
20
59
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
60
|
+
const dir = cacheDir();
|
|
61
|
+
if (!fs.existsSync(dir))
|
|
62
|
+
return;
|
|
63
|
+
const entries = fs
|
|
64
|
+
.readdirSync(dir)
|
|
65
|
+
.map((name) => {
|
|
66
|
+
const file = path.join(dir, name);
|
|
67
|
+
return { file, mtimeMs: fs.statSync(file).mtimeMs };
|
|
68
|
+
})
|
|
69
|
+
.filter((entry) => {
|
|
70
|
+
if (Date.now() - entry.mtimeMs > CACHE_EXPIRY_MS) {
|
|
71
|
+
fs.unlinkSync(entry.file);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
})
|
|
76
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
77
|
+
const excess = entries.length - MAX_CACHE_FILES;
|
|
78
|
+
if (excess > 0) {
|
|
79
|
+
for (const entry of entries.slice(0, excess))
|
|
80
|
+
fs.unlinkSync(entry.file);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Ignore cleanup errors.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function toAIError(err, model) {
|
|
88
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
89
|
+
if (message.includes('ECONNREFUSED') || message.includes('fetch failed') || message.includes('ENOTFOUND')) {
|
|
90
|
+
return { kind: 'connection', cause: message };
|
|
91
|
+
}
|
|
92
|
+
if (message.includes('returned empty response'))
|
|
93
|
+
return { kind: 'empty-response', model };
|
|
94
|
+
if (message.includes('not found'))
|
|
95
|
+
return { kind: 'model-not-found', model };
|
|
96
|
+
return { kind: 'request', cause: message };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* HTTP adapter: production. Standardizes on the Ollama client package —
|
|
100
|
+
* the raw-HTTP path existed only to work around a LangChain prompt-template
|
|
101
|
+
* issue, and that integration is gone.
|
|
102
|
+
*/
|
|
103
|
+
export class OllamaAIClient {
|
|
104
|
+
constructor(client) {
|
|
105
|
+
this.client = client ?? new Ollama();
|
|
106
|
+
}
|
|
107
|
+
async ask(request) {
|
|
108
|
+
const model = request.model ?? loadConfig().model;
|
|
109
|
+
const fullPrompt = request.systemMessage
|
|
110
|
+
? `System: ${request.systemMessage}\n\n${request.context ? `Context: ${request.context}\n\n` : ''}Query: ${request.prompt}`
|
|
111
|
+
: request.prompt;
|
|
112
|
+
const cached = readCache(request, model);
|
|
113
|
+
if (cached !== undefined) {
|
|
114
|
+
metricsCollector.recordCacheHit('ai-response');
|
|
115
|
+
if (request.onToken)
|
|
116
|
+
request.onToken(cached);
|
|
117
|
+
return cached;
|
|
118
|
+
}
|
|
119
|
+
metricsCollector.recordCacheMiss('ai-response');
|
|
120
|
+
try {
|
|
121
|
+
const streaming = Boolean(request.onToken);
|
|
122
|
+
let result = '';
|
|
123
|
+
if (streaming) {
|
|
124
|
+
const stream = await this.client.generate({ model, prompt: fullPrompt, stream: true });
|
|
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);
|
|
139
|
+
}
|
|
29
140
|
}
|
|
30
|
-
else
|
|
31
|
-
|
|
141
|
+
else {
|
|
142
|
+
const response = await this.client.generate({ model, prompt: fullPrompt, stream: false });
|
|
143
|
+
result = response.response ?? '';
|
|
32
144
|
}
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
145
|
+
if (!result.trim()) {
|
|
146
|
+
throw new Error(`Model '${model}' not found or returned empty response`);
|
|
147
|
+
}
|
|
148
|
+
writeCache(request, model, result.trim());
|
|
149
|
+
cleanupCache();
|
|
150
|
+
return result.trim();
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
throw toAIError(err, model);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async listModels() {
|
|
157
|
+
try {
|
|
158
|
+
const models = await this.client.list();
|
|
159
|
+
return (models.models ?? []).map((m) => m.name);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
throw toAIError(err, 'unknown');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* In-memory adapter: tests. Satisfies the same interface with no network,
|
|
168
|
+
* which is what makes AI behavior testable without Ollama installed.
|
|
169
|
+
*/
|
|
170
|
+
export class InMemoryAIClient {
|
|
171
|
+
constructor(responses = new Map()) {
|
|
172
|
+
this.responses = responses;
|
|
173
|
+
this.store = new Map();
|
|
174
|
+
/** Failures to simulate, keyed by prompt substring. */
|
|
175
|
+
this.failures = new Map();
|
|
176
|
+
/** Count of computations performed (not cache reads) — lets tests observe cache hits. */
|
|
177
|
+
this.computations = 0;
|
|
178
|
+
/** Simulated clock for expiry tests. */
|
|
179
|
+
this.now = () => Date.now();
|
|
180
|
+
/** TTL override for tests; defaults to the production expiry. */
|
|
181
|
+
this.ttlMs = CACHE_EXPIRY_MS;
|
|
182
|
+
}
|
|
183
|
+
async ask(request) {
|
|
184
|
+
const model = request.model ?? 'test-model';
|
|
185
|
+
for (const [substring, failure] of this.failures) {
|
|
186
|
+
if (request.prompt.includes(substring))
|
|
187
|
+
throw failure;
|
|
188
|
+
}
|
|
189
|
+
const key = `${model}:${request.systemMessage ?? ''}:${request.context ?? ''}:${request.prompt}`;
|
|
190
|
+
const hit = this.store.get(key);
|
|
191
|
+
if (hit && this.now() - hit.createdAt <= this.ttlMs) {
|
|
192
|
+
if (request.onToken)
|
|
193
|
+
request.onToken(hit.response);
|
|
194
|
+
return hit.response;
|
|
36
195
|
}
|
|
37
|
-
|
|
38
|
-
|
|
196
|
+
this.computations += 1;
|
|
197
|
+
const response = this.responses.get(request.prompt) ?? `response:${request.prompt}`;
|
|
198
|
+
this.store.set(key, { response, createdAt: this.now() });
|
|
199
|
+
if (request.onToken)
|
|
200
|
+
request.onToken(response);
|
|
201
|
+
return response;
|
|
39
202
|
}
|
|
40
|
-
|
|
41
|
-
|
|
203
|
+
async listModels() {
|
|
204
|
+
return ['test-model', 'other-model'];
|
|
42
205
|
}
|
|
43
206
|
}
|
|
207
|
+
/** Default client: the HTTP adapter. Tests inject InMemoryAIClient instead. */
|
|
208
|
+
let defaultClient;
|
|
209
|
+
export function getAIClient() {
|
|
210
|
+
if (!defaultClient)
|
|
211
|
+
defaultClient = new OllamaAIClient();
|
|
212
|
+
return defaultClient;
|
|
213
|
+
}
|
|
214
|
+
/** Test seam setter: swaps the adapter the module hands out. */
|
|
215
|
+
export function setAIClient(client) {
|
|
216
|
+
defaultClient = client;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The interface commands call. Kept as module functions so callers don't
|
|
220
|
+
* reach for a client object; the client is resolved internally.
|
|
221
|
+
*/
|
|
222
|
+
export async function ask(request) {
|
|
223
|
+
return getAIClient().ask(request);
|
|
224
|
+
}
|
|
225
|
+
export async function listModels() {
|
|
226
|
+
return getAIClient().listModels();
|
|
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
|
+
}
|
|
241
|
+
/** Default model, from configuration — one source of truth. */
|
|
242
|
+
export function defaultModel() {
|
|
243
|
+
return loadConfig().model;
|
|
244
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AIRequest } from '../core/ai.js';
|
|
2
|
+
/** What makes a command distinct. The runner owns everything else. */
|
|
3
|
+
export interface CommandSpec {
|
|
4
|
+
/** Commander command name, for validation, logging, and metrics. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** The user-facing arguments, validated as a unit. */
|
|
7
|
+
input: Record<string, string>;
|
|
8
|
+
/** Builds the AI request from validated input. */
|
|
9
|
+
buildRequest: (input: Record<string, string>, model: string) => AIRequest;
|
|
10
|
+
/** Header line under the banner, e.g. "📚 Explanation:". */
|
|
11
|
+
header: string;
|
|
12
|
+
/** Footer hint shown after success. */
|
|
13
|
+
footer?: string;
|
|
14
|
+
/** Post-processing on the full response (e.g. saving generated test files). */
|
|
15
|
+
onComplete?: (response: string, input: Record<string, string>) => void;
|
|
16
|
+
}
|
|
17
|
+
export declare function runCommand(spec: CommandSpec): Promise<void>;
|