can-i-merge 0.1.0 → 0.2.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/README.md +283 -3
- package/dist/cli/index.js +152 -0
- package/dist/config/credentialStore.js +118 -0
- package/dist/config/index.js +4 -0
- package/dist/context-engine/contextBudgetEngine.js +40 -0
- package/dist/context-engine/contextBuilder.js +182 -0
- package/dist/context-engine/contextScoreEngine.js +19 -0
- package/dist/context-engine/dependencyResolver.js +183 -0
- package/dist/context-engine/index.js +4 -0
- package/dist/core/index.js +1 -0
- package/dist/core/reviewOrchestrator.js +43 -0
- package/dist/git/gitAnalyzer.js +193 -0
- package/dist/git/index.js +1 -0
- package/dist/index.js +6 -0
- package/dist/normalizer/index.js +1 -0
- package/dist/normalizer/normalizer.js +112 -0
- package/dist/prompt/index.js +1 -0
- package/dist/prompt/promptBuilder.js +143 -0
- package/dist/provider/index.js +6 -0
- package/dist/provider/registry.js +26 -0
- package/dist/provider-anthropic/claudeProvider.js +123 -0
- package/dist/provider-anthropic/index.js +6 -0
- package/dist/provider-nvidia/index.js +1 -0
- package/dist/provider-nvidia/nvidiaProvider.js +33 -0
- package/dist/provider-ollama/index.js +6 -0
- package/dist/provider-ollama/ollamaProvider.js +34 -0
- package/dist/provider-openai/index.js +6 -0
- package/dist/provider-openai/openAIProvider.js +32 -0
- package/dist/reporter/consoleReporter.js +103 -0
- package/dist/reporter/index.js +2 -0
- package/dist/reporter/jsonReporter.js +3 -0
- package/dist/shared/colors.js +23 -0
- package/dist/shared/openaiCompatibleProvider.js +125 -0
- package/dist/shared/tokens.js +13 -0
- package/dist/shared/types.js +10 -0
- package/package.json +43 -18
package/README.md
CHANGED
|
@@ -1,4 +1,284 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
# can-i-merge
|
|
4
|
+
|
|
5
|
+
### Before you merge, ask one question.
|
|
6
|
+
|
|
7
|
+
> **can-i-merge?**
|
|
8
|
+
|
|
9
|
+
AI-powered Git review pipeline with intelligent context building.
|
|
10
|
+
|
|
11
|
+
[](#license)
|
|
12
|
+
[](#)
|
|
13
|
+
[](#)
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Why?
|
|
20
|
+
|
|
21
|
+
Most AI code review tools simply send your changed files to an LLM.
|
|
22
|
+
|
|
23
|
+
**can-i-merge** does something different.
|
|
24
|
+
|
|
25
|
+
Instead of asking:
|
|
26
|
+
|
|
27
|
+
> "Which AI should I use?"
|
|
28
|
+
|
|
29
|
+
It asks:
|
|
30
|
+
|
|
31
|
+
> **"What is the best context I can give the AI?"**
|
|
32
|
+
|
|
33
|
+
The AI is replaceable.
|
|
34
|
+
|
|
35
|
+
The **Context Engine** is the real product.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- Git Diff analysis
|
|
42
|
+
- Intelligent Context Builder
|
|
43
|
+
- Context Budget Engine
|
|
44
|
+
- Dependency-aware context collection
|
|
45
|
+
- Multiple AI providers
|
|
46
|
+
- Claude
|
|
47
|
+
- OpenAI
|
|
48
|
+
- NVIDIA
|
|
49
|
+
- Gemini
|
|
50
|
+
- Ollama
|
|
51
|
+
- OpenRouter
|
|
52
|
+
- Provider abstraction
|
|
53
|
+
- GitHub Action (Planned)
|
|
54
|
+
- VSCode Extension (Planned)
|
|
55
|
+
- MCP Server (Planned)
|
|
56
|
+
- Review Memory (Planned)
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Philosophy
|
|
61
|
+
|
|
62
|
+
Good AI reviews don't come from better models.
|
|
63
|
+
|
|
64
|
+
They come from better context.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
Git Diff
|
|
68
|
+
│
|
|
69
|
+
▼
|
|
70
|
+
Context Engine
|
|
71
|
+
│
|
|
72
|
+
▼
|
|
73
|
+
Claude / GPT / Gemini / NVIDIA
|
|
74
|
+
│
|
|
75
|
+
▼
|
|
76
|
+
Normalized Review
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Installation
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm install -g can-i-merge
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
Review current changes
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
can-i-merge
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Review latest commit
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
can-i-merge --commit HEAD
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Deep review
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
can-i-merge --level deep
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Choose AI provider
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
can-i-merge --provider claude
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Security review
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
can-i-merge --type security
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
JSON output
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
can-i-merge --json
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Example
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
$ can-i-merge
|
|
133
|
+
|
|
134
|
+
Analyzing Git Diff...
|
|
135
|
+
Building Context...
|
|
136
|
+
Reviewing with Claude...
|
|
137
|
+
|
|
138
|
+
────────────────────────────
|
|
139
|
+
|
|
140
|
+
Overall Score
|
|
141
|
+
|
|
142
|
+
92 / 100
|
|
143
|
+
|
|
144
|
+
Critical
|
|
145
|
+
0
|
|
146
|
+
|
|
147
|
+
High
|
|
148
|
+
1
|
|
149
|
+
|
|
150
|
+
Medium
|
|
151
|
+
2
|
|
152
|
+
|
|
153
|
+
Low
|
|
154
|
+
1
|
|
155
|
+
|
|
156
|
+
────────────────────────────
|
|
157
|
+
|
|
158
|
+
❌ Merge Status
|
|
159
|
+
|
|
160
|
+
NOT READY
|
|
161
|
+
|
|
162
|
+
────────────────────────────
|
|
163
|
+
|
|
164
|
+
High
|
|
165
|
+
|
|
166
|
+
src/auth/login.ts:48
|
|
167
|
+
|
|
168
|
+
JWT validation should happen after authorization.
|
|
169
|
+
|
|
170
|
+
Recommendation
|
|
171
|
+
|
|
172
|
+
Move authorization check before JWT validation.
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
# Architecture
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
Git Repository
|
|
181
|
+
│
|
|
182
|
+
▼
|
|
183
|
+
Git Analyzer
|
|
184
|
+
│
|
|
185
|
+
▼
|
|
186
|
+
Context Builder
|
|
187
|
+
┌──────────────────────────┐
|
|
188
|
+
│ Dependency Resolver │
|
|
189
|
+
│ Context Score Engine │
|
|
190
|
+
│ Context Budget Engine │
|
|
191
|
+
│ Prompt Builder │
|
|
192
|
+
│ Review Memory │
|
|
193
|
+
└──────────────────────────┘
|
|
194
|
+
│
|
|
195
|
+
▼
|
|
196
|
+
Provider Layer
|
|
197
|
+
┌──────────────────────────┐
|
|
198
|
+
│ Claude │
|
|
199
|
+
│ OpenAI │
|
|
200
|
+
│ NVIDIA │
|
|
201
|
+
│ Gemini │
|
|
202
|
+
│ Ollama │
|
|
203
|
+
└──────────────────────────┘
|
|
204
|
+
│
|
|
205
|
+
▼
|
|
206
|
+
Normalizer
|
|
207
|
+
│
|
|
208
|
+
▼
|
|
209
|
+
ReviewResult
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
# Why another AI review tool?
|
|
215
|
+
|
|
216
|
+
Most tools focus on the AI.
|
|
217
|
+
|
|
218
|
+
We focus on the context.
|
|
219
|
+
|
|
220
|
+
Instead of sending an entire repository to an LLM,
|
|
221
|
+
can-i-merge builds the smallest, most relevant context possible.
|
|
222
|
+
|
|
223
|
+
That means:
|
|
224
|
+
|
|
225
|
+
- Lower cost
|
|
226
|
+
- Faster reviews
|
|
227
|
+
- Better answers
|
|
228
|
+
- Model independence
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
# Roadmap
|
|
233
|
+
|
|
234
|
+
## v0.1
|
|
235
|
+
|
|
236
|
+
- CLI
|
|
237
|
+
- Git Diff Analyzer
|
|
238
|
+
- Context Builder
|
|
239
|
+
- Claude Provider
|
|
240
|
+
- Terminal Reporter
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## v0.2
|
|
245
|
+
|
|
246
|
+
- Context Budget Engine
|
|
247
|
+
- Context Score Engine
|
|
248
|
+
- OpenAI Provider
|
|
249
|
+
- NVIDIA Provider
|
|
250
|
+
- Ollama Provider
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## v0.3
|
|
255
|
+
|
|
256
|
+
- Review Memory
|
|
257
|
+
- Incremental Review
|
|
258
|
+
- GitHub Action
|
|
259
|
+
- VSCode Extension
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## v1.0
|
|
264
|
+
|
|
265
|
+
- Multi AI Consensus
|
|
266
|
+
- MCP Server
|
|
267
|
+
- Dashboard
|
|
268
|
+
- Auto Fix
|
|
269
|
+
- Team Review
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
# Contributing
|
|
274
|
+
|
|
275
|
+
Contributions are welcome.
|
|
276
|
+
|
|
277
|
+
If you have ideas for improving the Context Engine,
|
|
278
|
+
please open an issue or submit a pull request.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
# License
|
|
283
|
+
|
|
284
|
+
MIT License
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI composition root.
|
|
3
|
+
*
|
|
4
|
+
* This is the only place in the project that wires a concrete provider
|
|
5
|
+
* (ClaudeProvider) into the provider-agnostic Core pipeline. Adding a new
|
|
6
|
+
* provider later means registering it here - runReview() in core never
|
|
7
|
+
* changes.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { Command } from "commander";
|
|
13
|
+
import { clearProviderConfig, loadProviderConfig, saveProviderConfig } from "../config/index.js";
|
|
14
|
+
import { NoChangesError, runReview } from "../core/index.js";
|
|
15
|
+
import { isGitRepository } from "../git/index.js";
|
|
16
|
+
import { ProviderRegistry } from "../provider/index.js";
|
|
17
|
+
import { ClaudeProvider } from "../provider-anthropic/index.js";
|
|
18
|
+
import { NvidiaProvider } from "../provider-nvidia/index.js";
|
|
19
|
+
import { OllamaProvider } from "../provider-ollama/index.js";
|
|
20
|
+
import { OpenAIProvider } from "../provider-openai/index.js";
|
|
21
|
+
import { isMergeReady, reportConsole, reportJson } from "../reporter/index.js";
|
|
22
|
+
import { colors } from "../shared/colors.js";
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"));
|
|
25
|
+
const REVIEW_LEVELS = ["fast", "normal", "deep"];
|
|
26
|
+
const REVIEW_TYPES = [
|
|
27
|
+
"general",
|
|
28
|
+
"security",
|
|
29
|
+
"performance",
|
|
30
|
+
"architecture",
|
|
31
|
+
"style",
|
|
32
|
+
];
|
|
33
|
+
const STAGE_MESSAGES = {
|
|
34
|
+
analyzing: "Analyzing Git Diff...",
|
|
35
|
+
context: "Building Context...",
|
|
36
|
+
reviewing: "Reviewing...",
|
|
37
|
+
};
|
|
38
|
+
const PROVIDER_ENV_VARS = {
|
|
39
|
+
claude: { apiKey: "ANTHROPIC_API_KEY", model: "ANTHROPIC_MODEL" },
|
|
40
|
+
openai: { apiKey: "OPENAI_API_KEY", model: "OPENAI_MODEL" },
|
|
41
|
+
nvidia: { apiKey: "NVIDIA_API_KEY", model: "NVIDIA_MODEL" },
|
|
42
|
+
ollama: { apiKey: "OLLAMA_API_KEY", model: "OLLAMA_MODEL" },
|
|
43
|
+
};
|
|
44
|
+
function isReviewLevel(value) {
|
|
45
|
+
return REVIEW_LEVELS.includes(value);
|
|
46
|
+
}
|
|
47
|
+
function isReviewType(value) {
|
|
48
|
+
return REVIEW_TYPES.includes(value);
|
|
49
|
+
}
|
|
50
|
+
function buildProviderRegistry() {
|
|
51
|
+
const registry = new ProviderRegistry();
|
|
52
|
+
registry.register("claude", (config) => new ClaudeProvider(config));
|
|
53
|
+
registry.register("openai", (config) => new OpenAIProvider(config));
|
|
54
|
+
registry.register("nvidia", (config) => new NvidiaProvider(config));
|
|
55
|
+
registry.register("ollama", (config) => new OllamaProvider(config));
|
|
56
|
+
return registry;
|
|
57
|
+
}
|
|
58
|
+
export async function main(argv = process.argv) {
|
|
59
|
+
const program = new Command();
|
|
60
|
+
program
|
|
61
|
+
.name("can-i-merge")
|
|
62
|
+
.description("AI-powered Git Review Pipeline with Intelligent Context Building")
|
|
63
|
+
.version(pkg.version)
|
|
64
|
+
.option("--commit <ref>", "review a specific commit instead of the staged index")
|
|
65
|
+
.option("--provider <name>", "AI provider to use: claude, openai, nvidia, or ollama", "claude")
|
|
66
|
+
.option("--api-key <key>", "API key for the selected provider (saved encrypted for future runs)")
|
|
67
|
+
.option("--model <model>", "model name for the selected provider (saved for future runs)")
|
|
68
|
+
.option("--forget-credentials", "remove stored credentials for the selected provider and exit", false)
|
|
69
|
+
.option("--level <level>", "review level: fast, normal, or deep", "normal")
|
|
70
|
+
.option("--type <type>", "review focus: general, security, performance, architecture, or style", "general")
|
|
71
|
+
.option("--json", "output the review result as JSON", false)
|
|
72
|
+
.option("--fix", "automatically apply suggested fixes (not yet supported)", false);
|
|
73
|
+
program.parse(argv);
|
|
74
|
+
const opts = program.opts();
|
|
75
|
+
if (opts.forgetCredentials) {
|
|
76
|
+
clearProviderConfig(opts.provider);
|
|
77
|
+
console.log(colors.green(`Removed stored credentials for provider "${opts.provider}".`));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (opts.fix) {
|
|
81
|
+
console.error(colors.yellow("--fix is not supported yet (see TOBE.md roadmap, Phase 4)."));
|
|
82
|
+
process.exitCode = 2;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!isReviewLevel(opts.level)) {
|
|
86
|
+
console.error(colors.red(`Invalid --level "${opts.level}". Expected one of: ${REVIEW_LEVELS.join(", ")}.`));
|
|
87
|
+
process.exitCode = 2;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!isReviewType(opts.type)) {
|
|
91
|
+
console.error(colors.red(`Invalid --type "${opts.type}". Expected one of: ${REVIEW_TYPES.join(", ")}.`));
|
|
92
|
+
process.exitCode = 2;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const cwd = process.cwd();
|
|
96
|
+
if (!(await isGitRepository(cwd))) {
|
|
97
|
+
console.error(colors.red(`Not a git repository: ${cwd}`));
|
|
98
|
+
process.exitCode = 2;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (opts.apiKey !== undefined || opts.model !== undefined) {
|
|
102
|
+
saveProviderConfig(opts.provider, { apiKey: opts.apiKey, model: opts.model });
|
|
103
|
+
if (!opts.json) {
|
|
104
|
+
console.log(colors.dim(`Saved ${opts.provider} credentials to ~/.can-i-merge (encrypted).`));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const stored = loadProviderConfig(opts.provider);
|
|
108
|
+
const envVars = PROVIDER_ENV_VARS[opts.provider];
|
|
109
|
+
const apiKey = opts.apiKey ?? (envVars && process.env[envVars.apiKey]) ?? stored.apiKey;
|
|
110
|
+
const model = opts.model ?? (envVars && process.env[envVars.model]) ?? stored.model;
|
|
111
|
+
const registry = buildProviderRegistry();
|
|
112
|
+
let provider;
|
|
113
|
+
try {
|
|
114
|
+
provider = registry.create(opts.provider, { apiKey, model });
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
console.error(colors.red(err instanceof Error ? err.message : String(err)));
|
|
118
|
+
process.exitCode = 2;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!opts.json) {
|
|
122
|
+
console.log(colors.bold(`can-i-merge v${pkg.version}`));
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const result = await runReview({
|
|
126
|
+
cwd,
|
|
127
|
+
commit: opts.commit,
|
|
128
|
+
level: opts.level,
|
|
129
|
+
type: opts.type,
|
|
130
|
+
provider,
|
|
131
|
+
onStage: opts.json
|
|
132
|
+
? undefined
|
|
133
|
+
: (stage) => console.log(STAGE_MESSAGES[stage]),
|
|
134
|
+
});
|
|
135
|
+
if (opts.json) {
|
|
136
|
+
reportJson(result);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
reportConsole(result);
|
|
140
|
+
}
|
|
141
|
+
process.exitCode = isMergeReady(result.score) ? 0 : 1;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (err instanceof NoChangesError) {
|
|
145
|
+
console.error(colors.yellow(err.message));
|
|
146
|
+
process.exitCode = 0;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
console.error(colors.red(err instanceof Error ? err.message : String(err)));
|
|
150
|
+
process.exitCode = 2;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local, encrypted credential store for provider API keys.
|
|
3
|
+
*
|
|
4
|
+
* CLI flags and env vars are per-invocation. This store lets a user pass
|
|
5
|
+
* --api-key/--model once and have it picked up automatically on every later
|
|
6
|
+
* run, without re-exporting an env var each time.
|
|
7
|
+
*
|
|
8
|
+
* The encryption key is a random 32-byte value generated on first use and
|
|
9
|
+
* kept in a file (key) separate from the ciphertext (credentials.json),
|
|
10
|
+
* both under the user's home directory with owner-only permissions. This
|
|
11
|
+
* guards against accidental exposure (e.g. a backup or sync tool scooping up
|
|
12
|
+
* one file but not the other) - it does not protect against an attacker who
|
|
13
|
+
* already has read access to the user's home directory, since the key lives
|
|
14
|
+
* on the same machine as the ciphertext.
|
|
15
|
+
*/
|
|
16
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
const CONFIG_DIR = path.join(homedir(), ".can-i-merge");
|
|
21
|
+
const KEY_FILE = path.join(CONFIG_DIR, "key");
|
|
22
|
+
const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
|
|
23
|
+
const ALGORITHM = "aes-256-gcm";
|
|
24
|
+
function ensureConfigDir() {
|
|
25
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
26
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function getOrCreateKey() {
|
|
30
|
+
ensureConfigDir();
|
|
31
|
+
if (existsSync(KEY_FILE)) {
|
|
32
|
+
return Buffer.from(readFileSync(KEY_FILE, "utf8"), "base64");
|
|
33
|
+
}
|
|
34
|
+
const key = randomBytes(32);
|
|
35
|
+
writeFileSync(KEY_FILE, key.toString("base64"), { mode: 0o600 });
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
function encrypt(plaintext, key) {
|
|
39
|
+
const iv = randomBytes(12);
|
|
40
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
41
|
+
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
42
|
+
return {
|
|
43
|
+
iv: iv.toString("base64"),
|
|
44
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
45
|
+
data: data.toString("base64"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function decrypt(value, key) {
|
|
49
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(value.iv, "base64"));
|
|
50
|
+
decipher.setAuthTag(Buffer.from(value.tag, "base64"));
|
|
51
|
+
const data = Buffer.concat([
|
|
52
|
+
decipher.update(Buffer.from(value.data, "base64")),
|
|
53
|
+
decipher.final(),
|
|
54
|
+
]);
|
|
55
|
+
return data.toString("utf8");
|
|
56
|
+
}
|
|
57
|
+
function readCredentialsFile() {
|
|
58
|
+
if (!existsSync(CREDENTIALS_FILE)) {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function writeCredentialsFile(contents) {
|
|
69
|
+
ensureConfigDir();
|
|
70
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(contents, null, 2), { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
/** Persist an API key and/or model for a provider, encrypting the API key at rest. */
|
|
73
|
+
export function saveProviderConfig(provider, config) {
|
|
74
|
+
const file = readCredentialsFile();
|
|
75
|
+
const next = { ...file[provider] };
|
|
76
|
+
if (config.apiKey !== undefined) {
|
|
77
|
+
next.apiKey = encrypt(config.apiKey, getOrCreateKey());
|
|
78
|
+
}
|
|
79
|
+
if (config.model !== undefined) {
|
|
80
|
+
next.model = config.model;
|
|
81
|
+
}
|
|
82
|
+
file[provider] = next;
|
|
83
|
+
writeCredentialsFile(file);
|
|
84
|
+
}
|
|
85
|
+
/** Load a previously persisted API key/model for a provider, decrypting the API key. */
|
|
86
|
+
export function loadProviderConfig(provider) {
|
|
87
|
+
const stored = readCredentialsFile()[provider];
|
|
88
|
+
if (!stored) {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
const result = {};
|
|
92
|
+
if (stored.apiKey) {
|
|
93
|
+
try {
|
|
94
|
+
result.apiKey = decrypt(stored.apiKey, getOrCreateKey());
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Key file missing/rotated or ciphertext corrupted - treat as absent
|
|
98
|
+
// rather than crashing the CLI.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (stored.model) {
|
|
102
|
+
result.model = stored.model;
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
/** Remove stored credentials for a provider (or all providers if omitted). */
|
|
107
|
+
export function clearProviderConfig(provider) {
|
|
108
|
+
if (!existsSync(CREDENTIALS_FILE)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!provider) {
|
|
112
|
+
unlinkSync(CREDENTIALS_FILE);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const file = readCredentialsFile();
|
|
116
|
+
delete file[provider];
|
|
117
|
+
writeCredentialsFile(file);
|
|
118
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Budget Engine
|
|
3
|
+
*
|
|
4
|
+
* Context Builder operates on a TOKEN BUDGET, not a fixed depth. The git
|
|
5
|
+
* diff itself is always included in full and its token cost is reserved off
|
|
6
|
+
* the top; the budget only decides how many extra *related* files get
|
|
7
|
+
* pulled in around it.
|
|
8
|
+
*/
|
|
9
|
+
import { estimateTokens } from "../shared/tokens.js";
|
|
10
|
+
export const DEFAULT_BUDGET = {
|
|
11
|
+
maxTokens: 12000,
|
|
12
|
+
maxFiles: 15,
|
|
13
|
+
reservedDiffTokens: 3000,
|
|
14
|
+
};
|
|
15
|
+
export function applyBudget(relatedFiles, diffText, budget) {
|
|
16
|
+
const diffTokens = Math.max(estimateTokens(diffText), budget.reservedDiffTokens);
|
|
17
|
+
const sorted = [...relatedFiles].sort((a, b) => b.score - a.score);
|
|
18
|
+
const included = [];
|
|
19
|
+
let truncated = false;
|
|
20
|
+
let includedTokens = 0;
|
|
21
|
+
for (const candidate of sorted) {
|
|
22
|
+
if (included.length + 1 > budget.maxFiles) {
|
|
23
|
+
truncated = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const candidateTokens = estimateTokens(candidate.content);
|
|
27
|
+
const remaining = budget.maxTokens - diffTokens - includedTokens;
|
|
28
|
+
if (candidateTokens > remaining) {
|
|
29
|
+
truncated = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
included.push(candidate);
|
|
33
|
+
includedTokens += candidateTokens;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
included,
|
|
37
|
+
truncated,
|
|
38
|
+
tokenEstimate: diffTokens + includedTokens,
|
|
39
|
+
};
|
|
40
|
+
}
|