@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.
Files changed (76) hide show
  1. package/.github/workflows/ci.yml +18 -238
  2. package/.github/workflows/contribution.yml +6 -141
  3. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  4. package/.github/workflows/labeler.yml +1 -0
  5. package/.github/workflows/security.yml +3 -0
  6. package/CHANGELOG.md +16 -4
  7. package/README.md +145 -40
  8. package/__tests__/cli-contract.test.ts +170 -0
  9. package/__tests__/core.test.ts +193 -1
  10. package/__tests__/diagnostics.test.ts +179 -0
  11. package/__tests__/file-workflows.test.ts +234 -0
  12. package/__tests__/interactive.test.ts +134 -0
  13. package/__tests__/setup.ts +1 -0
  14. package/__tests__/workflows.test.ts +27 -4
  15. package/dist/commands/generate.d.ts +6 -1
  16. package/dist/commands/generate.js +44 -11
  17. package/dist/commands/health.d.ts +4 -1
  18. package/dist/commands/health.js +59 -16
  19. package/dist/commands/init.js +18 -7
  20. package/dist/commands/menu.js +125 -100
  21. package/dist/commands/metrics.d.ts +5 -1
  22. package/dist/commands/metrics.js +49 -10
  23. package/dist/commands/optimize.js +1 -1
  24. package/dist/commands/review.d.ts +4 -1
  25. package/dist/commands/review.js +66 -9
  26. package/dist/commands/security-check.d.ts +4 -1
  27. package/dist/commands/security-check.js +100 -6
  28. package/dist/commands/status.js +44 -4
  29. package/dist/config/config.d.ts +5 -1
  30. package/dist/config/config.js +24 -7
  31. package/dist/core/ai.d.ts +11 -0
  32. package/dist/core/ai.js +33 -9
  33. package/dist/core/command-catalog.d.ts +10 -0
  34. package/dist/core/command-catalog.js +27 -0
  35. package/dist/core/command-runner.js +106 -21
  36. package/dist/core/logger.js +1 -0
  37. package/dist/core/metrics.d.ts +28 -0
  38. package/dist/core/metrics.js +79 -0
  39. package/dist/index.js +98 -24
  40. package/dist/utils/projectType.d.ts +7 -1
  41. package/dist/utils/projectType.js +91 -13
  42. package/docs/api/assets/highlight.css +4 -4
  43. package/docs/api/index.html +161 -39
  44. package/docs/api/media/CONTRIBUTING.md +60 -0
  45. package/docs/api/media/SECURITY.md +8 -0
  46. package/docs/api/media/dhruv-cli-preview.svg +42 -0
  47. package/docs/api/media/publishing-fix.md +34 -0
  48. package/docs/dhruv-cli-preview.svg +42 -0
  49. package/docs/index.html +631 -533
  50. package/docs/publishing-fix.md +34 -0
  51. package/package.json +1 -1
  52. package/src/commands/generate.ts +50 -11
  53. package/src/commands/health.ts +62 -17
  54. package/src/commands/init.ts +18 -7
  55. package/src/commands/menu.ts +54 -30
  56. package/src/commands/metrics.ts +53 -9
  57. package/src/commands/optimize.ts +1 -1
  58. package/src/commands/review.ts +72 -9
  59. package/src/commands/security-check.ts +111 -6
  60. package/src/commands/status.ts +43 -5
  61. package/src/config/config.ts +26 -7
  62. package/src/core/ai.ts +36 -8
  63. package/src/core/command-catalog.ts +37 -0
  64. package/src/core/command-runner.ts +108 -22
  65. package/src/core/logger.ts +1 -0
  66. package/src/core/metrics.ts +105 -0
  67. package/src/index.ts +97 -24
  68. package/src/utils/projectType.ts +85 -9
  69. package/tsconfig.json +1 -1
  70. package/.github/workflows/auto-assign.yml +0 -14
  71. package/.github/workflows/build-publish.yml +0 -154
  72. package/.github/workflows/deploy.yml +0 -336
  73. package/.github/workflows/monitoring.yml +0 -270
  74. package/PUBLISHING_FIX.md +0 -92
  75. package/logs/.8a99b6cf655346317fdbf29f4fffcf91131432f3-audit.json +0 -15
  76. package/logs/.eee104bf8fff5ecd38a6a2842df260de6470a7c3-audit.json +0 -15
@@ -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
- /** Reads a file or the code files of a directory (up to 10), concatenated. */
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 = fs
25
- .readdirSync(dir)
26
- .filter((f) => f.match(/\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/))
27
- .slice(0, 10);
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
- export async function review(fileOrDir) {
44
- const code = readCode(fileOrDir);
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 this code and provide feedback on code quality, best practices, potential issues, and suggestions for improvement. Here is the code to review:\n\nCODE_START\n${code}\nCODE_END\n\nPlease provide your review in a structured format with clear categories and actionable feedback.`,
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
  }),
@@ -1 +1,4 @@
1
- export declare function securityCheck(fileOrDir?: string): Promise<void>;
1
+ export interface SecurityCheckOptions {
2
+ strict?: boolean;
3
+ }
4
+ export declare function securityCheck(fileOrDir?: string, options?: SecurityCheckOptions): Promise<void>;
@@ -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 = fs
25
- .readdirSync(dir)
26
- .filter((f) => f.match(/\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/))
27
- .slice(0, 10);
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${code}`,
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
  }),
@@ -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('\nTo start Ollama, run: ollama serve'));
43
- console.log(chalk.yellow('To install a model, run: ollama pull llama2'));
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
  }
@@ -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>): void;
10
+ export declare function saveConfig(config: Partial<DhruvConfig>, options?: {
11
+ scope?: ConfigScope;
12
+ }): void;
@@ -1,16 +1,19 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- const CONFIG_FILE = path.join(process.cwd(), '.dhruv-config.json');
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
- export function loadConfig() {
11
- if (fs.existsSync(CONFIG_FILE)) {
13
+ function readConfigFile(file) {
14
+ if (fs.existsSync(file)) {
12
15
  try {
13
- const fileContent = fs.readFileSync(CONFIG_FILE, 'utf-8');
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 current = loadConfig();
46
- fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2));
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('not found')) {
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
- for await (const chunk of stream) {
122
- const token = typeof chunk === 'object' && chunk !== null && 'response' in chunk ? chunk.response : '';
123
- if (!token)
124
- continue;
125
- result += token;
126
- if (request.onToken)
127
- request.onToken(token);
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
+ }