@rahul05ranjan/dhruv-cli 1.6.0 → 1.7.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/CHANGELOG.md +2 -16
- package/__tests__/cli-contract.test.ts +34 -1
- package/__tests__/core.test.ts +59 -1
- package/__tests__/source-bundle.test.ts +135 -0
- package/dist/config/config.d.ts +3 -0
- package/dist/config/config.js +22 -6
- package/dist/core/source-bundle.d.ts +18 -0
- package/dist/core/source-bundle.js +147 -0
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/src/config/config.ts +24 -4
- package/src/core/source-bundle.ts +180 -0
- package/src/index.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,23 +1,9 @@
|
|
|
1
|
-
# [1.4.0](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
### Bug Fixes
|
|
5
|
-
|
|
6
|
-
* **test:** allow health --json contract test to inspect stdout on offline service exit code ([6231027](https://github.com/rahul05ranjan/dhruv-cli/commit/623102722d5c3ac2604dfffa276868a3b7eecdb8))
|
|
1
|
+
# [1.4.0](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.6.1...v1.4.0) (2026-09-21)
|
|
7
2
|
|
|
8
3
|
|
|
9
4
|
### Features
|
|
10
5
|
|
|
11
|
-
* **
|
|
12
|
-
* **catalog:** unified command catalog, menu, and shell completions (closes [#79](https://github.com/rahul05ranjan/dhruv-cli/issues/79)) ([13a9c83](https://github.com/rahul05ranjan/dhruv-cli/commit/13a9c836a52933357b9ca187c787e3b9ae8f4948))
|
|
13
|
-
* **cli:** standardize exit codes, timeouts, and cancellation (closes [#72](https://github.com/rahul05ranjan/dhruv-cli/issues/72)) ([d075d16](https://github.com/rahul05ranjan/dhruv-cli/commit/d075d16d1cfb3c000d156bcd238e3c261f0fc66a))
|
|
14
|
-
* **cli:** standardize reliable json output contract (closes [#71](https://github.com/rahul05ranjan/dhruv-cli/issues/71)) ([e0de862](https://github.com/rahul05ranjan/dhruv-cli/commit/e0de86224b54dcf43652b5daf3f4b403d6aa7d8f))
|
|
15
|
-
* **detection:** expand project detection and diagnostics (closes [#77](https://github.com/rahul05ranjan/dhruv-cli/issues/77)) ([221414a](https://github.com/rahul05ranjan/dhruv-cli/commit/221414a7c1156a650c9c746b5345b871e5274c57))
|
|
16
|
-
* **diagnostics:** improve first-run model readiness and health diagnostics (closes [#78](https://github.com/rahul05ranjan/dhruv-cli/issues/78)) ([95cd3a8](https://github.com/rahul05ranjan/dhruv-cli/commit/95cd3a8d772024847a86ba53fb6391f8024ab962))
|
|
17
|
-
* **generate:** previewable generation and multi-language test support (closes [#75](https://github.com/rahul05ranjan/dhruv-cli/issues/75)) ([c7f3dbe](https://github.com/rahul05ranjan/dhruv-cli/commit/c7f3dbe3902ee903220a40a4fae051b3a03797ac))
|
|
18
|
-
* **metrics:** persistent invocation metrics with reset and export (closes [#76](https://github.com/rahul05ranjan/dhruv-cli/issues/76)) ([0b5c4db](https://github.com/rahul05ranjan/dhruv-cli/commit/0b5c4db69e85d10f6afeca1d26e8d9a089ce6e4c))
|
|
19
|
-
* **review:** project-aware review and diff scanning (closes [#73](https://github.com/rahul05ranjan/dhruv-cli/issues/73)) ([290e4a1](https://github.com/rahul05ranjan/dhruv-cli/commit/290e4a18f21dfa9ea5a1f5fb51e2f8d98d83df53))
|
|
20
|
-
* **security:** safe credential redaction and strict checks (closes [#74](https://github.com/rahul05ranjan/dhruv-cli/issues/74)) ([2467d37](https://github.com/rahul05ranjan/dhruv-cli/commit/2467d37f4404f7e16e2a10cfed860a76661d6c60))
|
|
6
|
+
* **core:** source ingestion module for path validation, traversal, and diffs (closes [#99](https://github.com/rahul05ranjan/dhruv-cli/issues/99)) ([3b5761c](https://github.com/rahul05ranjan/dhruv-cli/commit/3b5761c7d2da9037ee86cbb6d890810a0b1d3e64))
|
|
21
7
|
# Changelog
|
|
22
8
|
|
|
23
9
|
## [1.1.1](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.1.0...v1.1.1) (2025-06-29)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
-
import { resolve } from 'node:path';
|
|
4
|
+
import { resolve, join } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
5
8
|
|
|
6
9
|
const execFileAsync = promisify(execFile);
|
|
7
10
|
const repoRoot = resolve(__dirname, '..');
|
|
@@ -167,4 +170,34 @@ describe('CLI output contract', () => {
|
|
|
167
170
|
expect(parsed).toHaveProperty('ok');
|
|
168
171
|
expect(parsed.command).toBe('health');
|
|
169
172
|
});
|
|
173
|
+
|
|
174
|
+
it('does not persist global flags (--json, --model, --verbose, --timeout) to .dhruv-config.json', async () => {
|
|
175
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'dhruv-session-flags-'));
|
|
176
|
+
const tsNodeLoader = pathToFileURL(resolve(repoRoot, 'node_modules/ts-node/esm.mjs')).href;
|
|
177
|
+
try {
|
|
178
|
+
const result = await execFileAsync(
|
|
179
|
+
process.execPath,
|
|
180
|
+
['--loader', tsNodeLoader, resolve(repoRoot, sourceEntry), 'metrics', '--json'],
|
|
181
|
+
{
|
|
182
|
+
cwd: tempDir,
|
|
183
|
+
env: {
|
|
184
|
+
...process.env,
|
|
185
|
+
TS_NODE_PROJECT: resolve(repoRoot, 'tsconfig.json'),
|
|
186
|
+
DHRUV_METRICS_ENABLED: 'false',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
|
192
|
+
expect(parsed).toMatchObject({
|
|
193
|
+
ok: true,
|
|
194
|
+
command: 'metrics',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const localConfigFile = join(tempDir, '.dhruv-config.json');
|
|
198
|
+
expect(existsSync(localConfigFile)).toBe(false);
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
170
203
|
});
|
package/__tests__/core.test.ts
CHANGED
|
@@ -5,7 +5,13 @@ import {
|
|
|
5
5
|
setAIClient,
|
|
6
6
|
InMemoryAIClient,
|
|
7
7
|
} from '../src/core/ai';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
loadConfig,
|
|
10
|
+
saveConfig,
|
|
11
|
+
setSessionConfig,
|
|
12
|
+
resetSessionConfig,
|
|
13
|
+
getSessionConfig,
|
|
14
|
+
} from '../src/config/config';
|
|
9
15
|
import { createSpinner } from '../src/utils/ux';
|
|
10
16
|
import { detectProjectType, detectProjectDetails } from '../src/utils/projectType';
|
|
11
17
|
import { getSystemMessage } from '../src/core/prompts';
|
|
@@ -134,6 +140,58 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
134
140
|
fs.unlinkSync(configPath);
|
|
135
141
|
}
|
|
136
142
|
});
|
|
143
|
+
|
|
144
|
+
it('should apply in-memory session overrides without writing to disk', () => {
|
|
145
|
+
resetSessionConfig();
|
|
146
|
+
const configPath = path.join(process.cwd(), '.dhruv-config.json');
|
|
147
|
+
const existedBefore = fs.existsSync(configPath);
|
|
148
|
+
|
|
149
|
+
setSessionConfig({
|
|
150
|
+
model: 'session-model',
|
|
151
|
+
responseFormat: 'json',
|
|
152
|
+
verbose: true,
|
|
153
|
+
timeoutMs: 12345,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(getSessionConfig()).toEqual({
|
|
157
|
+
model: 'session-model',
|
|
158
|
+
responseFormat: 'json',
|
|
159
|
+
verbose: true,
|
|
160
|
+
timeoutMs: 12345,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const loaded = loadConfig();
|
|
164
|
+
expect(loaded.model).toBe('session-model');
|
|
165
|
+
expect(loaded.responseFormat).toBe('json');
|
|
166
|
+
expect(loaded.verbose).toBe(true);
|
|
167
|
+
expect(loaded.timeoutMs).toBe(12345);
|
|
168
|
+
|
|
169
|
+
if (!existedBefore) {
|
|
170
|
+
expect(fs.existsSync(configPath)).toBe(false);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
resetSessionConfig();
|
|
174
|
+
expect(getSessionConfig()).toEqual({});
|
|
175
|
+
const afterReset = loadConfig();
|
|
176
|
+
expect(afterReset.model).not.toBe('session-model');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should not persist session overrides when saving configuration', () => {
|
|
180
|
+
resetSessionConfig();
|
|
181
|
+
const configPath = path.join(process.cwd(), '.dhruv-config.json');
|
|
182
|
+
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
183
|
+
|
|
184
|
+
setSessionConfig({ responseFormat: 'json' });
|
|
185
|
+
saveConfig({ model: 'persistent-model' });
|
|
186
|
+
|
|
187
|
+
const fileContent = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
188
|
+
expect(fileContent.model).toBe('persistent-model');
|
|
189
|
+
// Must not snapshot the in-memory responseFormat into the saved file
|
|
190
|
+
expect(fileContent.responseFormat).not.toBe('json');
|
|
191
|
+
|
|
192
|
+
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
193
|
+
resetSessionConfig();
|
|
194
|
+
});
|
|
137
195
|
});
|
|
138
196
|
|
|
139
197
|
describe('Project Type Detection', () => {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { loadSource } from '../src/core/source-bundle';
|
|
7
|
+
|
|
8
|
+
jest.mock('../src/utils/ux', () => ({
|
|
9
|
+
printError: jest.fn(),
|
|
10
|
+
printInfo: jest.fn(),
|
|
11
|
+
printSuccess: jest.fn(),
|
|
12
|
+
printWarning: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe('Source Ingestion Module (loadSource)', () => {
|
|
16
|
+
let root: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'source-bundle-test-'));
|
|
20
|
+
process.exitCode = 0;
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
26
|
+
process.exitCode = 0;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('loads a single source file', () => {
|
|
30
|
+
const file = path.join(root, 'main.ts');
|
|
31
|
+
fs.writeFileSync(file, 'console.log("hello world");');
|
|
32
|
+
|
|
33
|
+
const bundle = loadSource(file);
|
|
34
|
+
|
|
35
|
+
expect(bundle).not.toBeNull();
|
|
36
|
+
expect(bundle?.isDiff).toBe(false);
|
|
37
|
+
expect(bundle?.capped).toBe(false);
|
|
38
|
+
expect(bundle?.files).toHaveLength(1);
|
|
39
|
+
expect(bundle?.files[0].path).toBe('main.ts');
|
|
40
|
+
expect(bundle?.files[0].content).toBe('console.log("hello world");');
|
|
41
|
+
expect(bundle?.promptContent).toBe('console.log("hello world");');
|
|
42
|
+
expect(process.exitCode).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('sets process.exitCode = 1 and returns null when target does not exist', () => {
|
|
46
|
+
const nonExistent = path.join(root, 'nonexistent.ts');
|
|
47
|
+
|
|
48
|
+
const bundle = loadSource(nonExistent);
|
|
49
|
+
|
|
50
|
+
expect(bundle).toBeNull();
|
|
51
|
+
expect(process.exitCode).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('recursively loads directory files while pruning dependency and build directories', () => {
|
|
55
|
+
fs.mkdirSync(path.join(root, 'src', 'deep'), { recursive: true });
|
|
56
|
+
fs.mkdirSync(path.join(root, 'node_modules', 'lib'), { recursive: true });
|
|
57
|
+
fs.mkdirSync(path.join(root, '.next'), { recursive: true });
|
|
58
|
+
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
|
|
59
|
+
|
|
60
|
+
fs.writeFileSync(path.join(root, 'src', 'index.ts'), 'export const a = 1;');
|
|
61
|
+
fs.writeFileSync(path.join(root, 'src', 'deep', 'util.js'), 'export const b = 2;');
|
|
62
|
+
fs.writeFileSync(path.join(root, 'node_modules', 'lib', 'index.js'), 'ignored');
|
|
63
|
+
fs.writeFileSync(path.join(root, '.next', 'bundle.js'), 'ignored');
|
|
64
|
+
fs.writeFileSync(path.join(root, 'dist', 'out.js'), 'ignored');
|
|
65
|
+
fs.writeFileSync(path.join(root, 'readme.md'), '# Markdown ignored');
|
|
66
|
+
|
|
67
|
+
const bundle = loadSource(root);
|
|
68
|
+
|
|
69
|
+
expect(bundle).not.toBeNull();
|
|
70
|
+
expect(bundle?.files).toHaveLength(2);
|
|
71
|
+
const paths = bundle?.files.map((f: { path: string }) => f.path.replace(/\\/g, '/')).sort();
|
|
72
|
+
expect(paths).toEqual(['src/deep/util.js', 'src/index.ts']);
|
|
73
|
+
expect(bundle?.promptContent).toContain('// File: src/index.ts');
|
|
74
|
+
expect(bundle?.promptContent).toContain('// File: src/deep/util.js');
|
|
75
|
+
expect(bundle?.capped).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('caps directory collection at maxFiles and sets capped to true', () => {
|
|
79
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
80
|
+
for (let i = 0; i < 15; i++) {
|
|
81
|
+
fs.writeFileSync(path.join(root, 'src', `file_${String(i).padStart(2, '0')}.ts`), `const v = ${i};`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const bundle = loadSource(root, { maxFiles: 10 });
|
|
85
|
+
|
|
86
|
+
expect(bundle).not.toBeNull();
|
|
87
|
+
expect(bundle?.files).toHaveLength(10);
|
|
88
|
+
expect(bundle?.capped).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns null and sets process.exitCode = 1 when directory contains no code files', () => {
|
|
92
|
+
fs.writeFileSync(path.join(root, 'notes.txt'), 'text only');
|
|
93
|
+
|
|
94
|
+
const bundle = loadSource(root);
|
|
95
|
+
|
|
96
|
+
expect(bundle).toBeNull();
|
|
97
|
+
expect(process.exitCode).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('loads git diff when diff option is specified', () => {
|
|
101
|
+
execFileSync('git', ['init'], { cwd: root });
|
|
102
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root });
|
|
103
|
+
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: root });
|
|
104
|
+
|
|
105
|
+
const file = path.join(root, 'code.ts');
|
|
106
|
+
fs.writeFileSync(file, 'const initial = 1;\n');
|
|
107
|
+
execFileSync('git', ['add', '.'], { cwd: root });
|
|
108
|
+
execFileSync('git', ['commit', '-m', 'initial'], { cwd: root });
|
|
109
|
+
|
|
110
|
+
fs.appendFileSync(file, 'const changed = 2;\n');
|
|
111
|
+
|
|
112
|
+
const bundle = loadSource(root, { diff: true });
|
|
113
|
+
|
|
114
|
+
expect(bundle).not.toBeNull();
|
|
115
|
+
expect(bundle?.isDiff).toBe(true);
|
|
116
|
+
expect(bundle?.promptContent).toContain('+const changed = 2;');
|
|
117
|
+
expect(process.exitCode).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns null and sets exitCode = 1 when git diff is empty', () => {
|
|
121
|
+
execFileSync('git', ['init'], { cwd: root });
|
|
122
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root });
|
|
123
|
+
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: root });
|
|
124
|
+
|
|
125
|
+
const file = path.join(root, 'code.ts');
|
|
126
|
+
fs.writeFileSync(file, 'const initial = 1;\n');
|
|
127
|
+
execFileSync('git', ['add', '.'], { cwd: root });
|
|
128
|
+
execFileSync('git', ['commit', '-m', 'initial'], { cwd: root });
|
|
129
|
+
|
|
130
|
+
const bundle = loadSource(root, { diff: true });
|
|
131
|
+
|
|
132
|
+
expect(bundle).toBeNull();
|
|
133
|
+
expect(process.exitCode).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/dist/config/config.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ export interface DhruvConfig {
|
|
|
6
6
|
timeoutMs: number;
|
|
7
7
|
theme?: 'default' | 'dark' | 'light' | 'mono';
|
|
8
8
|
}
|
|
9
|
+
export declare function setSessionConfig(config: Partial<DhruvConfig>): void;
|
|
10
|
+
export declare function resetSessionConfig(): void;
|
|
11
|
+
export declare function getSessionConfig(): Partial<DhruvConfig>;
|
|
9
12
|
export declare function loadConfig(): DhruvConfig;
|
|
10
13
|
export declare function saveConfig(config: Partial<DhruvConfig>, options?: {
|
|
11
14
|
scope?: ConfigScope;
|
package/dist/config/config.js
CHANGED
|
@@ -10,6 +10,16 @@ const defaultConfig = {
|
|
|
10
10
|
timeoutMs: 45000,
|
|
11
11
|
theme: 'default',
|
|
12
12
|
};
|
|
13
|
+
let sessionConfig = {};
|
|
14
|
+
export function setSessionConfig(config) {
|
|
15
|
+
sessionConfig = { ...sessionConfig, ...config };
|
|
16
|
+
}
|
|
17
|
+
export function resetSessionConfig() {
|
|
18
|
+
sessionConfig = {};
|
|
19
|
+
}
|
|
20
|
+
export function getSessionConfig() {
|
|
21
|
+
return { ...sessionConfig };
|
|
22
|
+
}
|
|
13
23
|
function readConfigFile(file) {
|
|
14
24
|
if (fs.existsSync(file)) {
|
|
15
25
|
try {
|
|
@@ -25,11 +35,17 @@ function readConfigFile(file) {
|
|
|
25
35
|
return defaultConfig;
|
|
26
36
|
}
|
|
27
37
|
export function loadConfig() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
let base;
|
|
39
|
+
if (fs.existsSync(LOCAL_CONFIG_FILE)) {
|
|
40
|
+
base = readConfigFile(LOCAL_CONFIG_FILE);
|
|
41
|
+
}
|
|
42
|
+
else if (fs.existsSync(GLOBAL_CONFIG_FILE)) {
|
|
43
|
+
base = readConfigFile(GLOBAL_CONFIG_FILE);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
base = defaultConfig;
|
|
47
|
+
}
|
|
48
|
+
return validateAndMergeConfig({ ...base, ...sessionConfig });
|
|
33
49
|
}
|
|
34
50
|
function validateAndMergeConfig(config) {
|
|
35
51
|
const validatedConfig = { ...defaultConfig };
|
|
@@ -57,7 +73,7 @@ function validateAndMergeConfig(config) {
|
|
|
57
73
|
export function saveConfig(config, options = {}) {
|
|
58
74
|
const scope = options.scope ?? 'local';
|
|
59
75
|
const file = scope === 'global' ? GLOBAL_CONFIG_FILE : LOCAL_CONFIG_FILE;
|
|
60
|
-
const current =
|
|
76
|
+
const current = fs.existsSync(file) ? readConfigFile(file) : defaultConfig;
|
|
61
77
|
if (scope === 'global')
|
|
62
78
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
79
|
fs.writeFileSync(file, JSON.stringify({ ...current, ...config }, null, 2));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const CODE_FILE: RegExp;
|
|
2
|
+
export declare const IGNORED_DIRECTORIES: Set<string>;
|
|
3
|
+
export interface SourceFile {
|
|
4
|
+
path: string;
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SourceBundle {
|
|
8
|
+
target: string;
|
|
9
|
+
files: SourceFile[];
|
|
10
|
+
isDiff: boolean;
|
|
11
|
+
capped: boolean;
|
|
12
|
+
promptContent: string;
|
|
13
|
+
}
|
|
14
|
+
export interface LoadSourceOptions {
|
|
15
|
+
diff?: boolean;
|
|
16
|
+
maxFiles?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function loadSource(target: string, options?: LoadSourceOptions): SourceBundle | null;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
import { printError, printInfo } from '../utils/ux.js';
|
|
5
|
+
export const CODE_FILE = /\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/;
|
|
6
|
+
export const IGNORED_DIRECTORIES = new Set([
|
|
7
|
+
'.git',
|
|
8
|
+
'node_modules',
|
|
9
|
+
'dist',
|
|
10
|
+
'build',
|
|
11
|
+
'coverage',
|
|
12
|
+
'.dhruv-cache',
|
|
13
|
+
'logs',
|
|
14
|
+
'.next',
|
|
15
|
+
'.turbo',
|
|
16
|
+
'__pycache__',
|
|
17
|
+
'.pytest_cache',
|
|
18
|
+
'target',
|
|
19
|
+
'vendor',
|
|
20
|
+
]);
|
|
21
|
+
export function loadSource(target, options = {}) {
|
|
22
|
+
if (options.diff) {
|
|
23
|
+
return loadGitDiff(target);
|
|
24
|
+
}
|
|
25
|
+
let stat;
|
|
26
|
+
try {
|
|
27
|
+
stat = fs.statSync(target);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
printError(`Path "${target}" does not exist or could not be read.`);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (stat.isFile()) {
|
|
35
|
+
return loadSingleFile(target);
|
|
36
|
+
}
|
|
37
|
+
if (stat.isDirectory()) {
|
|
38
|
+
return loadDirectory(target, options.maxFiles ?? 10);
|
|
39
|
+
}
|
|
40
|
+
printError(`Path "${target}" is neither a file nor a directory.`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function loadSingleFile(target) {
|
|
45
|
+
try {
|
|
46
|
+
const content = fs.readFileSync(target, 'utf-8');
|
|
47
|
+
const relativePath = path.basename(target);
|
|
48
|
+
return {
|
|
49
|
+
target,
|
|
50
|
+
files: [{ path: relativePath, content }],
|
|
51
|
+
isDiff: false,
|
|
52
|
+
capped: false,
|
|
53
|
+
promptContent: content,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
printError(`Path "${target}" does not exist or could not be read.`);
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function loadDirectory(dir, maxFiles) {
|
|
63
|
+
const collectedFiles = [];
|
|
64
|
+
let capped = false;
|
|
65
|
+
function collect(current) {
|
|
66
|
+
if (collectedFiles.length >= maxFiles) {
|
|
67
|
+
capped = true;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (collectedFiles.length >= maxFiles) {
|
|
79
|
+
capped = true;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const absolute = path.join(current, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
85
|
+
collect(absolute);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if (entry.isFile() && CODE_FILE.test(entry.name)) {
|
|
89
|
+
try {
|
|
90
|
+
const content = fs.readFileSync(absolute, 'utf-8');
|
|
91
|
+
const relPath = path.relative(dir, absolute).split(path.sep).join('/');
|
|
92
|
+
collectedFiles.push({ path: relPath, content });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// ignore unreadable file
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
collect(dir);
|
|
101
|
+
if (collectedFiles.length === 0) {
|
|
102
|
+
printError(`No code files found in directory "${dir}".`);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (capped) {
|
|
107
|
+
printInfo(`Note: Directory review is capped at the first ${maxFiles} source files.`);
|
|
108
|
+
}
|
|
109
|
+
let promptContent = '';
|
|
110
|
+
for (const f of collectedFiles) {
|
|
111
|
+
promptContent += `\n// File: ${f.path}\n${f.content}\n`;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
target: dir,
|
|
115
|
+
files: collectedFiles,
|
|
116
|
+
isDiff: false,
|
|
117
|
+
capped,
|
|
118
|
+
promptContent,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function loadGitDiff(fileOrDir) {
|
|
122
|
+
const root = fs.existsSync(fileOrDir) && fs.statSync(fileOrDir).isDirectory() ? fileOrDir : path.dirname(fileOrDir);
|
|
123
|
+
try {
|
|
124
|
+
const diff = execFileSync('git', ['diff', '--no-ext-diff', '--unified=80', '--'], {
|
|
125
|
+
cwd: root,
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
128
|
+
});
|
|
129
|
+
if (!diff.trim()) {
|
|
130
|
+
printError(`No uncommitted changes found in "${fileOrDir}".`);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
target: fileOrDir,
|
|
136
|
+
files: [],
|
|
137
|
+
isDiff: true,
|
|
138
|
+
capped: false,
|
|
139
|
+
promptContent: diff,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
printError(`Could not read a git diff for "${fileOrDir}".`);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -110,9 +110,9 @@ program
|
|
|
110
110
|
config.responseFormat = 'json';
|
|
111
111
|
if (opts.timeout)
|
|
112
112
|
config.timeoutMs = Number(opts.timeout);
|
|
113
|
-
//
|
|
113
|
+
// Set in-memory config overrides for the session
|
|
114
114
|
const configModule = await import('./config/config.js');
|
|
115
|
-
configModule.
|
|
115
|
+
configModule.setSessionConfig(config);
|
|
116
116
|
}
|
|
117
117
|
});
|
|
118
118
|
async function loadPlugins(program) {
|
package/package.json
CHANGED
package/src/config/config.ts
CHANGED
|
@@ -23,6 +23,20 @@ const defaultConfig: DhruvConfig = {
|
|
|
23
23
|
theme: 'default',
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
let sessionConfig: Partial<DhruvConfig> = {};
|
|
27
|
+
|
|
28
|
+
export function setSessionConfig(config: Partial<DhruvConfig>): void {
|
|
29
|
+
sessionConfig = { ...sessionConfig, ...config };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resetSessionConfig(): void {
|
|
33
|
+
sessionConfig = {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getSessionConfig(): Partial<DhruvConfig> {
|
|
37
|
+
return { ...sessionConfig };
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
function readConfigFile(file: string): DhruvConfig {
|
|
27
41
|
if (fs.existsSync(file)) {
|
|
28
42
|
try {
|
|
@@ -38,9 +52,15 @@ function readConfigFile(file: string): DhruvConfig {
|
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
export function loadConfig(): DhruvConfig {
|
|
41
|
-
|
|
42
|
-
if (fs.existsSync(
|
|
43
|
-
|
|
55
|
+
let base: DhruvConfig;
|
|
56
|
+
if (fs.existsSync(LOCAL_CONFIG_FILE)) {
|
|
57
|
+
base = readConfigFile(LOCAL_CONFIG_FILE);
|
|
58
|
+
} else if (fs.existsSync(GLOBAL_CONFIG_FILE)) {
|
|
59
|
+
base = readConfigFile(GLOBAL_CONFIG_FILE);
|
|
60
|
+
} else {
|
|
61
|
+
base = defaultConfig;
|
|
62
|
+
}
|
|
63
|
+
return validateAndMergeConfig({ ...base, ...sessionConfig });
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
function validateAndMergeConfig(config: Partial<DhruvConfig>): DhruvConfig {
|
|
@@ -76,7 +96,7 @@ function validateAndMergeConfig(config: Partial<DhruvConfig>): DhruvConfig {
|
|
|
76
96
|
export function saveConfig(config: Partial<DhruvConfig>, options: { scope?: ConfigScope } = {}) {
|
|
77
97
|
const scope = options.scope ?? 'local';
|
|
78
98
|
const file = scope === 'global' ? GLOBAL_CONFIG_FILE : LOCAL_CONFIG_FILE;
|
|
79
|
-
const current =
|
|
99
|
+
const current = fs.existsSync(file) ? readConfigFile(file) : defaultConfig;
|
|
80
100
|
if (scope === 'global') fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
81
101
|
fs.writeFileSync(file, JSON.stringify({ ...current, ...config }, null, 2));
|
|
82
102
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
import { printError, printInfo } from '../utils/ux.js';
|
|
5
|
+
|
|
6
|
+
export const CODE_FILE = /\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/;
|
|
7
|
+
|
|
8
|
+
export const IGNORED_DIRECTORIES = new Set([
|
|
9
|
+
'.git',
|
|
10
|
+
'node_modules',
|
|
11
|
+
'dist',
|
|
12
|
+
'build',
|
|
13
|
+
'coverage',
|
|
14
|
+
'.dhruv-cache',
|
|
15
|
+
'logs',
|
|
16
|
+
'.next',
|
|
17
|
+
'.turbo',
|
|
18
|
+
'__pycache__',
|
|
19
|
+
'.pytest_cache',
|
|
20
|
+
'target',
|
|
21
|
+
'vendor',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export interface SourceFile {
|
|
25
|
+
path: string;
|
|
26
|
+
content: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SourceBundle {
|
|
30
|
+
target: string;
|
|
31
|
+
files: SourceFile[];
|
|
32
|
+
isDiff: boolean;
|
|
33
|
+
capped: boolean;
|
|
34
|
+
promptContent: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LoadSourceOptions {
|
|
38
|
+
diff?: boolean;
|
|
39
|
+
maxFiles?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadSource(target: string, options: LoadSourceOptions = {}): SourceBundle | null {
|
|
43
|
+
if (options.diff) {
|
|
44
|
+
return loadGitDiff(target);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let stat: fs.Stats;
|
|
48
|
+
try {
|
|
49
|
+
stat = fs.statSync(target);
|
|
50
|
+
} catch {
|
|
51
|
+
printError(`Path "${target}" does not exist or could not be read.`);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (stat.isFile()) {
|
|
57
|
+
return loadSingleFile(target);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (stat.isDirectory()) {
|
|
61
|
+
return loadDirectory(target, options.maxFiles ?? 10);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
printError(`Path "${target}" is neither a file nor a directory.`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadSingleFile(target: string): SourceBundle | null {
|
|
70
|
+
try {
|
|
71
|
+
const content = fs.readFileSync(target, 'utf-8');
|
|
72
|
+
const relativePath = path.basename(target);
|
|
73
|
+
return {
|
|
74
|
+
target,
|
|
75
|
+
files: [{ path: relativePath, content }],
|
|
76
|
+
isDiff: false,
|
|
77
|
+
capped: false,
|
|
78
|
+
promptContent: content,
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
printError(`Path "${target}" does not exist or could not be read.`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadDirectory(dir: string, maxFiles: number): SourceBundle | null {
|
|
88
|
+
const collectedFiles: SourceFile[] = [];
|
|
89
|
+
let capped = false;
|
|
90
|
+
|
|
91
|
+
function collect(current: string): void {
|
|
92
|
+
if (collectedFiles.length >= maxFiles) {
|
|
93
|
+
capped = true;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let entries: fs.Dirent[];
|
|
98
|
+
try {
|
|
99
|
+
entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (collectedFiles.length >= maxFiles) {
|
|
106
|
+
capped = true;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const absolute = path.join(current, entry.name);
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
113
|
+
collect(absolute);
|
|
114
|
+
}
|
|
115
|
+
} else if (entry.isFile() && CODE_FILE.test(entry.name)) {
|
|
116
|
+
try {
|
|
117
|
+
const content = fs.readFileSync(absolute, 'utf-8');
|
|
118
|
+
const relPath = path.relative(dir, absolute).split(path.sep).join('/');
|
|
119
|
+
collectedFiles.push({ path: relPath, content });
|
|
120
|
+
} catch {
|
|
121
|
+
// ignore unreadable file
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
collect(dir);
|
|
128
|
+
|
|
129
|
+
if (collectedFiles.length === 0) {
|
|
130
|
+
printError(`No code files found in directory "${dir}".`);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (capped) {
|
|
136
|
+
printInfo(`Note: Directory review is capped at the first ${maxFiles} source files.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let promptContent = '';
|
|
140
|
+
for (const f of collectedFiles) {
|
|
141
|
+
promptContent += `\n// File: ${f.path}\n${f.content}\n`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
target: dir,
|
|
146
|
+
files: collectedFiles,
|
|
147
|
+
isDiff: false,
|
|
148
|
+
capped,
|
|
149
|
+
promptContent,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function loadGitDiff(fileOrDir: string): SourceBundle | null {
|
|
154
|
+
const root = fs.existsSync(fileOrDir) && fs.statSync(fileOrDir).isDirectory() ? fileOrDir : path.dirname(fileOrDir);
|
|
155
|
+
try {
|
|
156
|
+
const diff = execFileSync('git', ['diff', '--no-ext-diff', '--unified=80', '--'], {
|
|
157
|
+
cwd: root,
|
|
158
|
+
encoding: 'utf8',
|
|
159
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!diff.trim()) {
|
|
163
|
+
printError(`No uncommitted changes found in "${fileOrDir}".`);
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
target: fileOrDir,
|
|
170
|
+
files: [],
|
|
171
|
+
isDiff: true,
|
|
172
|
+
capped: false,
|
|
173
|
+
promptContent: diff,
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
printError(`Could not read a git diff for "${fileOrDir}".`);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -123,9 +123,9 @@ program
|
|
|
123
123
|
if (opts.verbose) config.verbose = true;
|
|
124
124
|
if (opts.json) config.responseFormat = 'json';
|
|
125
125
|
if (opts.timeout) config.timeoutMs = Number(opts.timeout);
|
|
126
|
-
//
|
|
126
|
+
// Set in-memory config overrides for the session
|
|
127
127
|
const configModule = await import('./config/config.js');
|
|
128
|
-
configModule.
|
|
128
|
+
configModule.setSessionConfig(config);
|
|
129
129
|
}
|
|
130
130
|
});
|
|
131
131
|
|