devlens-mcp 0.1.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/LICENSE +21 -0
- package/README.md +60 -0
- package/SKILL.md +65 -0
- package/dist/index.js +485 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PresenceLabs LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# DevLens
|
|
2
|
+
|
|
3
|
+
Real-time visual feedback for AI-assisted frontend development. A Claude Code plugin (MCP server) that gives Claude the ability to see what it's building — automatically, after every file write, in ~50ms.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
DevLens keeps a single Chromium instance alive for the duration of your Claude Code session. After every frontend file change, Claude calls `dl_capture` and receives a screenshot of the affected route. No user prompting needed — the companion skill handles it automatically.
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
| Tool | Speed | Use |
|
|
12
|
+
|------|-------|-----|
|
|
13
|
+
| `dl_warmup` | ~500ms cold, once | Pre-warm browser at session start |
|
|
14
|
+
| `dl_capture` | ~50ms warm | Screenshot a route or element |
|
|
15
|
+
| `dl_diff` | ~60ms warm | Pixel diff vs last capture |
|
|
16
|
+
| `dl_snapshot` | ~10ms | DOM + styles, no pixels |
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g devlens-mcp
|
|
22
|
+
npx playwright install chromium
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then in your project root:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx devlens-mcp init
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This creates `.mcp.json`, `.claude/skills/devlens.md`, and `devlens.config.ts` automatically.
|
|
32
|
+
|
|
33
|
+
Edit `devlens.config.ts` to map your source files to dev server routes, then restart Claude Code.
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// devlens.config.ts
|
|
37
|
+
const config = {
|
|
38
|
+
devServerUrl: 'http://localhost:5173',
|
|
39
|
+
routes: [
|
|
40
|
+
{ pattern: '**/pages/Home.tsx', route: '/' },
|
|
41
|
+
{ pattern: '**/pages/About.tsx', route: '/about' },
|
|
42
|
+
{ pattern: '**/components/**', route: null },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
export default config;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## What `init` sets up
|
|
49
|
+
|
|
50
|
+
- **`.mcp.json`** — registers the devlens MCP server so Claude Code can call the `dl_*` tools
|
|
51
|
+
- **`.claude/skills/devlens.md`** — the skill that tells Claude to automatically capture after every file write, diff before/after significant changes, and run a visual audit before deploying
|
|
52
|
+
- **`devlens.config.ts`** — maps file glob patterns to dev server routes
|
|
53
|
+
|
|
54
|
+
## Performance
|
|
55
|
+
|
|
56
|
+
Warm captures target **<80ms**. Cold first-load is ~500ms (one-time per session). Element-level captures (`selector` param) are faster and produce smaller images.
|
|
57
|
+
|
|
58
|
+
## Open source
|
|
59
|
+
|
|
60
|
+
MIT. Works with any MCP-compatible AI client.
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: devlens
|
|
3
|
+
description: Real-time visual QA for frontend development. Auto-invoked when editing frontend files.
|
|
4
|
+
triggers:
|
|
5
|
+
- frontend
|
|
6
|
+
- ui
|
|
7
|
+
- component
|
|
8
|
+
- tsx
|
|
9
|
+
- css
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# DevLens — Automatic Visual QA
|
|
13
|
+
|
|
14
|
+
You have access to four visual inspection tools: `dl_warmup`, `dl_capture`, `dl_diff`, `dl_snapshot`.
|
|
15
|
+
|
|
16
|
+
## Rules — follow these automatically, no user prompting needed
|
|
17
|
+
|
|
18
|
+
### Rule 1: Warmup at session start
|
|
19
|
+
The first time you are about to edit any file matching `apps/web/src/**`, call `dl_warmup` first.
|
|
20
|
+
This pre-warms the browser so subsequent captures cost ~50ms instead of ~500ms.
|
|
21
|
+
Only call it once per session.
|
|
22
|
+
|
|
23
|
+
### Rule 2: Capture after every frontend file write
|
|
24
|
+
After every `Edit` or `Write` to a file matching `apps/web/src/**/*.tsx` or `apps/web/src/**/*.css`:
|
|
25
|
+
1. Call `dl_capture` with either `route` (if you know it) or `filePath` (auto-resolved from devlens.config.ts)
|
|
26
|
+
2. Add `selector` if you only changed a specific component (keeps the image small and fast)
|
|
27
|
+
3. Look at the returned image — does it look right?
|
|
28
|
+
4. If something looks wrong (layout broken, text missing, wrong color, overlapping elements), fix it before moving on
|
|
29
|
+
|
|
30
|
+
Do this every time, automatically. Do not wait to be asked.
|
|
31
|
+
|
|
32
|
+
### Rule 3: Diff before/after for significant changes
|
|
33
|
+
When making a change you expect to visually alter the page (not just a bug fix):
|
|
34
|
+
1. Call `dl_diff` before the change to set the baseline
|
|
35
|
+
2. Make your code changes
|
|
36
|
+
3. Call `dl_diff` again to see exactly what pixels changed
|
|
37
|
+
4. Verify the diff matches your intent
|
|
38
|
+
|
|
39
|
+
### Rule 4: Pre-deploy visual audit
|
|
40
|
+
Before running any deploy command (`wrangler pages deploy`, `npm run deploy`, etc.):
|
|
41
|
+
1. Call `dl_capture` on the key routes that were changed during this session
|
|
42
|
+
2. Review each screenshot
|
|
43
|
+
3. Only proceed with deploy if all look correct
|
|
44
|
+
|
|
45
|
+
Routes to always check before deploy:
|
|
46
|
+
- `/` (Dashboard)
|
|
47
|
+
- Any route you edited during this session
|
|
48
|
+
|
|
49
|
+
### Rule 5: Snap on request
|
|
50
|
+
If the user asks "does this look right?", "show me what it looks like", or "screenshot this",
|
|
51
|
+
call `dl_capture` immediately and show the result.
|
|
52
|
+
|
|
53
|
+
## Performance expectations
|
|
54
|
+
- `dl_warmup`: ~500ms (cold), do once
|
|
55
|
+
- `dl_capture` warm: ~50–80ms
|
|
56
|
+
- `dl_snapshot`: ~10ms
|
|
57
|
+
- `dl_diff` warm: ~60ms
|
|
58
|
+
|
|
59
|
+
These are fast enough to call after every file write without slowing down development.
|
|
60
|
+
|
|
61
|
+
## Selector tips
|
|
62
|
+
- Use `.classname` to target a specific component
|
|
63
|
+
- Use `[data-testid="x"]` for test-id targeting
|
|
64
|
+
- Omit selector for full-page captures (slower, larger image)
|
|
65
|
+
- Prefer element-level captures during iteration, full-page before deploy
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/init.ts
|
|
4
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs";
|
|
5
|
+
import { resolve, join } from "path";
|
|
6
|
+
var SKILL_CONTENT = `---
|
|
7
|
+
name: devlens
|
|
8
|
+
description: Real-time visual QA for frontend development. Auto-invoked when editing frontend files.
|
|
9
|
+
triggers:
|
|
10
|
+
- frontend
|
|
11
|
+
- ui
|
|
12
|
+
- component
|
|
13
|
+
- tsx
|
|
14
|
+
- css
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# DevLens \u2014 Automatic Visual QA
|
|
18
|
+
|
|
19
|
+
You have access to four visual inspection tools: \`dl_warmup\`, \`dl_capture\`, \`dl_diff\`, \`dl_snapshot\`.
|
|
20
|
+
|
|
21
|
+
## Rules \u2014 follow these automatically, no user prompting needed
|
|
22
|
+
|
|
23
|
+
### Rule 1: Warmup at session start
|
|
24
|
+
The first time you are about to edit any frontend file (\`.tsx\`, \`.jsx\`, \`.css\`, \`.vue\`, \`.svelte\`), call \`dl_warmup\` first.
|
|
25
|
+
This pre-warms the browser so subsequent captures cost ~50ms instead of ~500ms.
|
|
26
|
+
Only call it once per session.
|
|
27
|
+
|
|
28
|
+
### Rule 2: Capture after every frontend file write
|
|
29
|
+
After every \`Edit\` or \`Write\` to a \`.tsx\`, \`.jsx\`, \`.css\`, \`.vue\`, or \`.svelte\` file:
|
|
30
|
+
1. Call \`dl_capture\` with either \`route\` (if you know it) or \`filePath\` (auto-resolved from devlens.config.ts)
|
|
31
|
+
2. Add \`selector\` if you only changed a specific component (keeps the image small and fast)
|
|
32
|
+
3. Look at the returned image \u2014 does it look right?
|
|
33
|
+
4. If something looks wrong (layout broken, text missing, wrong color, overlapping elements), fix it before moving on
|
|
34
|
+
|
|
35
|
+
Do this every time, automatically. Do not wait to be asked.
|
|
36
|
+
|
|
37
|
+
### Rule 3: Diff before/after for significant changes
|
|
38
|
+
When making a change you expect to visually alter the page:
|
|
39
|
+
1. Call \`dl_diff\` before the change to set the baseline
|
|
40
|
+
2. Make your code changes
|
|
41
|
+
3. Call \`dl_diff\` again to see exactly what pixels changed
|
|
42
|
+
4. Verify the diff matches your intent
|
|
43
|
+
|
|
44
|
+
### Rule 4: Pre-deploy visual audit
|
|
45
|
+
Before running any deploy command:
|
|
46
|
+
1. Call \`dl_capture\` on the key routes that were changed during this session
|
|
47
|
+
2. Review each screenshot
|
|
48
|
+
3. Only proceed with deploy if all look correct
|
|
49
|
+
|
|
50
|
+
### Rule 5: Snap on request
|
|
51
|
+
If the user asks "does this look right?", "show me what it looks like", or "screenshot this",
|
|
52
|
+
call \`dl_capture\` immediately and show the result.
|
|
53
|
+
|
|
54
|
+
## Performance expectations
|
|
55
|
+
- \`dl_warmup\`: ~500ms (cold), do once
|
|
56
|
+
- \`dl_capture\` warm: ~50\u201380ms
|
|
57
|
+
- \`dl_snapshot\`: ~10ms
|
|
58
|
+
- \`dl_diff\` warm: ~60ms
|
|
59
|
+
|
|
60
|
+
## Selector tips
|
|
61
|
+
- Use \`.classname\` to target a specific component
|
|
62
|
+
- Use \`[data-testid="x"]\` for test-id targeting
|
|
63
|
+
- Omit selector for full-page captures (slower, larger image)
|
|
64
|
+
- Prefer element-level captures during iteration, full-page before deploy
|
|
65
|
+
`;
|
|
66
|
+
var CONFIG_TEMPLATE = `// devlens.config.ts \u2014 map your source files to dev server routes
|
|
67
|
+
const config = {
|
|
68
|
+
devServerUrl: 'http://localhost:5173',
|
|
69
|
+
hmrDebounceMs: 150,
|
|
70
|
+
defaultViewport: { width: 1280, height: 900 },
|
|
71
|
+
routes: [
|
|
72
|
+
// { pattern: '**/pages/Home.tsx', route: '/' },
|
|
73
|
+
// { pattern: '**/pages/About.tsx', route: '/about' },
|
|
74
|
+
// { pattern: '**/components/**', route: null }, // null = no auto-route for components
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export default config;
|
|
79
|
+
`;
|
|
80
|
+
async function runInit() {
|
|
81
|
+
const cwd = process.cwd();
|
|
82
|
+
const mcpPath = resolve(cwd, ".mcp.json");
|
|
83
|
+
let mcpConfig = {};
|
|
84
|
+
if (existsSync(mcpPath)) {
|
|
85
|
+
try {
|
|
86
|
+
mcpConfig = JSON.parse(readFileSync(mcpPath, "utf8"));
|
|
87
|
+
} catch {
|
|
88
|
+
console.warn(" Warning: existing .mcp.json could not be parsed \u2014 overwriting");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
mcpConfig.mcpServers = mcpConfig.mcpServers ?? {};
|
|
92
|
+
mcpConfig.mcpServers["devlens"] = { command: "npx", args: ["--yes", "devlens-mcp"] };
|
|
93
|
+
writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n");
|
|
94
|
+
console.log(" \u2713 .mcp.json updated");
|
|
95
|
+
const skillsDir = resolve(cwd, ".claude", "skills");
|
|
96
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
97
|
+
writeFileSync(join(skillsDir, "devlens.md"), SKILL_CONTENT);
|
|
98
|
+
console.log(" \u2713 .claude/skills/devlens.md written");
|
|
99
|
+
const configPath = resolve(cwd, "devlens.config.ts");
|
|
100
|
+
if (!existsSync(configPath)) {
|
|
101
|
+
writeFileSync(configPath, CONFIG_TEMPLATE);
|
|
102
|
+
console.log(" \u2713 devlens.config.ts created (edit this to add your routes)");
|
|
103
|
+
} else {
|
|
104
|
+
console.log(" \u2713 devlens.config.ts already exists \u2014 skipped");
|
|
105
|
+
}
|
|
106
|
+
console.log("");
|
|
107
|
+
console.log("DevLens initialized. Next steps:");
|
|
108
|
+
console.log("");
|
|
109
|
+
console.log(" 1. Install Chromium (one-time):");
|
|
110
|
+
console.log(" npx playwright install chromium");
|
|
111
|
+
console.log("");
|
|
112
|
+
console.log(" 2. Edit devlens.config.ts to map your pages to routes");
|
|
113
|
+
console.log("");
|
|
114
|
+
console.log(" 3. Restart Claude Code");
|
|
115
|
+
console.log("");
|
|
116
|
+
console.log(" Claude will then automatically screenshot your UI after every file write.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/server.ts
|
|
120
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
121
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
122
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
123
|
+
|
|
124
|
+
// src/config.ts
|
|
125
|
+
import { pathToFileURL } from "url";
|
|
126
|
+
import { resolve as resolve2 } from "path";
|
|
127
|
+
var defaults = {
|
|
128
|
+
defaultViewport: { width: 1280, height: 900 },
|
|
129
|
+
hmrDebounceMs: 150
|
|
130
|
+
};
|
|
131
|
+
async function loadConfig(cwd = process.cwd()) {
|
|
132
|
+
const configPath = resolve2(cwd, "devlens.config.ts");
|
|
133
|
+
try {
|
|
134
|
+
const jsPath = configPath.replace(/\.ts$/, ".js");
|
|
135
|
+
const mod = await import(pathToFileURL(jsPath).href);
|
|
136
|
+
return { ...defaults, ...mod.default };
|
|
137
|
+
} catch {
|
|
138
|
+
return {
|
|
139
|
+
...defaults,
|
|
140
|
+
devServerUrl: "http://localhost:5173",
|
|
141
|
+
routes: []
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/browser/manager.ts
|
|
147
|
+
import { chromium } from "playwright";
|
|
148
|
+
var BrowserManager = class {
|
|
149
|
+
static browser = null;
|
|
150
|
+
static page = null;
|
|
151
|
+
static async getPage() {
|
|
152
|
+
if (!this.browser) {
|
|
153
|
+
this.browser = await chromium.launch({ headless: true });
|
|
154
|
+
}
|
|
155
|
+
if (!this.page || this.page.isClosed()) {
|
|
156
|
+
this.page = await this.browser.newPage();
|
|
157
|
+
this.page.on("console", () => {
|
|
158
|
+
});
|
|
159
|
+
this.page.on("pageerror", () => {
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return this.page;
|
|
163
|
+
}
|
|
164
|
+
static async close() {
|
|
165
|
+
if (this.page && !this.page.isClosed()) await this.page.close();
|
|
166
|
+
if (this.browser) await this.browser.close();
|
|
167
|
+
this.page = null;
|
|
168
|
+
this.browser = null;
|
|
169
|
+
}
|
|
170
|
+
static isWarm() {
|
|
171
|
+
return this.browser !== null && this.page !== null && !this.page.isClosed();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// src/utils/diff.ts
|
|
176
|
+
import { PNG } from "pngjs";
|
|
177
|
+
import pixelmatch from "pixelmatch";
|
|
178
|
+
var DiffStore = class {
|
|
179
|
+
baselines = /* @__PURE__ */ new Map();
|
|
180
|
+
async diffAndStore(routeKey, newImageBase64) {
|
|
181
|
+
const newBuf = Buffer.from(newImageBase64, "base64");
|
|
182
|
+
const previous = this.baselines.get(routeKey);
|
|
183
|
+
this.baselines.set(routeKey, newBuf);
|
|
184
|
+
if (!previous) return null;
|
|
185
|
+
const imgA = PNG.sync.read(previous);
|
|
186
|
+
const imgB = PNG.sync.read(newBuf);
|
|
187
|
+
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
|
188
|
+
return { diffImageBase64: newImageBase64, changedPercent: 100, changedPixels: -1, totalPixels: -1 };
|
|
189
|
+
}
|
|
190
|
+
const { width, height } = imgA;
|
|
191
|
+
const diff2 = new PNG({ width, height });
|
|
192
|
+
const changedPixels = pixelmatch(imgA.data, imgB.data, diff2.data, width, height, { threshold: 0.1 });
|
|
193
|
+
const totalPixels = width * height;
|
|
194
|
+
return {
|
|
195
|
+
diffImageBase64: PNG.sync.write(diff2).toString("base64"),
|
|
196
|
+
changedPercent: Math.round(changedPixels / totalPixels * 100 * 10) / 10,
|
|
197
|
+
changedPixels,
|
|
198
|
+
totalPixels
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
clear(routeKey) {
|
|
202
|
+
if (routeKey) this.baselines.delete(routeKey);
|
|
203
|
+
else this.baselines.clear();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// src/browser/capture.ts
|
|
208
|
+
function normaliseUrl(raw) {
|
|
209
|
+
try {
|
|
210
|
+
const u = new URL(raw);
|
|
211
|
+
if (u.pathname === "/") u.pathname = "";
|
|
212
|
+
return u.toString().replace(/\/$/, "");
|
|
213
|
+
} catch {
|
|
214
|
+
return raw.replace(/\/$/, "");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function captureRoute(url, selector, viewport) {
|
|
218
|
+
const start = Date.now();
|
|
219
|
+
const page = await BrowserManager.getPage();
|
|
220
|
+
await page.setViewportSize(viewport);
|
|
221
|
+
if (normaliseUrl(page.url()) !== normaliseUrl(url)) {
|
|
222
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 1e4 });
|
|
223
|
+
}
|
|
224
|
+
const screenshot = await page.screenshot({ type: "png", fullPage: false });
|
|
225
|
+
return {
|
|
226
|
+
imageBase64: screenshot.toString("base64"),
|
|
227
|
+
durationMs: Date.now() - start,
|
|
228
|
+
url,
|
|
229
|
+
selector: void 0,
|
|
230
|
+
found: true
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async function captureElement(url, selector, viewport) {
|
|
234
|
+
const start = Date.now();
|
|
235
|
+
const page = await BrowserManager.getPage();
|
|
236
|
+
await page.setViewportSize(viewport);
|
|
237
|
+
if (normaliseUrl(page.url()) !== normaliseUrl(url)) {
|
|
238
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 1e4 });
|
|
239
|
+
}
|
|
240
|
+
const locator = page.locator(selector).first();
|
|
241
|
+
const count = await locator.count();
|
|
242
|
+
if (count === 0) {
|
|
243
|
+
return { imageBase64: "", durationMs: Date.now() - start, url, selector, found: false };
|
|
244
|
+
}
|
|
245
|
+
const screenshot = await locator.screenshot({ type: "png" });
|
|
246
|
+
return {
|
|
247
|
+
imageBase64: screenshot.toString("base64"),
|
|
248
|
+
durationMs: Date.now() - start,
|
|
249
|
+
url,
|
|
250
|
+
selector,
|
|
251
|
+
found: true
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/tools/warmup.ts
|
|
256
|
+
async function warmup(devServerUrl, config) {
|
|
257
|
+
const url = devServerUrl ?? config.devServerUrl;
|
|
258
|
+
const start = Date.now();
|
|
259
|
+
await captureRoute(url, void 0, config.defaultViewport ?? { width: 1280, height: 900 });
|
|
260
|
+
return { ok: true, durationMs: Date.now() - start, url };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/utils/route-mapper.ts
|
|
264
|
+
import { minimatch } from "minimatch";
|
|
265
|
+
function resolveRoute(filePath, config) {
|
|
266
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
267
|
+
for (const mapping of config.routes) {
|
|
268
|
+
if (minimatch(normalized, mapping.pattern, { matchBase: false })) {
|
|
269
|
+
return mapping.route;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/utils/wait-for-hmr.ts
|
|
276
|
+
function waitForHmr(ms) {
|
|
277
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/tools/capture.ts
|
|
281
|
+
async function capture(input, config) {
|
|
282
|
+
let route = input.route;
|
|
283
|
+
if (!route && input.filePath) {
|
|
284
|
+
route = resolveRoute(input.filePath, config) ?? void 0;
|
|
285
|
+
}
|
|
286
|
+
if (!route) {
|
|
287
|
+
return { error: "No route resolved \u2014 pass route directly or add filePath to devlens.config.ts mappings" };
|
|
288
|
+
}
|
|
289
|
+
const url = `${config.devServerUrl}${route}`;
|
|
290
|
+
const debounce = input.waitMs ?? config.hmrDebounceMs ?? 150;
|
|
291
|
+
await waitForHmr(debounce);
|
|
292
|
+
const viewport = config.defaultViewport ?? { width: 1280, height: 900 };
|
|
293
|
+
if (input.selector) {
|
|
294
|
+
return captureElement(url, input.selector, viewport);
|
|
295
|
+
}
|
|
296
|
+
return captureRoute(url, void 0, viewport);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/tools/snapshot.ts
|
|
300
|
+
async function snapshot(route, selector, config) {
|
|
301
|
+
const url = `${config.devServerUrl}${route}`;
|
|
302
|
+
const page = await BrowserManager.getPage();
|
|
303
|
+
if (normaliseUrl(page.url()) !== normaliseUrl(url)) {
|
|
304
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 1e4 });
|
|
305
|
+
}
|
|
306
|
+
const target = selector ? page.locator(selector).first() : page.locator("body");
|
|
307
|
+
const found = await target.count() > 0;
|
|
308
|
+
if (!found) return { found: false, elements: [] };
|
|
309
|
+
const elements = await target.evaluate((el) => {
|
|
310
|
+
function extract(node) {
|
|
311
|
+
const style = window.getComputedStyle(node);
|
|
312
|
+
const rect = node.getBoundingClientRect();
|
|
313
|
+
return {
|
|
314
|
+
tag: node.tagName.toLowerCase(),
|
|
315
|
+
id: node.id || void 0,
|
|
316
|
+
classes: Array.from(node.classList).slice(0, 5),
|
|
317
|
+
text: node.textContent?.trim().slice(0, 120) || void 0,
|
|
318
|
+
role: node.getAttribute("role") || void 0,
|
|
319
|
+
ariaLabel: node.getAttribute("aria-label") || void 0,
|
|
320
|
+
visible: rect.width > 0 && rect.height > 0,
|
|
321
|
+
bounds: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
322
|
+
styles: {
|
|
323
|
+
display: style.display,
|
|
324
|
+
color: style.color,
|
|
325
|
+
backgroundColor: style.backgroundColor,
|
|
326
|
+
fontSize: style.fontSize,
|
|
327
|
+
fontWeight: style.fontWeight
|
|
328
|
+
},
|
|
329
|
+
children: Array.from(node.children).slice(0, 8).map(extract)
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return extract(el);
|
|
333
|
+
});
|
|
334
|
+
return { found: true, url, selector, elements };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/tools/diff.ts
|
|
338
|
+
async function diff(route, selector, store, config) {
|
|
339
|
+
const result = await capture({ route, selector }, config);
|
|
340
|
+
if ("error" in result) return result;
|
|
341
|
+
if (!result.found) return { found: false };
|
|
342
|
+
const key = `${route}${selector ?? ""}`;
|
|
343
|
+
const diffResult = await store.diffAndStore(key, result.imageBase64);
|
|
344
|
+
if (!diffResult) {
|
|
345
|
+
return { ...result, diff: null, message: "Baseline set \u2014 call again after making changes to see the diff." };
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
...result,
|
|
349
|
+
diff: {
|
|
350
|
+
diffImageBase64: diffResult.diffImageBase64,
|
|
351
|
+
changedPercent: diffResult.changedPercent,
|
|
352
|
+
changedPixels: diffResult.changedPixels
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/server.ts
|
|
358
|
+
var diffStore = new DiffStore();
|
|
359
|
+
async function startServer() {
|
|
360
|
+
const config = await loadConfig();
|
|
361
|
+
const server = new Server(
|
|
362
|
+
{ name: "devlens", version: "0.1.0" },
|
|
363
|
+
{ capabilities: { tools: {} } }
|
|
364
|
+
);
|
|
365
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
366
|
+
tools: [
|
|
367
|
+
{
|
|
368
|
+
name: "dl_warmup",
|
|
369
|
+
description: "Pre-warm the browser by loading the dev server. Call once at the start of a frontend task. First call takes ~500ms; subsequent captures cost ~50ms.",
|
|
370
|
+
inputSchema: {
|
|
371
|
+
type: "object",
|
|
372
|
+
properties: {
|
|
373
|
+
devServerUrl: { type: "string", description: 'Override the dev server URL from config (e.g. "http://localhost:5173")' }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
name: "dl_capture",
|
|
379
|
+
description: 'Screenshot a dev server route and return a PNG image. Pass either `route` ("/dashboard") or `filePath` (auto-resolved via devlens.config.ts). Optionally pass `selector` to crop to a specific element. Call this after every frontend file write.',
|
|
380
|
+
inputSchema: {
|
|
381
|
+
type: "object",
|
|
382
|
+
properties: {
|
|
383
|
+
route: { type: "string", description: 'Dev server route, e.g. "/dashboard"' },
|
|
384
|
+
filePath: { type: "string", description: "The file you just edited \u2014 route resolved from devlens.config.ts mappings" },
|
|
385
|
+
selector: { type: "string", description: 'CSS selector to crop to (e.g. ".onboarding-banner"). Omit for full page.' },
|
|
386
|
+
waitMs: { type: "number", description: "ms to wait for HMR to settle (default: 150)" }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
name: "dl_diff",
|
|
392
|
+
description: "Screenshot a route and compare to the last capture for that route. Returns a pixel diff image and the percentage of pixels changed. First call sets the baseline.",
|
|
393
|
+
inputSchema: {
|
|
394
|
+
type: "object",
|
|
395
|
+
properties: {
|
|
396
|
+
route: { type: "string" },
|
|
397
|
+
selector: { type: "string" }
|
|
398
|
+
},
|
|
399
|
+
required: ["route"]
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
name: "dl_snapshot",
|
|
404
|
+
description: "Fast structural snapshot \u2014 returns DOM tree, computed styles, and bounding boxes without a visual screenshot. ~10ms. Use for quick layout/content verification.",
|
|
405
|
+
inputSchema: {
|
|
406
|
+
type: "object",
|
|
407
|
+
properties: {
|
|
408
|
+
route: { type: "string", description: "Dev server route" },
|
|
409
|
+
selector: { type: "string", description: "CSS selector to scope snapshot to" }
|
|
410
|
+
},
|
|
411
|
+
required: ["route"]
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
]
|
|
415
|
+
}));
|
|
416
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
417
|
+
const { name, arguments: args } = req.params;
|
|
418
|
+
try {
|
|
419
|
+
if (name === "dl_warmup") {
|
|
420
|
+
const result = await warmup(args.devServerUrl, config);
|
|
421
|
+
return {
|
|
422
|
+
content: [{ type: "text", text: `Warmed up in ${result.durationMs}ms \u2014 ${result.url}` }]
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
if (name === "dl_capture") {
|
|
426
|
+
const input = args;
|
|
427
|
+
const result = await capture(input, config);
|
|
428
|
+
if ("error" in result) {
|
|
429
|
+
return { content: [{ type: "text", text: `Error: ${result.error}` }] };
|
|
430
|
+
}
|
|
431
|
+
const content = [
|
|
432
|
+
{ type: "text", text: `Captured in ${result.durationMs}ms \u2014 ${result.url}${result.selector ? ` (${result.selector})` : ""}` }
|
|
433
|
+
];
|
|
434
|
+
if (result.imageBase64) {
|
|
435
|
+
content.push({ type: "image", data: result.imageBase64, mimeType: "image/png" });
|
|
436
|
+
}
|
|
437
|
+
return { content };
|
|
438
|
+
}
|
|
439
|
+
if (name === "dl_diff") {
|
|
440
|
+
const { route, selector } = args;
|
|
441
|
+
const result = await diff(route, selector, diffStore, config);
|
|
442
|
+
if ("error" in result) return { content: [{ type: "text", text: `Error: ${result.error}` }] };
|
|
443
|
+
const content = [
|
|
444
|
+
{ type: "text", text: result.diff ? `Changed: ${result.diff.changedPercent}% (${result.diff.changedPixels} pixels)` : result.message ?? "Baseline set." }
|
|
445
|
+
];
|
|
446
|
+
if (result.diff?.diffImageBase64) {
|
|
447
|
+
content.push({ type: "image", data: result.diff.diffImageBase64, mimeType: "image/png" });
|
|
448
|
+
}
|
|
449
|
+
return { content };
|
|
450
|
+
}
|
|
451
|
+
if (name === "dl_snapshot") {
|
|
452
|
+
const { route, selector } = args;
|
|
453
|
+
const result = await snapshot(route, selector, config);
|
|
454
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
455
|
+
}
|
|
456
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
|
457
|
+
} catch (err) {
|
|
458
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
459
|
+
return { content: [{ type: "text", text: `Tool error: ${msg}` }], isError: true };
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
process.on("SIGINT", async () => {
|
|
463
|
+
await BrowserManager.close();
|
|
464
|
+
process.exit(0);
|
|
465
|
+
});
|
|
466
|
+
const transport = new StdioServerTransport();
|
|
467
|
+
await server.connect(transport);
|
|
468
|
+
console.error("[devlens] MCP server ready");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/index.ts
|
|
472
|
+
var command = process.argv[2];
|
|
473
|
+
if (command === "init") {
|
|
474
|
+
console.log("Initializing DevLens...");
|
|
475
|
+
console.log("");
|
|
476
|
+
runInit().catch((err) => {
|
|
477
|
+
console.error("Init failed:", err instanceof Error ? err.message : String(err));
|
|
478
|
+
process.exit(1);
|
|
479
|
+
});
|
|
480
|
+
} else {
|
|
481
|
+
startServer().catch((err) => {
|
|
482
|
+
console.error("[devlens] fatal:", err);
|
|
483
|
+
process.exit(1);
|
|
484
|
+
});
|
|
485
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "devlens-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Real-time visual feedback plugin for Claude Code frontend development",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"devlens-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"SKILL.md",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude",
|
|
17
|
+
"mcp",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"playwright",
|
|
20
|
+
"visual-testing",
|
|
21
|
+
"frontend"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"start": "node dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
33
|
+
"minimatch": "^10.2.5",
|
|
34
|
+
"pixelmatch": "^6.0.0",
|
|
35
|
+
"playwright": "^1.45.0",
|
|
36
|
+
"pngjs": "^7.0.0",
|
|
37
|
+
"zod": "^3.22.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.0.0",
|
|
41
|
+
"@types/pngjs": "^6.0.0",
|
|
42
|
+
"tsup": "^8.0.0",
|
|
43
|
+
"typescript": "^5.4.0",
|
|
44
|
+
"vitest": "^1.6.0"
|
|
45
|
+
}
|
|
46
|
+
}
|