@progen-labs/spectra 0.6.0 → 0.7.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/CHANGELOG.md +94 -0
- package/README.md +156 -295
- package/agents/spectra.md +55 -37
- package/bin/spectra.js +63 -23
- package/dist/assets/index-BDIjT029.js +11 -0
- package/dist/index.html +1 -1
- package/package.json +29 -6
- package/schema/config.schema.json +1 -1
- package/schema/result.schema.json +64 -3
- package/server/agents-cli.js +6 -2
- package/server/agents.js +69 -17
- package/server/explore/boot.js +2 -0
- package/server/fix/agent.js +90 -7
- package/server/fix/cli.js +180 -208
- package/server/fix/policy.js +3 -1
- package/server/fix/test-fix.js +43 -2
- package/server/improve/cli.js +304 -0
- package/server/improve/session.js +188 -0
- package/server/improve/test-improve.js +135 -0
- package/server/mcp.js +64 -2
- package/server/signin.js +25 -2
- package/server/test-agents.js +39 -15
- package/server/test-compare.js +10 -0
- package/server/test-mcp.js +8 -3
- package/server/test-signin.js +1 -0
- package/shared/compare.js +18 -3
- package/dist/assets/index-mtYxDBW7.js +0 -11
- package/server/postinstall.js +0 -20
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Performance Profiler</title>
|
|
7
7
|
<link rel="icon" href="data:," />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-BDIjT029.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-z14kjW8r.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@progen-labs/spectra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"index.html",
|
|
25
25
|
"vite.config.js",
|
|
26
26
|
"README.md",
|
|
27
|
-
"LICENSE"
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"CHANGELOG.md"
|
|
28
29
|
],
|
|
29
30
|
"scripts": {
|
|
30
31
|
"spectra": "node bin/spectra.js",
|
|
@@ -36,10 +37,12 @@
|
|
|
36
37
|
"test": "node --test \"server/**/test-*.js\" \"action/test-*.js\"",
|
|
37
38
|
"test:vscode": "npm --prefix vscode test",
|
|
38
39
|
"bench": "node bench/run.mjs",
|
|
39
|
-
"
|
|
40
|
+
"size": "npm run build && node scripts/size-report.mjs",
|
|
41
|
+
"lint": "eslint .",
|
|
42
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include=\"server/**\" --test-coverage-include=\"shared/**\" --test-coverage-include=\"bin/**\" --test-coverage-exclude=\"**/test-*.js\" --test-coverage-exclude=\"server/probe.browser.js\" --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=72 \"server/**/test-*.js\" \"action/test-*.js\""
|
|
40
43
|
},
|
|
41
44
|
"engines": {
|
|
42
|
-
"node": ">=
|
|
45
|
+
"node": ">=22.12"
|
|
43
46
|
},
|
|
44
47
|
"keywords": [
|
|
45
48
|
"performance",
|
|
@@ -63,7 +66,7 @@
|
|
|
63
66
|
],
|
|
64
67
|
"author": "Progen Labs (Sourav Debnath)",
|
|
65
68
|
"license": "MIT",
|
|
66
|
-
"homepage": "https://
|
|
69
|
+
"homepage": "https://progen-labs.github.io/website/#spectra",
|
|
67
70
|
"repository": {
|
|
68
71
|
"type": "git",
|
|
69
72
|
"url": "git+https://github.com/progen-labs/spectra.git"
|
|
@@ -79,12 +82,32 @@
|
|
|
79
82
|
"puppeteer-core": "^25.13.0"
|
|
80
83
|
},
|
|
81
84
|
"devDependencies": {
|
|
85
|
+
"@eslint/js": "^9.39.5",
|
|
82
86
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
87
|
+
"@size-limit/file": "^14.2.0",
|
|
83
88
|
"@vitejs/plugin-react": "^6.1.2",
|
|
84
89
|
"d3": "^7.9.0",
|
|
90
|
+
"eslint": "^9.39.5",
|
|
91
|
+
"eslint-plugin-react": "^7.37.5",
|
|
92
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
93
|
+
"globals": "^17.13.0",
|
|
85
94
|
"highlight.js": "^11.10.0",
|
|
95
|
+
"publint": "^0.3.25",
|
|
86
96
|
"react": "^18.3.1",
|
|
87
97
|
"react-dom": "^18.3.1",
|
|
98
|
+
"size-limit": "^14.2.0",
|
|
88
99
|
"vite": "^8.3.4"
|
|
89
|
-
}
|
|
100
|
+
},
|
|
101
|
+
"size-limit": [
|
|
102
|
+
{
|
|
103
|
+
"name": "Dashboard JavaScript (brotli)",
|
|
104
|
+
"path": "dist/assets/*.js",
|
|
105
|
+
"limit": "115 kB"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "Dashboard CSS (brotli)",
|
|
109
|
+
"path": "dist/assets/*.css",
|
|
110
|
+
"limit": "10 kB"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
90
113
|
}
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
}
|
|
142
142
|
},
|
|
143
143
|
"agentDocs": {
|
|
144
|
-
"description": "Spectra's knowledge doc for AI agents, written
|
|
144
|
+
"description": "Spectra's knowledge doc for AI agents, written the first time Spectra runs in the project (see `spectra agents`). false: never write it; a list: only for these agents. Left out: AGENTS.md and the agents this project or machine uses.",
|
|
145
145
|
"oneOf": [
|
|
146
146
|
{
|
|
147
147
|
"type": "boolean"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://unpkg.com/@progen-labs/spectra/schema/result.schema.json",
|
|
4
4
|
"title": "Spectra result",
|
|
5
|
-
"description": "What `spectra
|
|
5
|
+
"description": "What `spectra \u2026 --json` prints and `--out` writes. A small envelope that points at the full reports on disk. Additive changes keep schemaVersion; breaking changes bump it.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": [
|
|
8
8
|
"schemaVersion",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"profile",
|
|
20
20
|
"evaluation",
|
|
21
21
|
"compare",
|
|
22
|
-
"reports"
|
|
22
|
+
"reports",
|
|
23
|
+
"improve"
|
|
23
24
|
]
|
|
24
25
|
},
|
|
25
26
|
"spectraVersion": {
|
|
@@ -81,6 +82,18 @@
|
|
|
81
82
|
"then": {
|
|
82
83
|
"$ref": "#/$defs/reports"
|
|
83
84
|
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"if": {
|
|
88
|
+
"properties": {
|
|
89
|
+
"kind": {
|
|
90
|
+
"const": "improve"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"then": {
|
|
95
|
+
"$ref": "#/$defs/improve"
|
|
96
|
+
}
|
|
84
97
|
}
|
|
85
98
|
],
|
|
86
99
|
"$defs": {
|
|
@@ -258,7 +271,7 @@
|
|
|
258
271
|
"string",
|
|
259
272
|
"null"
|
|
260
273
|
],
|
|
261
|
-
"description": "exact, or located (
|
|
274
|
+
"description": "exact, or located (\u2248, found by searching the source)."
|
|
262
275
|
},
|
|
263
276
|
"abs": {
|
|
264
277
|
"type": [
|
|
@@ -744,6 +757,54 @@
|
|
|
744
757
|
}
|
|
745
758
|
}
|
|
746
759
|
}
|
|
760
|
+
},
|
|
761
|
+
"improve": {
|
|
762
|
+
"type": "object",
|
|
763
|
+
"description": "One step of a guided `spectra improve` session (next, check, report or stop): the text shown to whoever makes the changes, and the session state.",
|
|
764
|
+
"required": [
|
|
765
|
+
"step",
|
|
766
|
+
"message",
|
|
767
|
+
"session"
|
|
768
|
+
],
|
|
769
|
+
"properties": {
|
|
770
|
+
"step": {
|
|
771
|
+
"enum": [
|
|
772
|
+
"next",
|
|
773
|
+
"check",
|
|
774
|
+
"report",
|
|
775
|
+
"stop"
|
|
776
|
+
]
|
|
777
|
+
},
|
|
778
|
+
"message": {
|
|
779
|
+
"type": "string",
|
|
780
|
+
"description": "What was printed: the problem to fix, the verdict on a change, or the report."
|
|
781
|
+
},
|
|
782
|
+
"task": {
|
|
783
|
+
"type": [
|
|
784
|
+
"object",
|
|
785
|
+
"null"
|
|
786
|
+
],
|
|
787
|
+
"description": "The problem to work on now, if any: title, file, line, severity, impactMs, fix, culprits."
|
|
788
|
+
},
|
|
789
|
+
"attempt": {
|
|
790
|
+
"type": "object",
|
|
791
|
+
"description": "check only: the change just measured, with outcome (kept, no-gain, slower, mixed, new-problem, unproven, checks-failed) and reason."
|
|
792
|
+
},
|
|
793
|
+
"result": {
|
|
794
|
+
"type": [
|
|
795
|
+
"object",
|
|
796
|
+
"null"
|
|
797
|
+
],
|
|
798
|
+
"description": "report and stop: the overall before \u2192 after (verdict, confidence, headlines) when something was kept."
|
|
799
|
+
},
|
|
800
|
+
"session": {
|
|
801
|
+
"type": [
|
|
802
|
+
"object",
|
|
803
|
+
"null"
|
|
804
|
+
],
|
|
805
|
+
"description": "The whole session: baseline, current (the result files of the latest kept state), queue, task, attempts, skipped, kept."
|
|
806
|
+
}
|
|
807
|
+
}
|
|
747
808
|
}
|
|
748
809
|
}
|
|
749
810
|
}
|
package/server/agents-cli.js
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* spectra agents --print print the knowledge doc
|
|
11
11
|
* … --dir <project> --dry-run
|
|
12
12
|
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
13
14
|
import path from 'node:path';
|
|
14
15
|
import { parseArgs } from './args.js';
|
|
15
|
-
import { TARGETS, TARGET_IDS, describe, detectTargets, installAgentDocs, knowledge, removeAgentDocs } from './agents.js';
|
|
16
|
+
import { TARGETS, TARGET_IDS, describe, detectTargets, installAgentDocs, knowledge, removeAgentDocs, writeNote } from './agents.js';
|
|
16
17
|
|
|
17
18
|
const { opt, flag } = parseArgs(process.argv.slice(2), { values: ['dir', 'only'] });
|
|
18
19
|
const root = path.resolve(opt('dir', process.cwd()));
|
|
@@ -25,7 +26,7 @@ Teaches your AI agents about Spectra: what it does, which command answers which
|
|
|
25
26
|
request, and how to read the results. It writes Spectra's knowledge doc where
|
|
26
27
|
each agent reads its project instructions, so you can ask in plain words
|
|
27
28
|
("why is checkout slow?", "did my change help?") and the agent runs Spectra.
|
|
28
|
-
It
|
|
29
|
+
It happens by itself the first time Spectra runs in a project; this does it by hand.
|
|
29
30
|
|
|
30
31
|
Agents: ${TARGET_IDS.join(', ')}
|
|
31
32
|
Off for a project: "agentDocs": false in spectra.config.json (or SPECTRA_AGENT_DOCS=0).`);
|
|
@@ -46,6 +47,8 @@ if (flag('list')) {
|
|
|
46
47
|
|
|
47
48
|
if (flag('remove')) {
|
|
48
49
|
const results = removeAgentDocs({ root, dryRun });
|
|
50
|
+
// and don't bring them back on the next run
|
|
51
|
+
if (!dryRun) writeNote(root, { removed: true });
|
|
49
52
|
console.log(results.length ? `${dryRun ? 'Would remove' : 'Removed'} Spectra's knowledge doc:\n${describe(results)}` : 'Nothing to remove.');
|
|
50
53
|
process.exit(0);
|
|
51
54
|
}
|
|
@@ -61,6 +64,7 @@ else if (opt('only')) {
|
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
66
|
const results = installAgentDocs({ root, targets, dryRun });
|
|
67
|
+
if (!dryRun) writeNote(root, { version: JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version });
|
|
64
68
|
console.log(`${dryRun ? 'Would write' : 'Wrote'} Spectra's knowledge doc for your AI agents:\n${describe(results)}`);
|
|
65
69
|
console.log('\nAsk your agent in plain words, e.g. "why is the checkout page slow?" or "did my last change make it faster?".');
|
|
66
70
|
console.log('Remove it with: npx spectra agents --remove');
|
package/server/agents.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* instructions, so a user can just ask in plain words ("why is checkout
|
|
6
6
|
* slow?", "did my change help?") and the agent runs the right Spectra command.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Written the first time Spectra runs in a project it is installed in (no
|
|
9
|
+
* install script, so package managers and supply-chain scanners have nothing
|
|
10
|
+
* to flag), refreshed when Spectra updates, and on demand with `spectra agents`.
|
|
10
11
|
*
|
|
11
12
|
* Files Spectra owns carry a `spectra:managed` marker and are replaced on
|
|
12
13
|
* update; in shared files (AGENTS.md, CLAUDE.md, GEMINI.md) only the block
|
|
@@ -250,33 +251,84 @@ export function removeAgentDocs({ root, dryRun = false } = {}) {
|
|
|
250
251
|
return out;
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
// what Spectra did in a project, so it acts once per version and never undoes a removal
|
|
255
|
+
const noteFile = root => path.join(root, '.spectra', 'agent-docs.json');
|
|
256
|
+
const readNote = root => {
|
|
257
|
+
try {
|
|
258
|
+
return JSON.parse(fs.readFileSync(noteFile(root), 'utf8'));
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
export function writeNote(root, note) {
|
|
264
|
+
try {
|
|
265
|
+
fs.mkdirSync(path.dirname(noteFile(root)), { recursive: true });
|
|
266
|
+
fs.writeFileSync(noteFile(root), `${JSON.stringify({ ...note, at: new Date().toISOString() }, null, 2)}\n`);
|
|
267
|
+
} catch {}
|
|
268
|
+
}
|
|
269
|
+
|
|
253
270
|
/**
|
|
254
|
-
* Should
|
|
255
|
-
*
|
|
256
|
-
* not
|
|
271
|
+
* Should this run of `spectra` write the docs? Only in a project Spectra is
|
|
272
|
+
* installed in (its node_modules holds this copy of Spectra, and the command
|
|
273
|
+
* runs inside it): not under `npx` from its cache, not a global install, not
|
|
274
|
+
* Spectra's own repository, not in CI, and not when the user opted out or
|
|
275
|
+
* removed the docs. Once per Spectra version: the first run writes them for
|
|
276
|
+
* the agents in use; after an update, only what is already there is refreshed.
|
|
257
277
|
*/
|
|
258
|
-
export function
|
|
259
|
-
const root = env.INIT_CWD && path.resolve(env.INIT_CWD);
|
|
278
|
+
export function firstRunPlan({ cwd = process.cwd(), env = process.env, pkgRoot = PKG_ROOT } = {}) {
|
|
260
279
|
if (env.SPECTRA_AGENT_DOCS === '0' || env.SPECTRA_AGENT_DOCS === 'false') return { skip: 'SPECTRA_AGENT_DOCS=0' };
|
|
261
280
|
if (env.CI && env.CI !== 'false' && env.CI !== '0') return { skip: 'CI' };
|
|
262
|
-
if (env.npm_config_global === 'true') return { skip: 'a global install' };
|
|
263
|
-
if (!root) return { skip: 'no project directory (INIT_CWD)' };
|
|
264
|
-
const pkg = path.resolve(pkgRoot);
|
|
265
|
-
if (pkg === root) return { skip: "Spectra's own repository" };
|
|
266
|
-
// installed into this project's node_modules (pnpm keeps it under node_modules/.pnpm), not into npx's cache
|
|
267
281
|
const real = (() => {
|
|
268
282
|
try {
|
|
269
|
-
return fs.realpathSync(
|
|
283
|
+
return fs.realpathSync(pkgRoot);
|
|
270
284
|
} catch {
|
|
271
|
-
return
|
|
285
|
+
return path.resolve(pkgRoot);
|
|
272
286
|
}
|
|
273
287
|
})();
|
|
274
|
-
const
|
|
275
|
-
if (
|
|
288
|
+
const at = real.indexOf(`${path.sep}node_modules${path.sep}`);
|
|
289
|
+
if (at < 0) return { skip: "not installed from npm (Spectra's own repository)" };
|
|
290
|
+
const root = real.slice(0, at);
|
|
291
|
+
const here = (() => {
|
|
292
|
+
try {
|
|
293
|
+
return fs.realpathSync(cwd);
|
|
294
|
+
} catch {
|
|
295
|
+
return path.resolve(cwd);
|
|
296
|
+
}
|
|
297
|
+
})();
|
|
298
|
+
if (here !== root && !here.startsWith(root + path.sep)) return { skip: 'not run inside the project Spectra is installed in (npx or a global install)' };
|
|
276
299
|
if (!exists(path.join(root, 'package.json'))) return { skip: 'no package.json' };
|
|
277
300
|
const setting = readAgentDocsSetting(root);
|
|
278
301
|
if (setting === false) return { skip: '"agentDocs": false in spectra.config.json' };
|
|
279
|
-
|
|
302
|
+
const note = readNote(root);
|
|
303
|
+
if (note?.removed) return { skip: 'removed with `spectra agents --remove`' };
|
|
304
|
+
if (note?.version === VERSION) return { skip: 'already done for this version' };
|
|
305
|
+
return { root, mode: note ? 'refresh' : 'install', targets: Array.isArray(setting) ? setting.filter(id => TARGET_IDS.includes(id)) : undefined };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Rewrites only the Spectra content that is already in the project (after an update). */
|
|
309
|
+
export function refreshAgentDocs({ root, dryRun = false } = {}) {
|
|
310
|
+
const out = [];
|
|
311
|
+
for (const t of TARGETS) {
|
|
312
|
+
const files = [{ file: t.file, write: f => (t.block ? writeBlock(f, t.block(), dryRun) : writeOwn(f, t.content(), dryRun)) }];
|
|
313
|
+
if (t.also) files.push({ file: t.also.file, write: f => writeBlock(f, t.also.block(), dryRun) });
|
|
314
|
+
for (const { file: rel, write } of files) {
|
|
315
|
+
const file = path.join(root, rel);
|
|
316
|
+
if (!exists(file)) continue;
|
|
317
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
318
|
+
if (!findBlock(text) && !text.includes(MANAGED)) continue;
|
|
319
|
+
out.push({ id: t.id, name: t.name, file: rel, result: write(file) });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The first-run step: does what firstRunPlan says, and remembers it. Returns the files it changed. */
|
|
326
|
+
export function ensureAgentDocs(opts = {}) {
|
|
327
|
+
const plan = firstRunPlan(opts);
|
|
328
|
+
if (plan.skip) return { skip: plan.skip, changed: [] };
|
|
329
|
+
const results = plan.mode === 'install' ? installAgentDocs({ root: plan.root, targets: plan.targets }) : refreshAgentDocs({ root: plan.root });
|
|
330
|
+
writeNote(plan.root, { version: VERSION });
|
|
331
|
+
return { root: plan.root, changed: results.filter(r => r.result === 'created' || r.result === 'updated') };
|
|
280
332
|
}
|
|
281
333
|
|
|
282
334
|
/** One line per file, for people. */
|
package/server/explore/boot.js
CHANGED
|
@@ -8,6 +8,8 @@ import { spawn } from 'node:child_process';
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
|
|
11
|
+
// terminal colour codes in the dev server's output
|
|
12
|
+
// eslint-disable-next-line no-control-regex
|
|
11
13
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
12
14
|
const URL_RE =
|
|
13
15
|
/(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?[^\s)'"<]*)/i;
|
package/server/fix/agent.js
CHANGED
|
@@ -1,18 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The coding agent `spectra
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The coding agent `spectra improve` hands one finding to when it runs
|
|
3
|
+
* unattended. Spectra knows the common agent CLIs by name (AGENTS below) and
|
|
4
|
+
* finds one on the machine by itself; Claude Code runs allowed only to read
|
|
5
|
+
* and edit files: no shell, no web, no MCP servers. Any other agent works
|
|
6
|
+
* through --agent "<command>": it gets the instructions on stdin (and in
|
|
7
|
+
* $SPECTRA_FIX_PROMPT_FILE) and runs in the copy of the project.
|
|
7
8
|
*/
|
|
8
9
|
import { spawn } from 'node:child_process';
|
|
9
10
|
import fs from 'node:fs';
|
|
10
11
|
import os from 'node:os';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The agents Spectra can run by name: how each one takes a single prompt,
|
|
16
|
+
* edits files in the current directory without asking, and exits (the
|
|
17
|
+
* "print" mode every CLI agent has), and the signs that it is in use.
|
|
18
|
+
*/
|
|
19
|
+
export const AGENTS = {
|
|
20
|
+
claude: { name: 'Claude Code', bin: 'claude', project: ['CLAUDE.md', '.claude'], home: ['.claude', '.claude.json'], also: ['.local/bin/claude', '.claude/local/claude'] },
|
|
21
|
+
codex: { name: 'Codex CLI', bin: 'codex', args: p => ['exec', '--full-auto', p], project: ['.codex'], home: ['.codex'] },
|
|
22
|
+
gemini: { name: 'Gemini CLI', bin: 'gemini', args: p => ['-p', p, '--yolo'], project: ['GEMINI.md', '.gemini'], home: ['.gemini'] },
|
|
23
|
+
copilot: { name: 'GitHub Copilot CLI', bin: 'copilot', args: p => ['-p', p, '--allow-all-tools'], project: ['.github/copilot-instructions.md', '.github/instructions'], home: ['.copilot'] },
|
|
24
|
+
cursor: { name: 'Cursor CLI', bin: 'cursor-agent', args: p => ['-p', p, '--force'], project: ['.cursor', '.cursorrules'], home: ['.cursor'] },
|
|
25
|
+
opencode: { name: 'OpenCode', bin: 'opencode', args: p => ['run', p], project: ['opencode.json', '.opencode'], home: ['.config/opencode'] },
|
|
26
|
+
amp: { name: 'Amp', bin: 'amp', args: p => ['-x', p], project: [], home: ['.config/amp'] },
|
|
27
|
+
};
|
|
28
|
+
export const AGENT_IDS = Object.keys(AGENTS);
|
|
29
|
+
|
|
30
|
+
const exists = p => {
|
|
31
|
+
try {
|
|
32
|
+
fs.statSync(p);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Where an agent's executable is: on PATH, or in the places its installer uses. Null when it is not installed. */
|
|
40
|
+
export function findExecutable(id, { env = process.env, home = os.homedir() } = {}) {
|
|
41
|
+
const a = AGENTS[id];
|
|
42
|
+
if (id === 'claude' && env.SPECTRA_CLAUDE) return env.SPECTRA_CLAUDE;
|
|
43
|
+
const names = process.platform === 'win32' ? [`${a.bin}.cmd`, `${a.bin}.exe`, a.bin] : [a.bin];
|
|
44
|
+
for (const dir of (env.PATH || '').split(path.delimiter).filter(Boolean))
|
|
45
|
+
for (const n of names) if (exists(path.join(dir, n))) return path.join(dir, n);
|
|
46
|
+
for (const rel of a.also || []) if (home && exists(path.join(home, rel))) return path.join(home, rel);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The agent to run unattended, when none was named: first one the project or
|
|
52
|
+
* the machine shows signs of (so it is the agent the user already works with),
|
|
53
|
+
* then any that is installed. Null when there is none.
|
|
54
|
+
*/
|
|
55
|
+
export function detectAgent({ root = process.cwd(), home = os.homedir(), env = process.env } = {}) {
|
|
56
|
+
const installed = AGENT_IDS.filter(id => findExecutable(id, { env, home }));
|
|
57
|
+
const inUse = id => (AGENTS[id].project || []).some(p => exists(path.join(root, p))) || (AGENTS[id].home || []).some(p => home && exists(path.join(home, p)));
|
|
58
|
+
const id = installed.find(inUse) || installed[0];
|
|
59
|
+
return id ? { id, ...AGENTS[id], bin: findExecutable(id, { env, home }) } : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The agent for --agent: a name from AGENTS, or any shell command. */
|
|
63
|
+
export function agentFromOption(value, opts = {}) {
|
|
64
|
+
return AGENTS[value] ? namedAgent(value, opts) : commandAgent(String(value), opts);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One of AGENTS, ready to run. */
|
|
68
|
+
export function namedAgent(id, { bin, model, budgetUsd, timeoutMs = 10 * 60_000, env, home } = {}) {
|
|
69
|
+
const a = AGENTS[id];
|
|
70
|
+
if (!a) throw new Error(`unknown agent "${id}"; the names are ${AGENT_IDS.join(', ')}, or pass a command.`);
|
|
71
|
+
const executable = bin || findExecutable(id, { env, home }) || a.bin;
|
|
72
|
+
if (id === 'claude') return claudeAgent({ bin: executable, model, budgetUsd, timeoutMs });
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
name: a.name,
|
|
76
|
+
async run(prompt, { cwd, signal }) {
|
|
77
|
+
let res;
|
|
78
|
+
try {
|
|
79
|
+
res = await run(executable, a.args(prompt), { cwd, signal, timeoutMs, env: { CI: '1' } });
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(`could not start ${a.name} (${err.message}). Install it, or pass --agent "<command>".`);
|
|
82
|
+
}
|
|
83
|
+
if (res.code !== 0) throw new Error(`${a.name} exited with code ${res.code}: ${(res.err || res.out).trim().split('\n').slice(-3).join(' ')}`);
|
|
84
|
+
return { summary: res.out.trim().split('\n').filter(Boolean).pop()?.slice(0, 300) || '', costUsd: null, turns: null, error: null };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// `input` goes to the agent's stdin; without it, stdin is closed from the start
|
|
90
|
+
// (the prompt is in the arguments). An agent that exits before reading its
|
|
91
|
+
// stdin makes the write fail with EPIPE, which must not take Spectra down.
|
|
13
92
|
const run = (cmd, args, { cwd, input, env, signal, timeoutMs, shell = false }) =>
|
|
14
93
|
new Promise((resolve, reject) => {
|
|
15
|
-
const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], shell });
|
|
94
|
+
const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe'], shell });
|
|
16
95
|
let out = '';
|
|
17
96
|
let err = '';
|
|
18
97
|
child.stdout.on('data', d => (out += d));
|
|
@@ -29,12 +108,16 @@ const run = (cmd, args, { cwd, input, env, signal, timeoutMs, shell = false }) =
|
|
|
29
108
|
signal?.removeEventListener('abort', abort);
|
|
30
109
|
resolve({ code, out, err });
|
|
31
110
|
});
|
|
32
|
-
|
|
111
|
+
if (input != null) {
|
|
112
|
+
child.stdin.on('error', () => {}); // the agent closed its end first
|
|
113
|
+
child.stdin.end(input);
|
|
114
|
+
}
|
|
33
115
|
});
|
|
34
116
|
|
|
35
117
|
/** Claude Code, headless. */
|
|
36
118
|
export function claudeAgent({ bin = process.env.SPECTRA_CLAUDE || 'claude', model, budgetUsd = 2, timeoutMs = 10 * 60_000 } = {}) {
|
|
37
119
|
return {
|
|
120
|
+
id: 'claude',
|
|
38
121
|
name: `Claude Code${model ? ` (${model})` : ''}`,
|
|
39
122
|
async run(prompt, { cwd, signal }) {
|
|
40
123
|
const args = [
|