Skip to content

Commit 10079d7

Browse files
committed
feat(mcp): add repo comparison tool
1 parent e5f0512 commit 10079d7

7 files changed

Lines changed: 502 additions & 4 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,17 @@ Example prompts:
4545
> Before installing this package, run a RepoLens scan and open the report.
4646
>
4747
> Generate a RepoLens deep dive for `github.com/fastify/fastify`.
48+
>
49+
> Compare `honojs/hono` vs `fastify/fastify` for an edge API and open the report.
4850
4951
MCP tools:
5052

5153
- `scan_repo` — fast verdict-first dependency report.
5254
- `deep_dive` — plain-English architecture explanation with gaps/assumptions.
5355
- `blueprint_scene` — graph-shaped architecture map.
56+
- `compare_repos` — visual bake-off report with a default pick, ranking, tradeoff matrix, choose-if guidance, and risk comparison.
5457

55-
Each tool writes a self-contained local `.html` report and opens it by default. MCP supports Anthropic, OpenAI, OpenRouter, and Google env keys, and `scan_repo` accepts GitHub, GitLab, npm, and PyPI inputs. See [`mcp/README.md`](mcp/README.md) for Claude Desktop config and environment options.
58+
Each tool writes a self-contained local `.html` report and opens it by default. MCP supports Anthropic, OpenAI, OpenRouter, and Google env keys, and `scan_repo`/`compare_repos` accept GitHub, GitLab, npm, and PyPI inputs. See [`mcp/README.md`](mcp/README.md) for Claude Desktop config and environment options.
5659

5760
---
5861

mcp/README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ posts. RepoLens gives the agent a dependency due-diligence tool:
2121
- `deep_dive` — plain-English architecture explanation, weak spots, assumptions,
2222
self-test questions, atoms + lineage.
2323
- `blueprint_scene` — graph-shaped architecture map with nodes/edges/positions.
24+
- `compare_repos` — compare 2-5 repos/packages for a use case, pick a winner,
25+
and open a visual bake-off report.
2426

25-
Every tool accepts:
27+
Single-repo tools accept:
2628

2729
```json
2830
{
@@ -32,6 +34,17 @@ Every tool accepts:
3234
}
3335
```
3436

