can-i-merge 0.1.1 → 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/dist/cli/index.js CHANGED
@@ -10,10 +10,14 @@ import { readFileSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { Command } from "commander";
13
+ import { clearProviderConfig, loadProviderConfig, saveProviderConfig } from "../config/index.js";
13
14
  import { NoChangesError, runReview } from "../core/index.js";
14
15
  import { isGitRepository } from "../git/index.js";
15
16
  import { ProviderRegistry } from "../provider/index.js";
16
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";
17
21
  import { isMergeReady, reportConsole, reportJson } from "../reporter/index.js";
18
22
  import { colors } from "../shared/colors.js";
19
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -31,6 +35,12 @@ const STAGE_MESSAGES = {
31
35
  context: "Building Context...",
32
36
  reviewing: "Reviewing...",
33
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
+ };
34
44
  function isReviewLevel(value) {
35
45
  return REVIEW_LEVELS.includes(value);
36
46
  }
@@ -40,6 +50,9 @@ function isReviewType(value) {
40
50
  function buildProviderRegistry() {
41
51
  const registry = new ProviderRegistry();
42
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));
43
56
  return registry;
44
57
  }
45
58
  export async function main(argv = process.argv) {
@@ -49,13 +62,21 @@ export async function main(argv = process.argv) {
49
62
  .description("AI-powered Git Review Pipeline with Intelligent Context Building")
50
63
  .version(pkg.version)
51
64
  .option("--commit <ref>", "review a specific commit instead of the staged index")
52
- .option("--provider <name>", "AI provider to use", "claude")
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)
53
69
  .option("--level <level>", "review level: fast, normal, or deep", "normal")
54
70
  .option("--type <type>", "review focus: general, security, performance, architecture, or style", "general")
55
71
  .option("--json", "output the review result as JSON", false)
56
72
  .option("--fix", "automatically apply suggested fixes (not yet supported)", false);
57
73
  program.parse(argv);
58
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
+ }
59
80
  if (opts.fix) {
60
81
  console.error(colors.yellow("--fix is not supported yet (see TOBE.md roadmap, Phase 4)."));
61
82
  process.exitCode = 2;
@@ -77,10 +98,20 @@ export async function main(argv = process.argv) {
77
98
  process.exitCode = 2;
78
99
  return;
79
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;
80
111
  const registry = buildProviderRegistry();
81
112
  let provider;
82
113
  try {
83
- provider = registry.create(opts.provider);
114
+ provider = registry.create(opts.provider, { apiKey, model });
84
115
  }
85
116
  catch (err) {
86
117
  console.error(colors.red(err instanceof Error ? err.message : String(err)));
@@ -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,4 @@
1
+ /**
2
+ * config package entry point. Re-exports the encrypted credential store.
3
+ */
4
+ export * from "./credentialStore.js";
@@ -0,0 +1 @@
1
+ export * from "./nvidiaProvider.js";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * NVIDIA provider - talks to NVIDIA's OpenAI-chat-completions-compatible
3
+ * endpoint for NIM-hosted foundation models (e.g. Llama, Nemotron) at
4
+ * https://integrate.api.nvidia.com/v1. Delegates the actual request/response
5
+ * handling to the shared OpenAI-compatible reviewer.
6
+ */
7
+ import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
8
+ export const DEFAULT_NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
9
+ export class NvidiaProvider {
10
+ name = "nvidia";
11
+ delegate;
12
+ constructor(options = {}) {
13
+ const apiKey = options.apiKey ?? process.env.NVIDIA_API_KEY;
14
+ if (!apiKey) {
15
+ throw new Error("Missing NVIDIA API key. Set the NVIDIA_API_KEY environment variable, or pass { apiKey } when constructing NvidiaProvider.");
16
+ }
17
+ const model = options.model ?? process.env.NVIDIA_MODEL;
18
+ if (!model) {
19
+ throw new Error('Missing NVIDIA model. Set the NVIDIA_MODEL environment variable, or pass { model } when constructing NvidiaProvider (e.g. "meta/llama-3.1-70b-instruct" - see the model catalog at https://build.nvidia.com).');
20
+ }
21
+ const baseURL = options.baseURL ?? process.env.NVIDIA_BASE_URL ?? DEFAULT_NVIDIA_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-ollama package entry point. Re-exports the Ollama 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 "./ollamaProvider.js";
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Ollama provider - talks to a local Ollama instance via its OpenAI
3
+ * chat-completions-compatible endpoint (http://localhost:11434/v1 by
4
+ * default). Delegates the actual request/response handling to
5
+ * createOpenAICompatibleReviewer.
6
+ */
7
+ import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
8
+ export const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
9
+ export class OllamaProvider {
10
+ name = "ollama";
11
+ delegate;
12
+ constructor(options = {}) {
13
+ // Ollama does not require a real API key for local use, but the OpenAI
14
+ // SDK requires some non-empty string - fall back to a placeholder.
15
+ const apiKey = options.apiKey ?? process.env.OLLAMA_API_KEY ?? "ollama";
16
+ const model = options.model ?? process.env.OLLAMA_MODEL;
17
+ if (!model) {
18
+ throw new Error('Missing Ollama model. Set the OLLAMA_MODEL environment variable, or pass { model } when ' +
19
+ 'constructing OllamaProvider to the name of a model you have pulled locally (e.g. "ollama pull ' +
20
+ 'llama3.1", then OLLAMA_MODEL=llama3.1). The model must support tool/function calling.');
21
+ }
22
+ const baseURL = options.baseURL ?? process.env.OLLAMA_BASE_URL ?? DEFAULT_OLLAMA_BASE_URL;
23
+ this.delegate = createOpenAICompatibleReviewer({
24
+ providerName: this.name,
25
+ apiKey,
26
+ model,
27
+ baseURL,
28
+ maxOutputTokens: options.maxOutputTokens,
29
+ });
30
+ }
31
+ async review(context) {
32
+ return this.delegate.review(context);
33
+ }
34
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * provider-openai package entry point. Re-exports the OpenAI 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 "./openAIProvider.js";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * OpenAI provider - implements ReviewProvider by delegating to the shared
3
+ * OpenAI-compatible reviewer (createOpenAICompatibleReviewer), which speaks
4
+ * the OpenAI chat-completions wire format and forces structured output via
5
+ * a "submit_review" tool call.
6
+ */
7
+ import { createOpenAICompatibleReviewer } from "../shared/openaiCompatibleProvider.js";
8
+ export class OpenAIProvider {
9
+ name = "openai";
10
+ delegate;
11
+ constructor(options = {}) {
12
+ const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
13
+ if (!apiKey) {
14
+ throw new Error("Missing OpenAI API key. Set the OPENAI_API_KEY environment variable, or pass { apiKey } when constructing OpenAIProvider.");
15
+ }
16
+ const model = options.model ?? process.env.OPENAI_MODEL;
17
+ if (!model) {
18
+ throw new Error('Missing OpenAI model. Set the OPENAI_MODEL environment variable, or pass { model } when constructing OpenAIProvider (e.g. "gpt-4.1").');
19
+ }
20
+ const baseURL = options.baseURL ?? process.env.OPENAI_BASE_URL;
21
+ this.delegate = createOpenAICompatibleReviewer({
22
+ providerName: this.name,
23
+ apiKey,
24
+ model,
25
+ baseURL,
26
+ maxOutputTokens: options.maxOutputTokens,
27
+ });
28
+ }
29
+ async review(context) {
30
+ return this.delegate.review(context);
31
+ }
32
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Shared base for any ReviewProvider that speaks the OpenAI chat-completions
3
+ * wire format - OpenAI itself, and OpenAI-compatible endpoints (NVIDIA NIM,
4
+ * Ollama's /v1 API). Each concrete provider (provider-openai, provider-nvidia,
5
+ * provider-ollama) just supplies its own api key / model / base URL defaults
6
+ * and delegates review() to the reviewer this factory returns.
7
+ */
8
+ import OpenAI from "openai";
9
+ const SUBMIT_REVIEW_FUNCTION_NAME = "submit_review";
10
+ function buildSubmitReviewTool() {
11
+ return {
12
+ type: "function",
13
+ function: {
14
+ name: SUBMIT_REVIEW_FUNCTION_NAME,
15
+ description: "Submit the code review findings: an overall summary plus a list of issues discovered in the diff.",
16
+ parameters: {
17
+ type: "object",
18
+ properties: {
19
+ summary: {
20
+ type: "string",
21
+ description: "A concise, high-level summary of the overall review.",
22
+ },
23
+ issues: {
24
+ type: "array",
25
+ description: "The list of individual issues found while reviewing the diff.",
26
+ items: {
27
+ type: "object",
28
+ properties: {
29
+ severity: {
30
+ type: "string",
31
+ enum: ["critical", "high", "medium", "low"],
32
+ description: "How severe this issue is.",
33
+ },
34
+ category: {
35
+ type: "string",
36
+ enum: ["security", "performance", "architecture", "style", "bug"],
37
+ description: "The category this issue falls under.",
38
+ },
39
+ title: { type: "string", description: "A short, human-readable title for the issue." },
40
+ description: { type: "string", description: "A detailed explanation of the issue." },
41
+ file: { type: "string", description: "The path of the file this issue was found in." },
42
+ line: { type: "number", description: "The line number in the file where this issue occurs." },
43
+ suggestion: {
44
+ type: "string",
45
+ description: "A concrete suggestion for how to fix or address the issue.",
46
+ },
47
+ confidence: {
48
+ type: "number",
49
+ description: "A confidence score (0 to 1) that this issue is a real, valid finding.",
50
+ },
51
+ },
52
+ required: [
53
+ "severity",
54
+ "category",
55
+ "title",
56
+ "description",
57
+ "file",
58
+ "line",
59
+ "suggestion",
60
+ "confidence",
61
+ ],
62
+ },
63
+ },
64
+ },
65
+ required: ["summary", "issues"],
66
+ },
67
+ },
68
+ };
69
+ }
70
+ const submitReviewTool = buildSubmitReviewTool();
71
+ /**
72
+ * Builds a ReviewProvider that talks to any OpenAI chat-completions-compatible
73
+ * endpoint, forcing structured output via a named tool call (mirrors how
74
+ * ClaudeProvider forces its submit_review tool use).
75
+ */
76
+ export function createOpenAICompatibleReviewer(options) {
77
+ const client = new OpenAI({ apiKey: options.apiKey, baseURL: options.baseURL });
78
+ return {
79
+ name: options.providerName,
80
+ async review(context) {
81
+ if (!context.prompt) {
82
+ throw new Error("ReviewContext.prompt is not set - run the Prompt Builder (buildPrompt) before calling provider.review().");
83
+ }
84
+ const start = Date.now();
85
+ let response;
86
+ try {
87
+ response = await client.chat.completions.create({
88
+ model: options.model,
89
+ max_tokens: options.maxOutputTokens ?? 4096,
90
+ messages: [
91
+ { role: "system", content: context.prompt.system },
92
+ { role: "user", content: context.prompt.user },
93
+ ],
94
+ tools: [submitReviewTool],
95
+ tool_choice: { type: "function", function: { name: SUBMIT_REVIEW_FUNCTION_NAME } },
96
+ });
97
+ }
98
+ catch (err) {
99
+ throw new Error(`${options.providerName} API request failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
100
+ }
101
+ const toolCall = response.choices[0]?.message?.tool_calls?.find((call) => call.type === "function" && call.function.name === SUBMIT_REVIEW_FUNCTION_NAME);
102
+ if (!toolCall) {
103
+ throw new Error(`${options.providerName} did not return a submit_review tool call. Make sure the selected ` +
104
+ `model ("${options.model}") supports tool/function calling.`);
105
+ }
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(toolCall.function.arguments);
109
+ }
110
+ catch (err) {
111
+ throw new Error(`${options.providerName} returned malformed JSON for the submit_review tool call arguments.`, { cause: err });
112
+ }
113
+ const input = parsed;
114
+ return {
115
+ payload: {
116
+ summary: input.summary ?? "",
117
+ issues: input.issues ?? [],
118
+ },
119
+ provider: options.providerName,
120
+ model: options.model,
121
+ durationMs: Date.now() - start,
122
+ };
123
+ },
124
+ };
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "can-i-merge",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "AI-powered Git Review Pipeline with Intelligent Context Building",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,7 +32,8 @@
32
32
  "license": "SEE LICENSE IN license",
33
33
  "dependencies": {
34
34
  "@anthropic-ai/sdk": "^0.110.0",
35
- "commander": "^15.0.0"
35
+ "commander": "^15.0.0",
36
+ "openai": "^6.45.0"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@types/node": "^26.1.0",