Skip to content

Commit 979ddd2

Browse files
author
github-actions
committed
fix: added support for multiple LLM providers
1 parent 2759ca8 commit 979ddd2

6 files changed

Lines changed: 139 additions & 76 deletions

File tree

packages/cli/package.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,21 @@
99
"main": "./dist/index.js",
1010
"scripts": {
1111
"build": "tsc",
12-
1312
"dev": "tsc --watch",
1413
"test": "vitest run"
1514
},
1615
"dependencies": {
1716
"@grotto/core": "workspace:*",
18-
"commander": "^12.1.0",
17+
"@inquirer/prompts": "8.3.2",
18+
"boxen": "^7.1.1",
1919
"chalk": "^5.3.0",
20-
"ora": "^8.0.1",
21-
"conf": "^12.0.0",
2220
"chokidar": "^3.6.0",
23-
"boxen": "^7.1.1",
24-
"table": "^6.8.2",
25-
"dotenv": "^16.4.5"
21+
"commander": "^12.1.0",
22+
"conf": "^12.0.0",
23+
"dotenv": "^16.4.5",
24+
"inquirer": "13.3.2",
25+
"ora": "^8.0.1",
26+
"table": "^6.8.2"
2627
},
2728
"devDependencies": {
2829
"@types/node": "^20.14.0",

packages/cli/src/commands/analyze.ts

Lines changed: 55 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
analyzeKnowledge,
1414
analyzeImpact,
1515
analyzeRot,
16-
createAIClient,
16+
getAIProvider,
1717
generateSummary,
1818
type AnalysisResult
1919
} from "@grotto/core";
@@ -82,67 +82,82 @@ export const analyzeCommand = new Command("analyze")
8282
const cachedResult = latestCommit ? getCachedResult(cache, repoRoot, latestCommit) : null;
8383

