@progen-labs/spectra 0.5.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 -275
- package/agents/spectra.md +145 -0
- package/bin/spectra.js +74 -23
- package/dist/assets/index-BDIjT029.js +11 -0
- package/dist/index.html +1 -1
- package/package.json +31 -6
- package/schema/config.schema.json +24 -0
- package/schema/result.schema.json +64 -3
- package/server/agents-cli.js +70 -0
- package/server/agents.js +335 -0
- 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 +129 -0
- 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/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": {
|
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
"server",
|
|
19
19
|
"shared",
|
|
20
20
|
"schema",
|
|
21
|
+
"agents",
|
|
21
22
|
"src",
|
|
22
23
|
"dist",
|
|
23
24
|
"index.html",
|
|
24
25
|
"vite.config.js",
|
|
25
26
|
"README.md",
|
|
26
|
-
"LICENSE"
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"CHANGELOG.md"
|
|
27
29
|
],
|
|
28
30
|
"scripts": {
|
|
29
31
|
"spectra": "node bin/spectra.js",
|
|
@@ -34,10 +36,13 @@
|
|
|
34
36
|
"profile": "node server/cli.js",
|
|
35
37
|
"test": "node --test \"server/**/test-*.js\" \"action/test-*.js\"",
|
|
36
38
|
"test:vscode": "npm --prefix vscode test",
|
|
37
|
-
"bench": "node bench/run.mjs"
|
|
39
|
+
"bench": "node bench/run.mjs",
|
|
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\""
|
|
38
43
|
},
|
|
39
44
|
"engines": {
|
|
40
|
-
"node": ">=
|
|
45
|
+
"node": ">=22.12"
|
|
41
46
|
},
|
|
42
47
|
"keywords": [
|
|
43
48
|
"performance",
|
|
@@ -61,7 +66,7 @@
|
|
|
61
66
|
],
|
|
62
67
|
"author": "Progen Labs (Sourav Debnath)",
|
|
63
68
|
"license": "MIT",
|
|
64
|
-
"homepage": "https://
|
|
69
|
+
"homepage": "https://progen-labs.github.io/website/#spectra",
|
|
65
70
|
"repository": {
|
|
66
71
|
"type": "git",
|
|
67
72
|
"url": "git+https://github.com/progen-labs/spectra.git"
|
|
@@ -77,12 +82,32 @@
|
|
|
77
82
|
"puppeteer-core": "^25.13.0"
|
|
78
83
|
},
|
|
79
84
|
"devDependencies": {
|
|
85
|
+
"@eslint/js": "^9.39.5",
|
|
80
86
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
87
|
+
"@size-limit/file": "^14.2.0",
|
|
81
88
|
"@vitejs/plugin-react": "^6.1.2",
|
|
82
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",
|
|
83
94
|
"highlight.js": "^11.10.0",
|
|
95
|
+
"publint": "^0.3.25",
|
|
84
96
|
"react": "^18.3.1",
|
|
85
97
|
"react-dom": "^18.3.1",
|
|
98
|
+
"size-limit": "^14.2.0",
|
|
86
99
|
"vite": "^8.3.4"
|
|
87
|
-
}
|
|
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
|
+
]
|
|
88
113
|
}
|
|
@@ -139,6 +139,30 @@
|
|
|
139
139
|
"description": "How long to wait for a manual sign-in (default 300)"
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
+
},
|
|
143
|
+
"agentDocs": {
|
|
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
|
+
"oneOf": [
|
|
146
|
+
{
|
|
147
|
+
"type": "boolean"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"type": "array",
|
|
151
|
+
"items": {
|
|
152
|
+
"enum": [
|
|
153
|
+
"agents-md",
|
|
154
|
+
"claude",
|
|
155
|
+
"cursor",
|
|
156
|
+
"copilot",
|
|
157
|
+
"windsurf",
|
|
158
|
+
"cline",
|
|
159
|
+
"roo",
|
|
160
|
+
"gemini"
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
"uniqueItems": true
|
|
164
|
+
}
|
|
165
|
+
]
|
|
142
166
|
}
|
|
143
167
|
}
|
|
144
168
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spectra agents: puts Spectra's knowledge doc where AI agents read their
|
|
3
|
+
* project instructions (server/agents.js), or takes it out again.
|
|
4
|
+
*
|
|
5
|
+
* spectra agents the agents this project or machine uses, and AGENTS.md
|
|
6
|
+
* spectra agents --all every supported agent
|
|
7
|
+
* spectra agents --only claude,copilot
|
|
8
|
+
* spectra agents --remove take it out of every agent's files
|
|
9
|
+
* spectra agents --list the supported agents, and which ones were detected
|
|
10
|
+
* spectra agents --print print the knowledge doc
|
|
11
|
+
* … --dir <project> --dry-run
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { parseArgs } from './args.js';
|
|
16
|
+
import { TARGETS, TARGET_IDS, describe, detectTargets, installAgentDocs, knowledge, removeAgentDocs, writeNote } from './agents.js';
|
|
17
|
+
|
|
18
|
+
const { opt, flag } = parseArgs(process.argv.slice(2), { values: ['dir', 'only'] });
|
|
19
|
+
const root = path.resolve(opt('dir', process.cwd()));
|
|
20
|
+
const dryRun = flag('dry-run');
|
|
21
|
+
|
|
22
|
+
if (flag('help') || process.argv.includes('-h')) {
|
|
23
|
+
console.log(`usage: spectra agents [--all | --only <ids>] [--remove] [--list] [--print] [--dir <project>] [--dry-run]
|
|
24
|
+
|
|
25
|
+
Teaches your AI agents about Spectra: what it does, which command answers which
|
|
26
|
+
request, and how to read the results. It writes Spectra's knowledge doc where
|
|
27
|
+
each agent reads its project instructions, so you can ask in plain words
|
|
28
|
+
("why is checkout slow?", "did my change help?") and the agent runs Spectra.
|
|
29
|
+
It happens by itself the first time Spectra runs in a project; this does it by hand.
|
|
30
|
+
|
|
31
|
+
Agents: ${TARGET_IDS.join(', ')}
|
|
32
|
+
Off for a project: "agentDocs": false in spectra.config.json (or SPECTRA_AGENT_DOCS=0).`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (flag('print')) {
|
|
37
|
+
console.log(knowledge());
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (flag('list')) {
|
|
42
|
+
const found = new Set(detectTargets({ root }));
|
|
43
|
+
for (const t of TARGETS) console.log(` ${found.has(t.id) ? '✓' : ' '} ${t.id.padEnd(10)} ${t.file.padEnd(46)} ${t.name}`);
|
|
44
|
+
console.log('\n✓ = written by default (detected in this project or on this machine). --all writes every one.');
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (flag('remove')) {
|
|
49
|
+
const results = removeAgentDocs({ root, dryRun });
|
|
50
|
+
// and don't bring them back on the next run
|
|
51
|
+
if (!dryRun) writeNote(root, { removed: true });
|
|
52
|
+
console.log(results.length ? `${dryRun ? 'Would remove' : 'Removed'} Spectra's knowledge doc:\n${describe(results)}` : 'Nothing to remove.');
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let targets;
|
|
57
|
+
if (flag('all')) targets = TARGET_IDS;
|
|
58
|
+
else if (opt('only')) {
|
|
59
|
+
targets = String(opt('only')).split(',').map(s => s.trim()).filter(Boolean);
|
|
60
|
+
const unknown = targets.filter(id => !TARGET_IDS.includes(id));
|
|
61
|
+
if (unknown.length) {
|
|
62
|
+
console.error(`spectra: unknown agent ${unknown.join(', ')} (known: ${TARGET_IDS.join(', ')})`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
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 });
|
|
68
|
+
console.log(`${dryRun ? 'Would write' : 'Wrote'} Spectra's knowledge doc for your AI agents:\n${describe(results)}`);
|
|
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?".');
|
|
70
|
+
console.log('Remove it with: npx spectra agents --remove');
|
package/server/agents.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teaches AI coding agents about Spectra. The knowledge doc (agents/spectra.md:
|
|
3
|
+
* what Spectra does, which command answers which request, how to read the
|
|
4
|
+
* results, the rules) is written where each agent reads its project
|
|
5
|
+
* instructions, so a user can just ask in plain words ("why is checkout
|
|
6
|
+
* slow?", "did my change help?") and the agent runs the right Spectra command.
|
|
7
|
+
*
|
|
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`.
|
|
11
|
+
*
|
|
12
|
+
* Files Spectra owns carry a `spectra:managed` marker and are replaced on
|
|
13
|
+
* update; in shared files (AGENTS.md, CLAUDE.md, GEMINI.md) only the block
|
|
14
|
+
* between the `spectra:start` and `spectra:end` markers is touched. A file
|
|
15
|
+
* without the marker is never overwritten, and `--remove` takes everything
|
|
16
|
+
* back out, leaving the rest of each file as it was.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
export const DOC_FILE = path.join(PKG_ROOT, 'agents', 'spectra.md');
|
|
25
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
|
|
26
|
+
|
|
27
|
+
const MANAGED = 'spectra:managed';
|
|
28
|
+
const START = '<!-- spectra:start -->';
|
|
29
|
+
const END = '<!-- spectra:end -->';
|
|
30
|
+
const note = `written by @progen-labs/spectra ${VERSION} and replaced when it updates; remove with: npx spectra agents --remove`;
|
|
31
|
+
const DESCRIPTION =
|
|
32
|
+
'Spectra (@progen-labs/spectra, installed in this project) measures and improves frontend performance. Use it when the user asks why the app or a page is slow, about jank, re-renders, long tasks, load time or Web Vitals (LCP, TBT, CLS, INP), to profile a page or an interaction, to prove that a change made things faster, to set performance budgets, or to profile pages behind a login.';
|
|
33
|
+
|
|
34
|
+
/** The knowledge doc, with its headings one level deeper when it sits inside another file. */
|
|
35
|
+
export function knowledge({ nested = false } = {}) {
|
|
36
|
+
const doc = fs.readFileSync(DOC_FILE, 'utf8').trim();
|
|
37
|
+
return nested ? doc.replace(/^(#{1,5}) /gm, '#$1 ') : doc;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const frontmatter = fields =>
|
|
41
|
+
`---\n${Object.entries(fields)
|
|
42
|
+
.map(([k, v]) => (v === '' ? `${k}:` : `${k}: ${v}`))
|
|
43
|
+
.join('\n')}\n---\n`;
|
|
44
|
+
const ownFile = (head = '') => `${head}<!-- ${MANAGED}: ${note} -->\n\n${knowledge()}\n`;
|
|
45
|
+
const quoted = s => JSON.stringify(s);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where each agent reads project instructions. `home` and `project` are the
|
|
49
|
+
* signs that the agent is in use: a folder or file in the user's home, or in
|
|
50
|
+
* the project. AGENTS.md, the cross-agent standard, is always written.
|
|
51
|
+
*/
|
|
52
|
+
export const TARGETS = [
|
|
53
|
+
{
|
|
54
|
+
id: 'agents-md',
|
|
55
|
+
name: 'AGENTS.md (Codex, GitHub Copilot, Cursor, Windsurf, Zed, Jules, Amp and other agents)',
|
|
56
|
+
file: 'AGENTS.md',
|
|
57
|
+
block: () => knowledge({ nested: true }),
|
|
58
|
+
always: true,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'claude',
|
|
62
|
+
name: 'Claude Code',
|
|
63
|
+
file: '.claude/skills/spectra/SKILL.md',
|
|
64
|
+
content: () => ownFile(frontmatter({ name: 'spectra', description: quoted(DESCRIPTION) })),
|
|
65
|
+
// and a short note in its memory file, which it always reads
|
|
66
|
+
also: {
|
|
67
|
+
file: 'CLAUDE.md',
|
|
68
|
+
block: () =>
|
|
69
|
+
'## Spectra (performance)\n\nThis project has Spectra (`@progen-labs/spectra`) installed. For anything about speed, slowness, re-renders, Web Vitals or proving that a change made things faster, follow the `spectra` skill (`.claude/skills/spectra/SKILL.md`) and measure with `npx spectra …` instead of guessing.',
|
|
70
|
+
},
|
|
71
|
+
project: ['CLAUDE.md', '.claude'],
|
|
72
|
+
home: ['.claude', '.claude.json'],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'cursor',
|
|
76
|
+
name: 'Cursor',
|
|
77
|
+
file: '.cursor/rules/spectra.mdc',
|
|
78
|
+
content: () => ownFile(frontmatter({ description: quoted(DESCRIPTION), globs: '', alwaysApply: 'false' })),
|
|
79
|
+
project: ['.cursor', '.cursorrules'],
|
|
80
|
+
home: ['.cursor'],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'copilot',
|
|
84
|
+
name: 'GitHub Copilot (VS Code, Visual Studio, JetBrains, the coding agent)',
|
|
85
|
+
file: '.github/instructions/spectra.instructions.md',
|
|
86
|
+
content: () => ownFile(frontmatter({ applyTo: quoted('**'), description: quoted(DESCRIPTION) })),
|
|
87
|
+
project: ['.github/copilot-instructions.md', '.github/instructions', '.github/prompts', '.vscode'],
|
|
88
|
+
home: ['.vscode', '.vscode-insiders', '.config/github-copilot'],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'windsurf',
|
|
92
|
+
name: 'Windsurf',
|
|
93
|
+
file: '.windsurf/rules/spectra.md',
|
|
94
|
+
content: () => ownFile(frontmatter({ trigger: 'model_decision', description: quoted(DESCRIPTION) })),
|
|
95
|
+
project: ['.windsurf', '.windsurfrules'],
|
|
96
|
+
home: ['.codeium/windsurf'],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'cline',
|
|
100
|
+
name: 'Cline',
|
|
101
|
+
file: '.clinerules/spectra.md',
|
|
102
|
+
content: () => ownFile(),
|
|
103
|
+
project: ['.clinerules'],
|
|
104
|
+
home: ['Documents/Cline'],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'roo',
|
|
108
|
+
name: 'Roo Code',
|
|
109
|
+
file: '.roo/rules/spectra.md',
|
|
110
|
+
content: () => ownFile(),
|
|
111
|
+
project: ['.roo', '.roorules'],
|
|
112
|
+
home: ['.roo'],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'gemini',
|
|
116
|
+
name: 'Gemini CLI',
|
|
117
|
+
file: 'GEMINI.md',
|
|
118
|
+
block: () => knowledge({ nested: true }),
|
|
119
|
+
project: ['GEMINI.md', '.gemini'],
|
|
120
|
+
home: ['.gemini'],
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
export const TARGET_IDS = TARGETS.map(t => t.id);
|
|
124
|
+
|
|
125
|
+
const exists = p => {
|
|
126
|
+
try {
|
|
127
|
+
fs.statSync(p);
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The agents this project or machine uses, plus AGENTS.md. */
|
|
135
|
+
export function detectTargets({ root, home = os.homedir() }) {
|
|
136
|
+
return TARGETS.filter(
|
|
137
|
+
t => t.always || (t.project || []).some(p => exists(path.join(root, p))) || (t.home || []).some(p => home && exists(path.join(home, p))),
|
|
138
|
+
).map(t => t.id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What `agentDocs` in spectra.config.json asks for: false (none), an array of target ids, or undefined (the default). */
|
|
142
|
+
export function readAgentDocsSetting(root) {
|
|
143
|
+
try {
|
|
144
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, 'spectra.config.json'), 'utf8'));
|
|
145
|
+
return config?.agentDocs;
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const blockText = text => `${START}\n<!-- ${note} -->\n\n${text}\n${END}`;
|
|
152
|
+
|
|
153
|
+
// where Spectra's block is in a shared file: [start, end), or null
|
|
154
|
+
function findBlock(text) {
|
|
155
|
+
const i = text.indexOf(START);
|
|
156
|
+
const j = i < 0 ? -1 : text.indexOf(END, i);
|
|
157
|
+
return i < 0 || j < 0 ? null : [i, j + END.length];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// the file without Spectra's block, and the blank lines it was set apart with
|
|
161
|
+
function withoutBlock(text) {
|
|
162
|
+
const [i, j] = findBlock(text);
|
|
163
|
+
const head = text.slice(0, i).replace(/\s*$/, '');
|
|
164
|
+
const tail = text.slice(j).replace(/^\s*/, '');
|
|
165
|
+
const rest = head && tail ? `${head}\n\n${tail}` : head || tail;
|
|
166
|
+
return rest ? `${rest.replace(/\s*$/, '')}\n` : '';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function writeBlock(file, text, dryRun) {
|
|
170
|
+
const before = exists(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
171
|
+
const block = blockText(text);
|
|
172
|
+
const at = before == null ? null : findBlock(before);
|
|
173
|
+
const after =
|
|
174
|
+
before == null
|
|
175
|
+
? `${block}\n`
|
|
176
|
+
: at
|
|
177
|
+
? `${before.slice(0, at[0])}${block}${before.slice(at[1])}` // in place: the rest stays exactly as it was
|
|
178
|
+
: `${before.replace(/\s*$/, '')}${before.trim() ? '\n\n' : ''}${block}\n`;
|
|
179
|
+
if (after === before) return 'unchanged';
|
|
180
|
+
if (!dryRun) {
|
|
181
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
182
|
+
fs.writeFileSync(file, after);
|
|
183
|
+
}
|
|
184
|
+
return before == null ? 'created' : 'updated';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function writeOwn(file, content, dryRun) {
|
|
188
|
+
const before = exists(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
189
|
+
if (before != null && !before.includes(MANAGED)) return 'skipped (a file of yours is already there)';
|
|
190
|
+
if (before === content) return 'unchanged';
|
|
191
|
+
if (!dryRun) {
|
|
192
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
193
|
+
fs.writeFileSync(file, content);
|
|
194
|
+
}
|
|
195
|
+
return before == null ? 'created' : 'updated';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Writes the knowledge doc for the given targets (default: the detected ones).
|
|
200
|
+
* Returns [{ id, name, file, result }], result being created, updated,
|
|
201
|
+
* unchanged or skipped (…).
|
|
202
|
+
*/
|
|
203
|
+
export function installAgentDocs({ root, targets, home, dryRun = false } = {}) {
|
|
204
|
+
const ids = targets || detectTargets({ root, home });
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const t of TARGETS.filter(x => ids.includes(x.id))) {
|
|
207
|
+
const file = path.join(root, t.file);
|
|
208
|
+
out.push({ id: t.id, name: t.name, file: t.file, result: t.block ? writeBlock(file, t.block(), dryRun) : writeOwn(file, t.content(), dryRun) });
|
|
209
|
+
if (t.also) out.push({ id: t.id, name: t.name, file: t.also.file, result: writeBlock(path.join(root, t.also.file), t.also.block(), dryRun) });
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// removes now-empty folders Spectra made, up to (not including) the project root
|
|
215
|
+
function pruneDirs(dir, root) {
|
|
216
|
+
while (dir.startsWith(root) && dir !== root) {
|
|
217
|
+
try {
|
|
218
|
+
if (fs.readdirSync(dir).length) return;
|
|
219
|
+
fs.rmdirSync(dir);
|
|
220
|
+
} catch {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
dir = path.dirname(dir);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Takes the knowledge doc out of every agent's files; the rest of each file stays. */
|
|
228
|
+
export function removeAgentDocs({ root, dryRun = false } = {}) {
|
|
229
|
+
const out = [];
|
|
230
|
+
const files = TARGETS.flatMap(t => [{ t, file: t.file, block: !!t.block }, ...(t.also ? [{ t, file: t.also.file, block: true }] : [])]);
|
|
231
|
+
for (const { t, file: rel, block } of files) {
|
|
232
|
+
const file = path.join(root, rel);
|
|
233
|
+
if (!exists(file)) continue;
|
|
234
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
235
|
+
if (block) {
|
|
236
|
+
if (!findBlock(text)) continue;
|
|
237
|
+
const rest = withoutBlock(text);
|
|
238
|
+
if (!dryRun) {
|
|
239
|
+
if (rest) fs.writeFileSync(file, rest);
|
|
240
|
+
else fs.rmSync(file); // Spectra made it: nothing else was in it
|
|
241
|
+
}
|
|
242
|
+
out.push({ id: t.id, name: t.name, file: rel, result: rest ? 'removed' : 'deleted' });
|
|
243
|
+
} else if (text.includes(MANAGED)) {
|
|
244
|
+
if (!dryRun) {
|
|
245
|
+
fs.rmSync(file);
|
|
246
|
+
pruneDirs(path.dirname(file), root);
|
|
247
|
+
}
|
|
248
|
+
out.push({ id: t.id, name: t.name, file: rel, result: 'deleted' });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
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
|
+
|
|
270
|
+
/**
|
|
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.
|
|
277
|
+
*/
|
|
278
|
+
export function firstRunPlan({ cwd = process.cwd(), env = process.env, pkgRoot = PKG_ROOT } = {}) {
|
|
279
|
+
if (env.SPECTRA_AGENT_DOCS === '0' || env.SPECTRA_AGENT_DOCS === 'false') return { skip: 'SPECTRA_AGENT_DOCS=0' };
|
|
280
|
+
if (env.CI && env.CI !== 'false' && env.CI !== '0') return { skip: 'CI' };
|
|
281
|
+
const real = (() => {
|
|
282
|
+
try {
|
|
283
|
+
return fs.realpathSync(pkgRoot);
|
|
284
|
+
} catch {
|
|
285
|
+
return path.resolve(pkgRoot);
|
|
286
|
+
}
|
|
287
|
+
})();
|
|
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)' };
|
|
299
|
+
if (!exists(path.join(root, 'package.json'))) return { skip: 'no package.json' };
|
|
300
|
+
const setting = readAgentDocsSetting(root);
|
|
301
|
+
if (setting === false) return { skip: '"agentDocs": false in spectra.config.json' };
|
|
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') };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** One line per file, for people. */
|
|
335
|
+
export const describe = results => results.map(r => ` ${r.result.padEnd(9)} ${r.file} (${r.name})`).join('\n');
|
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;
|