@rahul05ranjan/dhruv-cli 1.4.6 → 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/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 +9 -4
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +104 -0
- package/__tests__/core.test.ts +121 -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 +1 -0
- package/__tests__/workflows.test.ts +27 -4
- package/dist/commands/generate.d.ts +6 -1
- package/dist/commands/generate.js +18 -4
- package/dist/commands/health.d.ts +4 -1
- package/dist/commands/health.js +59 -16
- package/dist/commands/init.js +11 -2
- package/dist/commands/menu.js +125 -100
- package/dist/commands/metrics.d.ts +5 -1
- package/dist/commands/metrics.js +37 -8
- package/dist/commands/optimize.js +1 -1
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +48 -8
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +67 -6
- package/dist/commands/status.js +40 -2
- 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 +100 -21
- package/dist/core/logger.js +1 -0
- package/dist/core/metrics.d.ts +28 -0
- package/dist/core/metrics.js +78 -0
- package/dist/index.js +46 -24
- package/dist/utils/projectType.d.ts +1 -1
- package/dist/utils/projectType.js +26 -7
- 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 +23 -4
- package/src/commands/health.ts +62 -17
- package/src/commands/init.ts +11 -2
- package/src/commands/menu.ts +54 -30
- package/src/commands/metrics.ts +42 -7
- package/src/commands/optimize.ts +1 -1
- package/src/commands/review.ts +53 -8
- package/src/commands/security-check.ts +80 -6
- package/src/commands/status.ts +39 -3
- 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 +102 -22
- package/src/core/logger.ts +1 -0
- package/src/core/metrics.ts +103 -0
- package/src/index.ts +45 -24
- package/src/utils/projectType.ts +22 -7
- 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/README.md
CHANGED
|
@@ -1,61 +1,166 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/rahul05ranjan/dhruv-cli">
|
|
3
|
+
<strong>⚡ Dhruv CLI</strong>
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
A local-first AI command center for your terminal.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
Ask better questions. Understand unfamiliar code. Ship with confidence.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="https://rahul05ranjan.github.io/dhruv-cli/">Website</a>
|
|
17
|
+
·
|
|
18
|
+
<a href="https://www.npmjs.com/package/@rahul05ranjan/dhruv-cli">npm</a>
|
|
19
|
+
·
|
|
20
|
+
<a href="https://github.com/rahul05ranjan/dhruv-cli/issues">Issues</a>
|
|
21
|
+
·
|
|
22
|
+
<a href="CONTRIBUTING.md">Contributing</a>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<p align="center">
|
|
26
|
+
<a href="https://github.com/rahul05ranjan/dhruv-cli/actions/workflows/deploy.yml"><img src="https://github.com/rahul05ranjan/dhruv-cli/actions/workflows/deploy.yml/badge.svg" alt="Deployment status"></a>
|
|
27
|
+
<a href="https://www.npmjs.com/package/@rahul05ranjan/dhruv-cli"><img src="https://img.shields.io/npm/v/%40rahul05ranjan%2Fdhruv-cli?style=flat-square&label=npm" alt="npm version"></a>
|
|
28
|
+
<a href="https://www.npmjs.com/package/@rahul05ranjan/dhruv-cli"><img src="https://img.shields.io/npm/dm/%40rahul05ranjan%2Fdhruv-cli?style=flat-square&label=downloads" alt="npm downloads"></a>
|
|
29
|
+
<img src="https://img.shields.io/github/license/rahul05ranjan/dhruv-cli?style=flat-square" alt="MIT license">
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<img src="docs/dhruv-cli-preview.svg" alt="Dhruv CLI terminal preview" width="760">
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
## Why Dhruv?
|
|
37
|
+
|
|
38
|
+
Dhruv brings a focused AI toolkit into the terminal, powered by local Ollama models. It keeps your workflow close to the codebase, works without a hosted AI account, and turns everyday developer questions into fast, actionable output.
|
|
39
|
+
|
|
40
|
+
<table>
|
|
41
|
+
<tr>
|
|
42
|
+
<td width="33%"><strong>✦ Think with you</strong><br>Explain concepts, suggest approaches, and turn errors into clear next steps.</td>
|
|
43
|
+
<td width="33%"><strong>◈ Inspect deeply</strong><br>Review code, optimize files, and scan projects for common security risks.</td>
|
|
44
|
+
<td width="33%"><strong>⌁ Fit your flow</strong><br>Use an interactive menu, shell completion, JSON output, or your own plugins.</td>
|
|
45
|
+
</tr>
|
|
46
|
+
</table>
|
|
47
|
+
|
|
48
|
+
## Start in 60 seconds
|
|
49
|
+
|
|
50
|
+
### 1. Install Dhruv
|
|
51
|
+
|
|
25
52
|
```bash
|
|
26
53
|
npm install -g @rahul05ranjan/dhruv-cli
|
|
27
54
|
```
|
|
28
55
|
|
|
29
|
-
|
|
56
|
+
### 2. Start a local model
|
|
57
|
+
|
|
58
|
+
Install [Ollama](https://ollama.com/), then run:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
ollama serve
|
|
62
|
+
ollama pull gemma3:270m
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 3. Configure once, then go
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
dhruv init
|
|
69
|
+
dhruv suggest "how should I structure this Node.js service?"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Dhruv requires Node.js 18 or newer. The default model is `gemma3:270m`; choose any model available in your Ollama installation during setup.
|
|
73
|
+
|
|
74
|
+
## A command surface built for shipping
|
|
75
|
+
|
|
76
|
+
| Command | What it does |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `dhruv suggest <query>` | Generate practical suggestions for a development task |
|
|
79
|
+
| `dhruv explain <query>` | Explain a concept, command, or unfamiliar error |
|
|
80
|
+
| `dhruv fix <query>` | Analyze a coding issue and propose a fix |
|
|
81
|
+
| `dhruv review <file-or-dir>` | Review up to ten code files for quality and maintainability; add `--diff` for uncommitted changes |
|
|
82
|
+
| `dhruv optimize <file>` | Find actionable improvements for source or configuration files |
|
|
83
|
+
| `dhruv security-check [file-or-dir]` | Run a redacted local security analysis; add `--strict` for CI failure on high-confidence findings |
|
|
84
|
+
| `dhruv generate <type> <target>` | Preview generated tests by default; use `--apply`, `--output`, or `--overwrite` to write safely |
|
|
85
|
+
| `dhruv status` | Check Ollama connectivity and configured models |
|
|
86
|
+
| `dhruv health` | Show a concise health summary; use `--details` for diagnostics |
|
|
87
|
+
| `dhruv metrics` | Inspect local usage and performance metrics; use `--raw` or `--reset` explicitly |
|
|
88
|
+
| `dhruv project-type` | Detect the current project type |
|
|
89
|
+
| `dhruv menu` | Open the interactive command palette |
|
|
90
|
+
| `dhruv completion [shell]` | Generate Bash, Zsh, or Fish completion |
|
|
91
|
+
|
|
92
|
+
### Common workflows
|
|
30
93
|
|
|
31
|
-
## 📝 Usage Examples
|
|
32
94
|
```bash
|
|
33
|
-
|
|
95
|
+
# Understand and plan
|
|
34
96
|
dhruv explain "git rebase vs merge"
|
|
35
|
-
dhruv
|
|
97
|
+
dhruv suggest "deploy a React app to Vercel"
|
|
98
|
+
|
|
99
|
+
# Improve an existing project
|
|
36
100
|
dhruv review src/
|
|
101
|
+
dhruv review --diff .
|
|
37
102
|
dhruv optimize package.json
|
|
38
103
|
dhruv security-check src/
|
|
39
|
-
dhruv
|
|
40
|
-
|
|
41
|
-
|
|
104
|
+
dhruv security-check src/ --strict
|
|
105
|
+
|
|
106
|
+
# Generate and automate
|
|
107
|
+
dhruv generate tests src/utils/helpers.js # preview only
|
|
108
|
+
dhruv generate tests src/utils/helpers.js --apply
|
|
109
|
+
dhruv completion zsh > ~/.zsh/completions/_dhruv
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Configuration that stays out of your way
|
|
113
|
+
|
|
114
|
+
Run `dhruv init` to set the Ollama model, response format, verbosity, terminal theme, and whether settings are project-local or user-global. Local configuration is stored in `.dhruv-config.json`; global settings live under your user config directory.
|
|
115
|
+
|
|
116
|
+
For one-off runs, use global flags:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
dhruv suggest "summarize this migration" --model llama3.2 --json
|
|
120
|
+
dhruv review src/ --verbose
|
|
121
|
+
dhruv explain "what changed?" --timeout 60000
|
|
42
122
|
```
|
|
43
123
|
|
|
44
|
-
##
|
|
45
|
-
|
|
124
|
+
## Extend it with plugins
|
|
125
|
+
|
|
126
|
+
Drop an ESM module into your project's `plugins/` directory. Dhruv loads `.js` plugins before parsing the command line, so you can add commands without changing the core CLI.
|
|
127
|
+
|
|
46
128
|
```js
|
|
47
129
|
// plugins/hello.js
|
|
48
130
|
export default (program) => {
|
|
49
|
-
program
|
|
131
|
+
program
|
|
132
|
+
.command('hello-plugin')
|
|
133
|
+
.description('Say hello from a project plugin')
|
|
134
|
+
.action(() => console.log('Hello from Dhruv!'));
|
|
50
135
|
};
|
|
51
136
|
```
|
|
52
137
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
138
|
+
Then run:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
dhruv hello-plugin
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Documentation & project guides
|
|
56
145
|
|
|
57
|
-
|
|
58
|
-
|
|
146
|
+
| Resource | Link |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| Product site | [rahul05ranjan.github.io/dhruv-cli](https://rahul05ranjan.github.io/dhruv-cli/) |
|
|
149
|
+
| API reference | [Generated TypeDoc](https://rahul05ranjan.github.io/dhruv-cli/api/) |
|
|
150
|
+
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
|
151
|
+
| Security policy | [SECURITY.md](SECURITY.md) |
|
|
152
|
+
| Publishing notes | [docs/publishing-fix.md](docs/publishing-fix.md) |
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm install
|
|
158
|
+
npm run type-check
|
|
159
|
+
npm test
|
|
160
|
+
npm run lint
|
|
161
|
+
npm run build
|
|
162
|
+
```
|
|
59
163
|
|
|
60
164
|
## License
|
|
61
|
-
|
|
165
|
+
|
|
166
|
+
MIT © [Rahul Ranjan](https://github.com/rahul05ranjan)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const repoRoot = resolve(__dirname, '..');
|
|
8
|
+
// Package/relative specifiers so Windows does not pass `D:\...` into the ESM loader.
|
|
9
|
+
const sourceEntry = './src/index.ts';
|
|
10
|
+
const loaderEntry = 'ts-node/esm';
|
|
11
|
+
|
|
12
|
+
describe('CLI output contract', () => {
|
|
13
|
+
it('keeps shell completion stdout free of startup telemetry', async () => {
|
|
14
|
+
const result = await execFileAsync(
|
|
15
|
+
process.execPath,
|
|
16
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'bash'],
|
|
17
|
+
{
|
|
18
|
+
cwd: repoRoot,
|
|
19
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect(result.stdout).toMatch(/^#!\/bin\/bash/);
|
|
24
|
+
expect(result.stdout).not.toContain('Dhruv CLI starting');
|
|
25
|
+
expect(result.stdout).not.toContain('\u001b[');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('includes diagnostic commands and shared options in completion scripts', async () => {
|
|
29
|
+
const result = await execFileAsync(
|
|
30
|
+
process.execPath,
|
|
31
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'bash'],
|
|
32
|
+
{
|
|
33
|
+
cwd: repoRoot,
|
|
34
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(result.stdout).toContain('status');
|
|
39
|
+
expect(result.stdout).toContain('--json');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects unsupported completion shells', async () => {
|
|
43
|
+
await expect(execFileAsync(
|
|
44
|
+
process.execPath,
|
|
45
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'powershell'],
|
|
46
|
+
{ cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
|
|
47
|
+
)).rejects.toMatchObject({ code: 2 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('documents strict security checks in command help', async () => {
|
|
51
|
+
const result = await execFileAsync(
|
|
52
|
+
process.execPath,
|
|
53
|
+
['--loader', loaderEntry, sourceEntry, 'security-check', '--help'],
|
|
54
|
+
{
|
|
55
|
+
cwd: repoRoot,
|
|
56
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(result.stdout).toContain('--strict');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('documents safe generation controls in command help', async () => {
|
|
64
|
+
const result = await execFileAsync(
|
|
65
|
+
process.execPath,
|
|
66
|
+
['--loader', loaderEntry, sourceEntry, 'generate', '--help'],
|
|
67
|
+
{
|
|
68
|
+
cwd: repoRoot,
|
|
69
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(result.stdout).toContain('--apply');
|
|
74
|
+
expect(result.stdout).toContain('--output');
|
|
75
|
+
expect(result.stdout).toContain('--overwrite');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('documents explicit metrics export and reset controls', async () => {
|
|
79
|
+
const result = await execFileAsync(
|
|
80
|
+
process.execPath,
|
|
81
|
+
['--loader', loaderEntry, sourceEntry, 'metrics', '--help'],
|
|
82
|
+
{
|
|
83
|
+
cwd: repoRoot,
|
|
84
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(result.stdout).toContain('--raw');
|
|
89
|
+
expect(result.stdout).toContain('--reset');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('documents detailed health diagnostics as an explicit option', async () => {
|
|
93
|
+
const result = await execFileAsync(
|
|
94
|
+
process.execPath,
|
|
95
|
+
['--loader', loaderEntry, sourceEntry, 'health', '--help'],
|
|
96
|
+
{
|
|
97
|
+
cwd: repoRoot,
|
|
98
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(result.stdout).toContain('--details');
|
|
103
|
+
});
|
|
104
|
+
});
|
package/__tests__/core.test.ts
CHANGED
|
@@ -185,6 +185,30 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
185
185
|
|
|
186
186
|
mockExistsSync.mockRestore();
|
|
187
187
|
});
|
|
188
|
+
|
|
189
|
+
it('should detect a TypeScript Node project', () => {
|
|
190
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
191
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'package.json');
|
|
192
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
193
|
+
mockReadFileSync.mockReturnValue(JSON.stringify({ devDependencies: { typescript: '^5.0.0' } }));
|
|
194
|
+
|
|
195
|
+
expect(detectProjectType()).toBe('node-typescript');
|
|
196
|
+
|
|
197
|
+
mockExistsSync.mockRestore();
|
|
198
|
+
mockReadFileSync.mockRestore();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should return unknown instead of throwing for malformed package metadata', () => {
|
|
202
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
203
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'package.json');
|
|
204
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
205
|
+
mockReadFileSync.mockReturnValue('{ malformed');
|
|
206
|
+
|
|
207
|
+
expect(detectProjectType()).toBe('unknown');
|
|
208
|
+
|
|
209
|
+
mockExistsSync.mockRestore();
|
|
210
|
+
mockReadFileSync.mockRestore();
|
|
211
|
+
});
|
|
188
212
|
});
|
|
189
213
|
|
|
190
214
|
describe('System Message Templates', () => {
|
|
@@ -287,6 +311,41 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
287
311
|
expect(completed).toBe('the answer');
|
|
288
312
|
});
|
|
289
313
|
|
|
314
|
+
it('shows the AI response to the user', async () => {
|
|
315
|
+
const output: string[] = [];
|
|
316
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
317
|
+
output.push(String(chunk));
|
|
318
|
+
return true;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await runCommand(makeSpec());
|
|
322
|
+
|
|
323
|
+
expect(output.join('')).toContain('the answer');
|
|
324
|
+
write.mockRestore();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('emits one structured result in JSON mode', async () => {
|
|
328
|
+
saveConfig({ responseFormat: 'json' });
|
|
329
|
+
const output: string[] = [];
|
|
330
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
331
|
+
output.push(String(chunk));
|
|
332
|
+
return true;
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
await runCommand(makeSpec());
|
|
337
|
+
const result = JSON.parse(output.join('')) as Record<string, unknown>;
|
|
338
|
+
expect(result).toMatchObject({
|
|
339
|
+
ok: true,
|
|
340
|
+
command: 'explain',
|
|
341
|
+
response: 'the answer',
|
|
342
|
+
});
|
|
343
|
+
} finally {
|
|
344
|
+
write.mockRestore();
|
|
345
|
+
saveConfig({ responseFormat: 'text' });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
290
349
|
it('short-circuits on validation failure before the AI call', async () => {
|
|
291
350
|
const computationsBefore = client.computations;
|
|
292
351
|
await runCommand(makeSpec({ input: { query: '<script>alert(1)</script>' } }));
|
|
@@ -306,6 +365,68 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
306
365
|
const { printError } = await import('../src/utils/ux');
|
|
307
366
|
expect(jest.mocked(printError).mock.calls.length).toBeGreaterThan(0);
|
|
308
367
|
});
|
|
368
|
+
|
|
369
|
+
it('marks the process unsuccessful when an AI command fails', async () => {
|
|
370
|
+
const originalExitCode = process.exitCode;
|
|
371
|
+
process.exitCode = undefined;
|
|
372
|
+
client.failures.set('happy', { kind: 'connection', cause: 'ECONNREFUSED' });
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
await runCommand(makeSpec());
|
|
376
|
+
expect(process.exitCode).toBe(1);
|
|
377
|
+
} finally {
|
|
378
|
+
process.exitCode = originalExitCode;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('treats an empty AI response as a failed command', async () => {
|
|
383
|
+
setAIClient(new InMemoryAIClient(new Map([['happy', '']])));
|
|
384
|
+
process.exitCode = undefined;
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
await runCommand(makeSpec());
|
|
388
|
+
expect(process.exitCode).toBe(1);
|
|
389
|
+
} finally {
|
|
390
|
+
process.exitCode = undefined;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('times out a long-running AI request with a failing exit code', async () => {
|
|
395
|
+
saveConfig({ timeoutMs: 5 });
|
|
396
|
+
setAIClient({
|
|
397
|
+
ask: () => new Promise<string>(() => {}),
|
|
398
|
+
listModels: async () => [],
|
|
399
|
+
});
|
|
400
|
+
process.exitCode = undefined;
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
await runCommand(makeSpec());
|
|
404
|
+
expect(process.exitCode).toBe(1);
|
|
405
|
+
} finally {
|
|
406
|
+
saveConfig({ timeoutMs: 45000 });
|
|
407
|
+
process.exitCode = undefined;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('cancels an in-flight request on Ctrl-C', async () => {
|
|
412
|
+
saveConfig({ timeoutMs: 1000 });
|
|
413
|
+
setAIClient({
|
|
414
|
+
ask: () => new Promise<string>(() => {}),
|
|
415
|
+
listModels: async () => [],
|
|
416
|
+
});
|
|
417
|
+
process.exitCode = undefined;
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const running = runCommand(makeSpec());
|
|
421
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
422
|
+
process.emit('SIGINT');
|
|
423
|
+
await running;
|
|
424
|
+
expect(process.exitCode).toBe(130);
|
|
425
|
+
} finally {
|
|
426
|
+
saveConfig({ timeoutMs: 45000 });
|
|
427
|
+
process.exitCode = undefined;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
309
430
|
});
|
|
310
431
|
|
|
311
432
|
describe('UX Utilities', () => {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
import { saveConfig } from '../src/config/config';
|
|
3
|
+
import { status } from '../src/commands/status';
|
|
4
|
+
import { health } from '../src/commands/health';
|
|
5
|
+
import { metrics } from '../src/commands/metrics';
|
|
6
|
+
import { getOllamaStatus, listModels } from '../src/core/ai';
|
|
7
|
+
import { metricsCollector } from '../src/core/metrics';
|
|
8
|
+
|
|
9
|
+
jest.mock('chalk', () => {
|
|
10
|
+
const identity = (value: unknown) => String(value);
|
|
11
|
+
const makeChalk = (): unknown => new Proxy(identity, {
|
|
12
|
+
get: (_target, property: string | symbol) => property === 'level' ? 0 : makeChalk(),
|
|
13
|
+
apply: (_target, _thisArg, args: unknown[]) => String(args[0]),
|
|
14
|
+
});
|
|
15
|
+
const chalk = makeChalk() as Record<string, unknown>;
|
|
16
|
+
return { __esModule: true, default: chalk, ...chalk };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
jest.mock('../src/utils/ux', () => ({
|
|
20
|
+
printError: jest.fn(),
|
|
21
|
+
printSuccess: jest.fn(),
|
|
22
|
+
printWarning: jest.fn(),
|
|
23
|
+
printInfo: jest.fn(),
|
|
24
|
+
createSpinner: jest.fn(),
|
|
25
|
+
themed: jest.fn((value: string) => value),
|
|
26
|
+
highlightCode: jest.fn((value: string) => value),
|
|
27
|
+
createProgressBar: jest.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
jest.mock('../src/core/ai', () => ({
|
|
31
|
+
listModels: jest.fn(),
|
|
32
|
+
getOllamaStatus: jest.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
jest.mock('../src/core/logger', () => ({
|
|
36
|
+
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
|
37
|
+
logInfo: jest.fn(),
|
|
38
|
+
logError: jest.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
describe('diagnostic commands', () => {
|
|
42
|
+
it('emits structured status output in JSON mode', async () => {
|
|
43
|
+
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
44
|
+
jest.mocked(getOllamaStatus).mockResolvedValue({ endpoint: 'http://127.0.0.1:11434', version: '0.12.3' });
|
|
45
|
+
saveConfig({ model: 'test-model', responseFormat: 'json' });
|
|
46
|
+
const output: string[] = [];
|
|
47
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
48
|
+
output.push(String(chunk));
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await status();
|
|
54
|
+
expect(JSON.parse(output.join(''))).toMatchObject({
|
|
55
|
+
ok: true,
|
|
56
|
+
command: 'status',
|
|
57
|
+
model: 'test-model',
|
|
58
|
+
availableModels: ['test-model'],
|
|
59
|
+
endpoint: 'http://127.0.0.1:11434',
|
|
60
|
+
version: '0.12.3',
|
|
61
|
+
});
|
|
62
|
+
} finally {
|
|
63
|
+
write.mockRestore();
|
|
64
|
+
saveConfig({ responseFormat: 'text' });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('emits structured health output in JSON mode', async () => {
|
|
69
|
+
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
70
|
+
saveConfig({ model: 'test-model', responseFormat: 'json' });
|
|
71
|
+
const output: string[] = [];
|
|
72
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
73
|
+
output.push(String(chunk));
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const memory = jest.spyOn(process, 'memoryUsage').mockReturnValue({
|
|
78
|
+
rss: 40 * 1024 * 1024,
|
|
79
|
+
heapTotal: 30 * 1024 * 1024,
|
|
80
|
+
heapUsed: 20 * 1024 * 1024,
|
|
81
|
+
external: 1024 * 1024,
|
|
82
|
+
arrayBuffers: 0,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await health();
|
|
87
|
+
const result = JSON.parse(output.join('')) as Record<string, unknown>;
|
|
88
|
+
expect(result).toMatchObject({ ok: true, command: 'health' });
|
|
89
|
+
expect(result).toHaveProperty('results');
|
|
90
|
+
expect(result).toHaveProperty('summary');
|
|
91
|
+
} finally {
|
|
92
|
+
memory.mockRestore();
|
|
93
|
+
write.mockRestore();
|
|
94
|
+
saveConfig({ responseFormat: 'text' });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('retains a local command summary across collector reads', () => {
|
|
99
|
+
metricsCollector.resetPersistent();
|
|
100
|
+
metricsCollector.recordCommand('explain', 1250, true);
|
|
101
|
+
|
|
102
|
+
expect(metricsCollector.getSummary()).toMatchObject({
|
|
103
|
+
commands: {
|
|
104
|
+
explain: {
|
|
105
|
+
runs: 1,
|
|
106
|
+
successes: 1,
|
|
107
|
+
failures: 0,
|
|
108
|
+
durationMs: 1250,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
metricsCollector.resetPersistent();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('retains model and cache activity in the local summary', () => {
|
|
117
|
+
metricsCollector.resetPersistent();
|
|
118
|
+
metricsCollector.recordAIRequest('test-model', 'explain', 250, true);
|
|
119
|
+
metricsCollector.recordCacheHit('ai-response');
|
|
120
|
+
metricsCollector.recordCacheMiss('ai-response');
|
|
121
|
+
|
|
122
|
+
expect(metricsCollector.getSummary()).toMatchObject({
|
|
123
|
+
models: {
|
|
124
|
+
'test-model': { requests: 1, successes: 1, failures: 0, durationMs: 250 },
|
|
125
|
+
},
|
|
126
|
+
cache: { hits: 1, misses: 1 },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
metricsCollector.resetPersistent();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('emits persisted metrics in JSON mode', async () => {
|
|
133
|
+
metricsCollector.resetPersistent();
|
|
134
|
+
metricsCollector.recordCommand('status', 50, true);
|
|
135
|
+
saveConfig({ responseFormat: 'json' });
|
|
136
|
+
const output: string[] = [];
|
|
137
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
138
|
+
output.push(String(chunk));
|
|
139
|
+
return true;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await metrics();
|
|
144
|
+
expect(JSON.parse(output.join(''))).toMatchObject({
|
|
145
|
+
ok: true,
|
|
146
|
+
command: 'metrics',
|
|
147
|
+
summary: { commands: { status: { runs: 1 } } },
|
|
148
|
+
});
|
|
149
|
+
} finally {
|
|
150
|
+
write.mockRestore();
|
|
151
|
+
saveConfig({ responseFormat: 'text' });
|
|
152
|
+
metricsCollector.resetPersistent();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|