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