svelte-vitals 0.35.0 → 0.36.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,20 @@ 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 explain <rule-id>`
88
+
89
+ Prints one 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. `--json` emits the same data as an object. This is the per-rule detail an agent needs when it has to decide whether a finding is a defect or a threshold disagreement.
90
+
91
+ ```bash
92
+ npx svelte-vitals@latest explain performance/heavy-import
93
+ ```
94
+
87
95
  ### CI integration
88
96
 
89
97
  `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
@@ -12,7 +12,7 @@ import {
12
12
  } from "./chunk-D6AUX2GC.js";
13
13
 
14
14
  // src/bin.ts
15
- import mri3 from "mri";
15
+ import mri4 from "mri";
16
16
  import * as p2 from "@clack/prompts";
17
17
 
18
18
  // src/resolve-args.ts
@@ -179,96 +179,12 @@ function resolveArgs(argv) {
179
179
  // src/install/cli.ts
180
180
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
181
181
  import { dirname } from "path";
182
- import { homedir } from "os";
183
182
  import { spawnSync } from "child_process";
184
183
  import mri from "mri";
185
184
  import * as p from "@clack/prompts";
186
185
 
187
186
  // 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
- }
187
+ import { join as join3 } from "path";
272
188
 
273
189
  // src/install/vite-targets.ts
