web-tester-for-claude 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/LICENSE +21 -0
- package/README.md +651 -0
- package/bin/web-tester.js +35 -0
- package/package.json +64 -0
- package/src/browser/attrs.ts +79 -0
- package/src/browser/session.ts +139 -0
- package/src/cli.ts +1488 -0
- package/src/impact.ts +165 -0
- package/src/init.ts +260 -0
- package/src/inspector/capture.ts +293 -0
- package/src/inspector/deep.ts +147 -0
- package/src/inspector/packs.ts +98 -0
- package/src/inspector/report.ts +667 -0
- package/src/inspector/run.ts +544 -0
- package/src/inspector/steps.ts +380 -0
- package/src/inspector/summarise.ts +178 -0
- package/src/inspector/verdict.ts +275 -0
- package/src/journeys.ts +78 -0
- package/src/kb.ts +84 -0
- package/src/map/classify.ts +149 -0
- package/src/map/crawl.ts +394 -0
- package/src/map/generate.ts +253 -0
- package/src/map/report.ts +112 -0
- package/src/map/run.ts +219 -0
- package/src/sitemap.ts +75 -0
- package/src/sweep.ts +476 -0
- package/src/templates/agent-section.md +77 -0
- package/src/templates/dot-web-tester/impact-rules.json +36 -0
- package/src/templates/dot-web-tester/instructions/getting-started.md +62 -0
- package/src/templates/dot-web-tester/instructions/recipes.md +105 -0
- package/src/templates/dot-web-tester/journeys/example-signup.json +17 -0
- package/src/templates/dot-web-tester/urls-smoke.txt +19 -0
- package/src/templates/skill.md +59 -0
- package/src/util/log.ts +26 -0
- package/src/util/paths.ts +141 -0
- package/src/util/prompt.ts +50 -0
- package/tsconfig.json +14 -0
package/src/impact.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { log } from "./util/log";
|
|
4
|
+
import { userImpactRulesPath } from "./util/paths";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* impact-rules.json schema. Each rule has a name, a list of path globs,
|
|
8
|
+
* and one of {sweep, journey} describing what to run if any glob matches.
|
|
9
|
+
*/
|
|
10
|
+
export type SweepDirective = {
|
|
11
|
+
/** Bundled preset name (urls-<name>.txt). */
|
|
12
|
+
preset?: string;
|
|
13
|
+
/** Inline URL list — replaces preset if both set. */
|
|
14
|
+
urls?: string[];
|
|
15
|
+
/** Built-in packs applied to every URL in this sweep. */
|
|
16
|
+
packs?: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ImpactRule = {
|
|
20
|
+
name: string;
|
|
21
|
+
when_changed_any: string[];
|
|
22
|
+
/** Mutually exclusive — exactly one of sweep / journey. */
|
|
23
|
+
sweep?: SweepDirective;
|
|
24
|
+
journey?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ImpactRulesFile = {
|
|
28
|
+
rules: ImpactRule[];
|
|
29
|
+
/** Anything else (e.g. `$comment`) is ignored. */
|
|
30
|
+
[k: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Default rules location for the current cwd. Lazy so the cwd is honoured per-call. */
|
|
34
|
+
export function defaultImpactRulesPath(): string {
|
|
35
|
+
return userImpactRulesPath();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function loadImpactRules(path: string = defaultImpactRulesPath()): ImpactRule[] {
|
|
39
|
+
if (!existsSync(path))
|
|
40
|
+
throw new Error(
|
|
41
|
+
`impact-rules.json not found at ${path}. Create one at .web-tester/impact-rules.json with at least one rule.`
|
|
42
|
+
);
|
|
43
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as ImpactRulesFile;
|
|
44
|
+
if (!Array.isArray(parsed.rules))
|
|
45
|
+
throw new Error(`${path} must have a top-level "rules" array.`);
|
|
46
|
+
for (const r of parsed.rules) {
|
|
47
|
+
if (!r.name || !Array.isArray(r.when_changed_any))
|
|
48
|
+
throw new Error(
|
|
49
|
+
`rule missing required fields: ${JSON.stringify(r).slice(0, 120)}`
|
|
50
|
+
);
|
|
51
|
+
const hasSweep = !!r.sweep;
|
|
52
|
+
const hasJourney = !!r.journey;
|
|
53
|
+
if (!hasSweep && !hasJourney)
|
|
54
|
+
throw new Error(
|
|
55
|
+
`rule "${r.name}" must specify either "sweep" or "journey".`
|
|
56
|
+
);
|
|
57
|
+
if (hasSweep && hasJourney)
|
|
58
|
+
throw new Error(
|
|
59
|
+
`rule "${r.name}" specifies both "sweep" and "journey" — pick one.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return parsed.rules;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Convert a path glob (`**`, `*`) into a regex anchored at both ends. */
|
|
66
|
+
function globToRegex(glob: string): RegExp {
|
|
67
|
+
// Park double-star with a unique multi-char marker so the single-* rewrite
|
|
68
|
+
// below does not claw at it; restore as .* afterwards.
|
|
69
|
+
const escaped = glob
|
|
70
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
71
|
+
.replace(/\*\*/g, "__DOUBLESTAR__")
|
|
72
|
+
.replace(/\*/g, "[^/]*")
|
|
73
|
+
.replaceAll("__DOUBLESTAR__", ".*");
|
|
74
|
+
return new RegExp(`^${escaped}$`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns the list of files changed between `base` and the current HEAD
|
|
79
|
+
* (including unstaged + uncommitted changes — that's what a developer is
|
|
80
|
+
* about to push). Empty list = no rules match = no impact run.
|
|
81
|
+
*/
|
|
82
|
+
export function getChangedFiles(base: string, cwd: string): string[] {
|
|
83
|
+
// Combine three sources: committed diff vs base, staged-but-uncommitted,
|
|
84
|
+
// and unstaged tracked changes. Sorted-unique result. Untracked new files
|
|
85
|
+
// (`?? `) are intentionally not included — they can't have been changed.
|
|
86
|
+
const safeExec = (cmd: string): string => {
|
|
87
|
+
try {
|
|
88
|
+
// Swallow git's stderr too (e.g. "Not a git repository") — a missing
|
|
89
|
+
// ref or non-repo cwd just means "no changed files", not an error worth
|
|
90
|
+
// printing.
|
|
91
|
+
return execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });
|
|
92
|
+
} catch {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const committed = safeExec(`git diff ${base}...HEAD --name-only`)
|
|
97
|
+
.split("\n")
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
const staged = safeExec(`git diff --cached --name-only`).split("\n").filter(Boolean);
|
|
100
|
+
const unstaged = safeExec(`git diff --name-only`).split("\n").filter(Boolean);
|
|
101
|
+
return Array.from(new Set([...committed, ...staged, ...unstaged])).sort();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type MatchedRule = {
|
|
105
|
+
rule: ImpactRule;
|
|
106
|
+
/** Subset of changed files that triggered this rule (for the plan output). */
|
|
107
|
+
triggers: string[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function matchRules(
|
|
111
|
+
rules: ImpactRule[],
|
|
112
|
+
changedFiles: string[]
|
|
113
|
+
): MatchedRule[] {
|
|
114
|
+
const matched: MatchedRule[] = [];
|
|
115
|
+
for (const rule of rules) {
|
|
116
|
+
const triggers: string[] = [];
|
|
117
|
+
for (const glob of rule.when_changed_any) {
|
|
118
|
+
const re = globToRegex(glob);
|
|
119
|
+
for (const file of changedFiles)
|
|
120
|
+
if (re.test(file) && !triggers.includes(file)) triggers.push(file);
|
|
121
|
+
}
|
|
122
|
+
if (triggers.length > 0) matched.push({ rule, triggers });
|
|
123
|
+
}
|
|
124
|
+
return matched;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Pretty-print the impact plan so the developer can see what's about to
|
|
129
|
+
* happen before any browser launches. Always called before execution.
|
|
130
|
+
*/
|
|
131
|
+
export function printPlan(
|
|
132
|
+
matched: MatchedRule[],
|
|
133
|
+
changedFiles: string[],
|
|
134
|
+
base: string
|
|
135
|
+
): void {
|
|
136
|
+
log.header(
|
|
137
|
+
`impact plan — ${changedFiles.length} changed file(s) vs ${base}`
|
|
138
|
+
);
|
|
139
|
+
if (matched.length === 0) {
|
|
140
|
+
log.dim(
|
|
141
|
+
" no rules matched the changed files. Nothing to run."
|
|
142
|
+
);
|
|
143
|
+
log.dim(
|
|
144
|
+
" (You can still run `web-tester inspect <url>` manually.)"
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const m of matched) {
|
|
149
|
+
log.info(` · ${m.rule.name}`);
|
|
150
|
+
if (m.rule.sweep) {
|
|
151
|
+
const tgt = m.rule.sweep.preset
|
|
152
|
+
? `preset ${m.rule.sweep.preset}`
|
|
153
|
+
: `${m.rule.sweep.urls?.length ?? 0} URL(s)`;
|
|
154
|
+
const packs = m.rule.sweep.packs
|
|
155
|
+
? ` + packs [${m.rule.sweep.packs.join(", ")}]`
|
|
156
|
+
: "";
|
|
157
|
+
log.dim(` → sweep (${tgt}${packs})`);
|
|
158
|
+
}
|
|
159
|
+
if (m.rule.journey) log.dim(` → journey ${m.rule.journey}`);
|
|
160
|
+
log.dim(
|
|
161
|
+
` triggered by: ${m.triggers.slice(0, 3).join(", ")}${m.triggers.length > 3 ? `, +${m.triggers.length - 3} more` : ""}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
log.info("");
|
|
165
|
+
}
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname, relative, resolve } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
claudeSettingsLocalPath,
|
|
12
|
+
claudeSkillPath,
|
|
13
|
+
projectConfigPath,
|
|
14
|
+
TEMPLATES_DIR,
|
|
15
|
+
USER_CONFIG_DIRNAME
|
|
16
|
+
} from "./util/paths";
|
|
17
|
+
|
|
18
|
+
export type AutoUse = "on" | "ask" | "off";
|
|
19
|
+
|
|
20
|
+
export type InitOptions = {
|
|
21
|
+
cwd: string;
|
|
22
|
+
/** Overwrite files that already exist (default: skip them). */
|
|
23
|
+
force: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Agent-instructions file to update (CLAUDE.md, AGENTS.md, …). When unset,
|
|
26
|
+
* an existing CLAUDE.md or AGENTS.md is reused; otherwise CLAUDE.md is
|
|
27
|
+
* created. Pass `null` to skip the agent file entirely.
|
|
28
|
+
*/
|
|
29
|
+
agentFile: string | null | undefined;
|
|
30
|
+
/** Base URL to persist to `.web-tester/config.json`. Skipped when undefined. */
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
/** Auto-use preference written to `.claude/settings.local.json`. */
|
|
33
|
+
autoUse?: AutoUse;
|
|
34
|
+
/** Generate `.claude/skills/web-tester/SKILL.md` (default true). */
|
|
35
|
+
skill?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type InitResult = {
|
|
39
|
+
/** Files created or overwritten, relative to cwd. */
|
|
40
|
+
written: string[];
|
|
41
|
+
/** Files left untouched because they already existed (no --force). */
|
|
42
|
+
skipped: string[];
|
|
43
|
+
/** Path to the agent file that was written, relative to cwd, if any. */
|
|
44
|
+
agentFile?: string;
|
|
45
|
+
/** Whether the agent block was newly added (false = updated in place). */
|
|
46
|
+
agentAdded?: boolean;
|
|
47
|
+
/** Path to the generated skill, relative to cwd, if requested. */
|
|
48
|
+
skillFile?: string;
|
|
49
|
+
/** The auto-use value written to settings.local.json, if any. */
|
|
50
|
+
autoUse?: AutoUse;
|
|
51
|
+
/** Non-fatal warnings (e.g. a settings file we couldn't safely merge). */
|
|
52
|
+
warnings: string[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const SECTION_START = "<!-- web-tester:start -->";
|
|
56
|
+
const SECTION_END = "<!-- web-tester:end -->";
|
|
57
|
+
|
|
58
|
+
/** Recursively list files under `dir`, returning paths relative to it. */
|
|
59
|
+
function listFiles(dir: string, base = dir): string[] {
|
|
60
|
+
const out: string[] = [];
|
|
61
|
+
for (const entry of readdirSync(dir)) {
|
|
62
|
+
const abs = resolve(dir, entry);
|
|
63
|
+
if (statSync(abs).isDirectory()) out.push(...listFiles(abs, base));
|
|
64
|
+
else out.push(relative(base, abs));
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Scaffold `.web-tester/`, persist project config, generate the Claude Code
|
|
71
|
+
* skill + agent-instructions block, and set the auto-use preference. Every
|
|
72
|
+
* step is independent and idempotent; existing files are skipped unless
|
|
73
|
+
* `force` is set (settings and config are merged, never clobbered).
|
|
74
|
+
*/
|
|
75
|
+
export function runInit(opts: InitOptions): InitResult {
|
|
76
|
+
const written: string[] = [];
|
|
77
|
+
const skipped: string[] = [];
|
|
78
|
+
const warnings: string[] = [];
|
|
79
|
+
const rel = (p: string): string => relative(opts.cwd, p);
|
|
80
|
+
|
|
81
|
+
// 1. Scaffold .web-tester/ from the bundled templates.
|
|
82
|
+
const srcRoot = resolve(TEMPLATES_DIR, "dot-web-tester");
|
|
83
|
+
const destRoot = resolve(opts.cwd, USER_CONFIG_DIRNAME);
|
|
84
|
+
for (const r of listFiles(srcRoot)) {
|
|
85
|
+
const from = resolve(srcRoot, r);
|
|
86
|
+
const to = resolve(destRoot, r);
|
|
87
|
+
if (existsSync(to) && !opts.force) {
|
|
88
|
+
skipped.push(rel(to));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
92
|
+
writeFileSync(to, readFileSync(from));
|
|
93
|
+
written.push(rel(to));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Keep run artifacts out of version control via a scoped .gitignore.
|
|
97
|
+
const gitignorePath = resolve(destRoot, ".gitignore");
|
|
98
|
+
if (!existsSync(gitignorePath)) {
|
|
99
|
+
mkdirSync(destRoot, { recursive: true });
|
|
100
|
+
writeFileSync(gitignorePath, "runs/\n");
|
|
101
|
+
written.push(rel(gitignorePath));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Persist the chosen base URL so commands work without an env var.
|
|
105
|
+
if (opts.baseUrl !== undefined) {
|
|
106
|
+
const cfg = writeProjectConfig(opts.cwd, opts.baseUrl);
|
|
107
|
+
if (cfg.changed) written.push(rel(cfg.path));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result: InitResult = { written, skipped, warnings };
|
|
111
|
+
|
|
112
|
+
// 4. Generate the Claude Code skill (.claude/skills/web-tester/SKILL.md).
|
|
113
|
+
if (opts.skill !== false) {
|
|
114
|
+
const skill = writeSkill(opts.cwd, opts.force);
|
|
115
|
+
result.skillFile = rel(skill.path);
|
|
116
|
+
(skill.written ? written : skipped).push(result.skillFile);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 5. Inject the agent-instructions block into CLAUDE.md / AGENTS.md.
|
|
120
|
+
if (opts.agentFile !== null) {
|
|
121
|
+
const agent = writeAgentSection(opts.cwd, opts.agentFile);
|
|
122
|
+
result.agentFile = rel(agent.path);
|
|
123
|
+
result.agentAdded = agent.added;
|
|
124
|
+
if (agent.added) written.push(result.agentFile);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 6. Record the auto-use preference (merged into settings.local.json).
|
|
128
|
+
if (opts.autoUse !== undefined) {
|
|
129
|
+
const settings = writeAutoUse(opts.cwd, opts.autoUse);
|
|
130
|
+
if (settings.changed) {
|
|
131
|
+
const p = rel(settings.path);
|
|
132
|
+
if (!written.includes(p) && !skipped.includes(p)) written.push(p);
|
|
133
|
+
result.autoUse = opts.autoUse;
|
|
134
|
+
}
|
|
135
|
+
if (settings.warning) warnings.push(settings.warning);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Merge `baseUrl` into `.web-tester/config.json`, preserving other keys. */
|
|
142
|
+
function writeProjectConfig(
|
|
143
|
+
cwd: string,
|
|
144
|
+
baseUrl: string
|
|
145
|
+
): { path: string; changed: boolean } {
|
|
146
|
+
const path = projectConfigPath(cwd);
|
|
147
|
+
let config: Record<string, unknown> = {};
|
|
148
|
+
if (existsSync(path)) {
|
|
149
|
+
try {
|
|
150
|
+
config = JSON.parse(readFileSync(path, "utf-8"));
|
|
151
|
+
} catch {
|
|
152
|
+
config = {};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (config.baseUrl === baseUrl) return { path, changed: false };
|
|
156
|
+
config.baseUrl = baseUrl;
|
|
157
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
158
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
159
|
+
return { path, changed: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Write the bundled SKILL.md. Won't overwrite an existing skill unless forced. */
|
|
163
|
+
function writeSkill(
|
|
164
|
+
cwd: string,
|
|
165
|
+
force: boolean
|
|
166
|
+
): { path: string; written: boolean } {
|
|
167
|
+
const path = claudeSkillPath(cwd);
|
|
168
|
+
if (existsSync(path) && !force) return { path, written: false };
|
|
169
|
+
const content = readFileSync(resolve(TEMPLATES_DIR, "skill.md"), "utf-8");
|
|
170
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
171
|
+
writeFileSync(path, content);
|
|
172
|
+
return { path, written: true };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Merge `env.WEB_TESTER_AUTO_USE` into `.claude/settings.local.json`. Never
|
|
177
|
+
* clobbers: if the file exists but isn't valid JSON, we leave it alone and
|
|
178
|
+
* return a warning rather than risk destroying a developer's settings.
|
|
179
|
+
*/
|
|
180
|
+
function writeAutoUse(
|
|
181
|
+
cwd: string,
|
|
182
|
+
value: AutoUse
|
|
183
|
+
): { path: string; changed: boolean; warning?: string } {
|
|
184
|
+
const path = claudeSettingsLocalPath(cwd);
|
|
185
|
+
let settings: { env?: Record<string, unknown>; [k: string]: unknown } = {};
|
|
186
|
+
if (existsSync(path)) {
|
|
187
|
+
try {
|
|
188
|
+
settings = JSON.parse(readFileSync(path, "utf-8"));
|
|
189
|
+
} catch {
|
|
190
|
+
return {
|
|
191
|
+
path,
|
|
192
|
+
changed: false,
|
|
193
|
+
warning: `left ${relative(cwd, path)} untouched — it isn't valid JSON. Add \`"env": { "WEB_TESTER_AUTO_USE": "${value}" }\` yourself.`
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (settings.env?.WEB_TESTER_AUTO_USE === value) return { path, changed: false };
|
|
198
|
+
settings.env = { ...(settings.env ?? {}), WEB_TESTER_AUTO_USE: value };
|
|
199
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
200
|
+
writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`);
|
|
201
|
+
return { path, changed: true };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Resolve which agent file to write, honouring an explicit choice. */
|
|
205
|
+
function resolveAgentFile(cwd: string, explicit: string | undefined): string {
|
|
206
|
+
if (explicit) return resolve(cwd, explicit);
|
|
207
|
+
for (const name of ["CLAUDE.md", "AGENTS.md"]) {
|
|
208
|
+
const candidate = resolve(cwd, name);
|
|
209
|
+
if (existsSync(candidate)) return candidate;
|
|
210
|
+
}
|
|
211
|
+
return resolve(cwd, "CLAUDE.md");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Insert or refresh the marker-fenced web-tester block in the agent file.
|
|
216
|
+
* Idempotent: re-running replaces the block in place rather than duplicating.
|
|
217
|
+
* Returns `added: true` when the block was newly inserted.
|
|
218
|
+
*/
|
|
219
|
+
function writeAgentSection(
|
|
220
|
+
cwd: string,
|
|
221
|
+
explicit: string | undefined
|
|
222
|
+
): { path: string; added: boolean } {
|
|
223
|
+
const path = resolveAgentFile(cwd, explicit);
|
|
224
|
+
const section = readFileSync(
|
|
225
|
+
resolve(TEMPLATES_DIR, "agent-section.md"),
|
|
226
|
+
"utf-8"
|
|
227
|
+
).trim();
|
|
228
|
+
const block = `${SECTION_START}\n\n${section}\n\n${SECTION_END}`;
|
|
229
|
+
|
|
230
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
231
|
+
|
|
232
|
+
if (!existsSync(path)) {
|
|
233
|
+
writeFileSync(path, `# Agent instructions\n\n${block}\n`);
|
|
234
|
+
return { path, added: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const current = readFileSync(path, "utf-8");
|
|
238
|
+
const start = current.indexOf(SECTION_START);
|
|
239
|
+
const end = current.indexOf(SECTION_END);
|
|
240
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
241
|
+
const before = current.slice(0, start);
|
|
242
|
+
const after = current.slice(end + SECTION_END.length);
|
|
243
|
+
writeFileSync(path, `${before}${block}${after}`);
|
|
244
|
+
return { path, added: false };
|
|
245
|
+
}
|
|
246
|
+
// Exactly one marker, or end-before-start: the block is corrupted. Appending
|
|
247
|
+
// would duplicate it (and again on every future run), so stop and let the
|
|
248
|
+
// user fix it rather than silently make a mess.
|
|
249
|
+
if (start !== -1 || end !== -1) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`${path} has a malformed web-tester block (a "${SECTION_START}" / ` +
|
|
252
|
+
`"${SECTION_END}" marker is missing or out of order). Fix or remove ` +
|
|
253
|
+
`the stray marker and re-run, or pass --no-agent to skip the agent file.`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const sep = current.endsWith("\n") ? "\n" : "\n\n";
|
|
258
|
+
writeFileSync(path, `${current}${sep}${block}\n`);
|
|
259
|
+
return { path, added: true };
|
|
260
|
+
}
|