@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 +114 -1
- package/bin/spectra.js +53 -4
- package/package.json +11 -4
- package/server/fix/agent.js +96 -0
- package/server/fix/checks.js +53 -0
- package/server/fix/cli.js +237 -0
- package/server/fix/harness.js +138 -0
- package/server/fix/policy.js +126 -0
- package/server/fix/report.js +38 -0
- package/server/fix/test-fix.js +211 -0
- package/server/fix/workspace.js +130 -0
- package/server/mcp.js +678 -0
- package/server/test-mcp.js +170 -0
- package/server/test-result.js +1 -44
- package/server/testing/fixtures.js +49 -0
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),
|
|
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
|
|
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.
|
|
4
|
-
"description": "Spectra
|
|
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
|
+
}
|