274
190
  var VITE_TARGETS = [
@@ -444,7 +360,7 @@ Use this whenever you are writing or reviewing SvelteKit route files (\`+page.sv
444
360
 
445
361
  1. After writing or editing code, run \`npx svelte-vitals . --diff --reporter agent\` and fix any findings it reports.
446
362
  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.
363
+ 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.
448
364
 
449
365
  ## Rule digest
450
366
 
@@ -535,9 +451,9 @@ Every svelte-vitals rule already carries a reviewer-written fix:
535
451
  \`recommendation\` (one line), and where applicable \`fix.description\` +
536
452
  \`fix.snippet\` (literal code to drop in). These are embedded verbatim in the
537
453
  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.
454
+ approximate from memory. For the full rationale behind a rule, run
455
+ \`npx svelte-vitals explain <rule-id>\` (it also names the rule's configurable
456
+ options) or open its docs link, also in the catalog below.
541
457
 
542
458
  ## Workflow
543
459
 
@@ -559,8 +475,7 @@ Get the machine map before applying judgment:
559
475
  findings even appear (see Hard Rule 5).
560
476
  - **Stack**: SvelteKit version, static/prerendered vs. SSR vs. adapter-node,
561
477
  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.
478
+ already wired up, whether the \`svelte-vitals\` skill is already installed.
564
479
  - **Verification commands**: read \`package.json\`'s \`scripts\` \u2014 do not assume
565
480
  a specific package manager; this project's build/typecheck/test/lint
566
481
  commands may differ from svelte-vitals' own repo.
@@ -786,7 +701,7 @@ adapted to this file \u2014 never approximated from memory.
786
701
  ## Tone
787
702
 
788
703
  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
704
+ look it up in the catalog above or via \`svelte-vitals explain\`. A short list of
790
705
  high-confidence, high-leverage plans beats a long padded one \u2014 "this route
791
706
  is already solid" is a valid audit result. Flag uncertainty honestly: when
792
707
  correctness can't be judged from static code alone (a race that depends on
@@ -824,7 +739,7 @@ ${options}
824
739
  }
825
740
 
826
741
  // src/install/config-file-format.ts
827
- import { join as join2 } from "path";
742
+ import { join } from "path";
828
743
  function nodeSupportsNativeTypeScript(version) {
829
744
  const match = /^v?(\d+)\.(\d+)/.exec(version);
830
745
  if (!match) return false;
@@ -833,10 +748,10 @@ function nodeSupportsNativeTypeScript(version) {
833
748
  return major > 23 || major === 23 && minor >= 6 || major === 22 && minor >= 18;
834
749
  }
835
750
  function findExistingConfigFile(readFile, cwd) {
836
- return CONFIG_FILENAMES.find((rel) => readFile(join2(cwd, rel)) !== void 0);
751
+ return CONFIG_FILENAMES.find((rel) => readFile(join(cwd, rel)) !== void 0);
837
752
  }
838
753
  function hasSvelteVitalsDependency(readFile, cwd) {
839
- const raw = readFile(join2(cwd, "package.json"));
754
+ const raw = readFile(join(cwd, "package.json"));
840
755
  if (raw === void 0) return false;
841
756
  try {
842
757
  const pkg = JSON.parse(raw);
@@ -846,7 +761,7 @@ function hasSvelteVitalsDependency(readFile, cwd) {
846
761
  }
847
762
  }
848
763
  function isEsmProject(readFile, cwd) {
849
- const raw = readFile(join2(cwd, "package.json"));
764
+ const raw = readFile(join(cwd, "package.json"));
850
765
  if (raw === void 0) return false;
851
766
  try {
852
767
  return JSON.parse(raw).type === "module";
@@ -856,7 +771,7 @@ function isEsmProject(readFile, cwd) {
856
771
  }
857
772
  function detectBestConfigExtension(opts) {
858
773
  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;
774
+ const looksTypeScript = opts.readFile(join(opts.cwd, "tsconfig.json")) !== void 0 || opts.readFile(join(opts.cwd, "vite.config.ts")) !== void 0;
860
775
  if (!looksTypeScript) return "mjs";
861
776
  return hasSvelteVitalsDependency(opts.readFile, opts.cwd) ? "ts" : "mjs";
862
777
  }
@@ -967,7 +882,7 @@ function codemodHooksServer(existing) {
967
882
  }
968
883
 
969
884
  // src/install/package-manager.ts
970
- import { join as join3 } from "path";
885
+ import { join as join2 } from "path";
971
886
  var LOCKFILE_TO_PM = {
972
887
  "pnpm-lock.yaml": "pnpm",
973
888
  "yarn.lock": "yarn",
@@ -977,7 +892,7 @@ var LOCKFILE_TO_PM = {
977
892
  };
978
893
  function detectPackageManagerFromLockfile(io) {
979
894
  for (const [file, pm] of Object.entries(LOCKFILE_TO_PM)) {
980
- if (io.readFile(join3(io.cwd, file)) !== void 0) return pm;
895
+ if (io.readFile(join2(io.cwd, file)) !== void 0) return pm;
981
896
  }
982
897
  return void 0;
983
898
  }
@@ -985,7 +900,7 @@ function detectPackageManager(io) {
985
900
  return detectPackageManagerFromLockfile(io) ?? "npm";
986
901
  }
987
902
  function hasVitePackage(io) {
988
- const raw = io.readFile(join3(io.cwd, "package.json"));
903
+ const raw = io.readFile(join2(io.cwd, "package.json"));
989
904
  if (raw === void 0) return false;
990
905
  try {
991
906
  const pkg = JSON.parse(raw);
@@ -999,7 +914,7 @@ function installCommand(pm) {
999
914
  return { command: pm, args: [action, "-D", "@svelte-vitals/vite"] };
1000
915
  }
1001
916
  function readInstalledViteVersion(io) {
1002
- const raw = io.readFile(join3(io.cwd, "node_modules/@svelte-vitals/vite/package.json"));
917
+ const raw = io.readFile(join2(io.cwd, "node_modules/@svelte-vitals/vite/package.json"));
1003
918
  if (raw === void 0) return void 0;
1004
919
  try {
1005
920
  return JSON.parse(raw).version;
@@ -1016,19 +931,13 @@ var ACTION_VERSION = "0.4.0";
1016
931
  function detectPackageManagerNear(io, appDir) {
1017
932
  return detectPackageManagerFromLockfile({ ...io, cwd: appDir }) ?? detectPackageManager(io);
1018
933
  }
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
934
  function resolveCandidate(io, baseDir, candidates) {
1026
935
  for (const rel of candidates) {
1027
- const path = join4(baseDir, rel);
936
+ const path = join3(baseDir, rel);
1028
937
  const content = io.readFile(path);
1029
938
  if (content !== void 0) return { path, content };
1030
939
  }
1031
- return { path: join4(baseDir, candidates[0]), content: void 0 };
940
+ return { path: join3(baseDir, candidates[0]), content: void 0 };
1032
941
  }
1033
942
  function planForVitePlugin(io, appDir) {
1034
943
  const { path, content } = resolveCandidate(io, appDir, ["vite.config.ts", "vite.config.js", "vite.config.mjs"]);
@@ -1057,7 +966,7 @@ function agentTargetContent(id, version) {
1057
966
  function planForAgentTarget(target, io, force, version) {
1058
967
  const content = agentTargetContent(target.id, version);
1059
968
  return target.relPaths.map((relPath) => {
1060
- const path = join4(io.cwd, relPath);
969
+ const path = join3(io.cwd, relPath);
1061
970
  const existing = io.readFile(path);
1062
971
  const status = existing === void 0 ? "created" : force ? "updated" : "exists";
1063
972
  return { id: target.id, label: target.label, path, status, content };
@@ -1066,7 +975,7 @@ function planForAgentTarget(target, io, force, version) {
1066
975
  function planForConfigTarget(target, io, force, appDir) {
1067
976
  const existingRel = findExistingConfigFile(io.readFile, appDir);
1068
977
  if (existingRel !== void 0) {
1069
- const path2 = join4(appDir, existingRel);
978
+ const path2 = join3(appDir, existingRel);
1070
979
  const status = force ? "updated" : "exists";
1071
980
  const content2 = force ? buildConfigFileTemplate({
1072
981
  useDefineConfig: existingRel.endsWith(".ts") && hasSvelteVitalsDependency(io.readFile, appDir),
@@ -1079,12 +988,12 @@ function planForConfigTarget(target, io, force, appDir) {
1079
988
  cwd: appDir,
1080
989
  nodeVersion: io.nodeVersion ?? process.version
1081
990
  });
1082
- const path = join4(appDir, `svelte-vitals.config.${ext}`);
991
+ const path = join3(appDir, `svelte-vitals.config.${ext}`);
1083
992
  const content = buildConfigFileTemplate({ useDefineConfig: ext === "ts" });
1084
993
  return { id: target.id, label: target.label, path, status: "created", content };
1085
994
  }
1086
995
  function planForCiTarget(target, io, force) {
1087
- const path = join4(io.cwd, target.relPath);
996
+ const path = join3(io.cwd, target.relPath);
1088
997
  const existing = io.readFile(path);
1089
998
  const plan = planWorkflowWrite(existing, force);
1090
999
  const content = plan.status === "exists" ? void 0 : buildWorkflowYaml({ actionSha: ACTION_SHA, actionVersion: ACTION_VERSION });
@@ -1094,7 +1003,7 @@ function indent(text) {
1094
1003
  return text.split("\n").map((l) => ` ${l}`).join("\n");
1095
1004
  }
1096
1005
  function rowLine(r) {
1097
- const head = ` ${r.label}${r.scope ? ` (${r.scope})` : ""} \u2192 ${r.path} [${r.status}]`;
1006
+ const head = ` ${r.label} \u2192 ${r.path} [${r.status}]`;
1098
1007
  return r.status === "manual" && r.snippet ? `${head}
1099
1008
  ${indent(r.snippet)}` : head;
1100
1009
  }
@@ -1104,7 +1013,7 @@ async function runRefresh(io, flags, version) {
1104
1013
  for (const target of AGENT_TARGETS) {
1105
1014
  const content = agentTargetContent(target.id, version);
1106
1015
  for (const relPath of target.relPaths) {
1107
- const path = join4(io.cwd, relPath);
1016
+ const path = join3(io.cwd, relPath);
1108
1017
  try {
1109
1018
  if (io.readFile(path) === void 0) continue;
1110
1019
  rows.push({ id: target.id, label: target.label, path, status: "updated", content });
@@ -1157,29 +1066,30 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1157
1066
  return false;
1158
1067
  }
1159
1068
  };
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
1069
  const viteConfigExists = ["vite.config.ts", "vite.config.js", "vite.config.mjs"].some(
1164
- (f) => configExists(join4(io.cwd, f))
1070
+ (f) => configExists(join3(io.cwd, f))
1165
1071
  );
1166
- const claudeSkillDetected = configExists(join4(io.cwd, ".claude", "settings.json"));
1167
- const cursorRulesDetected = configExists(join4(io.cwd, ".cursor", "mcp.json"));
1072
+ const claudeSkillDetected = configExists(join3(io.cwd, ".claude", "settings.json"));
1073
+ const cursorRulesDetected = [
1074
+ ".cursor/mcp.json",
1075
+ ".cursor/environment.json",
1076
+ ".cursorrules",
1077
+ ".cursorignore",
1078
+ agentTargetById("cursor-rules").relPaths[0]
1079
+ ].some((rel) => configExists(join3(io.cwd, rel)));
1168
1080
  const detectedAgents = [
1169
1081
  ...claudeSkillDetected ? ["claude-skill"] : [],
1170
1082
  ...cursorRulesDetected ? ["cursor-rules"] : []
1171
1083
  ];
1172
- const ciWorkflowDetected = configExists(join4(io.cwd, CI_TARGETS[0].relPath));
1084
+ const ciWorkflowDetected = configExists(join3(io.cwd, CI_TARGETS[0].relPath));
1173
1085
  const configFileDetected = findExistingConfigFile((p3) => configExists(p3) ? "" : void 0, io.cwd) !== void 0;
1174
1086
  const detected = [
1175
- ...detectedClients,
1176
1087
  ...viteConfigExists ? VITE_TARGETS.map((t) => t.id) : [],
1177
1088
  ...detectedAgents,
1178
1089
  ...ciWorkflowDetected ? CI_TARGETS.map((t) => t.id) : [],
1179
1090
  ...configFileDetected ? CONFIG_TARGETS.map((t) => t.id) : []
1180
1091
  ];
1181
1092
  const groups = {
1182
- "MCP server": CLIENTS.map((c) => ({ id: c.id, label: c.label })),
1183
1093
  "Vite integration": VITE_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint })),
1184
1094
  "Agent Skills & rules": AGENT_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint })),
1185
1095
  "CI (GitHub Actions)": CI_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint })),
@@ -1193,25 +1103,24 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1193
1103
  ids = picked;
1194
1104
  } else {
1195
1105
  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."
1106
+ "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
1107
  );
1198
1108
  return 2;
1199
1109
  }
1200
- const clients = ids.map(clientById).filter((c) => c !== void 0);
1201
1110
  const viteIds = ids.filter(isViteTargetId);
1202
1111
  const agentIds = ids.filter(isAgentTargetId);
1203
1112
  const configIds = ids.filter(isConfigTargetId);
1204
1113
  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.");
1114
+ if (viteIds.length === 0 && agentIds.length === 0 && configIds.length === 0 && ciIds.length === 0) {
1115
+ io.errorLog("svelte-vitals: no valid targets selected.");
1207
1116
  return 2;
1208
1117
  }
1209
1118
  const isSvelteKitApp = (dir) => {
1210
1119
  try {
1211
- if (io.readFile(join4(dir, "svelte.config.js")) !== void 0 || io.readFile(join4(dir, "svelte.config.ts")) !== void 0) {
1120
+ if (io.readFile(join3(dir, "svelte.config.js")) !== void 0 || io.readFile(join3(dir, "svelte.config.ts")) !== void 0) {
1212
1121
  return true;
1213
1122
  }
1214
- const pkgRaw = io.readFile(join4(dir, "package.json"));
1123
+ const pkgRaw = io.readFile(join3(dir, "package.json"));
1215
1124
  if (pkgRaw === void 0) return false;
1216
1125
  const pkg = JSON.parse(pkgRaw);
1217
1126
  return Boolean(pkg.dependencies?.["@sveltejs/kit"] ?? pkg.devDependencies?.["@sveltejs/kit"]);
@@ -1223,7 +1132,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1223
1132
  let appDir = io.cwd;
1224
1133
  if (needsApp) {
1225
1134
  if (flags.app) {
1226
- const candidate = join4(io.cwd, flags.app);
1135
+ const candidate = join3(io.cwd, flags.app);
1227
1136
  if (!isSvelteKitApp(candidate)) {
1228
1137
  io.errorLog(
1229
1138
  `svelte-vitals: --app '${flags.app}' is not a SvelteKit app (no svelte.config.{js,ts} or @sveltejs/kit dependency there).`
@@ -1235,7 +1144,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1235
1144
  const apps = await (io.discoverApps ?? discoverApps)(io.cwd);
1236
1145
  if (apps.length === 1) {
1237
1146
  io.errorLog(`svelte-vitals: detected SvelteKit app at ${apps[0]}; targeting it for the Vite/config targets.`);
1238
- appDir = join4(io.cwd, apps[0]);
1147
+ appDir = join3(io.cwd, apps[0]);
1239
1148
  } else if (apps.length > 1) {
1240
1149
  if (io.isTTY) {
1241
1150
  const picked = await prompts.selectApp(apps);
@@ -1243,7 +1152,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1243
1152
  io.log("Cancelled.");
1244
1153
  return 0;
1245
1154
  }
1246
- appDir = join4(io.cwd, picked);
1155
+ appDir = join3(io.cwd, picked);
1247
1156
  } else {
1248
1157
  io.errorLog(`svelte-vitals: multiple SvelteKit apps found: ${apps.join(", ")}.`);
1249
1158
  io.errorLog(`svelte-vitals: pass one with --app, e.g. \`svelte-vitals install --app ${apps[0]}\`.`);
@@ -1253,35 +1162,16 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1253
1162
  }
