@rahul05ranjan/dhruv-cli 1.4.6 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +18 -238
- package/.github/workflows/contribution.yml +6 -141
- package/.github/workflows/dependabot-auto-merge.yml +1 -0
- package/.github/workflows/labeler.yml +1 -0
- package/.github/workflows/security.yml +3 -0
- package/CHANGELOG.md +16 -4
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +170 -0
- package/__tests__/core.test.ts +193 -1
- package/__tests__/diagnostics.test.ts +179 -0
- package/__tests__/file-workflows.test.ts +234 -0
- package/__tests__/interactive.test.ts +134 -0
- package/__tests__/setup.ts +1 -0
- package/__tests__/workflows.test.ts +27 -4
- package/dist/commands/generate.d.ts +6 -1
- package/dist/commands/generate.js +44 -11
- package/dist/commands/health.d.ts +4 -1
- package/dist/commands/health.js +59 -16
- package/dist/commands/init.js +18 -7
- package/dist/commands/menu.js +125 -100
- package/dist/commands/metrics.d.ts +5 -1
- package/dist/commands/metrics.js +49 -10
- package/dist/commands/optimize.js +1 -1
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +66 -9
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +100 -6
- package/dist/commands/status.js +44 -4
- package/dist/config/config.d.ts +5 -1
- package/dist/config/config.js +24 -7
- package/dist/core/ai.d.ts +11 -0
- package/dist/core/ai.js +33 -9
- package/dist/core/command-catalog.d.ts +10 -0
- package/dist/core/command-catalog.js +27 -0
- package/dist/core/command-runner.js +106 -21
- package/dist/core/logger.js +1 -0
- package/dist/core/metrics.d.ts +28 -0
- package/dist/core/metrics.js +79 -0
- package/dist/index.js +98 -24
- package/dist/utils/projectType.d.ts +7 -1
- package/dist/utils/projectType.js +91 -13
- package/docs/api/assets/highlight.css +4 -4
- package/docs/api/index.html +161 -39
- package/docs/api/media/CONTRIBUTING.md +60 -0
- package/docs/api/media/SECURITY.md +8 -0
- package/docs/api/media/dhruv-cli-preview.svg +42 -0
- package/docs/api/media/publishing-fix.md +34 -0
- package/docs/dhruv-cli-preview.svg +42 -0
- package/docs/index.html +631 -533
- package/docs/publishing-fix.md +34 -0
- package/package.json +1 -1
- package/src/commands/generate.ts +50 -11
- package/src/commands/health.ts +62 -17
- package/src/commands/init.ts +18 -7
- package/src/commands/menu.ts +54 -30
- package/src/commands/metrics.ts +53 -9
- package/src/commands/optimize.ts +1 -1
- package/src/commands/review.ts +72 -9
- package/src/commands/security-check.ts +111 -6
- package/src/commands/status.ts +43 -5
- package/src/config/config.ts +26 -7
- package/src/core/ai.ts +36 -8
- package/src/core/command-catalog.ts +37 -0
- package/src/core/command-runner.ts +108 -22
- package/src/core/logger.ts +1 -0
- package/src/core/metrics.ts +105 -0
- package/src/index.ts +97 -24
- package/src/utils/projectType.ts +85 -9
- package/tsconfig.json +1 -1
- package/.github/workflows/auto-assign.yml +0 -14
- package/.github/workflows/build-publish.yml +0 -154
- package/.github/workflows/deploy.yml +0 -336
- package/.github/workflows/monitoring.yml +0 -270
- package/PUBLISHING_FIX.md +0 -92
- package/logs/.8a99b6cf655346317fdbf29f4fffcf91131432f3-audit.json +0 -15
- package/logs/.eee104bf8fff5ecd38a6a2842df260de6470a7c3-audit.json +0 -15
package/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,170 @@
|
|
|
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
|
+
expect(result.stdout).toContain('tests documentation docs component');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('generates valid zsh and fish completion scripts with subcommands', async () => {
|
|
44
|
+
const zshResult = await execFileAsync(
|
|
45
|
+
process.execPath,
|
|
46
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'zsh'],
|
|
47
|
+
{ cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
|
|
48
|
+
);
|
|
49
|
+
expect(zshResult.stdout).toContain('#compdef dhruv');
|
|
50
|
+
expect(zshResult.stdout).toContain('generate');
|
|
51
|
+
expect(zshResult.stdout).not.toContain('\u001b[');
|
|
52
|
+
|
|
53
|
+
const fishResult = await execFileAsync(
|
|
54
|
+
process.execPath,
|
|
55
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'fish'],
|
|
56
|
+
{ cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
|
|
57
|
+
);
|
|
58
|
+
expect(fishResult.stdout).toContain('complete -c dhruv');
|
|
59
|
+
expect(fishResult.stdout).toContain('__fish_seen_subcommand_from generate');
|
|
60
|
+
expect(fishResult.stdout).not.toContain('\u001b[');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects unsupported completion shells', async () => {
|
|
64
|
+
await expect(execFileAsync(
|
|
65
|
+
process.execPath,
|
|
66
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'powershell'],
|
|
67
|
+
{ cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
|
|
68
|
+
)).rejects.toMatchObject({ code: 2 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('documents strict security checks in command help', async () => {
|
|
72
|
+
const result = await execFileAsync(
|
|
73
|
+
process.execPath,
|
|
74
|
+
['--loader', loaderEntry, sourceEntry, 'security-check', '--help'],
|
|
75
|
+
{
|
|
76
|
+
cwd: repoRoot,
|
|
77
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(result.stdout).toContain('--strict');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('documents safe generation controls in command help', async () => {
|
|
85
|
+
const result = await execFileAsync(
|
|
86
|
+
process.execPath,
|
|
87
|
+
['--loader', loaderEntry, sourceEntry, 'generate', '--help'],
|
|
88
|
+
{
|
|
89
|
+
cwd: repoRoot,
|
|
90
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(result.stdout).toContain('--apply');
|
|
95
|
+
expect(result.stdout).toContain('--output');
|
|
96
|
+
expect(result.stdout).toContain('--overwrite');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('documents explicit metrics export and reset controls', async () => {
|
|
100
|
+
const result = await execFileAsync(
|
|
101
|
+
process.execPath,
|
|
102
|
+
['--loader', loaderEntry, sourceEntry, 'metrics', '--help'],
|
|
103
|
+
{
|
|
104
|
+
cwd: repoRoot,
|
|
105
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(result.stdout).toContain('--raw');
|
|
110
|
+
expect(result.stdout).toContain('--reset');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('documents detailed health diagnostics as an explicit option', async () => {
|
|
114
|
+
const result = await execFileAsync(
|
|
115
|
+
process.execPath,
|
|
116
|
+
['--loader', loaderEntry, sourceEntry, 'health', '--help'],
|
|
117
|
+
{
|
|
118
|
+
cwd: repoRoot,
|
|
119
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(result.stdout).toContain('--details');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('outputs exactly one valid JSON document on stdout with no ANSI or extra text', async () => {
|
|
127
|
+
const result = await execFileAsync(
|
|
128
|
+
process.execPath,
|
|
129
|
+
['--loader', loaderEntry, sourceEntry, 'metrics', '--json'],
|
|
130
|
+
{
|
|
131
|
+
cwd: repoRoot,
|
|
132
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(result.stdout).not.toContain('\u001b[');
|
|
137
|
+
const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
|
138
|
+
expect(parsed).toMatchObject({
|
|
139
|
+
ok: true,
|
|
140
|
+
command: 'metrics',
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('outputs valid JSON for health command with expected top-level schema', async () => {
|
|
145
|
+
let stdout = '';
|
|
146
|
+
try {
|
|
147
|
+
const result = await execFileAsync(
|
|
148
|
+
process.execPath,
|
|
149
|
+
['--loader', loaderEntry, sourceEntry, 'health', '--json'],
|
|
150
|
+
{
|
|
151
|
+
cwd: repoRoot,
|
|
152
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
stdout = result.stdout;
|
|
156
|
+
} catch (err: unknown) {
|
|
157
|
+
const execError = err as { stdout?: string; code?: number };
|
|
158
|
+
if (typeof execError?.stdout === 'string' && execError.stdout.trim().length > 0) {
|
|
159
|
+
stdout = execError.stdout;
|
|
160
|
+
} else {
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
expect(stdout).not.toContain('\u001b[');
|
|
166
|
+
const parsed = JSON.parse(stdout.trim()) as Record<string, unknown>;
|
|
167
|
+
expect(parsed).toHaveProperty('ok');
|
|
168
|
+
expect(parsed.command).toBe('health');
|
|
169
|
+
});
|
|
170
|
+
});
|
package/__tests__/core.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from '../src/core/ai';
|
|
8
8
|
import { loadConfig, saveConfig } from '../src/config/config';
|
|
9
9
|
import { createSpinner } from '../src/utils/ux';
|
|
10
|
-
import { detectProjectType } from '../src/utils/projectType';
|
|
10
|
+
import { detectProjectType, detectProjectDetails } from '../src/utils/projectType';
|
|
11
11
|
import { getSystemMessage } from '../src/core/prompts';
|
|
12
12
|
import { runCommand } from '../src/core/command-runner';
|
|
13
13
|
import fs from 'fs';
|
|
@@ -185,6 +185,75 @@ 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
|
+
const details = detectProjectDetails();
|
|
208
|
+
expect(details.type).toBe('unknown');
|
|
209
|
+
expect(details.diagnostic).toContain('Malformed package.json');
|
|
210
|
+
expect(detectProjectType()).toBe('unknown');
|
|
211
|
+
|
|
212
|
+
mockExistsSync.mockRestore();
|
|
213
|
+
mockReadFileSync.mockRestore();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should detect Python project with FastAPI framework', () => {
|
|
217
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
218
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'requirements.txt');
|
|
219
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
220
|
+
mockReadFileSync.mockReturnValue('fastapi>=0.100.0\nuvicorn>=0.20.0');
|
|
221
|
+
|
|
222
|
+
expect(detectProjectType()).toBe('python-fastapi');
|
|
223
|
+
|
|
224
|
+
mockExistsSync.mockRestore();
|
|
225
|
+
mockReadFileSync.mockRestore();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should detect Go project from go.mod', () => {
|
|
229
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
230
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'go.mod');
|
|
231
|
+
|
|
232
|
+
expect(detectProjectType()).toBe('go');
|
|
233
|
+
|
|
234
|
+
mockExistsSync.mockRestore();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should detect Rust project from Cargo.toml', () => {
|
|
238
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
239
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'Cargo.toml');
|
|
240
|
+
|
|
241
|
+
expect(detectProjectType()).toBe('rust');
|
|
242
|
+
|
|
243
|
+
mockExistsSync.mockRestore();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should detect Java Spring Boot project', () => {
|
|
247
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
248
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'pom.xml');
|
|
249
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
250
|
+
mockReadFileSync.mockReturnValue('<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>');
|
|
251
|
+
|
|
252
|
+
expect(detectProjectType()).toBe('java-spring-boot');
|
|
253
|
+
|
|
254
|
+
mockExistsSync.mockRestore();
|
|
255
|
+
mockReadFileSync.mockRestore();
|
|
256
|
+
});
|
|
188
257
|
});
|
|
189
258
|
|
|
190
259
|
describe('System Message Templates', () => {
|
|
@@ -287,6 +356,41 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
287
356
|
expect(completed).toBe('the answer');
|
|
288
357
|
});
|
|
289
358
|
|
|
359
|
+
it('shows the AI response to the user', async () => {
|
|
360
|
+
const output: string[] = [];
|
|
361
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
362
|
+
output.push(String(chunk));
|
|
363
|
+
return true;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await runCommand(makeSpec());
|
|
367
|
+
|
|
368
|
+
expect(output.join('')).toContain('the answer');
|
|
369
|
+
write.mockRestore();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('emits one structured result in JSON mode', async () => {
|
|
373
|
+
saveConfig({ responseFormat: 'json' });
|
|
374
|
+
const output: string[] = [];
|
|
375
|
+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
376
|
+
output.push(String(chunk));
|
|
377
|
+
return true;
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
await runCommand(makeSpec());
|
|
382
|
+
const result = JSON.parse(output.join('')) as Record<string, unknown>;
|
|
383
|
+
expect(result).toMatchObject({
|
|
384
|
+
ok: true,
|
|
385
|
+
command: 'explain',
|
|
386
|
+
response: 'the answer',
|
|
387
|
+
});
|
|
388
|
+
} finally {
|
|
389
|
+
write.mockRestore();
|
|
390
|
+
saveConfig({ responseFormat: 'text' });
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
290
394
|
it('short-circuits on validation failure before the AI call', async () => {
|
|
291
395
|
const computationsBefore = client.computations;
|
|
292
396
|
await runCommand(makeSpec({ input: { query: '<script>alert(1)</script>' } }));
|
|
@@ -306,6 +410,94 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
306
410
|
const { printError } = await import('../src/utils/ux');
|
|
307
411
|
expect(jest.mocked(printError).mock.calls.length).toBeGreaterThan(0);
|
|
308
412
|
});
|
|
413
|
+
|
|
414
|
+
it('maps an untyped ECONNREFUSED error to the ollama-serve hint', async () => {
|
|
415
|
+
setAIClient({
|
|
416
|
+
ask: async () => { throw new Error('connect ECONNREFUSED 127.0.0.1:11434'); },
|
|
417
|
+
listModels: async () => [],
|
|
418
|
+
});
|
|
419
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
420
|
+
await runCommand(makeSpec());
|
|
421
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('ollama serve'));
|
|
422
|
+
consoleSpy.mockRestore();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('displays completed response when streaming is unavailable', async () => {
|
|
426
|
+
setAIClient({
|
|
427
|
+
ask: async () => 'static completed response',
|
|
428
|
+
listModels: async () => [],
|
|
429
|
+
});
|
|
430
|
+
const output: string[] = [];
|
|
431
|
+
const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
432
|
+
output.push(String(chunk));
|
|
433
|
+
return true;
|
|
434
|
+
});
|
|
435
|
+
await runCommand(makeSpec());
|
|
436
|
+
expect(output.join('')).toContain('static completed response');
|
|
437
|
+
writeSpy.mockRestore();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('marks the process unsuccessful when an AI command fails', async () => {
|
|
441
|
+
const originalExitCode = process.exitCode;
|
|
442
|
+
process.exitCode = undefined;
|
|
443
|
+
client.failures.set('happy', { kind: 'connection', cause: 'ECONNREFUSED' });
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
await runCommand(makeSpec());
|
|
447
|
+
expect(process.exitCode).toBe(1);
|
|
448
|
+
} finally {
|
|
449
|
+
process.exitCode = originalExitCode;
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('treats an empty AI response as a failed command', async () => {
|
|
454
|
+
setAIClient(new InMemoryAIClient(new Map([['happy', '']])));
|
|
455
|
+
process.exitCode = undefined;
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
await runCommand(makeSpec());
|
|
459
|
+
expect(process.exitCode).toBe(1);
|
|
460
|
+
} finally {
|
|
461
|
+
process.exitCode = undefined;
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('times out a long-running AI request with a failing exit code', async () => {
|
|
466
|
+
saveConfig({ timeoutMs: 5 });
|
|
467
|
+
setAIClient({
|
|
468
|
+
ask: () => new Promise<string>(() => {}),
|
|
469
|
+
listModels: async () => [],
|
|
470
|
+
});
|
|
471
|
+
process.exitCode = undefined;
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
await runCommand(makeSpec());
|
|
475
|
+
expect(process.exitCode).toBe(1);
|
|
476
|
+
} finally {
|
|
477
|
+
saveConfig({ timeoutMs: 45000 });
|
|
478
|
+
process.exitCode = undefined;
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('cancels an in-flight request on Ctrl-C', async () => {
|
|
483
|
+
saveConfig({ timeoutMs: 1000 });
|
|
484
|
+
setAIClient({
|
|
485
|
+
ask: () => new Promise<string>(() => {}),
|
|
486
|
+
listModels: async () => [],
|
|
487
|
+
});
|
|
488
|
+
process.exitCode = undefined;
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
const running = runCommand(makeSpec());
|
|
492
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
493
|
+
process.emit('SIGINT');
|
|
494
|
+
await running;
|
|
495
|
+
expect(process.exitCode).toBe(130);
|
|
496
|
+
} finally {
|
|
497
|
+
saveConfig({ timeoutMs: 45000 });
|
|
498
|
+
process.exitCode = undefined;
|
|
499
|
+
}
|
|
500
|
+
});
|
|
309
501
|
});
|
|
310
502
|
|
|
311
503
|
describe('UX Utilities', () => {
|