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 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 ollama", "claude")
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, { apiKey: opts.apiKey, model: opts.model });
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 model for a provider, encrypting the API key at rest. */
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,6 @@
1
+ /**
2
+ * provider-custom package entry point. Re-exports the Custom provider.
3
+ * Does NOT register the provider into any registry - that wiring happens
4
+ * elsewhere (e.g. in the CLI or core composition root).
5
+ */
6
+ export * from "./customProvider.js";
@@ -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,6 @@
1
+ /**
2
+ * provider-gemini package entry point. Re-exports the Gemini provider.
3
+ * Does NOT register the provider into any registry - that wiring happens
4
+ * elsewhere (e.g. in the CLI or core composition root).
5
+ */
6
+ export * from "./geminiProvider.js";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * provider-openrouter package entry point. Re-exports the OpenRouter
3
+ * provider. Does NOT register the provider into any registry - that wiring
4
+ * happens elsewhere (e.g. in the CLI or core composition root).
5
+ */
6
+ export * from "./openRouterProvider.js";
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "can-i-merge",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "AI-powered Git Review Pipeline with Intelligent Context Building",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",