@progen-labs/spectra 0.2.0 → 0.4.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
@@ -14,6 +14,8 @@ and per-component render/change-detection time; source files too, where the fram
14
14
  functions. A [Progen Labs](https://github.com/progen-labs) project — open source, non-profit, no
15
15
  telemetry, no account required.
16
16
 
17
+ **Watch the two-minute demo:** [progen-labs.github.io/website/#spectra/demo](https://progen-labs.github.io/website/#spectra/demo).
18
+
17
19
  **Documentation:** the full guide, with annotated screenshots of every screen, is at
18
20
  [progen-labs.github.io/website](https://progen-labs.github.io/website/#spectra/docs/introduction).
19
21
 
@@ -70,6 +72,7 @@ spectra performance [options] Golden-path evaluation (default)
70
72
  spectra performance --quick One page-load profile (about 15 s)
71
73
  spectra compare <before> <after> Did it get faster? Noise-aware before/after
72
74
  spectra reports List saved reports
75
+ spectra mcp [--project <dir>] MCP server for AI tools (stdio)
73
76
  spectra dashboard [--port N] [--open] Open the dashboard UI
74
77
  spectra --help Full option list
75
78
  spectra --version
@@ -141,6 +144,37 @@ Exit codes make it a CI gate:
141
144
  | `1` | Could not run (no Chrome, server down, bad config) |
142
145
  | `2` | Ran, but a budget was exceeded, or `compare --fail-on-regression` found a regression |
143
146
 
147
+ ### GitHub Action
148
+
149
+ The repository is also a GitHub Action. On every pull request it profiles the change and its base commit on the
150
+ same runner, compares them beyond run-to-run noise, enforces your budgets, and writes the result to the job summary
151
+ and (optionally) a PR comment:
152
+
153
+ ```yaml
154
+ name: Performance
155
+ on: pull_request
156
+ permissions:
157
+ contents: read
158
+ pull-requests: write # only for comment: true
159
+ jobs:
160
+ spectra:
161
+ runs-on: ubuntu-latest
162
+ steps:
163
+ - uses: actions/checkout@v4
164
+ - uses: actions/setup-node@v4
165
+ with: { node-version: 22 }
166
+ - run: npm ci
167
+ - uses: progen-labs/spectra@v1
168
+ with:
169
+ compare-to-base: true
170
+ fail-on-regression: true
171
+ comment: true
172
+ ```
173
+
174
+ Inputs include `url`, `command`, `mode` (`quick` or `golden-paths`), `runs`, `config`, `fail-on-budget` and
175
+ `working-directory`; outputs are `result-file`, `verdict`, `over-budget` and `summary-file`. Full reports are
176
+ uploaded as an artifact. See the [Action guide](https://progen-labs.github.io/website/#spectra/docs/github-action).
177
+
144
178
  ## Machine-readable output
145
179
 
146
180
  `--json` prints a small, versioned result on stdout and sends every human-readable line to stderr, so it can
@@ -150,6 +184,84 @@ Findings carry the file, line, cost in ms and a suggested fix. The format is des
150
184
  shipped with the package (`schema/result.schema.json`); additive changes keep `schemaVersion`, breaking
151
185
  changes bump it. Saved reports carry their own `schemaVersion` too.
152
186
 
187
+ ## Use it from your AI assistant
188
+
189
+ Spectra is an [MCP](https://modelcontextprotocol.io) server too, so an AI coding assistant can profile a page,
190
+ fix what it finds, and prove each fix with before/after measurements.
191
+
192
+ **Claude Code**, with the plugin (adds an *improve-performance* skill and `/spectra:audit`):
193
+
194
+ ```
195
+ /plugin marketplace add progen-labs/spectra
196
+ /plugin install spectra@progen-labs
197
+ ```
198
+
199
+ **Any MCP client** (Claude Code without the plugin, Claude Desktop, Cursor, VS Code, Windsurf…): run
200
+ `npx -y @progen-labs/spectra mcp` as a stdio server (Spectra 0.3.0 or later). For example:
201
+
202
+ ```bash
203
+ claude mcp add spectra -- npx -y @progen-labs/spectra mcp
204
+ ```
205
+
206
+ ```json
207
+ { "mcpServers": { "spectra": { "command": "npx", "args": ["-y", "@progen-labs/spectra", "mcp", "--project", "/path/to/your/app"] } } }
208
+ ```
209
+
210
+ Tools: `profile_page`, `verify_change` (re-profile and compare against a baseline), `compare_profiles`,
211
+ `run_golden_paths`, `list_reports` and `get_report`. The server profiles pages on this machine only (start it with
212
+ `--allow-remote` to lift that), runs one browser at a time, and stops Chrome and any dev server it started when a
213
+ call is cancelled. See the [MCP guide](https://progen-labs.github.io/website/#spectra/docs/mcp-server).
214
+
215
+ ## The AI fix harness
216
+
217
+ `spectra fix` hands the costliest findings in your own code to a coding agent, one at a time, and keeps a change only
218
+ when your checks still pass and Spectra measures the page faster beyond run-to-run noise. Kept changes arrive as
219
+ commits on a new branch, `spectra/fix-<time>`, for you to review; everything else is undone.
220
+
221
+ ```bash
222
+ npx spectra fix # Claude Code (signed in) fixes up to 3 findings
223
+ npx spectra fix --check "npm run e2e" --max-fixes 5
224
+ npx spectra fix --agent "./my-agent.sh" # any agent: instructions on stdin
225
+ ```
226
+
227
+ The harness, not the agent, makes every decision:
228
+
229
+ - **Isolation.** It works in a git worktree of your last commit, with its own dev server. Your working tree is never
230
+ touched, and nothing is pushed or merged.
231
+ - **Your checks first.** Your test, type-check and build scripts (or `--check`, which can test the running app at
232
+ `$SPECTRA_FIX_URL`) must pass before any change and after each one. A faster page that breaks is undone.
233
+ - **Scope rules.** A change may not touch `package.json`, lockfiles, configuration, tests or CI, delete files, or
234
+ exceed 150 lines.
235
+ - **Proof.** The change is kept only if `spectra compare` says *improved* with medium or high confidence and no new
236
+ critical finding; with low confidence it measures again.
237
+ - **A record.** Each kept commit carries its evidence; `.spectra/fix/<time>/report.md` summarises the session, and a
238
+ history skips findings that failed before.
239
+
240
+ By default the agent is Claude Code in headless mode, allowed only to read and edit files (no shell, no web, no MCP),
241
+ with a spending cap per finding. A test bench (`npm run bench`) checks the harness's decisions in CI: a real fix is
242
+ kept, a "faster" change that breaks the page is caught, a cosmetic change doesn't count, and an edit to
243
+ `package.json` is refused. See [the AI fix harness](https://progen-labs.github.io/website/#spectra/docs/harness).
244
+
245
+ ## In VS Code
246
+
247
+ The Spectra extension (`vscode/`) puts results where you work: every finding in the **Problems panel** at the file
248
+ and line responsible, **CodeLens** above the costliest components and functions
249
+ (`⚡ Spectra: Row · 7,200 renders · 1.44 s rendering`), headline numbers in the **status bar**, **Verify against
250
+ baseline** after a change, golden paths, and the dashboard inside the editor. Results that the CLI or an AI
251
+ assistant writes to `.spectra/results` appear automatically, and on VS Code 1.101+ it offers Spectra's MCP server to
252
+ agent mode. It runs your project's Spectra (0.2.0 or later) or `npx`.
253
+
254
+ Install **Spectra — frontend performance** from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=progen-labs.spectra-performance), or from a terminal:
255
+
256
+ ```bash
257
+ code --install-extension progen-labs.spectra-performance
258
+ ```
259
+
260
+ For Cursor, Windsurf or VSCodium (Open VSX), until it is listed there: `cd vscode && npx @vscode/vsce package
261
+ --no-dependencies`, then install `spectra-performance-0.1.0.vsix` with **Extensions: Install from VSIX…**.
262
+
263
+ See the [VS Code guide](https://progen-labs.github.io/website/#spectra/docs/vscode).
264
+
153
265
  ## Safe by default
154
266
 
155
267
  Auto-exploration never triggers destructive actions. Spectra detects controls that would mutate state —
@@ -178,7 +290,8 @@ and report generation work.
178
290
  [Quick start](https://progen-labs.github.io/website/#spectra/docs/quick-start),
179
291
  [Compare before and after](https://progen-labs.github.io/website/#spectra/docs/compare),
180
292
  [Budgets and CI](https://progen-labs.github.io/website/#spectra/docs/budgets),
181
- [JSON output](https://progen-labs.github.io/website/#spectra/docs/json-output), the
293
+ [JSON output](https://progen-labs.github.io/website/#spectra/docs/json-output),
294
+ [Claude Code](https://progen-labs.github.io/website/#spectra/docs/claude-code), the
182
295
  [Findings reference](https://progen-labs.github.io/website/#spectra/docs/findings) and
183
296
  [Troubleshooting](https://progen-labs.github.io/website/#spectra/docs/troubleshooting).
184
297
  - **[DOCS.md](DOCS.md)**: the in-repo manual, the technical deep dive, and how to contribute.
package/bin/spectra.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * spectra performance [--url <page>] [profiler options] profile a frontend
6
6
  * spectra compare <before> <after> before vs after, noise-aware
7
7
  * spectra reports list saved reports
8
+ * spectra mcp [--project <dir>] MCP server for AI tools (stdio)
9
+ * spectra fix [options] the AI fix harness: fix, check, verify
8
10
  * spectra dashboard open the dashboard UI
9
11
  * spectra --help show this help
10
12
  * spectra --version print the version
@@ -39,6 +41,13 @@ Usage:
39
41
  Compare two profiles; a change counts only when
40
42
  it is bigger than the noise between runs
41
43
  spectra reports [--json] List saved reports (ids for compare / dashboard)
44
+ spectra mcp [--project <dir>] [--allow-remote]
45
+ Run as an MCP server (stdio) for Claude Code,
46
+ Cursor, VS Code and other AI tools
47
+ spectra fix [options] The AI fix harness: an agent fixes the costliest
48
+ findings in a copy of your code; a change is kept
49
+ only if your checks pass and it measures faster
50
+ (spectra fix --help for its options)
42
51
  spectra dashboard Open the Spectra dashboard UI
43
52
  spectra --help, -h Show this help
44
53
  spectra --version, -v Print the version
@@ -91,9 +100,26 @@ Examples:
91
100
  spectra performance --quick --runs 5 --out before.json
92
101
  spectra performance --quick --runs 5 --out after.json
93
102
  spectra compare before.json after.json # did the change help?
103
+ spectra fix --max-fixes 3 # let an agent fix them, verified
94
104
  spectra performance --json | jq .summary # pipe it anywhere
95
105
  `;
96
106
 
107
+ // What a terminate signal must clean up: the running script and a dev server
108
+ // Spectra started. Without this, stopping Spectra from outside (an editor or
109
+ // an AI client cancelling a run) would leave the dev server running.
110
+ const active = { child: null, server: null };
111
+ let stopping = false;
112
+ for (const signal of ['SIGINT', 'SIGTERM']) {
113
+ process.on(signal, () => {
114
+ if (stopping) return;
115
+ stopping = true;
116
+ active.child?.kill(signal);
117
+ Promise.resolve(active.server?.stop())
118
+ .catch(() => {})
119
+ .finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
120
+ });
121
+ }
122
+
97
123
  function runNodeAsync(scriptRelPath, args, extraEnv = {}) {
98
124
  return new Promise((resolve, reject) => {
99
125
  const child = spawn(
@@ -105,7 +131,11 @@ function runNodeAsync(scriptRelPath, args, extraEnv = {}) {
105
131
  env: { ...process.env, ...extraEnv },
106
132
  },
107
133
  );
108
- child.on('exit', code => resolve(code ?? 0));
134
+ active.child = child;
135
+ child.on('exit', code => {
136
+ if (active.child === child) active.child = null;
137
+ resolve(code ?? 0);
138
+ });
109
139
  child.on('error', reject);
110
140
  });
111
141
  }
@@ -189,11 +219,10 @@ async function runProfile() {
189
219
  const { value: command, rest: afterCmd } = takeOption(args, 'command');
190
220
  const { value: bootTimeout, rest: profileArgs } = takeOption(afterCmd, 'boot-timeout');
191
221
  const fw = detectFramework(cwd);
192
- const label =
193
- fw.framework !== 'unknown' ? `${fw.framework}/${fw.bundler}` : fw.bundler;
222
+ const label = [fw.framework, fw.bundler].filter(x => x && x !== 'unknown').join('/');
194
223
  // Status goes to stderr so it never mixes with the report on stdout.
195
224
  console.error(
196
- `spectra: no --url given — starting the ${label} dev server in ${cwd} …`,
225
+ `spectra: no --url given — starting the ${label ? `${label} ` : ''}dev server in ${cwd} …`,
197
226
  );
198
227
 
199
228
  let server;
@@ -207,6 +236,7 @@ async function runProfile() {
207
236
  console.error(`spectra: ${err.message}`);
208
237
  return 1;
209
238
  }
239
+ active.server = server;
210
240
  console.error(`spectra: dev server ready at ${server.url} — profiling …\n`);
211
241
 
212
242
  let code = 1;
@@ -217,6 +247,7 @@ async function runProfile() {
217
247
  env,
218
248
  );
219
249
  } finally {
250
+ active.server = null;
220
251
  await server.stop();
221
252
  console.error('\nspectra: stopped the dev server.');
222
253
  }
@@ -246,6 +277,7 @@ function runDashboard() {
246
277
  env: { ...process.env, ...projectEnv() },
247
278
  },
248
279
  );
280
+ active.child = child;
249
281
  child.on('exit', code => process.exit(code ?? 0));
250
282
  child.on('error', err => {
251
283
  console.error(`spectra: failed to start the dashboard — ${err.message}`);
@@ -274,6 +306,23 @@ switch (command) {
274
306
  });
275
307
  break;
276
308
  }
309
+ case 'fix':
310
+ // In-process, in the user's project: the harness manages its own copy,
311
+ // dev server and child processes (server/fix/cli.js).
312
+ import('../server/fix/cli.js').catch(err => {
313
+ console.error(`spectra: ${err.message}`);
314
+ process.exit(1);
315
+ });
316
+ break;
317
+ case 'mcp':
318
+ // In-process: stdin/stdout are the protocol channel, and the working
319
+ // directory stays the user's (the project, unless --project or the
320
+ // client's roots say otherwise).
321
+ import('../server/mcp.js').catch(err => {
322
+ console.error(`spectra: ${err.message}`);
323
+ process.exit(1);
324
+ });
325
+ break;
277
326
  case 'dashboard':
278
327
  case 'ui':
279
328
  runDashboard();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@progen-labs/spectra",
3
- "version": "0.2.0",
4
- "description": "Spectra — a Chrome DevTools Protocol profiler that identifies the files, functions and components causing frontend slowness, across React, Vue, Svelte, Angular and Next.js builds.",
3
+ "version": "0.4.0",
4
+ "description": "Spectra \u2014 a Chrome DevTools Protocol profiler that identifies the files, functions and components causing frontend slowness, across React, Vue, Svelte, Angular and Next.js builds.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "spectra": "bin/spectra.js"
@@ -32,7 +32,9 @@
32
32
  "prepack": "npm run build",
33
33
  "demo": "vite --config fixtures/demo-app/vite.config.js",
34
34
  "profile": "node server/cli.js",
35
- "test": "node --test \"server/**/test-*.js\""
35
+ "test": "node --test \"server/**/test-*.js\" \"action/test-*.js\"",
36
+ "test:vscode": "npm --prefix vscode test",
37
+ "bench": "node bench/run.mjs"
36
38
  },
37
39
  "engines": {
38
40
  "node": ">=18"
@@ -51,7 +53,11 @@
51
53
  "angular",
52
54
  "nextjs",
53
55
  "spectra",
54
- "progen-labs"
56
+ "progen-labs",
57
+ "mcp",
58
+ "mcp-server",
59
+ "claude-code",
60
+ "ai"
55
61
  ],
56
62
  "author": "Progen Labs (Sourav Debnath)",
57
63
  "license": "MIT",
@@ -71,6 +77,7 @@
71
77
  "puppeteer-core": "^25.13.0"
72
78
  },
73
79
  "devDependencies": {
80
+ "@modelcontextprotocol/sdk": "^1.32.1",
74
81
  "@vitejs/plugin-react": "^6.1.2",
75
82
  "d3": "^7.9.0",
76
83
  "highlight.js": "^11.10.0",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The coding agent `spectra fix` hands one finding to. By default it is Claude
3
+ * Code in headless mode, allowed only to read and edit files: no shell, no
4
+ * web, no MCP servers. Any other agent works through --agent "<command>": it
5
+ * gets the instructions on stdin (and in $SPECTRA_FIX_PROMPT_FILE) and runs in
6
+ * the copy of the project.
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ const run = (cmd, args, { cwd, input, env, signal, timeoutMs, shell = false }) =>
14
+ new Promise((resolve, reject) => {
15
+ const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], shell });
16
+ let out = '';
17
+ let err = '';
18
+ child.stdout.on('data', d => (out += d));
19
+ child.stderr.on('data', d => (err += d));
20
+ const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
21
+ const abort = () => child.kill('SIGTERM');
22
+ signal?.addEventListener('abort', abort, { once: true });
23
+ child.on('error', e => {
24
+ clearTimeout(timer);
25
+ reject(e.code === 'ENOENT' ? new Error(`${cmd} was not found`) : e);
26
+ });
27
+ child.on('close', code => {
28
+ clearTimeout(timer);
29
+ signal?.removeEventListener('abort', abort);
30
+ resolve({ code, out, err });
31
+ });
32
+ child.stdin.end(input ?? '');
33
+ });
34
+
35
+ /** Claude Code, headless. */
36
+ export function claudeAgent({ bin = process.env.SPECTRA_CLAUDE || 'claude', model, budgetUsd = 2, timeoutMs = 10 * 60_000 } = {}) {
37
+ return {
38
+ name: `Claude Code${model ? ` (${model})` : ''}`,
39
+ async run(prompt, { cwd, signal }) {
40
+ const args = [
41
+ '-p',
42
+ prompt,
43
+ '--output-format',
44
+ 'json',
45
+ '--permission-mode',
46
+ 'acceptEdits',
47
+ '--allowedTools',
48
+ 'Read Edit Write Glob Grep',
49
+ '--disallowedTools',
50
+ 'Bash WebFetch WebSearch',
51
+ '--strict-mcp-config',
52
+ '--no-session-persistence',
53
+ '--max-budget-usd',
54
+ String(budgetUsd),
55
+ ...(model ? ['--model', model] : []),
56
+ ];
57
+ let res;
58
+ try {
59
+ res = await run(bin, args, { cwd, signal, timeoutMs });
60
+ } catch (err) {
61
+ throw new Error(`could not start Claude Code (${err.message}). Install it (https://claude.com/claude-code), set SPECTRA_CLAUDE to its path, or pass --agent "<command>".`);
62
+ }
63
+ let parsed = null;
64
+ try {
65
+ parsed = JSON.parse(res.out.trim().split('\n').pop());
66
+ } catch {
67
+ /* not JSON */
68
+ }
69
+ if (res.code !== 0 && !parsed) throw new Error(`Claude Code exited with code ${res.code}: ${(res.err || res.out).trim().split('\n').slice(-3).join(' ')}`);
70
+ return {
71
+ summary: String(parsed?.result ?? res.out).trim().split('\n').filter(Boolean).pop()?.slice(0, 300) || '',
72
+ costUsd: parsed?.total_cost_usd ?? null,
73
+ turns: parsed?.num_turns ?? null,
74
+ error: parsed?.is_error ? String(parsed.result || 'agent error') : null,
75
+ };
76
+ },
77
+ };
78
+ }
79
+
80
+ /** Any agent, as a shell command run in the copy of the project. */
81
+ export function commandAgent(command, { timeoutMs = 10 * 60_000 } = {}) {
82
+ return {
83
+ name: command,
84
+ async run(prompt, { cwd, signal, finding }) {
85
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-fix-')), 'prompt.md');
86
+ fs.writeFileSync(file, prompt);
87
+ try {
88
+ const res = await run(command, [], { cwd, input: prompt, signal, timeoutMs, shell: true, env: { SPECTRA_FIX_PROMPT_FILE: file, SPECTRA_FIX_FINDING: JSON.stringify(finding) } });
89
+ if (res.code !== 0) throw new Error(`the agent exited with code ${res.code}: ${(res.err || res.out).trim().split('\n').slice(-2).join(' ')}`);
90
+ return { summary: res.out.trim().split('\n').filter(Boolean).pop()?.slice(0, 300) || '', costUsd: null, turns: null, error: null };
91
+ } finally {
92
+ fs.rmSync(path.dirname(file), { recursive: true, force: true });
93
+ }
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The project's own checks: the tests, type check and build that must pass
3
+ * after every change, so a "faster" page is never a broken one. A check can
4
+ * test the live app at $SPECTRA_FIX_URL.
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const PLACEHOLDER = /no test specified/;
11
+
12
+ /** Checks from package.json scripts: test, typecheck and build. */
13
+ export function detectChecks(projectDir) {
14
+ let scripts = {};
15
+ try {
16
+ scripts = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8')).scripts || {};
17
+ } catch {
18
+ /* no package.json */
19
+ }
20
+ const checks = [];
21
+ if (scripts.test && !PLACEHOLDER.test(scripts.test)) checks.push({ name: 'npm test', cmd: 'npm test' });
22
+ const typecheck = ['typecheck', 'type-check', 'check-types', 'tsc'].find(s => scripts[s]);
23
+ if (typecheck) checks.push({ name: `npm run ${typecheck}`, cmd: `npm run ${typecheck}` });
24
+ if (scripts.build) checks.push({ name: 'npm run build', cmd: 'npm run build' });
25
+ return checks;
26
+ }
27
+
28
+ /** Run checks in order, stopping at the first failure. */
29
+ export async function runChecks(checks, { cwd, env = {}, signal, timeoutMs = 10 * 60_000 }) {
30
+ const results = [];
31
+ for (const check of checks) {
32
+ const t0 = Date.now();
33
+ const res = await new Promise(resolve => {
34
+ const child = spawn(check.cmd, { cwd, shell: true, env: { ...process.env, CI: '1', FORCE_COLOR: '0', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
35
+ let out = '';
36
+ child.stdout.on('data', d => (out = (out + d).slice(-4000)));
37
+ child.stderr.on('data', d => (out = (out + d).slice(-4000)));
38
+ const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
39
+ const abort = () => child.kill('SIGTERM');
40
+ signal?.addEventListener('abort', abort, { once: true });
41
+ child.on('error', e => resolve({ code: 127, out: e.message }));
42
+ child.on('close', code => {
43
+ clearTimeout(timer);
44
+ signal?.removeEventListener('abort', abort);
45
+ resolve({ code, out });
46
+ });
47
+ });
48
+ const result = { name: check.name, ok: res.code === 0, ms: Date.now() - t0, tail: res.out.trim().split('\n').slice(-12).join('\n') };
49
+ results.push(result);
50
+ if (!result.ok) break;
51
+ }
52
+ return results;
53
+ }