svelte-vitals 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,12 +78,25 @@ Useful as a CI gate.
78
78
 
79
79
  ### `svelte-vitals install`
80
80
 
81
- An interactive wizard that wires up the [MCP server](https://www.npmjs.com/package/@svelte-vitals/mcp), the [Vite plugin](https://www.npmjs.com/package/@svelte-vitals/vite)'s live dashboard, Agent Skills (`/svelte-vitals`, `/improve-svelte`) for Claude Code, Cursor, and Codex, and a GitHub Actions CI workflow — grouped by category in the picker so it's clear what each target is for:
81
+ An interactive wizard that wires up the [Vite plugin](https://www.npmjs.com/package/@svelte-vitals/vite)'s live dashboard, Agent Skills (`/svelte-vitals`, `/improve-svelte`) for Claude Code, Codex, and Cursor, a `svelte-vitals.config` file, and a GitHub Actions CI workflow — grouped by category in the picker so it's clear what each target is for:
82
82
 
83
83
  ```bash
84
84
  npx svelte-vitals@latest install
85
85
  ```
86
86
 
87
+ ### `svelte-vitals docs` / `svelte-vitals explain`
88
+
89
+ Both read out of the CLI itself, so the answer always matches the installed version and needs no network — the thing an AI agent otherwise guesses at or fetches from a page describing a different release.
90
+
91
+ ```bash
92
+ npx svelte-vitals@latest docs list # every bundled topic, with a one-line description
93
+ npx svelte-vitals@latest docs show scoping
94
+ npx svelte-vitals@latest explain --list # every rule, by category
95
+ npx svelte-vitals@latest explain performance/heavy-import
96
+ ```
97
+
98
+ `explain <rule-id>` prints that rule's rationale, docs link, fix template, and — for a configurable rule — every option's default, bounds, and how a configured value merges with the built-in default: the detail needed to decide whether a finding is a defect or a threshold disagreement. `docs list` and both forms of `explain` take `--json`; `docs show` prints the topic as Markdown.
99
+
87
100
  ### CI integration
88
101
 
89
102
  `svelte-vitals ci install` scaffolds a GitHub Actions workflow around `@svelte-vitals/action` — inline PR annotations, a job summary, and a sticky PR comment, no YAML to hand-write. The same workflow is also a selectable `ci-workflow` target inside `svelte-vitals install`, so it can be set up in the same pass as everything else. See [CI integration](https://oekazuma.github.io/svelte-vitals/guides/ci/).
package/dist/bin.js CHANGED
@@ -9,10 +9,13 @@ import {
9
9
  readCoreVersion,
10
10
  readPackageVersion,
11
11
  run
12
- } from "./chunk-D6AUX2GC.js";
12
+ } from "./chunk-I2MKPWWT.js";
13
+ import {
14
+ consoleIO
15
+ } from "./chunk-SLUMRYUD.js";
13
16
 
14
17
  // src/bin.ts
15
- import mri3 from "mri";
18
+ import mri4 from "mri";
16
19
  import * as p2 from "@clack/prompts";
17
20
 
18
21
  // src/resolve-args.ts
@@ -179,96 +182,12 @@ function resolveArgs(argv) {
179
182
  // src/install/cli.ts
180
183
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
181
184
  import { dirname } from "path";
182
- import { homedir } from "os";
183
185
  import { spawnSync } from "child_process";
184
186
  import mri from "mri";
185
187
  import * as p from "@clack/prompts";
186
188
 
187
189
  // src/install/index.ts
188
- import { join as join4 } from "path";
189
-
190
- // src/install/clients.ts
191
- import { join } from "path";
192
- var MCP_ENTRY = { command: "npx", args: ["-y", "@svelte-vitals/mcp"] };
193
- var CLIENTS = [
194
- {
195
- id: "claude-code",
196
- label: "Claude Code",
197
- scopes: ["project", "global"],
198
- format: "json",
199
- resolvePath: (scope, cwd, home) => scope === "project" ? join(cwd, ".mcp.json") : join(home, ".claude.json")
200
- },
201
- {
202
- id: "cursor",
203
- label: "Cursor",
204
- scopes: ["project", "global"],
205
- format: "json",
206
- resolvePath: (scope, cwd, home) => scope === "project" ? join(cwd, ".cursor", "mcp.json") : join(home, ".cursor", "mcp.json")
207
- },
208
- {
209
- id: "codex",
210
- label: "Codex",
211
- scopes: ["global"],
212
- format: "toml",
213
- resolvePath: (_scope, _cwd, home) => join(home, ".codex", "config.toml")
214
- }
215
- ];
216
- function clientById(id) {
217
- return CLIENTS.find((c) => c.id === id);
218
- }
219
-
220
- // src/install/merge.ts
221
- import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
222
- var SERVER_KEY = "svelte-vitals";
223
- function isPlainObject(v) {
224
- return typeof v === "object" && v !== null && !Array.isArray(v);
225
- }
226
- function sameEntry(prior, entry) {
227
- if (typeof prior !== "object" || prior === null) return false;
228
- const o = prior;
229
- return o.command === entry.command && Array.isArray(o.args) && o.args.length === entry.args.length && o.args.every((v, i) => v === entry.args[i]);
230
- }
231
- function statusFor(prior, entry, force, created) {
232
- if (prior !== void 0) {
233
- if (sameEntry(prior, entry)) return "exists";
234
- return force ? "updated" : "skip";
235
- }
236
- return created ? "created" : "added";
237
- }
238
- function mergeJson(existing, entry, force) {
239
- const created = existing === void 0;
240
- const parsed = created ? {} : JSON.parse(existing);
241
- if (!isPlainObject(parsed)) {
242
- throw new Error("existing config is not a JSON object");
243
- }
244
- const root = parsed;
245
- if (root.mcpServers !== void 0 && !isPlainObject(root.mcpServers)) {
246
- throw new Error('existing config has a non-object "mcpServers" table');
247
- }
248
- const servers = isPlainObject(root.mcpServers) ? root.mcpServers : {};
249
- const status = statusFor(servers[SERVER_KEY], entry, force, created);
250
- if (status === "exists" || status === "skip") return { content: existing, status: "exists" };
251
- servers[SERVER_KEY] = { command: entry.command, args: entry.args };
252
- root.mcpServers = servers;
253
- return { content: JSON.stringify(root, null, 2) + "\n", status };
254
- }
255
- function mergeToml(existing, entry, force) {
256
- const created = existing === void 0;
257
- const parsed = created ? {} : parseToml(existing);
258
- if (!isPlainObject(parsed)) {
259
- throw new Error("existing config is not a TOML table");
260
- }
261
- const root = parsed;
262
- if (root.mcp_servers !== void 0 && !isPlainObject(root.mcp_servers)) {
263
- throw new Error('existing config has a non-table "mcp_servers" section');
264
- }
265
- const servers = isPlainObject(root.mcp_servers) ? root.mcp_servers : {};
266
- const status = statusFor(servers[SERVER_KEY], entry, force, created);
267
- if (status === "exists" || status === "skip") return { content: existing, status: "exists" };
268
- servers[SERVER_KEY] = { command: entry.command, args: entry.args };
269
- root.mcp_servers = servers;
270
- return { content: stringifyToml(root), status };
271
- }
190
+ import { join as join3 } from "path";
272
191
 
273
192
  // src/install/vite-targets.ts
274
193
  var VITE_TARGETS = [
@@ -444,7 +363,8 @@ Use this whenever you are writing or reviewing SvelteKit route files (\`+page.sv
444
363
 
445
364
  1. After writing or editing code, run \`npx svelte-vitals . --diff --reporter agent\` and fix any findings it reports.
446
365
  2. Before committing, run \`npx svelte-vitals . --staged\` as a pre-commit gate.
447
- 3. For a rule's full rationale and fix examples, use the \`explain_rule\` MCP tool (svelte-vitals MCP server) or open its docs link below.
366
+ 3. For a rule's full rationale, configurable options and fix examples, run \`npx svelte-vitals explain <rule-id>\` (add \`--json\` for a structured object) or open its docs link below.
367
+ 4. For anything else \u2014 reporters, the config file, scoping to a change, CI, monorepos \u2014 run \`npx svelte-vitals docs list\` and then \`npx svelte-vitals docs show <name>\`. Those guides ship inside the CLI, so they match the version installed here; prefer them over searching the web.
448
368
 
449
369
  ## Rule digest
450
370
 
@@ -535,9 +455,9 @@ Every svelte-vitals rule already carries a reviewer-written fix:
535
455
  \`recommendation\` (one line), and where applicable \`fix.description\` +
536
456
  \`fix.snippet\` (literal code to drop in). These are embedded verbatim in the
537
457
  rule catalog below \u2014 copy them into the plan's Target section, never
538
- approximate from memory. For the full rationale behind a rule, use the
539
- \`explain_rule\` MCP tool (if the svelte-vitals MCP server is configured) or
540
- open its docs link, also in the catalog below.
458
+ approximate from memory. For the full rationale behind a rule, run
459
+ \`npx svelte-vitals explain <rule-id>\` (it also names the rule's configurable
460
+ options) or open its docs link, also in the catalog below.
541
461
 
542
462
  ## Workflow
543
463
 
@@ -559,8 +479,7 @@ Get the machine map before applying judgment:
559
479
  findings even appear (see Hard Rule 5).
560
480
  - **Stack**: SvelteKit version, static/prerendered vs. SSR vs. adapter-node,
561
481
  whether the Vite dev dashboard (\`@svelte-vitals/vite\`, \`ui: true\`) is
562
- already wired up, whether an MCP client or the \`svelte-vitals\` skill is
563
- already installed.
482
+ already wired up, whether the \`svelte-vitals\` skill is already installed.
564
483
  - **Verification commands**: read \`package.json\`'s \`scripts\` \u2014 do not assume
565
484
  a specific package manager; this project's build/typecheck/test/lint
566
485
  commands may differ from svelte-vitals' own repo.
@@ -786,7 +705,7 @@ adapted to this file \u2014 never approximated from memory.
786
705
  ## Tone
787
706
 
788
707
  State findings plainly with evidence, and cite the rule id so the reader can
789
- look it up in the catalog above or via \`explain_rule\`. A short list of
708
+ look it up in the catalog above or via \`svelte-vitals explain\`. A short list of
790
709
  high-confidence, high-leverage plans beats a long padded one \u2014 "this route
791
710
  is already solid" is a valid audit result. Flag uncertainty honestly: when
792
711
  correctness can't be judged from static code alone (a race that depends on
@@ -824,7 +743,7 @@ ${options}
824
743
  }
825
744
 
826
745
  // src/install/config-file-format.ts
827
- import { join as join2 } from "path";
746
+ import { join } from "path";
828
747
  function nodeSupportsNativeTypeScript(version) {
829
748
  const match = /^v?(\d+)\.(\d+)/.exec(version);
830
749
  if (!match) return false;
@@ -833,10 +752,10 @@ function nodeSupportsNativeTypeScript(version) {
833
752
  return major > 23 || major === 23 && minor >= 6 || major === 22 && minor >= 18;
834
753
  }
835
754
  function findExistingConfigFile(readFile, cwd) {
836
- return CONFIG_FILENAMES.find((rel) => readFile(join2(cwd, rel)) !== void 0);
755
+ return CONFIG_FILENAMES.find((rel) => readFile(join(cwd, rel)) !== void 0);
837
756
  }
838
757
  function hasSvelteVitalsDependency(readFile, cwd) {
839
- const raw = readFile(join2(cwd, "package.json"));
758
+ const raw = readFile(join(cwd, "package.json"));
840
759
  if (raw === void 0) return false;
841
760
  try {
842
761
  const pkg = JSON.parse(raw);
@@ -846,7 +765,7 @@ function hasSvelteVitalsDependency(readFile, cwd) {
846
765
  }
847
766
  }
848
767
  function isEsmProject(readFile, cwd) {
849
- const raw = readFile(join2(cwd, "package.json"));
768
+ const raw = readFile(join(cwd, "package.json"));
850
769
  if (raw === void 0) return false;
851
770
  try {
852
771
  return JSON.parse(raw).type === "module";
@@ -856,7 +775,7 @@ function isEsmProject(readFile, cwd) {
856
775
  }
857
776
  function detectBestConfigExtension(opts) {
858
777
  if (!nodeSupportsNativeTypeScript(opts.nodeVersion)) return "mjs";
859
- const looksTypeScript = opts.readFile(join2(opts.cwd, "tsconfig.json")) !== void 0 || opts.readFile(join2(opts.cwd, "vite.config.ts")) !== void 0;
778
+ const looksTypeScript = opts.readFile(join(opts.cwd, "tsconfig.json")) !== void 0 || opts.readFile(join(opts.cwd, "vite.config.ts")) !== void 0;
860
779
  if (!looksTypeScript) return "mjs";
861
780
  return hasSvelteVitalsDependency(opts.readFile, opts.cwd) ? "ts" : "mjs";
862
781
  }
@@ -967,7 +886,7 @@ function codemodHooksServer(existing) {
967
886
  }
968
887
 
969
888
  // src/install/package-manager.ts
970
- import { join as join3 } from "path";
889
+ import { join as join2 } from "path";
971
890
  var LOCKFILE_TO_PM = {
972
891
  "pnpm-lock.yaml": "pnpm",
973
892
  "yarn.lock": "yarn",
@@ -977,7 +896,7 @@ var LOCKFILE_TO_PM = {
977
896
  };
978
897
  function detectPackageManagerFromLockfile(io) {
979
898
  for (const [file, pm] of Object.entries(LOCKFILE_TO_PM)) {
980
- if (io.readFile(join3(io.cwd, file)) !== void 0) return pm;
899
+ if (io.readFile(join2(io.cwd, file)) !== void 0) return pm;
981
900
  }
982
901
  return void 0;
983
902
  }
@@ -985,7 +904,7 @@ function detectPackageManager(io) {
985
904
  return detectPackageManagerFromLockfile(io) ?? "npm";
986
905
  }
987
906
  function hasVitePackage(io) {
988
- const raw = io.readFile(join3(io.cwd, "package.json"));
907
+ const raw = io.readFile(join2(io.cwd, "package.json"));
989
908
  if (raw === void 0) return false;
990
909
  try {
991
910
  const pkg = JSON.parse(raw);
@@ -999,7 +918,7 @@ function installCommand(pm) {
999
918
  return { command: pm, args: [action, "-D", "@svelte-vitals/vite"] };
1000
919
  }
1001
920
  function readInstalledViteVersion(io) {
1002
- const raw = io.readFile(join3(io.cwd, "node_modules/@svelte-vitals/vite/package.json"));
921
+ const raw = io.readFile(join2(io.cwd, "node_modules/@svelte-vitals/vite/package.json"));
1003
922
  if (raw === void 0) return void 0;
1004
923
  try {
1005
924
  return JSON.parse(raw).version;
@@ -1016,19 +935,13 @@ var ACTION_VERSION = "0.4.0";
1016
935
  function detectPackageManagerNear(io, appDir) {
1017
936
  return detectPackageManagerFromLockfile({ ...io, cwd: appDir }) ?? detectPackageManager(io);
1018
937
  }
1019
- function planForClient(client, scope, io, force) {
1020
- const path = client.resolvePath(scope, io.cwd, io.home);
1021
- const existing = io.readFile(path);
1022
- const merged = client.format === "toml" ? mergeToml(existing, MCP_ENTRY, force) : mergeJson(existing, MCP_ENTRY, force);
1023
- return { id: client.id, label: client.label, scope, path, status: merged.status, content: merged.content };
1024
- }
1025
938
  function resolveCandidate(io, baseDir, candidates) {
1026
939
  for (const rel of candidates) {
1027
- const path = join4(baseDir, rel);
940
+ const path = join3(baseDir, rel);
1028
941
  const content = io.readFile(path);
1029
942
  if (content !== void 0) return { path, content };
1030
943
  }
1031
- return { path: join4(baseDir, candidates[0]), content: void 0 };
944
+ return { path: join3(baseDir, candidates[0]), content: void 0 };
1032
945
  }
1033
946
  function planForVitePlugin(io, appDir) {
1034
947
  const { path, content } = resolveCandidate(io, appDir, ["vite.config.ts", "vite.config.js", "vite.config.mjs"]);
@@ -1057,7 +970,7 @@ function agentTargetContent(id, version) {
1057
970
  function planForAgentTarget(target, io, force, version) {
1058
971
  const content = agentTargetContent(target.id, version);
1059
972
  return target.relPaths.map((relPath) => {
1060
- const path = join4(io.cwd, relPath);
973
+ const path = join3(io.cwd, relPath);
1061
974
  const existing = io.readFile(path);
1062
975
  const status = existing === void 0 ? "created" : force ? "updated" : "exists";
1063
976
  return { id: target.id, label: target.label, path, status, content };
@@ -1066,7 +979,7 @@ function planForAgentTarget(target, io, force, version) {
1066
979
  function planForConfigTarget(target, io, force, appDir) {
1067
980
  const existingRel = findExistingConfigFile(io.readFile, appDir);
1068
981
  if (existingRel !== void 0) {
1069
- const path2 = join4(appDir, existingRel);
982
+ const path2 = join3(appDir, existingRel);
1070
983
  const status = force ? "updated" : "exists";
1071
984
  const content2 = force ? buildConfigFileTemplate({
1072
985
  useDefineConfig: existingRel.endsWith(".ts") && hasSvelteVitalsDependency(io.readFile, appDir),
@@ -1079,12 +992,12 @@ function planForConfigTarget(target, io, force, appDir) {
1079
992
  cwd: appDir,
1080
993
  nodeVersion: io.nodeVersion ?? process.version
1081
994
  });
1082
- const path = join4(appDir, `svelte-vitals.config.${ext}`);
995
+ const path = join3(appDir, `svelte-vitals.config.${ext}`);
1083
996
  const content = buildConfigFileTemplate({ useDefineConfig: ext === "ts" });
1084
997
  return { id: target.id, label: target.label, path, status: "created", content };
1085
998
  }
1086
999
  function planForCiTarget(target, io, force) {
1087
- const path = join4(io.cwd, target.relPath);
1000
+ const path = join3(io.cwd, target.relPath);
1088
1001
  const existing = io.readFile(path);
1089
1002
  const plan = planWorkflowWrite(existing, force);
1090
1003
  const content = plan.status === "exists" ? void 0 : buildWorkflowYaml({ actionSha: ACTION_SHA, actionVersion: ACTION_VERSION });
@@ -1094,7 +1007,7 @@ function indent(text) {
1094
1007
  return text.split("\n").map((l) => ` ${l}`).join("\n");
1095
1008
  }
1096
1009
  function rowLine(r) {
1097
- const head = ` ${r.label}${r.scope ? ` (${r.scope})` : ""} \u2192 ${r.path} [${r.status}]`;
1010
+ const head = ` ${r.label} \u2192 ${r.path} [${r.status}]`;
1098
1011
  return r.status === "manual" && r.snippet ? `${head}
1099
1012
  ${indent(r.snippet)}` : head;
1100
1013
  }
@@ -1104,7 +1017,7 @@ async function runRefresh(io, flags, version) {
1104
1017
  for (const target of AGENT_TARGETS) {
1105
1018
  const content = agentTargetContent(target.id, version);
1106
1019
  for (const relPath of target.relPaths) {
1107
- const path = join4(io.cwd, relPath);
1020
+ const path = join3(io.cwd, relPath);
1108
1021
  try {
1109
1022
  if (io.readFile(path) === void 0) continue;
1110
1023
  rows.push({ id: target.id, label: target.label, path, status: "updated", content });
@@ -1157,29 +1070,30 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1157
1070
  return false;
1158
1071
  }
1159
1072
  };
1160
- const detectedClients = CLIENTS.filter(
1161
- (c) => c.scopes.some((s) => configExists(c.resolvePath(s, io.cwd, io.home)))
1162
- ).map((c) => c.id);
1163
1073
  const viteConfigExists = ["vite.config.ts", "vite.config.js", "vite.config.mjs"].some(
1164
- (f) => configExists(join4(io.cwd, f))
1074
+ (f) => configExists(join3(io.cwd, f))
1165
1075
  );
1166
- const claudeSkillDetected = configExists(join4(io.cwd, ".claude", "settings.json"));
1167
- const cursorRulesDetected = configExists(join4(io.cwd, ".cursor", "mcp.json"));
1076
+ const claudeSkillDetected = configExists(join3(io.cwd, ".claude", "settings.json"));
1077
+ const cursorRulesDetected = [
1078
+ ".cursor/mcp.json",
1079
+ ".cursor/environment.json",
1080
+ ".cursorrules",
1081
+ ".cursorignore",
1082
+ agentTargetById("cursor-rules").relPaths[0]
1083
+ ].some((rel) => configExists(join3(io.cwd, rel)));
1168
1084
  const detectedAgents = [
1169
1085
  ...claudeSkillDetected ? ["claude-skill"] : [],
1170
1086
  ...cursorRulesDetected ? ["cursor-rules"] : []
1171
1087
  ];
1172
- const ciWorkflowDetected = configExists(join4(io.cwd, CI_TARGETS[0].relPath));
1088
+ const ciWorkflowDetected = configExists(join3(io.cwd, CI_TARGETS[0].relPath));
1173
1089
  const configFileDetected = findExistingConfigFile((p3) => configExists(p3) ? "" : void 0, io.cwd) !== void 0;
1174
1090
  const detected = [
1175
- ...detectedClients,
1176
1091
  ...viteConfigExists ? VITE_TARGETS.map((t) => t.id) : [],
1177
1092
  ...detectedAgents,
1178
1093
  ...ciWorkflowDetected ? CI_TARGETS.map((t) => t.id) : [],
1179
1094
  ...configFileDetected ? CONFIG_TARGETS.map((t) => t.id) : []
1180
1095
  ];
1181
1096
  const groups = {
1182
- "MCP server": CLIENTS.map((c) => ({ id: c.id, label: c.label })),
1183
1097
  "Vite integration": VITE_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint })),
1184
1098
  "Agent Skills & rules": AGENT_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint })),
1185
1099
  "CI (GitHub Actions)": CI_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint })),
@@ -1193,25 +1107,24 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1193
1107
  ids = picked;
1194
1108
  } else {
1195
1109
  io.errorLog(
1196
- "svelte-vitals: no TTY; pass --client <claude-code,cursor,codex,vite-plugin,vite-hooks,claude-skill,cursor-rules,claude-skill-improve,config-file,ci-workflow> to install non-interactively."
1110
+ "svelte-vitals: no TTY; pass --client <vite-plugin,vite-hooks,claude-skill,cursor-rules,claude-skill-improve,config-file,ci-workflow> to install non-interactively."
1197
1111
  );
1198
1112
  return 2;
1199
1113
  }
1200
- const clients = ids.map(clientById).filter((c) => c !== void 0);
1201
1114
  const viteIds = ids.filter(isViteTargetId);
1202
1115
  const agentIds = ids.filter(isAgentTargetId);
1203
1116
  const configIds = ids.filter(isConfigTargetId);
1204
1117
  const ciIds = ids.filter(isCiTargetId);
1205
- if (clients.length === 0 && viteIds.length === 0 && agentIds.length === 0 && configIds.length === 0 && ciIds.length === 0) {
1206
- io.errorLog("svelte-vitals: no valid clients or targets selected.");
1118
+ if (viteIds.length === 0 && agentIds.length === 0 && configIds.length === 0 && ciIds.length === 0) {
1119
+ io.errorLog("svelte-vitals: no valid targets selected.");
1207
1120
  return 2;
1208
1121
  }
1209
1122
  const isSvelteKitApp = (dir) => {
1210
1123
  try {
1211
- if (io.readFile(join4(dir, "svelte.config.js")) !== void 0 || io.readFile(join4(dir, "svelte.config.ts")) !== void 0) {
1124
+ if (io.readFile(join3(dir, "svelte.config.js")) !== void 0 || io.readFile(join3(dir, "svelte.config.ts")) !== void 0) {
1212
1125
  return true;
1213
1126
  }
1214
- const pkgRaw = io.readFile(join4(dir, "package.json"));
1127
+ const pkgRaw = io.readFile(join3(dir, "package.json"));
1215
1128
  if (pkgRaw === void 0) return false;
1216
1129
  const pkg = JSON.parse(pkgRaw);
1217
1130
  return Boolean(pkg.dependencies?.["@sveltejs/kit"] ?? pkg.devDependencies?.["@sveltejs/kit"]);
@@ -1223,7 +1136,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1223
1136
  let appDir = io.cwd;
1224
1137
  if (needsApp) {
1225
1138
  if (flags.app) {
1226
- const candidate = join4(io.cwd, flags.app);
1139
+ const candidate = join3(io.cwd, flags.app);
1227
1140
  if (!isSvelteKitApp(candidate)) {
1228
1141
  io.errorLog(
1229
1142
  `svelte-vitals: --app '${flags.app}' is not a SvelteKit app (no svelte.config.{js,ts} or @sveltejs/kit dependency there).`
@@ -1235,7 +1148,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1235
1148
  const apps = await (io.discoverApps ?? discoverApps)(io.cwd);
1236
1149
  if (apps.length === 1) {
1237
1150
  io.errorLog(`svelte-vitals: detected SvelteKit app at ${apps[0]}; targeting it for the Vite/config targets.`);
1238
- appDir = join4(io.cwd, apps[0]);
1151
+ appDir = join3(io.cwd, apps[0]);
1239
1152
  } else if (apps.length > 1) {
1240
1153
  if (io.isTTY) {
1241
1154
  const picked = await prompts.selectApp(apps);
@@ -1243,7 +1156,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1243
1156
  io.log("Cancelled.");
1244
1157
  return 0;
1245
1158
  }
1246
- appDir = join4(io.cwd, picked);
1159
+ appDir = join3(io.cwd, picked);
1247
1160
  } else {
1248
1161
  io.errorLog(`svelte-vitals: multiple SvelteKit apps found: ${apps.join(", ")}.`);
1249
1162
  io.errorLog(`svelte-vitals: pass one with --app, e.g. \`svelte-vitals install --app ${apps[0]}\`.`);
@@ -1253,35 +1166,16 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1253
1166
  }
1254
1167
  }
1255
1168
  const rows = [];
1256
- for (const client of clients) {
1257
- let scope;
1258
- if (client.scopes.length === 1) {
1259
- scope = client.scopes[0];
1260
- } else if (flags.scope) {
1261
- scope = flags.scope;
1262
- } else if (io.isTTY) {
1263
- const picked = await prompts.selectScope(client);
1264
- if (picked === null) {
1265
- io.log("Cancelled.");
1266
- return 0;
1267
- }
1268
- scope = picked;
1269
- } else {
1270
- scope = "project";
1271
- }
1169
+ for (const viteId of viteIds) {
1272
1170
  try {
1273
- rows.push(planForClient(client, scope, io, flags.force ?? false));
1171
+ rows.push(viteId === "vite-plugin" ? planForVitePlugin(io, appDir) : planForViteHooks(io, appDir));
1274
1172
  } catch (err) {
1275
- const path = client.resolvePath(scope, io.cwd, io.home);
1276
1173
  io.errorLog(
1277
- `svelte-vitals: could not parse existing config at ${path}: ${err instanceof Error ? err.message : String(err)}`
1174
+ `svelte-vitals: could not check existing Vite target ${viteId}: ${err instanceof Error ? err.message : String(err)}`
1278
1175
  );
1279
1176
  return 2;
1280
1177
  }
1281
1178
  }
1282
- for (const viteId of viteIds) {
1283
- rows.push(viteId === "vite-plugin" ? planForVitePlugin(io, appDir) : planForViteHooks(io, appDir));
1284
- }
1285
1179
  for (const agentId of agentIds) {
1286
1180
  const target = agentTargetById(agentId);
1287
1181
  try {
@@ -1310,7 +1204,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1310
1204
  rows.push(planForCiTarget(target, io, flags.force ?? false));
1311
1205
  } catch (err) {
1312
1206
  io.errorLog(
1313
- `svelte-vitals: could not check existing workflow at ${join4(io.cwd, target.relPath)}: ${err instanceof Error ? err.message : String(err)}`
1207
+ `svelte-vitals: could not check existing workflow at ${join3(io.cwd, target.relPath)}: ${err instanceof Error ? err.message : String(err)}`
1314
1208
  );
1315
1209
  return 2;
1316
1210
  }
@@ -1375,7 +1269,7 @@ ${indent(r.snippet ?? "")}`);
1375
1269
  }
1376
1270
  if (hadFailure) return 2;
1377
1271
  io.log("");
1378
- if (clients.length > 0) io.log("Restart your client to load the svelte-vitals MCP server.");
1272
+ if (agentIds.length > 0) io.log("Restart your agent (or start a new session) to pick up the generated skill.");
1379
1273
  if (viteWasWritten) io.log("Restart `vite dev` (or your build) to pick up the change.");
1380
1274
  io.log("Done.");
1381
1275
  return 0;
@@ -1383,7 +1277,6 @@ ${indent(r.snippet ?? "")}`);
1383
1277
 
1384
1278
  // src/install/args.ts
1385
1279
  var VALID_TARGETS = [
1386
- ...CLIENTS.map((c) => c.id),
1387
1280
  ...VITE_TARGETS.map((t) => t.id),
1388
1281
  ...AGENT_TARGETS.map((t) => t.id),
1389
1282
  ...CONFIG_TARGETS.map((t) => t.id),
@@ -1405,11 +1298,8 @@ function resolveInstallArgs(argv) {
1405
1298
  if (rawClients.length > 0 && client.length === 0) {
1406
1299
  errors.push(`svelte-vitals: no valid --client values; expected ${EXPECTED_TARGETS}.`);
1407
1300
  }
1408
- let scope;
1409
- const rawScope = argv.scope;
1410
- if (typeof rawScope === "string") {
1411
- if (rawScope === "project" || rawScope === "global") scope = rawScope;
1412
- else errors.push(`svelte-vitals: unknown --scope '${rawScope}'; expected project|global.`);
1301
+ if (argv.scope !== void 0) {
1302
+ warnings.push("svelte-vitals: --scope is no longer used (all install targets are project-scoped). Ignoring.");
1413
1303
  }
1414
1304
  const app = typeof argv.app === "string" && argv.app.trim() !== "" ? argv.app.trim() : void 0;
1415
1305
  const refresh = Boolean(argv.refresh);
@@ -1417,13 +1307,12 @@ function resolveInstallArgs(argv) {
1417
1307
  errors.push("svelte-vitals: --refresh regenerates existing files and cannot be combined with --client.");
1418
1308
  }
1419
1309
  if (errors.length > 0) return { flags: null, warnings, errors };
1420
- if (refresh && (scope !== void 0 || Boolean(argv.yes) || Boolean(argv.force) || app !== void 0)) {
1421
- warnings.push("svelte-vitals: --scope, --yes, --force, and --app are ignored with --refresh.");
1310
+ if (refresh && (Boolean(argv.yes) || Boolean(argv.force) || app !== void 0)) {
1311
+ warnings.push("svelte-vitals: --yes, --force, and --app are ignored with --refresh.");
1422
1312
  }
1423
1313
  return {
1424
1314
  flags: {
1425
1315
  ...client.length > 0 ? { client } : {},
1426
- ...scope ? { scope } : {},
1427
1316
  ...app !== void 0 && !refresh ? { app } : {},
1428
1317
  yes: Boolean(argv.yes),
1429
1318
  dryRun: Boolean(argv["dry-run"]),
@@ -1436,15 +1325,15 @@ function resolveInstallArgs(argv) {
1436
1325
  }
1437
1326
 
1438
1327
  // src/install/cli.ts
1439
- var INSTALL_HELP = `svelte-vitals install \u2014 set up the svelte-vitals MCP server, Vite integration, agent skills/rules, and CI
1328
+ var INSTALL_HELP = `svelte-vitals install \u2014 set up the svelte-vitals Vite integration, agent skills/rules, config file, and CI
1440
1329
 
1441
1330
  Usage:
1442
1331
  svelte-vitals install [options]
1443
1332
 
1444
1333
  Options:
1445
- --client <ids> Comma-separated: claude-code,cursor,codex,vite-plugin,vite-hooks,claude-skill,cursor-rules,claude-skill-improve,config-file,ci-workflow
1334
+ --client <ids> Comma-separated: vite-plugin,vite-hooks,claude-skill,cursor-rules,claude-skill-improve,config-file,ci-workflow
1446
1335
  (skips the interactive picker; the picker groups these by category \u2014
1447
- MCP server, Vite integration, Agent Skills & rules, CI, Config file)
1336
+ Vite integration, Agent Skills & rules, CI, Config file)
1448
1337
  vite-plugin registers the build-mode plugin in vite.config.{ts,js,mjs}; vite-hooks
1449
1338
  wires up the svelteVitalsHandle hook in src/hooks.server.{ts,js}, which improves the
1450
1339
  live dashboard's per-route accuracy as you browse. --force does not apply
@@ -1467,13 +1356,12 @@ Options:
1467
1356
  \`svelte-vitals ci install\` writes standalone \u2014 pick it here to set it up in
1468
1357
  the same pass as everything else; supports --force to regenerate. \`svelte-vitals
1469
1358
  ci upgrade\` remains the way to bump an existing workflow's pinned action version.
1470
- --scope <scope> project | global (applies to all selected clients; codex is always global)
1471
1359
  --app <dir> Monorepo: the SvelteKit app directory the vite-plugin/vite-hooks/config-file
1472
1360
  targets write into (e.g. --app apps/web). Without it, when the current
1473
1361
  directory isn't itself a SvelteKit app, one detected app is used
1474
1362
  automatically (with a notice), several prompt a picker on a TTY, and
1475
- non-interactive runs exit 2 asking for --app. All other targets (MCP
1476
- configs, skills, ci-workflow) always write at the current directory \u2014
1363
+ non-interactive runs exit 2 asking for --app. All other targets
1364
+ (skills, ci-workflow) always write at the current directory \u2014
1477
1365
  the repo root is their correct home.
1478
1366
  --yes, -y Skip the confirmation prompt
1479
1367
  --dry-run Print the planned changes and exit without writing
@@ -1497,7 +1385,6 @@ function realIO() {
1497
1385
  writeFileSync(path, content);
1498
1386
  },
1499
1387
  cwd: process.cwd(),
1500
- home: homedir(),
1501
1388
  isTTY: Boolean(process.stdout.isTTY),
1502
1389
  nodeVersion: process.version,
1503
1390
  log: (line) => console.log(line),
@@ -1537,14 +1424,6 @@ function clackPrompts() {
1537
1424
  });
1538
1425
  return p.isCancel(res) ? null : res;
1539
1426
  },
1540
- selectScope: async (client) => {
1541
- const res = await p.select({
1542
- message: `Scope for ${client.label}?`,
1543
- options: client.scopes.map((s) => ({ value: s, label: s })),
1544
- initialValue: client.scopes[0]
1545
- });
1546
- return p.isCancel(res) ? null : res;
1547
- },
1548
1427
  selectApp: async (apps) => {
1549
1428
  const res = await p.select({
1550
1429
  message: "Multiple SvelteKit apps found \u2014 which one should the Vite/config targets go into?",
@@ -1563,6 +1442,8 @@ ${planText}` });
1563
1442
  async function runInstallCli(args) {
1564
1443
  const argv = mri(args, {
1565
1444
  boolean: ["yes", "dry-run", "force", "refresh", "help"],
1445
+ // `scope` is still declared although the flag is gone: it keeps `--scope global` from
1446
+ // parsing its value as a positional, so resolveInstallArgs can warn and carry on.
1566
1447
  string: ["client", "scope", "app"],
1567
1448
  alias: { y: "yes", h: "help" }
1568
1449
  });
@@ -1578,7 +1459,7 @@ async function runInstallCli(args) {
1578
1459
  }
1579
1460
 
1580
1461
  // src/ci/cli.ts
1581
- import { join as join5 } from "path";
1462
+ import { join as join4 } from "path";
1582
1463
  import mri2 from "mri";
1583
1464
 
1584
1465
  // src/ci/upgrade.ts
@@ -1656,7 +1537,7 @@ async function runCiCli(args, io = realIO()) {
1656
1537
  io.log(CI_HELP);
1657
1538
  return 0;
1658
1539
  }
1659
- const path = join5(io.cwd, WORKFLOW_PATH);
1540
+ const path = join4(io.cwd, WORKFLOW_PATH);
1660
1541
  const existing = io.readFile(path);
1661
1542
  const plan = planWorkflowWrite(existing, Boolean(argv.force));
1662
1543
  io.log("Plan:");
@@ -1690,7 +1571,7 @@ async function runCiUpgrade(args, io) {
1690
1571
  io.log(CI_HELP);
1691
1572
  return 0;
1692
1573
  }
1693
- const path = join5(io.cwd, WORKFLOW_PATH);
1574
+ const path = join4(io.cwd, WORKFLOW_PATH);
1694
1575
  const existing = io.readFile(path);
1695
1576
  if (existing === void 0) {
1696
1577
  io.errorLog(`svelte-vitals: no ${WORKFLOW_PATH} found \u2014 run \`svelte-vitals ci install\` first.`);
@@ -1720,12 +1601,102 @@ async function runCiUpgrade(args, io) {
1720
1601
  return 0;
1721
1602
  }
1722
1603
 
1604
+ // src/explain.ts
1605
+ import mri3 from "mri";
1606
+ import { allRules as allRules2, CATEGORIES as CATEGORIES2, explainRule } from "@svelte-vitals/core";
1607
+ var EXPLAIN_HELP = `svelte-vitals explain \u2014 print a rule's rationale, fix, and configurable options
1608
+
1609
+ Usage:
1610
+ svelte-vitals explain --list List every rule id, grouped by category
1611
+ svelte-vitals explain <rule-id> Explain one rule
1612
+
1613
+ Options:
1614
+ --list List every rule instead of explaining one
1615
+ --json Machine-readable output (works with --list and with a rule id)
1616
+ -h, --help Show this help
1617
+
1618
+ Rule ids are category/kebab-case and matched exactly, e.g. \`svelte-vitals explain seo/ssr-disabled\`.`;
1619
+ function describeOptions(id, options) {
1620
+ const MERGE = {
1621
+ integer: "replaces the default",
1622
+ "string-list": "added to the default entries, never replaces them",
1623
+ "string-map": "merged over the default entries \u2014 a new key is added, a built-in key has its value overridden"
1624
+ };
1625
+ const lines = options.map((o) => {
1626
+ const bounds = [o.min !== void 0 ? `>= ${o.min}` : "", o.max !== void 0 ? `<= ${o.max}` : ""].filter(Boolean).join(", ");
1627
+ return `- ${o.name} (${o.kind}, default ${JSON.stringify(o.default)}${bounds ? `, ${bounds}` : ""}) \u2014 ${MERGE[o.kind]}`;
1628
+ });
1629
+ return `set in svelte-vitals.config.* as \`rules: { '${id}': { options: { \u2026 } } }\`, or per path in \`overrides\`:
1630
+ ${lines.join("\n")}`;
1631
+ }
1632
+ function formatRuleExplanation(info) {
1633
+ return `${info.id} \u2014 ${info.title} (${info.severity}, ${info.category})
1634
+
1635
+ ${info.rationale}
1636
+
1637
+ Docs: ${info.docsUrl}` + (info.fix ? `
1638
+
1639
+ Fix: ${info.fix.description}` : "") + (info.options ? `
1640
+
1641
+ Configurable: ${describeOptions(info.id, info.options)}` : "");
1642
+ }
1643
+ function renderRuleList() {
1644
+ const sections = CATEGORIES2.map((category) => {
1645
+ const rules = allRules2.filter((r) => r.category === category);
1646
+ const width = Math.max(...rules.map((r) => r.id.length));
1647
+ const lines = rules.map((r) => ` ${r.id.padEnd(width)} ${r.severity.padEnd(8)} ${r.title}`);
1648
+ return [`${category} (${rules.length})`, ...lines].join("\n");
1649
+ });
1650
+ return [...sections, "", `${allRules2.length} rules. Explain one with \`svelte-vitals explain <rule-id>\`.`].join(
1651
+ "\n\n"
1652
+ );
1653
+ }
1654
+ function runExplainCli(args, io = consoleIO) {
1655
+ const argv = mri3(args, { boolean: ["json", "list", "help"], alias: { h: "help" } });
1656
+ if (argv.help) {
1657
+ io.log(EXPLAIN_HELP);
1658
+ return 0;
1659
+ }
1660
+ if (argv.list) {
1661
+ if (argv._.length > 0) {
1662
+ io.errorLog("svelte-vitals: explain --list takes no rule id; drop --list to explain one.");
1663
+ return 2;
1664
+ }
1665
+ io.log(
1666
+ argv.json ? JSON.stringify(
1667
+ allRules2.map((r) => ({ id: r.id, category: r.category, severity: r.severity, title: r.title })),
1668
+ null,
1669
+ 2
1670
+ ) : renderRuleList()
1671
+ );
1672
+ return 0;
1673
+ }
1674
+ const id = argv._[0];
1675
+ if (id === void 0) {
1676
+ io.errorLog(
1677
+ "svelte-vitals: explain needs a rule id, e.g. `svelte-vitals explain seo/ssr-disabled`; `--list` shows them all."
1678
+ );
1679
+ io.errorLog(`svelte-vitals: known rule ids: ${knownRuleIds().join(", ")}.`);
1680
+ return 2;
1681
+ }
1682
+ const info = explainRule(id);
1683
+ if (!info) {
1684
+ io.errorLog(`svelte-vitals: unknown rule id '${id}'.`);
1685
+ io.errorLog(`svelte-vitals: known rule ids: ${knownRuleIds().join(", ")}.`);
1686
+ return 2;
1687
+ }
1688
+ io.log(argv.json ? JSON.stringify(info, null, 2) : formatRuleExplanation(info));
1689
+ return 0;
1690
+ }
1691
+
1723
1692
  // src/bin.ts
1724
1693
  var HELP = `svelte-vitals \u2014 a deterministic SvelteKit code-health scanner (SEO \xB7 performance \xB7 correctness \xB7 security \xB7 architecture)
1725
1694
 
1726
1695
  Usage:
1727
1696
  svelte-vitals [path] [options]
1728
- svelte-vitals install Set up the MCP server, Vite integration, or agent skills/rules
1697
+ svelte-vitals docs list List the bundled guides (docs show <name> prints one)
1698
+ svelte-vitals explain --list List every rule (explain <rule-id> explains one)
1699
+ svelte-vitals install Set up the Vite integration, agent skills/rules, config file, or CI
1729
1700
  svelte-vitals ci install Add a GitHub Actions PR gate (annotations + summary comment)
1730
1701
  svelte-vitals ci upgrade Refresh the pinned @svelte-vitals/action in an existing workflow
1731
1702
 
@@ -1760,7 +1731,24 @@ Config file:
1760
1731
  Exit codes:
1761
1732
  0 no failing findings
1762
1733
  1 critical finding present (or --fail-on threshold reached)
1763
- 2 execution error (not a SvelteKit project / internal error)`;
1734
+ 2 execution error (not a SvelteKit project / internal error)
1735
+
1736
+ If you are an AI agent:
1737
+ - \`svelte-vitals docs list\` then \`docs show <name>\` \u2014 the guides ship inside this CLI, so
1738
+ they match this exact version and need no network. Read those before searching the web.
1739
+ - \`--reporter agent\` gives every failing finding a location, a concrete fix and an acceptance
1740
+ check; it is auto-selected when an agent environment is detected. \`--reporter json\` is the
1741
+ structured form.
1742
+ - \`--diff\` scopes the report to what you just changed; \`--staged\` is the pre-commit gate.
1743
+ - \`svelte-vitals explain <rule-id>\` says why a rule exists and which options it takes, before
1744
+ you decide to turn it off.
1745
+ - Do NOT reach for \`--update-suppressions\` to make a run pass: it accepts every current
1746
+ finding into a committed file and un-gates CI for all of them. Fix the findings, or scope
1747
+ the run with \`--diff\`. Only a human should decide to accept a backlog.
1748
+ - Exit 2 is never a pass \u2014 it means the analysis did not run. Read stderr.
1749
+ - Analysis never prompts when stdout is not a TTY: where it would have asked, it exits 2
1750
+ naming the flag to pass. \`install\` is the exception \u2014 non-interactively it skips its
1751
+ confirmation and writes, so pass \`--dry-run\` first if you need to see the plan.`;
1764
1752
  var VERSION = readPackageVersion();
1765
1753
  async function selectApp(apps) {
1766
1754
  const res = await p2.select({
@@ -1771,6 +1759,15 @@ async function selectApp(apps) {
1771
1759
  }
1772
1760
  async function main() {
1773
1761
  const rawArgs = process.argv.slice(2);
1762
+ if (rawArgs[0] === "docs") {
1763
+ const { runDocsCli } = await import("./cli-SOIIGBH7.js");
1764
+ process.exitCode = runDocsCli(rawArgs.slice(1));
1765
+ return;
1766
+ }
1767
+ if (rawArgs[0] === "explain") {
1768
+ process.exitCode = runExplainCli(rawArgs.slice(1));
1769
+ return;
1770
+ }
1774
1771
  if (rawArgs[0] === "install") {
1775
1772
  const code2 = await runInstallCli(rawArgs.slice(1));
1776
1773
  process.exit(code2);
@@ -1779,7 +1776,7 @@ async function main() {
1779
1776
  const code2 = await runCiCli(rawArgs.slice(1));
1780
1777
  process.exit(code2);
1781
1778
  }
1782
- const argv = mri3(process.argv.slice(2), {
1779
+ const argv = mri4(process.argv.slice(2), {
1783
1780
  alias: { h: "help", v: "version" },
1784
1781
  boolean: ["by-route", "staged", "score", "verbose", "update-suppressions"],
1785
1782
  string: [
@@ -1800,11 +1797,12 @@ async function main() {
1800
1797
  });
1801
1798
  if (argv.help) {
1802
1799
  console.log(HELP);
1803
- process.exit(0);
1800
+ return;
1804
1801
  }
1805
1802
  if (argv.version) {
1806
1803
  console.log(`${VERSION} (core ${readCoreVersion()})`);
1807
- process.exit(0);
1804
+ console.error("svelte-vitals: run `svelte-vitals docs list` for the bundled guides.");
1805
+ return;
1808
1806
  }
1809
1807
  const { options, warnings, errors } = resolveArgs(argv);
1810
1808
  for (const w of warnings) console.error(w);
@@ -109,7 +109,7 @@ async function detectProject(rt, cwd) {
109
109
  const hasRoutes = await rt.exists(rt.join(cwd, ROUTES_DIR));
110
110
  if (hasKitDep || hasConfig && hasRoutes) return;
111
111
  throw new ProjectError(
112
- "No SvelteKit project found in the current directory. Run this inside a SvelteKit app, or pass a path (e.g. npx svelte-vitals apps/web)."
112
+ "No SvelteKit project found in the current directory. Run this inside a SvelteKit app, or pass a path (e.g. npx svelte-vitals apps/web). See `svelte-vitals docs show monorepo` for how the app is resolved."
113
113
  );
114
114
  }
115
115
  async function enumerateRoutePages(rt, cwd) {
@@ -1664,7 +1664,7 @@ async function run(opts = {}) {
1664
1664
  } else {
1665
1665
  if (reporter === "agent" && isAutoDetectedAgent(opts.reporter, env)) {
1666
1666
  errorLog(
1667
- "svelte-vitals: agent reporter auto-selected (AI-agent env detected); override with --reporter console|json."
1667
+ "svelte-vitals: agent reporter auto-selected (AI-agent env detected); override with --reporter console|json. Run `svelte-vitals docs list` for the bundled guides."
1668
1668
  );
1669
1669
  }
1670
1670
  if (reporter === "github" && isAutoDetectedGithub(opts.reporter, env)) {
@@ -0,0 +1,9 @@
1
+ // src/cli-io.ts
2
+ var consoleIO = {
3
+ log: (line) => console.log(line),
4
+ errorLog: (line) => console.error(line)
5
+ };
6
+
7
+ export {
8
+ consoleIO
9
+ };
@@ -0,0 +1,120 @@
1
+ import {
2
+ consoleIO
3
+ } from "./chunk-SLUMRYUD.js";
4
+
5
+ // src/docs/cli.ts
6
+ import mri from "mri";
7
+
8
+ // src/docs/generated.ts
9
+ var EMBEDDED_DOCS = [
10
+ {
11
+ name: "ci",
12
+ title: "Running in CI",
13
+ description: "Scaffold a GitHub Actions PR gate with `ci install`, what the generated workflow does, and how to gate a pipeline without the Action.",
14
+ body: "# Running in CI\n\n## Scaffold the GitHub Actions gate\n\n```bash\nnpx svelte-vitals@latest ci install # writes .github/workflows/svelte-vitals.yml\nnpx svelte-vitals@latest ci upgrade # bump only the pinned action ref in an existing file\n```\n\n`ci install` also exists as the `ci-workflow` target inside `svelte-vitals install`, so it can be\nset up in the same pass as everything else. Both support `--dry-run` and `--force`.\n\n## What the generated workflow does\n\nOn every `pull_request` it checks out with `fetch-depth: 0` (so the base ref is resolvable), then\ncalls `@svelte-vitals/action`, which runs the analysis **in-process** \u2014 no `npx`, no separate\nscan per output \u2014 scoped to the PR with `diff: origin/<base>` and `baseline: origin/<base>`. From\nthat one analysis it produces:\n\n- inline annotations on the diff,\n- a job summary,\n- a sticky PR comment (a hidden `<!-- svelte-vitals-report -->` marker updates the same comment\n instead of piling up new ones).\n\nIt fails the job **after** the summary and comment are written, so a failing run still leaves the\nfeedback behind.\n\nAction inputs: `path` (default `.`), `diff`, `baseline`, `github-token`. There is no `reporter`\ninput \u2014 the fan-out is fixed. The action reads your committed `svelte-vitals.config.*` and\n`svelte-vitals-suppressions.json` like the CLI does, so rule policy stays in those files.\n\nRequired permissions: `contents: read` and `pull-requests: write`. On PRs from forks GitHub\ndowngrades the token regardless, so the action detects that and skips the comment (never failing\nthe job); annotations and the summary still work.\n\n## Without the Action\n\nAny CI can run the CLI directly. On GitHub, `GITHUB_ACTIONS=true` auto-selects the `github`\nreporter, so annotations come for free \u2014 but a detected AI-agent environment outranks it, so a\njob driven by an agent gets `agent` instead. Pass `--reporter github` when you need the\nannotations regardless of who is running the job:\n\n```bash\npnpm build\nnpx svelte-vitals@latest --reporter github --fail-on warning\n```\n\nFor a PR gate that ignores a legacy backlog, pair the two scoping flags:\n\n```bash\nnpx svelte-vitals@latest --diff origin/main --baseline origin/main --fail-on warning\n```\n\nGate on the score instead of, or as well as, individual findings with `--min-health <0-100>`.\n\nExit `1` means findings gated the run; exit `2` means the run did not happen \u2014 fail the job\nloudly on `2` rather than treating it as a pass.\n\n## Related\n\n- `svelte-vitals docs show scoping` \u2014 what `--diff` / `--baseline` / suppressions each do\n- `svelte-vitals docs show output` \u2014 reporters and exit codes"
15
+ },
16
+ {
17
+ name: "config",
18
+ title: "The config file",
19
+ description: "Where svelte-vitals.config lives, every top-level option, how to disable or re-grade a rule, and how to scope rules to routes or files.",
20
+ body: "# The config file\n\n## Where it lives\n\nIn the **analyzed directory only** \u2014 no upward search. First match wins:\n\n1. `svelte-vitals.config.mjs`\n2. `svelte-vitals.config.js`\n3. `svelte-vitals.config.ts`\n\nNo file means built-in defaults. `svelte-vitals install --client config-file` scaffolds one with\nevery option commented out.\n\n```js\n// svelte-vitals.config.mjs\nexport default {\n treatDynamicAs: 'warn',\n metaComponents: ['Seo'],\n rules: { 'seo/json-ld': 'off' },\n failOn: 'warning',\n weights: { seo: 2 }\n};\n```\n\nA `.ts` config can `import { defineConfig } from 'svelte-vitals'` for type-checking, but that is\na **runtime** import \u2014 it requires svelte-vitals to be a declared dependency, and Node 22.18+\n(or 23.6+) to load `.ts` at all. A plain `export default {}` in `.mjs` behaves identically and\nalways works.\n\n## Options\n\n| Option | Type | Default |\n| ---------------- | -------------------------------------------------------------- | ------------------ |\n| `treatDynamicAs` | `'pass' \\| 'warn' \\| 'fail'` | `'pass'` |\n| `metaComponents` | `string[]` | `[]` |\n| `rules` | `Record<ruleId, 'off' \\| Severity \\| { severity?, options? }>` | `{}` |\n| `failOn` | `'critical' \\| 'warning' \\| 'info'` | `'critical'` |\n| `weights` | `Partial<Record<Category, number>>` | every category `1` |\n| `overrides` | `RuleOverride[]` | (none) |\n\n`Severity` is `'critical' | 'warning' | 'info'`. `Category` is `'seo' | 'performance' |\n'correctness' | 'security' | 'architecture'`. A weight of `0` drops a category from the Health\naverage; setting every category to `0` is an error (exit `2`).\n\n## Turning a rule off or down\n\n```js\nexport default {\n rules: {\n 'seo/json-ld': 'off', // remove its findings entirely\n 'architecture/prop-count': 'info' // keep it, stop it failing the build\n }\n};\n```\n\nBefore disabling a rule, check whether it is really a **threshold disagreement** rather than a\ndefect \u2014 many rules take options. `svelte-vitals explain <rule-id>` prints each option's name,\ndefault, bounds, and how a configured value merges with the built-in default (`integer`\nreplaces it, `string-list` appends to it, `string-map` is spread over it).\n\n```js\nexport default {\n rules: {\n 'architecture/prop-count': { options: { max: 12 } }\n }\n};\n```\n\n## Scoping to routes or files (`overrides`)\n\n`rules` applies everywhere; `overrides` applies only where it matches \u2014 typically routes that\nare deliberately not public.\n\n```js\nexport default {\n overrides: [\n { files: 'src/routes/(app)/**', rules: { seo: 'off' } },\n { route: '/admin/**', rules: { 'seo/title-presence': 'info' } }\n ]\n};\n```\n\nEach entry needs `rules` (keys are rule ids **or** category names) plus at least one of:\n\n- **`route`** \u2014 glob(s) against the route id as reported (`/blog/[slug]`). SvelteKit `(group)`\n segments are **not** in the route id, so use `files` to target a group.\n- **`files`** \u2014 glob(s) against the source path.\n\nGlobs are deliberately small: `*` within a segment, `**` across segments, a trailing `/**` also\nmatches the bare prefix. Everything else \u2014 including `(`, `)`, `[`, `]` \u2014 is literal. Later\nentries win.\n\n## Precedence\n\nPer field: **CLI flag > config file > built-in default**. One exception \u2014 `--rules`/`--ignore`\nreplace the config file's `rules` wholesale for that run rather than merging.\n\n`overrides` has no CLI flag; route policy belongs in a committed file.\n\n## Validation\n\nAn unknown rule id, an unknown category or negative weight, a malformed `overrides` entry, or an\ninvalid rule setting is a **hard error (exit `2`)** \u2014 a typo must not silently un-gate CI. An\nunrecognized `treatDynamicAs`/`failOn` value or an unknown top-level key only warns.\n\n## Related\n\n- `svelte-vitals explain --list` \u2014 every rule id\n- `svelte-vitals docs show scoping` \u2014 accepting an existing backlog instead of disabling rules"
21
+ },
22
+ {
23
+ name: "monorepo",
24
+ title: "Monorepos",
25
+ description: "How svelte-vitals picks which SvelteKit app to analyze, why it exits 2 instead of prompting in a non-interactive shell, and how to name the app explicitly.",
26
+ body: '# Monorepos\n\n## Naming the app is always safest\n\n```bash\nnpx svelte-vitals@latest apps/web\n```\n\nAn explicit `path` \u2014 or running from inside the app directory \u2014 takes priority and skips\ndetection entirely. In a script, a hook, or an agent\'s shell, prefer this over relying on\ndetection.\n\n## What happens without a path\n\nWhen no `path` is given and the current directory is not itself a SvelteKit app, svelte-vitals\nlooks for nearby apps \u2014 a directory with `src/routes` **and** either a\n`svelte.config.{js,ts}` or a `package.json` declaring `@sveltejs/kit` (current `sv create`\noutput folds the SvelteKit config into `vite.config.ts` and emits no `svelte.config` file):\n\n| Found | Interactive terminal | Non-interactive (CI, agents, piped output) |\n| ----------- | ----------------------------------- | ---------------------------------------------------------- |\n| exactly one | analyzed, notice on stderr | same \u2014 analyzed, notice on stderr |\n| several | single-select prompt | **exit `2`** listing the apps, asking for an explicit path |\n| none | exit `2`, "not a SvelteKit project" | same |\n\n**svelte-vitals never prompts when stdout is not a TTY.** A non-interactive run with several apps\nfails fast with the list rather than hanging or guessing \u2014 if you hit exit `2` here, re-run with\nthe path it printed.\n\nCancelling the interactive prompt exits `0` without analyzing anything.\n\n## `install` in a monorepo\n\n`svelte-vitals install` splits its targets by where they belong:\n\n- `vite-plugin`, `vite-hooks`, `config-file` write into the **app** directory \u2014 they resolve it\n the same way the analyzer does, and `--app <dir>` names it explicitly.\n- the agent skills and `ci-workflow` always write at the **current** directory, because the repo\n root is their correct home.\n\n```bash\nnpx svelte-vitals@latest install --client vite-plugin,config-file --app apps/web --yes\n```\n\n`--app` pointing at a directory that is not a SvelteKit app is an error (exit `2`).\n\n## Related\n\n- `svelte-vitals docs show output` \u2014 what exit `2` means versus exit `1`'
27
+ },
28
+ {
29
+ name: "output",
30
+ title: "Reading the output",
31
+ description: "Which reporter to use, how one is auto-selected, what goes to stdout vs stderr, and what each exit code means.",
32
+ body: '# Reading the output\n\n## Pick a reporter\n\n`--reporter <fmt>`: `console` (default) \xB7 `json` \xB7 `agent` \xB7 `sarif` \xB7 `github` \xB7 `html` \xB7 `md`.\n\n- **`agent`** \u2014 a Markdown remediation document: every failing finding with its location, a\n concrete fix (with a code snippet), and an acceptance check. This is the one to use when\n something will act on the findings rather than read them.\n- **`json`** \u2014 the full structured report (per-route and site-wide scores, every finding with\n `fix`, `recommendation` and `docsUrl`). Use it when you need to filter or count.\n- **`console`** \u2014 for a human at a terminal. Grouped and capped; add `--verbose` for everything.\n- **`md`** \u2014 a compact summary table for a PR comment or job summary (capped at 50 rows).\n- **`sarif`** \u2014 SARIF v2.1, for GitHub Code Scanning and other SAST tooling.\n- **`github`** \u2014 `::error` / `::warning` workflow annotations.\n- **`html`** \u2014 a self-contained report file; `--out-file <path>`, or `--out-file -` for stdout.\n\n## Auto-selection\n\nFirst match wins:\n\n1. an explicit `--reporter <fmt>`\n2. `SVELTE_VITALS_REPORTER=<fmt>`\n3. a known AI-agent environment (e.g. `CLAUDECODE` is set) \u2192 `agent`\n4. `GITHUB_ACTIONS=true` \u2192 `github`\n5. otherwise \u2192 `console`\n\nSo inside an agent harness you usually get `agent` without asking. When it is auto-selected\nrather than requested, a one-line hint goes to stderr explaining how to override.\n\n## stdout vs stderr\n\nThe report goes to **stdout**. Diagnostics \u2014 auto-selection hints, suppression counts,\napp-detection notices, warnings, errors \u2014 go to **stderr**. Piping stdout is safe; you will not\nget diagnostics mixed into the report.\n\n`--reporter html` is the exception: it writes a file and prints the path to stderr, unless you\npass `--out-file -`.\n\n## Exit codes\n\n| Code | Meaning |\n| ---- | -------------------------------------------------------------------------- |\n| `0` | no failing findings |\n| `1` | a critical finding is present, or `--fail-on` / `--min-health` was reached |\n| `2` | execution error \u2014 not a SvelteKit project, bad flag, unreadable config |\n\n`1` means "the code has problems". `2` means "the run did not happen" \u2014 never treat `2` as a\nclean result. `--fail-on <critical|warning|info>` lowers the bar for `1`; `--min-health <0-100>`\nadds a score gate.\n\n## Related\n\n- `svelte-vitals docs show scoping` \u2014 report only what a change introduced\n- `svelte-vitals explain <rule-id>` \u2014 one rule\'s rationale, fix and options'
33
+ },
34
+ {
35
+ name: "scoping",
36
+ title: "Scoping findings to a change",
37
+ description: "Use --diff, --staged, --baseline and the suppressions file so only what a change introduced is reported, instead of a legacy backlog.",
38
+ body: "# Scoping findings to a change\n\nRunning svelte-vitals on an existing project usually surfaces a backlog nobody is about to fix.\nDo not disable rules to get a green run \u2014 scope the report instead.\n\n## Scope by file\n\n- **`--diff [ref]`** \u2014 only findings in files changed versus `ref` (default `HEAD`).\n- **`--staged`** \u2014 only findings in staged files. The pre-commit gate.\n\n```bash\nsvelte-vitals . --diff --reporter agent # after editing: what did I just break?\nsvelte-vitals . --staged # before committing\n```\n\nBoth work when the project is not at the git repo root.\n\n## Scope by finding (`--baseline <ref>`)\n\n`--baseline` reports only findings **not already present** at `ref`. It scopes by finding\nidentity rather than by file, so a pre-existing problem in a file you touched does not fail the\ngate \u2014 only what your change actually introduced does. There is no default ref.\n\n```bash\nsvelte-vitals --diff origin/main --baseline origin/main --fail-on warning # PR gate\n```\n\nInternally it checks `ref` out into a temporary git worktree and subtracts those findings. If\nthat fails (no git, bad ref), it warns and reports everything rather than failing the run.\n\n## Accept a backlog once (`svelte-vitals-suppressions.json`)\n\n`--baseline` handles the transient case. For a persistent ramp, record today's findings once:\n\n```bash\nsvelte-vitals --update-suppressions\ngit add svelte-vitals-suppressions.json\n```\n\n`--update-suppressions` analyzes the whole project (any `--diff`/`--staged`/`--baseline` scoping\nis ignored), writes every currently-penalized finding, prints a summary to stderr, and exits `0`\nwithout printing a report.\n\nOnce the file exists it applies automatically on every run, after `--diff`/`--staged` and\n`--baseline`, and reports how many findings it removed. Fixing an accepted finding leaves a\n**stale** entry \u2014 that is reported on stderr as a reminder to re-run `--update-suppressions`,\nbut never fails the run. `--no-suppressions` ignores the file for one run.\n\nA malformed suppressions file is a hard error (exit `2`), not a silent skip.\n\n## Which one\n\n| Situation | Use |\n| -------------------------------------- | ---------------------------------------- |\n| Checking an edit you just made | `--diff` |\n| Pre-commit hook | `--staged` |\n| PR gate against a base branch | `--diff <base> --baseline <base>` |\n| Adopting on a legacy project, for good | `--update-suppressions`, commit the file |\n\nMatching ignores line numbers in both `--baseline` and the suppressions file, so a second\nviolation of the same rule lower in the same file does not surface as new.\n\n## Related\n\n- `svelte-vitals docs show ci` \u2014 the generated PR gate already does the `--diff`/`--baseline` pairing\n- `svelte-vitals docs show config` \u2014 turning a rule off for good, when that is genuinely right"
39
+ }
40
+ ];
41
+
42
+ // src/docs/cli.ts
43
+ var DOCS_HELP = `svelte-vitals docs \u2014 read the bundled guides without leaving the terminal
44
+
45
+ Usage:
46
+ svelte-vitals docs list [--json] List every topic with a one-line description
47
+ svelte-vitals docs show <name> Print a topic
48
+
49
+ Options:
50
+ --json Machine-readable output (list only)
51
+ -h, --help Show this help
52
+
53
+ The topics ship inside the CLI, so they always match the version you are running and need no
54
+ network. The full docs site is at https://oekazuma.github.io/svelte-vitals.
55
+
56
+ \`docs\` is a subcommand, so it wins over a directory of the same name: to analyze a directory
57
+ called \`docs\`, write \`svelte-vitals ./docs\`.`;
58
+ function knownTopicNames() {
59
+ return EMBEDDED_DOCS.map((d) => d.name).join(", ");
60
+ }
61
+ function renderList() {
62
+ const width = Math.max(...EMBEDDED_DOCS.map((d) => d.name.length));
63
+ const lines = EMBEDDED_DOCS.map((d) => ` ${d.name.padEnd(width)} ${d.description}`);
64
+ return [
65
+ "Topics (read one with `svelte-vitals docs show <name>`):",
66
+ "",
67
+ ...lines,
68
+ "",
69
+ "Rule-level detail is a separate command: `svelte-vitals explain --list`."
70
+ ].join("\n");
71
+ }
72
+ function runDocsCli(args, io = consoleIO) {
73
+ const argv = mri(args, { boolean: ["json", "help"], alias: { h: "help" } });
74
+ const [sub, ...rest] = argv._;
75
+ if (argv.help) {
76
+ io.log(DOCS_HELP);
77
+ return 0;
78
+ }
79
+ if (sub === void 0) {
80
+ io.errorLog(DOCS_HELP);
81
+ return 2;
82
+ }
83
+ if (sub === "list") {
84
+ if (rest.length > 0) {
85
+ io.errorLog("svelte-vitals: docs list takes no arguments; use `docs show <name>` to read one.");
86
+ return 2;
87
+ }
88
+ io.log(
89
+ argv.json ? JSON.stringify(
90
+ EMBEDDED_DOCS.map((d) => ({ name: d.name, title: d.title, description: d.description })),
91
+ null,
92
+ 2
93
+ ) : renderList()
94
+ );
95
+ return 0;
96
+ }
97
+ if (sub === "show") {
98
+ if (rest.length !== 1) {
99
+ io.errorLog(
100
+ rest.length === 0 ? "svelte-vitals: docs show needs a topic name, e.g. `svelte-vitals docs show config`." : "svelte-vitals: docs show takes one topic at a time."
101
+ );
102
+ io.errorLog(`svelte-vitals: known topics: ${knownTopicNames()}.`);
103
+ return 2;
104
+ }
105
+ const doc = EMBEDDED_DOCS.find((d) => d.name === rest[0]);
106
+ if (!doc) {
107
+ io.errorLog(`svelte-vitals: unknown docs topic '${rest[0]}'.`);
108
+ io.errorLog(`svelte-vitals: known topics: ${knownTopicNames()}.`);
109
+ return 2;
110
+ }
111
+ io.log(doc.body);
112
+ return 0;
113
+ }
114
+ io.errorLog(`svelte-vitals: unknown docs subcommand '${sub}'; expected list|show.`);
115
+ io.errorLog(DOCS_HELP);
116
+ return 2;
117
+ }
118
+ export {
119
+ runDocsCli
120
+ };
package/dist/index.d.ts CHANGED
@@ -190,7 +190,7 @@ interface AnalyzeOptions {
190
190
  * the route/layout (head-resolution) parse path via `collectRoutes` —
191
191
  * `collectComponentFacts` (Correctness facts) is unaffected and still scans
192
192
  * every component on each call. Callers that don't need cross-call reuse
193
- * (the CLI's `run()`, MCP, the Action — each analyzes once per process) can
193
+ * (the CLI's `run()`, the Action — each analyzes once per process) can
194
194
  * omit this; a fresh cache is created automatically.
195
195
  */
196
196
  parseCache?: ParseCache;
@@ -207,7 +207,7 @@ interface AnalyzeResult {
207
207
  * Throws ProjectError when `cwd` is not a SvelteKit project. Also throws when a
208
208
  * `svelte-vitals.config.{mjs,js,ts}` file in `cwd` fails to load or fails
209
209
  * validation (unknown rule ids in `rules`, invalid `weights` entries) — see
210
- * `loadConfigFile`. Shared by the CLI's run() and by @svelte-vitals/mcp (issue #24).
210
+ * `loadConfigFile`. Shared by the CLI's run() and by embedding callers (issue #24).
211
211
  *
212
212
  * Config precedence is per field: an explicit option here wins, otherwise the
213
213
  * config file's value is used, otherwise the built-in default (design doc
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  ruleOptionsSpec,
12
12
  run,
13
13
  spinnerEnabled
14
- } from "./chunk-D6AUX2GC.js";
14
+ } from "./chunk-I2MKPWWT.js";
15
15
  export {
16
16
  ProjectError,
17
17
  analyzeProject,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "A SvelteKit SEO checker — not a runtime Web Vitals reporter. Static analysis of your routes' head metadata.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,10 +43,9 @@
43
43
  "log-update": "^8.0.0",
44
44
  "magicast": "^0.5.3",
45
45
  "mri": "^1.2.0",
46
- "smol-toml": "^1.7.1",
47
46
  "svelte": "^5.56.8",
48
47
  "tinyglobby": "^0.2.17",
49
- "@svelte-vitals/core": "0.31.0"
48
+ "@svelte-vitals/core": "0.31.1"
50
49
  },
51
50
  "devDependencies": {
52
51
  "@types/estree": "^1.0.9",
@@ -57,6 +56,7 @@
57
56
  "typecheck": "tsc --noEmit",
58
57
  "test": "vitest run",
59
58
  "gen:rules-index": "pnpm --filter @svelte-vitals/core build && node scripts/gen-rules-index.mjs",
60
- "update-action-pin": "node scripts/gen-action-pin.mjs"
59
+ "update-action-pin": "node scripts/gen-action-pin.mjs",
60
+ "gen:docs": "node scripts/gen-docs.mjs"
61
61
  }
62
62
  }