1254
1163
  }
1255
1164
  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
- }
1165
+ for (const viteId of viteIds) {
1272
1166
  try {
1273
- rows.push(planForClient(client, scope, io, flags.force ?? false));
1167
+ rows.push(viteId === "vite-plugin" ? planForVitePlugin(io, appDir) : planForViteHooks(io, appDir));
1274
1168
  } catch (err) {
1275
- const path = client.resolvePath(scope, io.cwd, io.home);
1276
1169
  io.errorLog(
1277
- `svelte-vitals: could not parse existing config at ${path}: ${err instanceof Error ? err.message : String(err)}`
1170
+ `svelte-vitals: could not check existing Vite target ${viteId}: ${err instanceof Error ? err.message : String(err)}`
1278
1171
  );
1279
1172
  return 2;
1280
1173
  }
1281
1174
  }
1282
- for (const viteId of viteIds) {
1283
- rows.push(viteId === "vite-plugin" ? planForVitePlugin(io, appDir) : planForViteHooks(io, appDir));
1284
- }
1285
1175
  for (const agentId of agentIds) {
1286
1176
  const target = agentTargetById(agentId);
1287
1177
  try {
@@ -1310,7 +1200,7 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
1310
1200
  rows.push(planForCiTarget(target, io, flags.force ?? false));
1311
1201
  } catch (err) {
1312
1202
  io.errorLog(
1313
- `svelte-vitals: could not check existing workflow at ${join4(io.cwd, target.relPath)}: ${err instanceof Error ? err.message : String(err)}`
1203
+ `svelte-vitals: could not check existing workflow at ${join3(io.cwd, target.relPath)}: ${err instanceof Error ? err.message : String(err)}`
1314
1204
  );
1315
1205
  return 2;
1316
1206
  }
@@ -1375,7 +1265,7 @@ ${indent(r.snippet ?? "")}`);
1375
1265
  }
1376
1266
  if (hadFailure) return 2;
1377
1267
  io.log("");
1378
- if (clients.length > 0) io.log("Restart your client to load the svelte-vitals MCP server.");
1268
+ if (agentIds.length > 0) io.log("Restart your agent (or start a new session) to pick up the generated skill.");
1379
1269
  if (viteWasWritten) io.log("Restart `vite dev` (or your build) to pick up the change.");
1380
1270
  io.log("Done.");
1381
1271
  return 0;
@@ -1383,7 +1273,6 @@ ${indent(r.snippet ?? "")}`);
1383
1273
 