8484
if (cachedResult) {
85-
spinner.succeed(chalk.green(`Loaded from cache for ${latestCommit.slice(0, 7)}.`));
86-
if (options.output) {
87-
// Proceed to save output if requested
88-
await handleReportExport(cachedResult, repoPath, repoRoot, options, spinner);
89-
} else {
90-
printConsoleReport(cachedResult, options.detailLevel);
85+
if (!options.ai || (options.ai && cachedResult.aiSummary)) {
86+
spinner.succeed(chalk.green(`Loaded from cache for ${latestCommit.slice(0, 7)}.`));
87+
if (options.output) {
88+
await handleReportExport(cachedResult, repoPath, repoRoot, options, spinner);
89+
} else {
90+
printConsoleReport(cachedResult, options.detailLevel, !!options.ai);
91+
}
92+
return;
9193
}
92-
return;
94+
spinner.info(chalk.blue(`Found cached analysis for ${latestCommit.slice(0, 7)}, but AI summary is missing. Generating now...`));
9395
}
9496

95-
const commits = await getCommits(git, {
96-
branch: options.branch,
97-
maxCount: parseInt(options.maxCommits, 10)
98-
});
97+
const commits = cachedResult
98+
? [] // We won't re-fetch commits if we have a cached result and just need AI
99+
: await getCommits(git, {
100+
branch: options.branch,
101+
maxCount: parseInt(options.maxCommits, 10)
102+
});
99103

100-
if (commits.length === 0) {
104+
if (!cachedResult && commits.length === 0) {
101105
spinner.fail(chalk.red("No commits found in the specified window/branch."));
102106
return;
103107
}
104108

105-
spinner.text = `Analyzing ${commits.length} commits...`;
106-
107-
const hotspots = analyzeHotspots(commits, options.window as any);
108-
const riskScores = computeRiskScores(hotspots);
109-
const churn = analyzeChurn(commits, options.window as any);
110-
const contributors = analyzeContributors(commits);
111-
const burnout = analyzeBurnout(commits);
112-
const coupling = analyzeCoupling(commits);
113-
const knowledge = analyzeKnowledge(commits);
114-
const impact = analyzeImpact(commits);
115-
const rot = analyzeRot(commits);
116-
117-
const result: AnalysisResult = {
109+
const result: AnalysisResult = cachedResult || {
118110
meta: {
119111
repoPath,
120112
branch: options.branch,
121113
window: options.window,
122114
commitCount: commits.length,
123115
generatedAt: new Date(),
124116
},
125-
hotspots,
126-
riskScores,
127-
churn,
128-
contributors,
129-
burnout,
130-
coupling,
131-
knowledge,
132-
impact,
133-
rot
117+
hotspots: analyzeHotspots(commits, options.window as any),
118+
riskScores: computeRiskScores(analyzeHotspots(commits, options.window as any)), // Simplified for rebuild
119+
churn: analyzeChurn(commits, options.window as any),
120+
contributors: analyzeContributors(commits),
121+
burnout: analyzeBurnout(commits),
122+
coupling: analyzeCoupling(commits),
123+
knowledge: analyzeKnowledge(commits),
124+
impact: analyzeImpact(commits),
125+
rot: analyzeRot(commits)
134126
};
135127

128+
// Re-calculate hotspots/risk if we don't have cached result (above logic is a bit messy, let's fix)
129+
if (!cachedResult) {
130+
spinner.text = `Analyzing ${commits.length} commits...`;
131+
const h = analyzeHotspots(commits, options.window as any);
132+
result.hotspots = h;
133+
result.riskScores = computeRiskScores(h);
134+
result.churn = analyzeChurn(commits, options.window as any);
135+
result.contributors = analyzeContributors(commits);
136+
result.burnout = analyzeBurnout(commits);
137+
result.coupling = analyzeCoupling(commits);
138+
result.knowledge = analyzeKnowledge(commits);
139+
result.impact = analyzeImpact(commits);
140+
result.rot = analyzeRot(commits);
141+
}
142+
136143
if (options.ai) {
137144
spinner.text = "Generating AI insights...";
138-
const apiKey = process.env[ENV_VARS.ANTHROPIC_API_KEY] || (config.get(CONFIG_KEYS.AI_KEY) as string);
139145

146+
// Resolve provider and key
147+
let providerType = (config.get(CONFIG_KEYS.AI_PROVIDER) as string) || "anthropic";
148+
let apiKey = process.env[ENV_VARS.ANTHROPIC_API_KEY];
149+
150+
if (providerType === "openai") apiKey = process.env[ENV_VARS.OPENAI_API_KEY] || (config.get("ai.openaiKey") as string);
151+
else if (providerType === "gemini") apiKey = process.env[ENV_VARS.GEMINI_API_KEY] || (config.get("ai.geminiKey") as string);
152+
else apiKey = apiKey || (config.get("ai.anthropicKey") as string) || (config.get(CONFIG_KEYS.AI_KEY) as string);
153+
140154
if (!apiKey) {
141-
spinner.warn(chalk.yellow("AI summary requested but no API key found. Skipping AI layer."));
155+
spinner.warn(chalk.yellow(`AI summary requested but no API key found for ${providerType}. Skipping AI layer.`));
156+
spinner.info(chalk.blue(`Run 'grotto config set-ai' to configure your preferred provider.`));
142157
} else {
143158
try {
144-
const aiClient = createAIClient(apiKey);
145-
result.aiSummary = await generateSummary(aiClient, result);
159+
const aiProvider = getAIProvider(providerType as any, apiKey);
160+
result.aiSummary = await generateSummary(aiProvider, result);
146161
} catch (aiErr) {
147162
spinner.warn(chalk.yellow("AI summary failed: " + (aiErr as Error).message));
148163
}
@@ -160,7 +175,7 @@ export const analyzeCommand = new Command("analyze")
160175
if (options.output) {
161176
await handleReportExport(result, repoPath, repoRoot, options, spinner);
162177
} else {
163-
printConsoleReport(result, options.detailLevel);
178+
printConsoleReport(result, options.detailLevel, !!options.ai);
164179
}
165180

166181
} catch (err) {

packages/cli/src/commands/config.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,36 @@ configCommand
1010
.command("set <key> <value>")
1111
.description("Set a configuration value (e.g., ai.key)")
1212
.action((key, value) => {
13-
if (key === CONFIG_KEYS.AI_KEY) {
14-
config.set(CONFIG_KEYS.AI_KEY, value);
15-
console.log(chalk.green("API Key stored securely in global config."));
16-
} else {
17-
config.set(key, value);
18-
console.log(chalk.green(`${key} set to ${value}`));
13+
config.set(key, value);
14+
console.log(chalk.green(`${key} set successfully.`));
15+
});
16+
17+
configCommand
18+
.command("set-ai")
19+
.description("Interactively configure AI provider and API key")
20+
.action(async () => {
21+
const { select, password } = await import("@inquirer/prompts");
22+
23+
const provider = await select({
24+
message: "Select an AI provider:",
25+
choices: [
26+
{ name: "Anthropic (Claude)", value: "anthropic" },
27+
{ name: "OpenAI (GPT-4o)", value: "openai" },
28+
{ name: "Google Gemini (1.5 Pro)", value: "gemini" }
29+
]
30+
});
31+
32+
const apiKey = await password({
33+
message: `Enter your ${provider} API Key:`,
34+
mask: "*"
35+
});
36+
37+
if (apiKey) {
38+
config.set("ai.provider", provider);
39+
config.set(`ai.${provider}Key`, apiKey);
40+
// Also set the main ai.key for backward compatibility/simplicity
41+
config.set("ai.key", apiKey);
42+
console.log(chalk.green(`\nAI configured successfully with ${provider}!`));
1943
}
2044
});
2145

@@ -24,7 +48,7 @@ configCommand
2448
.description("Get a configuration value")
2549
.action((key) => {
2650
const value = config.get(key);
27-
if (key === CONFIG_KEYS.AI_KEY && value) {
51+
if (key.toLowerCase().includes("key") && value) {
2852
console.log(`${key}: ${maskKey(value as string)}`);
2953
} else {
3054
console.log(`${key}: ${value ?? "not set"}`);
@@ -35,12 +59,19 @@ configCommand
3559
.command("list")
3660
.description("List all configuration")
3761
.action(() => {
38-
const all = config.store;
62+
const all = config.store as any;
3963
console.log(chalk.blue.bold(`\n--- ${PROJECT_NAME} Configuration ---`));
40-
for (const [key, val] of Object.entries(all || {})) {
41-
if (key === "ai" && typeof val === "object" && val !== null && "key" in val) {
42-
console.log(`${CONFIG_KEYS.AI_KEY}: ${maskKey((val as any).key as string)}`);
43-
} else {
64+
65+
if (all.ai) {
66+
console.log(`${chalk.yellow("AI Provider:")} ${all.ai.provider || "not set"}`);
67+
if (all.ai.anthropicKey) console.log(`${chalk.yellow("Anthropic Key:")} ${maskKey(all.ai.anthropicKey)}`);
68+
if (all.ai.openaiKey) console.log(`${chalk.yellow("OpenAI Key:")} ${maskKey(all.ai.openaiKey)}`);
69+
if (all.ai.geminiKey) console.log(`${chalk.yellow("Gemini Key:")} ${maskKey(all.ai.geminiKey)}`);
70+
}
71+
72+
// List other top-level keys if any
73+
for (const [key, val] of Object.entries(all)) {
74+
if (key !== "ai") {
4475
console.log(`${key}: ${JSON.stringify(val)}`);
4576
}
4677
}

packages/cli/src/config/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ const schema = {
55
ai: {
66
type: "object",
77
properties: {
8-
key: { type: "string" }
8+
key: { type: "string" },
9+
provider: { type: "string", enum: ["anthropic", "openai", "gemini"] },
10+
anthropicKey: { type: "string" },
11+
openaiKey: { type: "string" },
12+
geminiKey: { type: "string" }
913
}
1014
}
1115
} as const;

packages/cli/src/constants/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ export const DEFAULT_WINDOW = "30d";
55
export const DEFAULT_MAX_COMMITS = 500;
66

77
export const CONFIG_KEYS = {
8-
AI_KEY: "ai.key"
8+
AI_KEY: "ai.key",
9+
AI_PROVIDER: "ai.provider"
910
} as const;
1011

1112
export const ENV_VARS = {
12-
ANTHROPIC_API_KEY: "ANTHROPIC_API_KEY"
13+
ANTHROPIC_API_KEY: "ANTHROPIC_API_KEY",
14+
OPENAI_API_KEY: "OPENAI_API_KEY",
15+
GEMINI_API_KEY: "GEMINI_API_KEY"
1316
} as const;

packages/cli/src/formatters/console.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import boxen from "boxen";
33
import { table, getBorderCharacters } from "table";
44
import type { AnalysisResult } from "@grotto/core";
55

6-
export function printConsoleReport(result: AnalysisResult, detailLevel: string = "normal") {
6+
export function printConsoleReport(result: AnalysisResult, detailLevel: string = "normal", showAI: boolean = false) {
77
try {
88
const { meta, hotspots, riskScores, contributors, burnout, coupling, knowledge, impact, rot } = result;
99
const isVerbose = detailLevel === "verbose";
@@ -31,23 +31,24 @@ export function printConsoleReport(result: AnalysisResult, detailLevel: string =
3131
);
3232

3333
if (isSummary) {
34-
printHealthIndicators(impact, rot);
34+
printHealthIndicators(impact, rot, !!result.aiSummary);
3535
return;
3636
}
3737

3838
// 2. AI Insights (Intuitive Format)
39-
if (result.aiSummary) {
39+
if (showAI && result.aiSummary) {
4040
console.log(
4141
boxen(
42-
`${chalk.white(result.aiSummary.digest)}\n\n` +
43-
`${chalk.gray.italic("Model: " + result.aiSummary.model)}`,
42+
chalk.white(result.aiSummary.digest) + "\n\n" +
43+
chalk.gray.italic(`Provider: ${result.aiSummary.provider} | Model: ${result.aiSummary.model}`),
4444
{
4545
padding: 1,
4646
margin: { bottom: 1 },
4747
borderStyle: "double",
4848
borderColor: "magenta",
49-
title: "AI INSIGHTS",
50-
titleAlignment: "center"
49+
title: "AI ARCHITECTURAL INSIGHTS",
50+
titleAlignment: "center",
51+
width: 80 // Limit width for better readability of structured text
5152
}
5253
)
5354
);
@@ -138,22 +139,30 @@ export function printConsoleReport(result: AnalysisResult, detailLevel: string =
138139
}
139140

140141
// 7. Health Indicators & Footer Tip
141-
printHealthIndicators(impact, rot);
142+
printHealthIndicators(impact, rot, showAI);
142143
} catch (err) {
143144
console.error(chalk.red("\nError printing report:"), err);
144145
}
145146
}
146147

147-
function printHealthIndicators(impact: any[], rot: any[]) {
148+
function printHealthIndicators(impact: any[], rot: any[], showAI: boolean) {
148149
const avgImpact = impact.length > 0 ? (impact.reduce((acc: number, i: any) => acc + i.blastRadius, 0) / impact.length).toFixed(2) : 0;
149150

151+
const footerContent = [
152+
`${chalk.bold("Overall Health Indicators")}`,
153+
`${chalk.gray("────────────────────────")}`,
154+
`${chalk.white("Average Blast Radius: ")} ${chalk.yellow(avgImpact + " files")}`,
155+
`${chalk.white("Abandoned Files (Rot): ")} ${chalk.red(rot.length)}`
156+
];
157+
158+
if (!showAI) {
159+
footerContent.push("");
160+
footerContent.push(`${chalk.gray.italic("Tip: Run 'grotto config set-ai' to unlock AI-powered insights (use --ai flag).")}`);
161+
}
162+
150163
console.log(
151164
boxen(
152-
`${chalk.bold("Overall Health Indicators")}\n` +
153-
`${chalk.gray("────────────────────────")}\n` +
154-
`${chalk.white("Average Blast Radius: ")} ${chalk.yellow(avgImpact + " files")}\n` +
155-
`${chalk.white("Abandoned Files (Rot): ")} ${chalk.red(rot.length)}\n\n` +
156-
`${chalk.gray.italic("Tip: Set ANTHROPIC_API_KEY to unlock deeper AI-powered code audits.")}`,
165+
footerContent.join("\n"),
157166
{
158167
padding: 1,
159168
margin: { top: 1, bottom: 1 },

0 commit comments

Comments
 (0)