can-i-merge 0.2.0 → 0.3.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 +26 -1
- package/dist/cli/index.js +30 -7
- package/dist/config/credentialStore.js +8 -2
- package/dist/provider-custom/customProvider.js +38 -0
- package/dist/provider-custom/index.js +6 -0
- package/dist/provider-gemini/geminiProvider.js +33 -0
- package/dist/provider-gemini/index.js +6 -0
- package/dist/provider-openrouter/index.js +6 -0
- package/dist/provider-openrouter/openRouterProvider.js +33 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,9 +47,11 @@ The **Context Engine** is the real product.
|
|
|
47
47
|
- OpenAI
|
|
48
48
|
- NVIDIA
|
|
49
49
|
- Gemini
|
|
50
|
-
- Ollama
|
|
50
|
+
- Ollama (local LLM)
|
|
51
51
|
- OpenRouter
|
|
52
|
+
- Custom (any OpenAI-compatible endpoint, including self-hosted/local LLM servers)
|
|
52
53
|
- Provider abstraction
|
|
54
|
+
- Encrypted local credential storage (`--api-key`/`--model`/`--base-url`, saved for future runs)
|
|
53
55
|
- GitHub Action (Planned)
|
|
54
56
|
- VSCode Extension (Planned)
|
|
55
57
|
- MCP Server (Planned)
|
|
@@ -112,6 +114,26 @@ Choose AI provider
|
|
|
112
114
|
can-i-merge --provider claude
|
|
113
115
|
```
|
|
114
116
|
|
|
117
|
+
Use a local LLM (e.g. Ollama)
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
can-i-merge --provider ollama --model llama3.1
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Point at any custom OpenAI-compatible endpoint (self-hosted, local server, etc.)
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
can-i-merge --provider custom --base-url http://localhost:1234/v1 --model local-model
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
API key / model / base URL are saved encrypted under `~/.can-i-merge` after the first run, so later runs don't need the flags again:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
can-i-merge --provider openai --api-key sk-... --model gpt-4.1 # saved
|
|
133
|
+
can-i-merge --provider openai # reuses the saved key/model
|
|
134
|
+
can-i-merge --provider openai --forget-credentials # removes them
|
|
135
|
+
```
|
|
136
|
+
|
|
115
137
|
Security review
|
|
116
138
|
|
|
117
139
|
```bash
|
|
@@ -200,6 +222,9 @@ Move authorization check before JWT validation.
|
|
|
200
222
|
│ NVIDIA │
|
|
201
223
|
│ Gemini │
|
|
202
224
|
│ Ollama │
|
|
225
|
+
│ OpenRouter │
|
|
226
|
+
│ Custom (any OpenAI- │
|
|
227
|
+
│ compatible endpoint) │
|
|
203
228
|
└──────────────────────────┘
|
|
204
229
|
│
|
|
205
230
|
▼
|
package/dist/cli/index.js
CHANGED
|
@@ -15,9 +15,12 @@ import { NoChangesError, runReview } from "../core/index.js";
|
|
|
15
15
|
import { isGitRepository } from "../git/index.js";
|
|
16
16
|
import { ProviderRegistry } from "../provider/index.js";
|
|
17
17
|
import { ClaudeProvider } from "../provider-anthropic/index.js";
|
|
18
|
+
import { CustomProvider } from "../provider-custom/index.js";
|
|
19
|
+
import { GeminiProvider } from "../provider-gemini/index.js";
|
|
18
20
|
import { NvidiaProvider } from "../provider-nvidia/index.js";
|
|
19
21
|
import { OllamaProvider } from "../provider-ollama/index.js";
|
|
20
22
|
import { OpenAIProvider } from "../provider-openai/index.js";
|
|
23
|
+
import { OpenRouterProvider } from "../provider-openrouter/index.js";
|
|
21
24
|
import { isMergeReady, reportConsole, reportJson } from "../reporter/index.js";
|
|
22
25
|
import { colors } from "../shared/colors.js";
|
|
23
26
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -37,9 +40,16 @@ const STAGE_MESSAGES = {
|
|
|
37
40
|
};
|
|
38
41
|
const PROVIDER_ENV_VARS = {
|
|
39
42
|
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
|
+
openai: { apiKey: "OPENAI_API_KEY", model: "OPENAI_MODEL", baseURL: "OPENAI_BASE_URL" },
|
|
44
|
+
nvidia: { apiKey: "NVIDIA_API_KEY", model: "NVIDIA_MODEL", baseURL: "NVIDIA_BASE_URL" },
|
|
45
|
+
ollama: { apiKey: "OLLAMA_API_KEY", model: "OLLAMA_MODEL", baseURL: "OLLAMA_BASE_URL" },
|
|
46
|
+
gemini: { apiKey: "GEMINI_API_KEY", model: "GEMINI_MODEL", baseURL: "GEMINI_BASE_URL" },
|
|
47
|
+
openrouter: {
|
|
48
|
+
apiKey: "OPENROUTER_API_KEY",
|
|
49
|
+
model: "OPENROUTER_MODEL",
|
|
50
|
+
baseURL: "OPENROUTER_BASE_URL",
|
|
51
|
+
},
|
|
52
|
+
custom: { apiKey: "CUSTOM_API_KEY", model: "CUSTOM_MODEL", baseURL: "CUSTOM_BASE_URL" },
|
|
43
53
|
};
|
|
44
54
|
function isReviewLevel(value) {
|
|
45
55
|
return REVIEW_LEVELS.includes(value);
|
|
@@ -53,6 +63,9 @@ function buildProviderRegistry() {
|
|
|
53
63
|
registry.register("openai", (config) => new OpenAIProvider(config));
|
|
54
64
|
registry.register("nvidia", (config) => new NvidiaProvider(config));
|
|
55
65
|
registry.register("ollama", (config) => new OllamaProvider(config));
|
|
66
|
+
registry.register("gemini", (config) => new GeminiProvider(config));
|
|
67
|
+
registry.register("openrouter", (config) => new OpenRouterProvider(config));
|
|
68
|
+
registry.register("custom", (config) => new CustomProvider(config));
|
|
56
69
|
return registry;
|
|
57
70
|
}
|
|
58
71
|
export async function main(argv = process.argv) {
|
|
@@ -62,9 +75,12 @@ export async function main(argv = process.argv) {
|
|
|
62
75
|
.description("AI-powered Git Review Pipeline with Intelligent Context Building")
|
|
63
76
|
.version(pkg.version)
|
|
64
77
|
.option("--commit <ref>", "review a specific commit instead of the staged index")
|
|
65
|
-
.option("--provider <name>", "AI provider to use: claude, openai, nvidia, or
|
|
78
|
+
.option("--provider <name>", "AI provider to use: claude, openai, nvidia, ollama, gemini, openrouter, or custom " +
|
|
79
|
+
"(any OpenAI-compatible endpoint, including local LLM servers)", "claude")
|
|
66
80
|
.option("--api-key <key>", "API key for the selected provider (saved encrypted for future runs)")
|
|
67
81
|
.option("--model <model>", "model name for the selected provider (saved for future runs)")
|
|
82
|
+
.option("--base-url <url>", "API base URL for the selected provider, e.g. a local LLM server or other " +
|
|
83
|
+
"OpenAI-compatible endpoint (saved for future runs; required for --provider custom)")
|
|
68
84
|
.option("--forget-credentials", "remove stored credentials for the selected provider and exit", false)
|
|
69
85
|
.option("--level <level>", "review level: fast, normal, or deep", "normal")
|
|
70
86
|
.option("--type <type>", "review focus: general, security, performance, architecture, or style", "general")
|
|
@@ -98,8 +114,12 @@ export async function main(argv = process.argv) {
|
|
|
98
114
|
process.exitCode = 2;
|
|
99
115
|
return;
|
|
100
116
|
}
|
|
101
|
-
if (opts.apiKey !== undefined || opts.model !== undefined) {
|
|
102
|
-
saveProviderConfig(opts.provider, {
|
|
117
|
+
if (opts.apiKey !== undefined || opts.model !== undefined || opts.baseUrl !== undefined) {
|
|
118
|
+
saveProviderConfig(opts.provider, {
|
|
119
|
+
apiKey: opts.apiKey,
|
|
120
|
+
model: opts.model,
|
|
121
|
+
baseURL: opts.baseUrl,
|
|
122
|
+
});
|
|
103
123
|
if (!opts.json) {
|
|
104
124
|
console.log(colors.dim(`Saved ${opts.provider} credentials to ~/.can-i-merge (encrypted).`));
|
|
105
125
|
}
|
|
@@ -108,10 +128,13 @@ export async function main(argv = process.argv) {
|
|
|
108
128
|
const envVars = PROVIDER_ENV_VARS[opts.provider];
|
|
109
129
|
const apiKey = opts.apiKey ?? (envVars && process.env[envVars.apiKey]) ?? stored.apiKey;
|
|
110
130
|
const model = opts.model ?? (envVars && process.env[envVars.model]) ?? stored.model;
|
|
131
|
+
const baseURL = opts.baseUrl ??
|
|
132
|
+
(envVars?.baseURL && process.env[envVars.baseURL]) ??
|
|
133
|
+
stored.baseURL;
|
|
111
134
|
const registry = buildProviderRegistry();
|
|
112
135
|
let provider;
|
|
113
136
|
try {
|
|
114
|
-
provider = registry.create(opts.provider, { apiKey, model });
|
|
137
|
+
provider = registry.create(opts.provider, { apiKey, model, baseURL });
|
|
115
138
|
}
|
|
116
139
|
catch (err) {
|
|
117
140
|
console.error(colors.red(err instanceof Error ? err.message : String(err)));
|
|
@@ -69,7 +69,7 @@ function writeCredentialsFile(contents) {
|
|
|
69
69
|
ensureConfigDir();
|
|
70
70
|
writeFileSync(CREDENTIALS_FILE, JSON.stringify(contents, null, 2), { mode: 0o600 });
|
|
71
71
|
}
|
|
72
|
-
/** Persist an API key and/or
|
|
72
|
+
/** Persist an API key, model, and/or base URL for a provider, encrypting the API key at rest. */
|
|
73
73
|
export function saveProviderConfig(provider, config) {
|
|
74
74
|
const file = readCredentialsFile();
|
|
75
75
|
const next = { ...file[provider] };
|
|
@@ -79,10 +79,13 @@ export function saveProviderConfig(provider, config) {
|
|
|
79
79
|
if (config.model !== undefined) {
|
|
80
80
|
next.model = config.model;
|
|
81
81
|
}
|
|
82
|
+
if (config.baseURL !== undefined) {
|
|
83
|
+
next.baseURL = config.baseURL;
|
|
84
|
+
}
|
|
82
85
|
file[provider] = next;
|
|
83
86
|
writeCredentialsFile(file);
|
|
84
87
|
}
|
|
85
|
-
/** Load a previously persisted API key/model for a provider, decrypting the API key. */
|
|
88
|
+
/** Load a previously persisted API key/model/base URL for a provider, decrypting the API key. */
|
|
86
89
|
export function loadProviderConfig(provider) {
|
|
87
90
|
const stored = readCredentialsFile()[provider];
|
|
88
91
|
if (!stored) {
|
|
@@ -101,6 +104,9 @@ export function loadProviderConfig(provider) {
|
|
|
101
104
|
if (stored.model) {
|
|
102
105
|
result.model = stored.model;
|
|
103
106
|
}
|
|
107
|
+
if (stored.baseURL) {
|
|
108
|
+
result.baseURL = stored.baseURL;
|
|
109
|
+
}
|
|
104
110
|
return result;
|
|
105
111
|
}
|
|
106
112
|
/** Remove stored credentials for a provider (or all providers if omitted). */
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom provider - talks to any OpenAI chat-completions-compatible
|
|
3
|
+
* endpoint the user points it at: a self-hosted/local LLM server (LM
|
|
4
|
+
* Studio, vLLM, llama.cpp server, text-generation-webui, ...), or any
|
|
5
|
+
* other API that speaks the OpenAI wire format. Unlike the other
|
|
6
|
+
* OpenAI-compatible providers, there is no default base URL - the caller
|
|
7
|
+
* must supply one. Delegates the actual request/response handling to the
|
|
8
|
+
* shared OpenAI-compatible reviewer.
|
|
9
|
+
*/
|
|
10
|
+
import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
|
|
11
|
+
export class CustomProvider {
|
|
12
|
+
name = "custom";
|
|
13
|
+
delegate;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
const baseURL = options.baseURL ?? process.env.CUSTOM_BASE_URL;
|
|
16
|
+
if (!baseURL) {
|
|
17
|
+
throw new Error('Missing endpoint URL for the custom provider. Set the CUSTOM_BASE_URL environment variable, or pass --base-url / { baseURL } (e.g. "http://localhost:1234/v1" for a local server, or the base URL of any OpenAI-compatible API).');
|
|
18
|
+
}
|
|
19
|
+
const model = options.model ?? process.env.CUSTOM_MODEL;
|
|
20
|
+
if (!model) {
|
|
21
|
+
throw new Error("Missing model for the custom provider. Set the CUSTOM_MODEL environment variable, or pass --model / { model }.");
|
|
22
|
+
}
|
|
23
|
+
// Many self-hosted/local servers don't require a real API key, but the
|
|
24
|
+
// OpenAI SDK still needs a non-empty string - fall back to a
|
|
25
|
+
// placeholder, same as the Ollama provider does.
|
|
26
|
+
const apiKey = options.apiKey ?? process.env.CUSTOM_API_KEY ?? "not-needed";
|
|
27
|
+
this.delegate = createOpenAICompatibleReviewer({
|
|
28
|
+
providerName: this.name,
|
|
29
|
+
apiKey,
|
|
30
|
+
model,
|
|
31
|
+
baseURL,
|
|
32
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async review(context) {
|
|
36
|
+
return this.delegate.review(context);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini provider - talks to Google's OpenAI-compatible endpoint for
|
|
3
|
+
* Gemini models at https://generativelanguage.googleapis.com/v1beta/openai/.
|
|
4
|
+
* Delegates the actual request/response handling to the shared
|
|
5
|
+
* OpenAI-compatible reviewer.
|
|
6
|
+
*/
|
|
7
|
+
import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
|
|
8
|
+
export const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
|
|
9
|
+
export class GeminiProvider {
|
|
10
|
+
name = "gemini";
|
|
11
|
+
delegate;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
const apiKey = options.apiKey ?? process.env.GEMINI_API_KEY;
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new Error("Missing Gemini API key. Set the GEMINI_API_KEY environment variable, or pass { apiKey } when constructing GeminiProvider.");
|
|
16
|
+
}
|
|
17
|
+
const model = options.model ?? process.env.GEMINI_MODEL;
|
|
18
|
+
if (!model) {
|
|
19
|
+
throw new Error('Missing Gemini model. Set the GEMINI_MODEL environment variable, or pass { model } when constructing GeminiProvider (e.g. "gemini-2.5-flash").');
|
|
20
|
+
}
|
|
21
|
+
const baseURL = options.baseURL ?? process.env.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL;
|
|
22
|
+
this.delegate = createOpenAICompatibleReviewer({
|
|
23
|
+
providerName: this.name,
|
|
24
|
+
apiKey,
|
|
25
|
+
model,
|
|
26
|
+
baseURL,
|
|
27
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async review(context) {
|
|
31
|
+
return this.delegate.review(context);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter provider - talks to OpenRouter's OpenAI-compatible endpoint at
|
|
3
|
+
* https://openrouter.ai/api/v1, which fronts many models (Claude, GPT,
|
|
4
|
+
* Gemini, Llama, and more) behind a single API key. Delegates the actual
|
|
5
|
+
* request/response handling to the shared OpenAI-compatible reviewer.
|
|
6
|
+
*/
|
|
7
|
+
import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
|
|
8
|
+
export const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
9
|
+
export class OpenRouterProvider {
|
|
10
|
+
name = "openrouter";
|
|
11
|
+
delegate;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
const apiKey = options.apiKey ?? process.env.OPENROUTER_API_KEY;
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new Error("Missing OpenRouter API key. Set the OPENROUTER_API_KEY environment variable, or pass { apiKey } when constructing OpenRouterProvider.");
|
|
16
|
+
}
|
|
17
|
+
const model = options.model ?? process.env.OPENROUTER_MODEL;
|
|
18
|
+
if (!model) {
|
|
19
|
+
throw new Error('Missing OpenRouter model. Set the OPENROUTER_MODEL environment variable, or pass { model } when constructing OpenRouterProvider (e.g. "anthropic/claude-sonnet-5" - see the model catalog at https://openrouter.ai/models).');
|
|
20
|
+
}
|
|
21
|
+
const baseURL = options.baseURL ?? process.env.OPENROUTER_BASE_URL ?? DEFAULT_OPENROUTER_BASE_URL;
|
|
22
|
+
this.delegate = createOpenAICompatibleReviewer({
|
|
23
|
+
providerName: this.name,
|
|
24
|
+
apiKey,
|
|
25
|
+
model,
|
|
26
|
+
baseURL,
|
|
27
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async review(context) {
|
|
31
|
+
return this.delegate.review(context);
|
|
32
|
+
}
|
|
33
|
+
}
|