1384
1274
  // src/install/args.ts
1385
1275
  var VALID_TARGETS = [
1386
- ...CLIENTS.map((c) => c.id),
1387
1276
  ...VITE_TARGETS.map((t) => t.id),
1388
1277
  ...AGENT_TARGETS.map((t) => t.id),
1389
1278
  ...CONFIG_TARGETS.map((t) => t.id),
@@ -1405,11 +1294,8 @@ function resolveInstallArgs(argv) {
1405
1294
  if (rawClients.length > 0 && client.length === 0) {
1406
1295
  errors.push(`svelte-vitals: no valid --client values; expected ${EXPECTED_TARGETS}.`);
1407
1296
  }
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.`);
1297
+ if (argv.scope !== void 0) {
1298
+ warnings.push("svelte-vitals: --scope is no longer used (all install targets are project-scoped). Ignoring.");
1413
1299
  }
1414
1300
  const app = typeof argv.app === "string" && argv.app.trim() !== "" ? argv.app.trim() : void 0;
1415
1301
  const refresh = Boolean(argv.refresh);
@@ -1417,13 +1303,12 @@ function resolveInstallArgs(argv) {
1417
1303
  errors.push("svelte-vitals: --refresh regenerates existing files and cannot be combined with --client.");
1418
1304
  }
1419
1305
  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.");
1306
+ if (refresh && (Boolean(argv.yes) || Boolean(argv.force) || app !== void 0)) {
1307
+ warnings.push("svelte-vitals: --yes, --force, and --app are ignored with --refresh.");
1422
1308
  }
1423
1309
  return {
1424
1310
  flags: {
1425
1311
  ...client.length > 0 ? { client } : {},
1426
- ...scope ? { scope } : {},
1427
1312
  ...app !== void 0 && !refresh ? { app } : {},
1428
1313
  yes: Boolean(argv.yes),
1429
1314
  dryRun: Boolean(argv["dry-run"]),
@@ -1436,15 +1321,15 @@ function resolveInstallArgs(argv) {
1436
1321
  }
1437
1322
 
1438
1323
  // 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
1324
+ var INSTALL_HELP = `svelte-vitals install \u2014 set up the svelte-vitals Vite integration, agent skills/rules, config file, and CI
1440
1325
 
1441
1326
  Usage:
1442
1327
  svelte-vitals install [options]
1443
1328
 
1444
1329
  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
1330
+ --client <ids> Comma-separated: vite-plugin,vite-hooks,claude-skill,cursor-rules,claude-skill-improve,config-file,ci-workflow
1446
1331
  (skips the interactive picker; the picker groups these by category \u2014
1447
- MCP server, Vite integration, Agent Skills & rules, CI, Config file)
1332
+ Vite integration, Agent Skills & rules, CI, Config file)
1448
1333
  vite-plugin registers the build-mode plugin in vite.config.{ts,js,mjs}; vite-hooks
1449
1334
  wires up the svelteVitalsHandle hook in src/hooks.server.{ts,js}, which improves the
1450
1335
  live dashboard's per-route accuracy as you browse. --force does not apply
@@ -1467,13 +1352,12 @@ Options:
1467
1352
  \`svelte-vitals ci install\` writes standalone \u2014 pick it here to set it up in
1468
1353
  the same pass as everything else; supports --force to regenerate. \`svelte-vitals
1469
1354
  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
1355
  --app <dir> Monorepo: the SvelteKit app directory the vite-plugin/vite-hooks/config-file
1472
1356
  targets write into (e.g. --app apps/web). Without it, when the current
1473
1357
  directory isn't itself a SvelteKit app, one detected app is used
1474
1358
  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
1359
+ non-interactive runs exit 2 asking for --app. All other targets
1360
+ (skills, ci-workflow) always write at the current directory \u2014
1477
1361
  the repo root is their correct home.
1478
1362
  --yes, -y Skip the confirmation prompt
1479
1363
  --dry-run Print the planned changes and exit without writing
@@ -1497,7 +1381,6 @@ function realIO() {
1497
1381
  writeFileSync(path, content);
1498
1382
  },
1499
1383
  cwd: process.cwd(),
1500
- home: homedir(),
1501
1384
  isTTY: Boolean(process.stdout.isTTY),
1502
1385
  nodeVersion: process.version,
1503
1386
  log: (line) => console.log(line),
@@ -1537,14 +1420,6 @@ function clackPrompts() {
1537
1420
  });
1538
1421
  return p.isCancel(res) ? null : res;
1539
1422
  },
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
1423
  selectApp: async (apps) => {
1549
1424
  const res = await p.select({
1550
1425
  message: "Multiple SvelteKit apps found \u2014 which one should the Vite/config targets go into?",
@@ -1563,6 +1438,8 @@ ${planText}` });
1563
1438
  async function runInstallCli(args) {
1564
1439
  const argv = mri(args, {
1565
1440
  boolean: ["yes", "dry-run", "force", "refresh", "help"],
1441
+ // `scope` is still declared although the flag is gone: it keeps `--scope global` from
1442
+ // parsing its value as a positional, so resolveInstallArgs can warn and carry on.
1566
1443
  string: ["client", "scope", "app"],
1567
1444
  alias: { y: "yes", h: "help" }
1568
1445
  });
@@ -1578,7 +1455,7 @@ async function runInstallCli(args) {
1578
1455
  }
1579
1456
 
1580
1457
  // src/ci/cli.ts
1581
- import { join as join5 } from "path";
1458
+ import { join as join4 } from "path";
1582
1459
  import mri2 from "mri";
1583
1460
 
1584
1461
  // src/ci/upgrade.ts
@@ -1656,7 +1533,7 @@ async function runCiCli(args, io = realIO()) {
1656
1533
  io.log(CI_HELP);
1657
1534
  return 0;
1658
1535
  }
1659
- const path = join5(io.cwd, WORKFLOW_PATH);
1536
+ const path = join4(io.cwd, WORKFLOW_PATH);
1660
1537
  const existing = io.readFile(path);
1661
1538
  const plan = planWorkflowWrite(existing, Boolean(argv.force));
1662
1539
  io.log("Plan:");
@@ -1690,7 +1567,7 @@ async function runCiUpgrade(args, io) {
1690
1567
  io.log(CI_HELP);
1691
1568
  return 0;
1692
1569
  }
1693
- const path = join5(io.cwd, WORKFLOW_PATH);
1570
+ const path = join4(io.cwd, WORKFLOW_PATH);
1694
1571
  const existing = io.readFile(path);
1695
1572
  if (existing === void 0) {
1696
1573
  io.errorLog(`svelte-vitals: no ${WORKFLOW_PATH} found \u2014 run \`svelte-vitals ci install\` first.`);
@@ -1720,12 +1597,76 @@ async function runCiUpgrade(args, io) {
1720
1597
  return 0;
1721
1598
  }
1722
1599
 
1600
+ // src/explain.ts
1601
+ import mri3 from "mri";
1602
+ import { explainRule } from "@svelte-vitals/core";
1603
+ var EXPLAIN_HELP = `svelte-vitals explain \u2014 print a rule's rationale, fix, and configurable options
1604
+
1605
+ Usage:
1606
+ svelte-vitals explain <rule-id>
1607
+
1608
+ Options:
1609
+ --json Print the rule metadata as JSON instead of text
1610
+ -h, --help Show this help
1611
+
1612
+ Rule ids are category/kebab-case and matched exactly, e.g. \`svelte-vitals explain seo/ssr-disabled\`.`;
1613
+ var realIO2 = {
1614
+ log: (line) => console.log(line),
1615
+ errorLog: (line) => console.error(line)
1616
+ };
1617
+ function describeOptions(id, options) {
1618
+ const MERGE = {
1619
+ integer: "replaces the default",
1620
+ "string-list": "added to the default entries, never replaces them",
1621
+ "string-map": "merged over the default entries \u2014 a new key is added, a built-in key has its value overridden"
1622
+ };
1623
+ const lines = options.map((o) => {
1624
+ const bounds = [o.min !== void 0 ? `>= ${o.min}` : "", o.max !== void 0 ? `<= ${o.max}` : ""].filter(Boolean).join(", ");
1625
+ return `- ${o.name} (${o.kind}, default ${JSON.stringify(o.default)}${bounds ? `, ${bounds}` : ""}) \u2014 ${MERGE[o.kind]}`;
1626
+ });
1627
+ return `set in svelte-vitals.config.* as \`rules: { '${id}': { options: { \u2026 } } }\`, or per path in \`overrides\`:
1628
+ ${lines.join("\n")}`;
1629
+ }
1630
+ function formatRuleExplanation(info) {
1631
+ return `${info.id} \u2014 ${info.title} (${info.severity}, ${info.category})
1632
+
1633
+ ${info.rationale}
1634
+
1635
+ Docs: ${info.docsUrl}` + (info.fix ? `
1636
+
1637
+ Fix: ${info.fix.description}` : "") + (info.options ? `
1638
+
1639
+ Configurable: ${describeOptions(info.id, info.options)}` : "");
1640
+ }
1641
+ function runExplainCli(args, io = realIO2) {
1642
+ const argv = mri3(args, { boolean: ["json", "help"], alias: { h: "help" } });
1643
+ if (argv.help) {
1644
+ io.log(EXPLAIN_HELP);
1645
+ return 0;
1646
+ }
1647
+ const id = argv._[0];
1648
+ if (id === void 0) {
1649
+ io.errorLog("svelte-vitals: explain needs a rule id, e.g. `svelte-vitals explain seo/ssr-disabled`.");
1650
+ io.errorLog(`svelte-vitals: known rule ids: ${knownRuleIds().join(", ")}.`);
1651
+ return 2;
1652
+ }
1653
+ const info = explainRule(String(id));
1654
+ if (!info) {
1655
+ io.errorLog(`svelte-vitals: unknown rule id '${id}'.`);
1656
+ io.errorLog(`svelte-vitals: known rule ids: ${knownRuleIds().join(", ")}.`);
1657
+ return 2;
1658
+ }
1659
+ io.log(argv.json ? JSON.stringify(info, null, 2) : formatRuleExplanation(info));
1660
+ return 0;
1661
+ }
1662
+
1723
1663
  // src/bin.ts
1724
1664
  var HELP = `svelte-vitals \u2014 a deterministic SvelteKit code-health scanner (SEO \xB7 performance \xB7 correctness \xB7 security \xB7 architecture)
1725
1665
 
1726
1666
  Usage:
1727
1667
  svelte-vitals [path] [options]
1728
- svelte-vitals install Set up the MCP server, Vite integration, or agent skills/rules
1668
+ svelte-vitals explain <id> Print a rule's rationale, fix, and configurable options
1669
+ svelte-vitals install Set up the Vite integration, agent skills/rules, config file, or CI
1729
1670
  svelte-vitals ci install Add a GitHub Actions PR gate (annotations + summary comment)
1730
1671
  svelte-vitals ci upgrade Refresh the pinned @svelte-vitals/action in an existing workflow
1731
1672
 
@@ -1771,6 +1712,9 @@ async function selectApp(apps) {
1771
1712
  }
1772
1713
  async function main() {
1773
1714
  const rawArgs = process.argv.slice(2);
1715
+ if (rawArgs[0] === "explain") {
1716
+ process.exit(runExplainCli(rawArgs.slice(1)));
1717
+ }
1774
1718
  if (rawArgs[0] === "install") {
1775
1719
  const code2 = await runInstallCli(rawArgs.slice(1));
1776
1720
  process.exit(code2);
@@ -1779,7 +1723,7 @@ async function main() {
1779
1723
  const code2 = await runCiCli(rawArgs.slice(1));
1780
1724
  process.exit(code2);
1781
1725
  }
1782
- const argv = mri3(process.argv.slice(2), {
1726
+ const argv = mri4(process.argv.slice(2), {
1783
1727
  alias: { h: "help", v: "version" },
1784
1728
  boolean: ["by-route", "staged", "score", "verbose", "update-suppressions"],
1785
1729
  string: [
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.35.0",
3
+ "version": "0.36.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",