37+
`compare_repos` accepts:
38+
39+
```json
40+
{
41+
"repos": ["honojs/hono", "fastify/fastify", "npm:@tinyhttp/app"],
42+
"useCase": "edge API on Cloudflare Workers",
43+
"report": true,
44+
"openReport": true
45+
}
46+
```
47+
3548
- `report` defaults to `true` and writes a local `.html` file.
3649
- `openReport` defaults to `true` and opens that file in the browser.
3750
- Set `openReport: false` if the agent should only return the report path.
@@ -134,6 +147,10 @@ Generate a RepoLens deep_dive for github.com/fastify/fastify and summarize the g
134147
Use blueprint_scene on remix-run/remix so I can see how the repo is structured.
135148
```
136149

150+
```text
151+
Compare honojs/hono vs fastify/fastify for an edge API and open the RepoLens report.
152+
```
153+
137154
## Supported inputs
138155

139156
`scan_repo` supports all fetcher-backed RepoLens targets:

mcp/compare-repos.js

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { fetchRepoData } from '../src/fetcher.js';
2+
import { buildPrompt } from '../src/prompt.js';
3+
import { parseClaudeResponse } from '../src/parser.js';
4+
import { deriveFit } from '../src/verdict.js';
5+
import { parseRepoInput } from './repo-input.js';
6+
import { callModel } from './model.js';
7+
import { ghOpts } from './github-auth.js';
8+
import { attachHtmlReport } from './report.js';
9+
10+
const MAX_REPOS = 5;
11+
12+
export const COMPARE_TOOL = {
13+
name: 'compare_repos',
14+
description:
15+
'Compare 2-5 GitHub/GitLab/npm/PyPI repos or packages for a concrete use case. ' +
16+
'Returns a winner, ranking, tradeoff matrix, choose-if guidance, and risk comparison. ' +
17+
'Use this before an agent installs or recommends a dependency.',
18+
inputSchema: {
19+
type: 'object',
20+
properties: {
21+
repos: {
22+
type: 'array',
23+
minItems: 2,
24+
maxItems: MAX_REPOS,
25+
items: { type: 'string' },
26+
description: 'Repos/packages to compare: owner/name, platform:name, or GitHub/GitLab/npm/PyPI URLs.',
27+
},
28+
useCase: {
29+
type: 'string',
30+
description: 'The specific job/constraints to compare against, e.g. edge API on Cloudflare Workers.',
31+
},
32+
report: { type: 'boolean', description: 'Write a local HTML comparison report. Default: true.' },
33+
openReport: {
34+
type: 'boolean',
35+
description: 'Open the local HTML comparison report in the browser. Default: true.',
36+
},
37+
},
38+
required: ['repos'],
39+
additionalProperties: false,
40+
},
41+
outputSchema: {
42+
type: 'object',
43+
properties: {
44+
useCase: { type: 'string' },
45+
bottom_line: { type: 'string' },
46+
winner: {
47+
type: 'object',
48+
properties: { repoId: { type: 'string' }, rationale: { type: 'string' } },
49+
},
50+
ranking: { type: 'array' },
51+
matrix: { type: 'array' },
52+
choose_if: { type: 'array' },
53+
risks: { type: 'array' },
54+
repos: { type: 'array' },
55+
report: {
56+
type: 'object',
57+
description: 'Local HTML report path/url opened for the user.',
58+
properties: { path: { type: 'string' }, url: { type: 'string' }, opened: { type: 'boolean' } },
59+
},
60+
},
61+
required: ['winner', 'ranking', 'repos'],
62+
},
63+
};
64+
65+
const strings = (xs, max = 6) =>
66+
Array.isArray(xs)
67+
? xs
68+
.map(String)
69+
.filter((s) => s.trim())
70+
.slice(0, max)
71+
: [];
72+
73+
function displayRepo(scan) {
74+
return `${scan.platform}:${scan.repoId}`;
75+
}
76+
77+
export function buildComparePrompt(scans, useCase = '') {
78+
const summaries = scans.map((s, i) => ({
79+
index: i + 1,
80+
repoId: displayRepo(s),
81+
description: s.description || '',
82+
language: s.language || 'Unknown',
83+
license: s.license || 'Unknown',
84+
stars: s.stars || 0,
85+
fit: s.fit,
86+
bottom_line: s.bottom_line,
87+
recommendation: s.recommendation,
88+
confidence: s.confidence,
89+
health: s.health,
90+
pros: strings(s.pros, 6),
91+
cons: strings(s.cons, 6),
92+
risks: Array.isArray(s.risk_register) ? s.risk_register.slice(0, 5) : [],
93+
red_flags: Array.isArray(s.red_flags) ? s.red_flags.slice(0, 5) : [],
94+
compare_hooks: s.compare_hooks || '',
95+
capabilities: strings(s.capabilities, 8),
96+
category: s.category || '',
97+
tech_stack: s.tech_stack || {},
98+
}));
99+
100+
return `You are a senior staff engineer choosing between dependencies for another experienced developer.
101+
102+
Use case / decision context: ${useCase || 'No specific use case provided; compare for general production adoption.'}
103+
104+
You are given RepoLens scan summaries. Treat them as evidence, not marketing. Pick a winner only when the evidence supports it. If the best answer is situational, still name the default choice and explain where another repo wins.
105+
106+
RepoLens scan summaries:
107+
${JSON.stringify(summaries, null, 2)}
108+
109+
Return ONLY a valid JSON object. No markdown fences.
110+
111+
{
112+
"bottom_line": "Two decisive sentences naming the default pick and the main tradeoff.",
113+
"winner": { "repoId": "platform:owner/name or platform:package", "rationale": "Why this is the best default for the use case." },
114+
"ranking": [{ "repoId": "platform:owner/name", "rank": 1, "fit": "best | strong | situational | risky", "score": 90, "why": "Specific reason for this rank." }],
115+
"matrix": [{ "criterion": "Use-case fit | API ergonomics | ecosystem | maintenance | operational risk | migration cost", "winner": "repoId or tie", "notes": "What matters for this criterion.", "scores": [{ "repoId": "platform:owner/name", "score": 1, "note": "Short evidence-backed note." }] }],
116+
"choose_if": [{ "repoId": "platform:owner/name", "reasons": ["Choose this when...", "Avoid it when..."] }],
117+
"risks": [{ "repoId": "platform:owner/name", "risk": "Concrete risk", "mitigation": "How to de-risk before adoption." }],
118+
"trial_plan": { "goal": "What the user should know after a short bake-off.", "steps": ["Concrete step 1", "Concrete step 2", "Concrete step 3"], "decision_rule": "Rule for choosing after the trial." }
119+
}`;
120+
}
121+
122+
function extractJson(rawText) {
123+
const text = String(rawText || '')
124+
.trim()
125+
.replace(/^```(?:json)?\s*/i, '')
126+
.replace(/\s*```$/, '');
127+
const start = text.indexOf('{');
128+
const end = text.lastIndexOf('}');
129+
if (start === -1 || end === -1) throw new Error('No JSON object found in comparison response');
130+
return JSON.parse(text.slice(start, end + 1));
131+
}
132+
133+
function normalizeRanking(raw, scans) {
134+
const ids = new Set(scans.map(displayRepo));
135+
const rows = Array.isArray(raw) ? raw : [];
136+
const normalized = rows
137+
.filter((r) => r && ids.has(String(r.repoId || '')))
138+
.map((r, i) => ({
139+
repoId: String(r.repoId),
140+
rank: Number(r.rank) || i + 1,
141+
fit: ['best', 'strong', 'situational', 'risky'].includes(r.fit) ? r.fit : 'situational',
142+
score: Math.max(0, Math.min(100, Number(r.score) || 0)),
143+
why: String(r.why || ''),
144+
}))
145+
.sort((a, b) => a.rank - b.rank);
146+
if (normalized.length) return normalized;
147+
return scans
148+
.map((s, i) => ({
149+
repoId: displayRepo(s),
150+
rank: i + 1,
151+
fit: i === 0 ? 'best' : 'situational',
152+
score: Number(s.health?.score) || 0,
153+
why: s.bottom_line || s.description || 'Ranked by available RepoLens health/fit evidence.',
154+
}))
155+
.sort((a, b) => b.score - a.score)
156+
.map((r, i) => ({ ...r, rank: i + 1, fit: i === 0 ? 'best' : r.fit }));
157+
}
158+
159+
export function parseComparisonResponse(rawText, scans, useCase = '') {
160+
const data = extractJson(rawText);
161+
const ranking = normalizeRanking(data.ranking, scans);
162+
const winnerId = String(data.winner?.repoId || ranking[0]?.repoId || displayRepo(scans[0]));
163+
return {
164+
useCase: useCase || 'General production adoption',
165+
bottom_line: String(data.bottom_line || ranking[0]?.why || ''),
166+
winner: {
167+
repoId: winnerId,
168+
rationale: String(data.winner?.rationale || ranking[0]?.why || ''),
169+
},
170+
ranking,
171+
matrix: Array.isArray(data.matrix) ? data.matrix.slice(0, 8) : [],
172+
choose_if: Array.isArray(data.choose_if)
173+
? data.choose_if
174+
.filter((x) => x && x.repoId)
175+
.slice(0, scans.length)
176+
.map((x) => ({ repoId: String(x.repoId), reasons: strings(x.reasons, 5) }))
177+
: [],
178+
risks: Array.isArray(data.risks)
179+
? data.risks
180+
.filter((x) => x && x.repoId)
181+
.slice(0, 10)
182+
.map((x) => ({
183+
repoId: String(x.repoId),
184+
risk: String(x.risk || ''),
185+
mitigation: String(x.mitigation || ''),
186+
}))
187+
: [],
188+
trial_plan: {
189+
goal: String(data.trial_plan?.goal || ''),
190+
steps: strings(data.trial_plan?.steps, 6),
191+
decision_rule: String(data.trial_plan?.decision_rule || ''),
192+
},
193+
};
194+
}
195+
196+
export function validateCompareArgs(args = {}) {
197+
if (!Array.isArray(args.repos)) throw new Error('compare_repos requires repos: ["owner/name", ...]');
198+
const repos = args.repos.map((r) => String(r || '').trim()).filter(Boolean);
199+
if (repos.length < 2) throw new Error('compare_repos needs at least 2 repos');
200+
if (repos.length > MAX_REPOS) throw new Error(`compare_repos supports at most ${MAX_REPOS} repos`);
201+
return { repos, useCase: String(args.useCase || '').trim() };
202+
}
203+
204+
export async function scanForComparison(repo) {
205+
const { platform, repoId } = parseRepoInput(repo);
206+
const repoData = await fetchRepoData(platform, repoId, ghOpts());
207+
const analysis = parseClaudeResponse(await callModel(buildPrompt(repoData)));
208+
return {
209+
repoId: repoData.repoId,
210+
platform,
211+
language: repoData.language,
212+
license: repoData.license,
213+
stars: repoData.stars,
214+
description: repoData.description,
215+
...analysis,
216+
fit: deriveFit(analysis),
217+
};
218+
}
219+
220+
export async function runCompareRepos(args) {
221+
const { repos, useCase } = validateCompareArgs(args);
222+
const scans = await Promise.all(repos.map(scanForComparison));
223+
const comparison = parseComparisonResponse(
224+
await callModel(buildComparePrompt(scans, useCase)),
225+
scans,
226+
useCase
227+
);
228+
const result = { ...comparison, repos: scans };
229+
const reportId = comparison.ranking.map((r) => r.repoId).join('-vs-');
230+
return attachHtmlReport('compare_repos', reportId, result, args);
231+
}

0 commit comments

Comments
 (0)