rulesync 12.0.0 → 14.0.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/dist/cli/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as removeFile, A as CLAUDECODE_SKILLS_DIR_PATH, At as RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH, B as ErrorCodes, C as RulesyncHooks, Ct as RULESYNC_PERMISSIONS_FILE_NAME, D as CLAUDECODE_LOCAL_RULE_FILE_NAME, Dt as RULESYNC_RELATIVE_DIR_PATH, E as CLAUDECODE_DIR, Et as RULESYNC_PERMISSIONS_SCHEMA_URL, F as ConfigResolver, G as fileExists, H as createTempDirectory, I as findControlCharacter, J as getHomeDirectory, K as findFilesByGlobs, L as ConsoleLogger, M as RulesyncCommandFrontmatterSchema, Mt as formatError, N as stringifyFrontmatter, Nt as ALL_FEATURES, O as CLAUDECODE_MEMORIES_DIR_NAME, Ot as RULESYNC_RULES_RELATIVE_DIR_PATH, P as loadYaml, Pt as ALL_FEATURES_WITH_WILDCARD, Q as removeDirectory, R as JsonLogger, S as HooksProcessor, St as RULESYNC_OVERVIEW_FILE_NAME, T as CODEXCLI_DIR, Tt as RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, U as directoryExists, V as checkPathTraversal, W as ensureDir, X as listDirectoryFiles, Y as isSymlink, Z as readFileContent, _ as RulesyncPermissions, _t as RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH, a as convertFromTool, at as ToolTargetSchema, b as IgnoreProcessor, bt as RULESYNC_MCP_RELATIVE_FILE_PATH, c as RulesyncRuleFrontmatterSchema, ct as RULESYNC_AIIGNORE_RELATIVE_FILE_PATH, d as RulesyncSubagentFrontmatterSchema, dt as RULESYNC_CONFIG_SCHEMA_URL, et as removeTempDirectory, f as SkillsProcessor, ft as RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH, g as SKILL_FILE_NAME$1, gt as RULESYNC_IGNORE_RELATIVE_FILE_PATH, h as RulesyncSkillFrontmatterSchema, ht as RULESYNC_HOOKS_RELATIVE_FILE_PATH, i as getProcessorRegistryEntry, it as ALL_TOOL_TARGETS_WITH_WILDCARD, j as RulesyncCommand, jt as RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, k as CLAUDECODE_SETTINGS_LOCAL_FILE_NAME, kt as RULESYNC_SKILLS_RELATIVE_DIR_PATH, l as SubagentsProcessor, lt as RULESYNC_COMMANDS_RELATIVE_DIR_PATH, m as RulesyncSkill, mt as RULESYNC_HOOKS_JSONC_FILE_NAME, n as checkRulesyncDirExists, nt as writeFileContent, o as RulesProcessor, ot as MAX_FILE_SIZE, p as getLocalSkillDirNames, pt as RULESYNC_HOOKS_FILE_NAME, q as getFileSize, r as generate, rt as ALL_TOOL_TARGETS, s as RulesyncRule, st as RULESYNC_AIIGNORE_FILE_NAME, t as importFromTool, tt as toPosixPath, u as RulesyncSubagent, ut as RULESYNC_CONFIG_RELATIVE_FILE_PATH, v as McpProcessor, vt as RULESYNC_MCP_FILE_NAME, w as CommandsProcessor, wt as RULESYNC_PERMISSIONS_JSONC_FILE_NAME, x as RulesyncIgnore, xt as RULESYNC_MCP_SCHEMA_URL, y as RulesyncMcp, yt as RULESYNC_MCP_JSONC_FILE_NAME, z as CLIError } from "../import-hM4jDZn1.js";
2
+ import { $ as isSymlink, A as CLAUDECODE_SETTINGS_LOCAL_FILE_NAME, At as RULESYNC_PERMISSIONS_JSONC_FILE_NAME, B as findControlCharacter, Bt as ALL_FEATURES_WITH_WILDCARD, C as CommandsProcessor, Ct as RULESYNC_MCP_FILE_NAME, D as CLAUDECODE_DIR, Dt as RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH, E as CODEXCLI_DIR, Et as RULESYNC_MCP_SCHEMA_URL, F as RulesyncCheck, Ft as RULESYNC_SKILLS_RELATIVE_DIR_PATH, G as checkPathTraversal, H as JsonLogger, I as RulesyncCheckFrontmatterSchema, It as RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH, J as ensureDir, K as createTempDirectory, L as stringifyFrontmatter, Lt as RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, M as RulesyncCommand, Mt as RULESYNC_PERMISSIONS_SCHEMA_URL, N as RulesyncCommandFrontmatterSchema, Nt as RULESYNC_RELATIVE_DIR_PATH, O as CLAUDECODE_LOCAL_RULE_FILE_NAME, Ot as RULESYNC_OVERVIEW_FILE_NAME, P as ChecksProcessor, Pt as RULESYNC_RULES_RELATIVE_DIR_PATH, Q as getHomeDirectory, R as loadYaml, Rt as formatError, S as RulesyncHooks, St as RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH, T as CODEXCLI_BASH_RULES_FILE_NAME, Tt as RULESYNC_MCP_RELATIVE_FILE_PATH, U as CLIError, V as ConsoleLogger, W as ErrorCodes, X as findFilesByGlobs, Y as fileExists, Z as getFileSize, _ as McpProcessor, _t as RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH, a as convertFromTool, at as toPosixPath, b as RulesyncIgnore, bt as RULESYNC_HOOKS_RELATIVE_FILE_PATH, c as RulesyncRuleFrontmatterSchema, ct as ALL_TOOL_TARGETS_WITH_WILDCARD, d as RulesyncSubagentFrontmatterSchema, dt as RULESYNC_AIIGNORE_FILE_NAME, et as listDirectoryFiles, f as SkillsProcessor, ft as RULESYNC_AIIGNORE_RELATIVE_FILE_PATH, g as RulesyncPermissions, gt as RULESYNC_CONFIG_SCHEMA_URL, h as RulesyncSkillFrontmatterSchema, ht as RULESYNC_CONFIG_RELATIVE_FILE_PATH, i as getProcessorRegistryEntry, it as removeTempDirectory, j as CLAUDECODE_SKILLS_DIR_PATH, jt as RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, k as CLAUDECODE_MEMORIES_DIR_NAME, kt as RULESYNC_PERMISSIONS_FILE_NAME, l as SubagentsProcessor, lt as ToolTargetSchema, m as RulesyncSkill, mt as RULESYNC_COMMANDS_RELATIVE_DIR_PATH, n as checkRulesyncDirExists, nt as removeDirectory, o as RulesProcessor, ot as writeFileContent, p as getLocalSkillDirNames, pt as RULESYNC_CHECKS_RELATIVE_DIR_PATH, q as directoryExists, r as generate, rt as removeFile, s as RulesyncRule, st as ALL_TOOL_TARGETS, t as importFromTool, tt as readFileContent, u as RulesyncSubagent, ut as MAX_FILE_SIZE, v as RulesyncMcp, vt as RULESYNC_HOOKS_FILE_NAME, w as SKILL_FILE_NAME$1, wt as RULESYNC_MCP_JSONC_FILE_NAME, x as HooksProcessor, xt as RULESYNC_IGNORE_RELATIVE_FILE_PATH, y as IgnoreProcessor, yt as RULESYNC_HOOKS_JSONC_FILE_NAME, z as ConfigResolver, zt as ALL_FEATURES } from "../import-CSnC5vl7.js";
3
3
  import { Command } from "commander";
4
4
  import { nonnegative, optional, refine, z } from "zod/mini";
5
5
  import * as path$1 from "node:path";
@@ -7,12 +7,13 @@ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } fr
7
7
  import * as os$1 from "node:os";
8
8
  import { promisify } from "node:util";
9
9
  import { dump } from "js-yaml";
10
+ import * as crypto from "node:crypto";
11
+ import { createHash } from "node:crypto";
10
12
  import { Semaphore } from "es-toolkit/promise";
11
13
  import { RequestError } from "@octokit/request-error";
12
14
  import { Octokit } from "@octokit/rest";
13
- import * as crypto from "node:crypto";
14
- import { createHash } from "node:crypto";
15
15
  import { execFile } from "node:child_process";
16
+ import { gunzipSync } from "node:zlib";
16
17
  import { FastMCP } from "fastmcp";
17
18
  import * as fs from "node:fs";
18
19
  import { Readable, Transform } from "node:stream";
@@ -34,7 +35,7 @@ const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim())
34
35
  * Calculate the total count from a result object
35
36
  */
36
37
  function calculateTotalCount(result) {
37
- return result.rulesCount + result.ignoreCount + result.mcpCount + result.commandsCount + result.subagentsCount + result.skillsCount + result.hooksCount + result.permissionsCount;
38
+ return result.rulesCount + result.ignoreCount + result.mcpCount + result.commandsCount + result.subagentsCount + result.skillsCount + result.hooksCount + result.permissionsCount + result.checksCount;
38
39
  }
39
40
  //#endregion
40
41
  //#region src/cli/commands/convert.ts
@@ -80,7 +81,8 @@ async function convertCommand(logger, options) {
80
81
  subagents: { count: result.subagentsCount },
81
82
  skills: { count: result.skillsCount },
82
83
  hooks: { count: result.hooksCount },
83
- permissions: { count: result.permissionsCount }
84
+ permissions: { count: result.permissionsCount },
85
+ checks: { count: result.checksCount }
84
86
  });
85
87
  logger.captureData("totalFiles", totalConverted);
86
88
  }
@@ -93,6 +95,7 @@ async function convertCommand(logger, options) {
93
95
  if (result.skillsCount > 0) parts.push(`${result.skillsCount} skills`);
94
96
  if (result.hooksCount > 0) parts.push(`${result.hooksCount} hooks`);
95
97
  if (result.permissionsCount > 0) parts.push(`${result.permissionsCount} permissions`);
98
+ if (result.checksCount > 0) parts.push(`${result.checksCount} checks`);
96
99
  const summary = `${modePrefix}${isPreview ? "Would convert" : "Converted"} ${totalConverted} file(s) total from ${fromTool} to ${toTools.join(", ")} (${parts.join(" + ")})`;
97
100
  if (isPreview) logger.info(summary);
98
101
  else logger.success(summary);
@@ -539,6 +542,7 @@ const FEATURE_PATHS = {
539
542
  commands: ["commands"],
540
543
  subagents: ["subagents"],
541
544
  skills: ["skills"],
545
+ checks: ["checks"],
542
546
  ignore: [RULESYNC_AIIGNORE_FILE_NAME],
543
547
  mcp: [RULESYNC_MCP_FILE_NAME, RULESYNC_MCP_JSONC_FILE_NAME],
544
548
  hooks: [RULESYNC_HOOKS_FILE_NAME, RULESYNC_HOOKS_JSONC_FILE_NAME],
@@ -624,6 +628,16 @@ async function convertFetchedFilesToRulesync(params) {
624
628
  logger
625
629
  })
626
630
  },
631
+ {
632
+ feature: "checks",
633
+ getTargets: () => ChecksProcessor.getToolTargets({ global: false }),
634
+ createProcessor: () => new ChecksProcessor({
635
+ outputRoot: tempDir,
636
+ toolTarget: target,
637
+ global: false,
638
+ logger
639
+ })
640
+ },
627
641
  {
628
642
  feature: "ignore",
629
643
  getTargets: () => IgnoreProcessor.getToolTargets(),
@@ -947,6 +961,10 @@ function getToolPathMapping(target) {
947
961
  const factory = SkillsProcessor.getFactory(target);
948
962
  if (factory) mapping.skills = factory.class.getSettablePaths({ global: false }).relativeDirPath;
949
963
  }
964
+ if (ChecksProcessor.getToolTargets({ global: false }).includes(target)) {
965
+ const factory = ChecksProcessor.getFactory(target);
966
+ if (factory) mapping.checks = factory.class.getSettablePaths({ global: false }).relativeDirPath;
967
+ }
950
968
  return mapping;
951
969
  }
952
970
  /**
@@ -970,6 +988,10 @@ function mapToToolPath(relativePath, toolPaths) {
970
988
  const restPath = relativePath.substring(7);
971
989
  if (toolPaths.skills) return join(toolPaths.skills, restPath);
972
990
  }
991
+ if (relativePath.startsWith("checks/")) {
992
+ const restPath = relativePath.substring(7);
993
+ if (toolPaths.checks) return join(toolPaths.checks, restPath);
994
+ }
973
995
  return relativePath;
974
996
  }
975
997
  /**
@@ -1045,6 +1067,7 @@ const FEATURE_DEBUG_MESSAGES = {
1045
1067
  subagents: "Generating subagent files...",
1046
1068
  skills: "Generating skill files...",
1047
1069
  hooks: "Generating hooks...",
1070
+ checks: "Generating check files...",
1048
1071
  rules: "Generating rule files..."
1049
1072
  };
1050
1073
  const FEATURE_DEBUG_ORDER = [
@@ -1054,6 +1077,7 @@ const FEATURE_DEBUG_ORDER = [
1054
1077
  "subagents",
1055
1078
  "skills",
1056
1079
  "hooks",
1080
+ "checks",
1057
1081
  "rules"
1058
1082
  ];
1059
1083
  function logFeatureDebugMessages(logger, features) {
@@ -1097,6 +1121,10 @@ function buildSummaryParts(result) {
1097
1121
  {
1098
1122
  count: result.permissionsCount,
1099
1123
  label: "permissions"
1124
+ },
1125
+ {
1126
+ count: result.checksCount,
1127
+ label: "checks"
1100
1128
  }
1101
1129
  ];
1102
1130
  const parts = [];
@@ -1147,6 +1175,10 @@ async function generateCommand(logger, options) {
1147
1175
  count: result.permissionsCount,
1148
1176
  paths: result.permissionsPaths
1149
1177
  },
1178
+ checks: {
1179
+ count: result.checksCount,
1180
+ paths: result.checksPaths
1181
+ },
1150
1182
  rules: {
1151
1183
  count: result.rulesCount,
1152
1184
  paths: result.rulesPaths
@@ -1160,7 +1192,8 @@ async function generateCommand(logger, options) {
1160
1192
  subagents: (count) => `${count === 1 ? "subagent" : "subagents"}`,
1161
1193
  skills: (count) => `${count === 1 ? "skill" : "skills"}`,
1162
1194
  hooks: (count) => `${count === 1 ? "hooks file" : "hooks files"}`,
1163
- permissions: (count) => `${count === 1 ? "permissions file" : "permissions files"}`
1195
+ permissions: (count) => `${count === 1 ? "permissions file" : "permissions files"}`,
1196
+ checks: (count) => `${count === 1 ? "check" : "checks"}`
1164
1197
  };
1165
1198
  for (const [feature, data] of Object.entries(featureResults)) logFeatureResult(logger, {
1166
1199
  count: data.count,
@@ -1208,6 +1241,7 @@ const DERIVED_PATHS_NOT_GITIGNORED = /* @__PURE__ */ new Set([
1208
1241
  "**/.grok/config.toml",
1209
1242
  "**/.vibe/config.toml",
1210
1243
  "**/reasonix.toml",
1244
+ "**/.vscode/settings.json",
1211
1245
  "**/.zed/settings.json",
1212
1246
  "**/kilo.json",
1213
1247
  "**/kilo.jsonc",
@@ -1261,13 +1295,16 @@ const deriveRulesEntries = () => {
1261
1295
  for (const root of [paths.root, ...paths.alternativeRoots ?? []]) if (root) pushEntry(entries, target, "rules", fileToGlob(root.relativeDirPath, root.relativeFilePath));
1262
1296
  const nonRootDir = paths.nonRoot?.relativeDirPath;
1263
1297
  if (nonRootDir && nonRootDir !== ".") pushEntry(entries, target, "rules", dirToGlob(nonRootDir));
1298
+ const classWithExtraFiles = factory.class;
1299
+ if (classWithExtraFiles.getExtraFixedFiles) for (const file of classWithExtraFiles.getExtraFixedFiles({ global: false })) pushEntry(entries, target, "rules", fileToGlob(file.relativeDirPath, file.relativeFilePath));
1264
1300
  }
1265
1301
  return entries;
1266
1302
  };
1267
1303
  const DIR_FEATURES = /* @__PURE__ */ new Set([
1268
1304
  "commands",
1269
1305
  "skills",
1270
- "subagents"
1306
+ "subagents",
1307
+ "checks"
1271
1308
  ]);
1272
1309
  const FILE_FEATURES = /* @__PURE__ */ new Set([
1273
1310
  "mcp",
@@ -1290,7 +1327,8 @@ const DERIVED_FEATURES = [
1290
1327
  "mcp",
1291
1328
  "hooks",
1292
1329
  "permissions",
1293
- "ignore"
1330
+ "ignore",
1331
+ "checks"
1294
1332
  ];
1295
1333
  const deriveAllGitignoreEntriesUnfiltered = () => DERIVED_FEATURES.flatMap((feature) => deriveFeatureGitignoreEntries(feature));
1296
1334
  const deriveAllGitignoreEntries = () => deriveAllGitignoreEntriesUnfiltered().filter((tag) => !DERIVED_PATHS_NOT_GITIGNORED.has(tag.entry));
@@ -1381,6 +1419,16 @@ const GITIGNORE_ENTRY_REGISTRY = [
1381
1419
  feature: "rules",
1382
1420
  entry: "**/.augment-guidelines"
1383
1421
  },
1422
+ {
1423
+ target: "devin",
1424
+ feature: "commands",
1425
+ entry: "**/.devin/workflows/"
1426
+ },
1427
+ {
1428
+ target: "junie",
1429
+ feature: "rules",
1430
+ entry: "**/.junie/memories/"
1431
+ },
1384
1432
  {
1385
1433
  target: "rovodev",
1386
1434
  feature: "skills",
@@ -1429,7 +1477,7 @@ const GITIGNORE_ENTRY_REGISTRY = [
1429
1477
  {
1430
1478
  target: "codexcli",
1431
1479
  feature: "permissions",
1432
- entry: `**/${CODEXCLI_DIR}/rules/`
1480
+ entry: `**/${CODEXCLI_DIR}/rules/${CODEXCLI_BASH_RULES_FILE_NAME}`
1433
1481
  }
1434
1482
  ],
1435
1483
  ...deriveAllGitignoreEntries(),
@@ -1738,7 +1786,8 @@ async function importCommand(logger, options) {
1738
1786
  subagents: { count: result.subagentsCount },
1739
1787
  skills: { count: result.skillsCount },
1740
1788
  hooks: { count: result.hooksCount },
1741
- permissions: { count: result.permissionsCount }
1789
+ permissions: { count: result.permissionsCount },
1790
+ checks: { count: result.checksCount }
1742
1791
  });
1743
1792
  logger.captureData("totalFiles", totalImported);
1744
1793
  }
@@ -1751,6 +1800,7 @@ async function importCommand(logger, options) {
1751
1800
  if (result.skillsCount > 0) parts.push(`${result.skillsCount} skills`);
1752
1801
  if (result.hooksCount > 0) parts.push(`${result.hooksCount} hooks`);
1753
1802
  if (result.permissionsCount > 0) parts.push(`${result.permissionsCount} permissions`);
1803
+ if (result.checksCount > 0) parts.push(`${result.checksCount} checks`);
1754
1804
  logger.success(`Imported ${totalImported} file(s) total (${parts.join(" + ")})`);
1755
1805
  }
1756
1806
  //#endregion
@@ -3498,6 +3548,516 @@ async function walkDirectory(dir, outputRoot, depth = 0, ctx = {
3498
3548
  }
3499
3549
  return results;
3500
3550
  }
3551
+ const DEFAULT_NPM_TOKEN_ENV = "NPM_TOKEN";
3552
+ /** Abbreviated packument media type (install metadata only). */
3553
+ const PACKUMENT_ACCEPT_HEADER = "application/vnd.npm.install-v1+json";
3554
+ /** Timeout for registry HTTP requests (60 seconds). */
3555
+ const NPM_FETCH_TIMEOUT_MS = 6e4;
3556
+ /** Maximum accepted tarball size (compressed), aligned with the extraction cap. */
3557
+ const MAX_TARBALL_SIZE = 100 * 1024 * 1024;
3558
+ /**
3559
+ * npm package name rules (scoped or unscoped, URL-safe characters only).
3560
+ * This also guards against URL path injection into registry requests.
3561
+ */
3562
+ const NPM_PACKAGE_NAME_REGEX = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
3563
+ const MAX_NPM_PACKAGE_NAME_LENGTH = 214;
3564
+ const INTEGRITY_ALGORITHM_PREFERENCE = [
3565
+ "sha512",
3566
+ "sha384",
3567
+ "sha256",
3568
+ "sha1"
3569
+ ];
3570
+ var NpmClientError = class extends Error {
3571
+ statusCode;
3572
+ constructor(message, options) {
3573
+ super(message, { cause: options?.cause });
3574
+ this.name = "NpmClientError";
3575
+ this.statusCode = options?.statusCode;
3576
+ }
3577
+ };
3578
+ function validateNpmPackageName(name) {
3579
+ if (name.length > MAX_NPM_PACKAGE_NAME_LENGTH || !NPM_PACKAGE_NAME_REGEX.test(name)) throw new NpmClientError(`Invalid npm package name: "${name}". Expected "name" or "@scope/name".`);
3580
+ }
3581
+ function validateNpmRegistryUrl(url, options) {
3582
+ const ctrl = findControlCharacter(url);
3583
+ if (ctrl) throw new NpmClientError(`Registry URL contains control character ${ctrl.hex} at position ${ctrl.position}`);
3584
+ if (!url.startsWith("https://") && !url.startsWith("http://")) throw new NpmClientError(`Unsupported registry URL: "${url}". Use https:// (or http://).`);
3585
+ if (url.startsWith("http://")) options?.logger?.warn(`Registry URL "${url}" uses an unencrypted protocol. Consider using https:// instead.`);
3586
+ }
3587
+ /**
3588
+ * Resolve the registry token from the environment. When `tokenEnv` is set it
3589
+ * must name an existing environment variable; otherwise `NPM_TOKEN` is used
3590
+ * when present. The token value itself is never logged.
3591
+ */
3592
+ function resolveNpmToken(params) {
3593
+ const { tokenEnv } = params;
3594
+ if (tokenEnv !== void 0) {
3595
+ const value = process.env[tokenEnv];
3596
+ if (value === void 0 || value === "") throw new NpmClientError(`Environment variable "${tokenEnv}" (from tokenEnv) is not set. Export it or remove the tokenEnv field.`);
3597
+ return value;
3598
+ }
3599
+ const fallback = process.env[DEFAULT_NPM_TOKEN_ENV];
3600
+ return fallback === void 0 || fallback === "" ? void 0 : fallback;
3601
+ }
3602
+ /** Build the packument URL for a (possibly scoped) package on a registry. */
3603
+ function buildPackumentUrl(params) {
3604
+ const { registryUrl, packageName } = params;
3605
+ validateNpmPackageName(packageName);
3606
+ const base = registryUrl.endsWith("/") ? registryUrl : `${registryUrl}/`;
3607
+ const encodedName = packageName.replaceAll("/", "%2F");
3608
+ return new URL(encodedName, base).toString();
3609
+ }
3610
+ async function fetchWithTimeout(url, headers) {
3611
+ try {
3612
+ return await fetch(url, {
3613
+ headers,
3614
+ redirect: "follow",
3615
+ signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS)
3616
+ });
3617
+ } catch (error) {
3618
+ throw new NpmClientError(`Network error while requesting ${url}`, { cause: error });
3619
+ }
3620
+ }
3621
+ /**
3622
+ * Fetch the (abbreviated) packument for a package from a registry.
3623
+ */
3624
+ async function fetchPackument(params) {
3625
+ const { registryUrl, packageName, token } = params;
3626
+ validateNpmPackageName(packageName);
3627
+ const url = buildPackumentUrl({
3628
+ registryUrl,
3629
+ packageName
3630
+ });
3631
+ const headers = { Accept: PACKUMENT_ACCEPT_HEADER };
3632
+ if (token) headers.Authorization = `Bearer ${token}`;
3633
+ const response = await fetchWithTimeout(url, headers);
3634
+ if (!response.ok) throw new NpmClientError(`Failed to fetch package metadata for "${packageName}" from ${registryUrl}: HTTP ${response.status}`, { statusCode: response.status });
3635
+ try {
3636
+ return await response.json();
3637
+ } catch (error) {
3638
+ throw new NpmClientError(`Failed to parse package metadata for "${packageName}" from ${registryUrl}`, { cause: error });
3639
+ }
3640
+ }
3641
+ /**
3642
+ * Resolve a requested version or dist-tag against a packument. Only exact
3643
+ * versions and dist-tags are supported — semver ranges are intentionally out
3644
+ * of scope (no semver dependency).
3645
+ */
3646
+ function resolvePackumentVersion(params) {
3647
+ const { packument, packageName, requested } = params;
3648
+ const versions = packument.versions ?? {};
3649
+ if (Object.prototype.hasOwnProperty.call(versions, requested)) return requested;
3650
+ const distTags = packument["dist-tags"] ?? {};
3651
+ const tagged = Object.prototype.hasOwnProperty.call(distTags, requested) ? distTags[requested] : void 0;
3652
+ if (tagged !== void 0 && Object.prototype.hasOwnProperty.call(versions, tagged)) return tagged;
3653
+ throw new NpmClientError(`Could not resolve "${packageName}@${requested}": not an exact published version or dist-tag. Note: semver ranges are not supported by the npm transport.`);
3654
+ }
3655
+ /** Get the dist metadata (tarball URL, integrity) for a resolved version. */
3656
+ function getPackumentVersionDist(params) {
3657
+ const { packument, packageName, version } = params;
3658
+ const versions = packument.versions ?? {};
3659
+ const dist = (Object.prototype.hasOwnProperty.call(versions, version) ? versions[version] : void 0)?.dist;
3660
+ if (!dist?.tarball) throw new NpmClientError(`Registry metadata for "${packageName}@${version}" is missing the dist.tarball URL.`);
3661
+ return dist;
3662
+ }
3663
+ /**
3664
+ * Download a package tarball. The Authorization header is only attached when
3665
+ * the tarball is hosted on the same origin (scheme + host) as the registry,
3666
+ * so the token never leaks to third-party CDNs or plaintext downgrades.
3667
+ */
3668
+ async function fetchTarball(params) {
3669
+ const { tarballUrl, registryUrl, token } = params;
3670
+ const maxSize = params.maxSize ?? MAX_TARBALL_SIZE;
3671
+ if (!tarballUrl.startsWith("https://") && !tarballUrl.startsWith("http://")) throw new NpmClientError(`Unsupported tarball URL: "${tarballUrl}". Use https:// (or http://).`);
3672
+ const headers = {};
3673
+ if (token && isSameOrigin(tarballUrl, registryUrl)) headers.Authorization = `Bearer ${token}`;
3674
+ const response = await fetchWithTimeout(tarballUrl, headers);
3675
+ if (!response.ok) throw new NpmClientError(`Failed to download tarball ${tarballUrl}: HTTP ${response.status}`, { statusCode: response.status });
3676
+ const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
3677
+ if (Number.isFinite(contentLength) && contentLength > maxSize) throw new NpmClientError(oversizedTarballMessage(tarballUrl, maxSize));
3678
+ return await readBodyWithLimit({
3679
+ response,
3680
+ tarballUrl,
3681
+ maxSize
3682
+ });
3683
+ }
3684
+ function oversizedTarballMessage(tarballUrl, maxSize) {
3685
+ return `Tarball ${tarballUrl} exceeds max size of ${maxSize / 1024 / 1024}MB.`;
3686
+ }
3687
+ /**
3688
+ * Read a response body incrementally, aborting as soon as the size cap is
3689
+ * exceeded. content-length can be absent or forged, so the streaming check is
3690
+ * the actual enforcement of the cap.
3691
+ */
3692
+ async function readBodyWithLimit(params) {
3693
+ const { response, tarballUrl, maxSize } = params;
3694
+ const reader = response.body?.getReader();
3695
+ if (!reader) {
3696
+ const arrayBuffer = await response.arrayBuffer();
3697
+ if (arrayBuffer.byteLength > maxSize) throw new NpmClientError(oversizedTarballMessage(tarballUrl, maxSize));
3698
+ return Buffer.from(arrayBuffer);
3699
+ }
3700
+ const chunks = [];
3701
+ let totalBytes = 0;
3702
+ while (true) {
3703
+ const { done, value } = await reader.read();
3704
+ if (done) break;
3705
+ totalBytes += value.byteLength;
3706
+ if (totalBytes > maxSize) {
3707
+ await reader.cancel();
3708
+ throw new NpmClientError(oversizedTarballMessage(tarballUrl, maxSize));
3709
+ }
3710
+ chunks.push(Buffer.from(value));
3711
+ }
3712
+ return Buffer.concat(chunks);
3713
+ }
3714
+ function isSameOrigin(urlA, urlB) {
3715
+ try {
3716
+ return new URL(urlA).origin === new URL(urlB).origin;
3717
+ } catch {
3718
+ return false;
3719
+ }
3720
+ }
3721
+ /**
3722
+ * Convert a hex sha1 shasum to the SRI form used by `verifyTarballIntegrity`.
3723
+ * Rejects malformed shasum values so a broken value is never recorded in the
3724
+ * lockfile as a seemingly valid SRI string.
3725
+ */
3726
+ function shasumToSri(shasum) {
3727
+ if (!/^[0-9a-f]{40}$/i.test(shasum)) throw new NpmClientError(`Malformed sha1 shasum in registry metadata: "${shasum}"`);
3728
+ return `sha1-${Buffer.from(shasum, "hex").toString("base64")}`;
3729
+ }
3730
+ /**
3731
+ * Verify a downloaded tarball against registry integrity metadata.
3732
+ * Prefers the strongest supported algorithm in the SRI `integrity` string and
3733
+ * falls back to the legacy sha1 `shasum`. An `integrity` string that is
3734
+ * present but cannot be parsed fails closed; a warning is only logged when
3735
+ * the registry provides no integrity metadata at all.
3736
+ */
3737
+ function verifyTarballIntegrity(params) {
3738
+ const { tarball, integrity, shasum, context, logger } = params;
3739
+ if (integrity !== void 0) {
3740
+ const sri = pickStrongestSriEntry(integrity);
3741
+ if (!sri) throw new NpmClientError(`Unsupported or malformed integrity metadata for ${context}. Expected an SRI string with sha512/sha384/sha256/sha1.`);
3742
+ const actual = createHash(sri.algorithm).update(tarball).digest("base64");
3743
+ if (actual !== sri.digest) throw new NpmClientError(`Integrity verification failed for ${context}: expected ${sri.algorithm}-${sri.digest}, got ${sri.algorithm}-${actual}. The tarball may have been tampered with.`);
3744
+ return;
3745
+ }
3746
+ if (shasum) {
3747
+ const actual = createHash("sha1").update(tarball).digest("hex");
3748
+ if (actual !== shasum.toLowerCase()) throw new NpmClientError(`Integrity verification failed for ${context}: expected sha1 ${shasum}, got ${actual}. The tarball may have been tampered with.`);
3749
+ return;
3750
+ }
3751
+ logger?.warn(`No integrity metadata available for ${context}; skipping tarball verification.`);
3752
+ }
3753
+ function pickStrongestSriEntry(integrity) {
3754
+ if (!integrity) return;
3755
+ const entries = integrity.split(/\s+/).map((entry) => {
3756
+ const separatorIndex = entry.indexOf("-");
3757
+ if (separatorIndex === -1) return void 0;
3758
+ const algorithm = entry.slice(0, separatorIndex);
3759
+ const digest = entry.slice(separatorIndex + 1).split("?")[0] ?? "";
3760
+ const known = INTEGRITY_ALGORITHM_PREFERENCE.find((a) => a === algorithm);
3761
+ if (!known || digest.length === 0) return void 0;
3762
+ return {
3763
+ algorithm: known,
3764
+ digest
3765
+ };
3766
+ }).filter((entry) => Boolean(entry));
3767
+ for (const algorithm of INTEGRITY_ALGORITHM_PREFERENCE) {
3768
+ const match = entries.find((entry) => entry.algorithm === algorithm);
3769
+ if (match) return match;
3770
+ }
3771
+ }
3772
+ /**
3773
+ * Log contextual hints for NpmClientError to help users troubleshoot
3774
+ * authentication problems without ever logging the token itself.
3775
+ */
3776
+ function logNpmAuthHints(params) {
3777
+ const { error, logger } = params;
3778
+ if (error.statusCode === 401 || error.statusCode === 403) logger.info("Hint: The registry rejected the request. Set NPM_TOKEN (or the per-source tokenEnv variable) to a token with read access. Note: .npmrc files are not read by the npm transport.");
3779
+ else if (error.statusCode === 404) logger.info("Hint: Package not found. Check the package name and the registry URL. Some registries also return 404 for unauthorized requests.");
3780
+ }
3781
+ const NpmLockedSkillSchema = z.object({ integrity: z.string() });
3782
+ /**
3783
+ * Schema for a single locked npm source entry.
3784
+ */
3785
+ const NpmLockedSourceSchema = z.object({
3786
+ registry: optional(z.string()),
3787
+ requestedVersion: optional(z.string()),
3788
+ resolvedVersion: z.string(),
3789
+ /** SRI integrity of the package tarball as reported by the registry. */
3790
+ integrity: optional(z.string()),
3791
+ resolvedAt: optional(z.string()),
3792
+ skills: z.record(z.string(), NpmLockedSkillSchema)
3793
+ });
3794
+ const NpmSourcesLockSchema = z.object({
3795
+ lockfileVersion: z.number(),
3796
+ sources: z.record(z.string(), NpmLockedSourceSchema)
3797
+ });
3798
+ /**
3799
+ * Create an empty npm lockfile structure.
3800
+ */
3801
+ function createEmptyNpmLock() {
3802
+ return {
3803
+ lockfileVersion: 1,
3804
+ sources: {}
3805
+ };
3806
+ }
3807
+ /**
3808
+ * Read the npm lockfile from disk.
3809
+ * @returns The parsed lockfile, or an empty lockfile if it doesn't exist or is invalid.
3810
+ */
3811
+ async function readNpmLockFile(params) {
3812
+ const { logger } = params;
3813
+ const lockPath = join(params.projectRoot, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH);
3814
+ if (!await fileExists(lockPath)) {
3815
+ logger.debug("No npm sources lockfile found, starting fresh.");
3816
+ return createEmptyNpmLock();
3817
+ }
3818
+ try {
3819
+ const content = await readFileContent(lockPath);
3820
+ const result = NpmSourcesLockSchema.safeParse(JSON.parse(content));
3821
+ if (result.success) return result.data;
3822
+ logger.warn(`Invalid npm sources lockfile format (${RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH}). Starting fresh.`);
3823
+ return createEmptyNpmLock();
3824
+ } catch {
3825
+ logger.warn(`Failed to read npm sources lockfile (${RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH}). Starting fresh.`);
3826
+ return createEmptyNpmLock();
3827
+ }
3828
+ }
3829
+ /**
3830
+ * Write the npm lockfile to disk.
3831
+ */
3832
+ async function writeNpmLockFile(params) {
3833
+ const { logger } = params;
3834
+ const lockPath = join(params.projectRoot, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH);
3835
+ await writeFileContent(lockPath, JSON.stringify(params.lock, null, 2) + "\n");
3836
+ logger.debug(`Wrote npm sources lockfile to ${lockPath}`);
3837
+ }
3838
+ /**
3839
+ * Normalize an npm source key (package name) for lockfile lookups.
3840
+ */
3841
+ function normalizeNpmSourceKey(source) {
3842
+ return source.trim();
3843
+ }
3844
+ /**
3845
+ * Get the locked entry for an npm source key, if it exists.
3846
+ */
3847
+ function getNpmLockedSource(lock, sourceKey) {
3848
+ const normalized = normalizeNpmSourceKey(sourceKey);
3849
+ return Object.prototype.hasOwnProperty.call(lock.sources, normalized) ? lock.sources[normalized] : void 0;
3850
+ }
3851
+ /**
3852
+ * Set (or update) a locked entry for an npm source key (immutable).
3853
+ */
3854
+ function setNpmLockedSource(lock, sourceKey, entry) {
3855
+ return {
3856
+ lockfileVersion: lock.lockfileVersion,
3857
+ sources: {
3858
+ ...lock.sources,
3859
+ [normalizeNpmSourceKey(sourceKey)]: entry
3860
+ }
3861
+ };
3862
+ }
3863
+ /**
3864
+ * Get the skill names from a locked npm source entry.
3865
+ */
3866
+ function getNpmLockedSkillNames(entry) {
3867
+ return Object.keys(entry.skills);
3868
+ }
3869
+ //#endregion
3870
+ //#region src/lib/npm-tar.ts
3871
+ /**
3872
+ * Minimal, hardened tar reader for npm package tarballs (EXPERIMENTAL npm
3873
+ * transport). Intentionally supports only the subset of the (pax-extended)
3874
+ * ustar format that npm-compatible registries produce:
3875
+ *
3876
+ * - regular files (typeflag "0" / "\0")
3877
+ * - directories (typeflag "5") — skipped; directories are created implicitly
3878
+ * - pax extended headers (typeflag "x") — only the `path` override is honored
3879
+ * - GNU long names (typeflag "L")
3880
+ *
3881
+ * Everything else (symlinks, hardlinks, devices, FIFOs, ...) is skipped and
3882
+ * never materialized. Extraction is bounded by file-count and total-byte caps
3883
+ * to prevent decompression bombs, and every entry path is validated against
3884
+ * traversal (absolute paths, `..` segments, backslashes).
3885
+ */
3886
+ const BLOCK_SIZE = 512;
3887
+ var NpmTarError = class extends Error {
3888
+ constructor(message, cause) {
3889
+ super(message, { cause });
3890
+ this.name = "NpmTarError";
3891
+ }
3892
+ };
3893
+ /**
3894
+ * Gunzip and extract a npm package tarball into an in-memory file list.
3895
+ * Throws {@link NpmTarError} on malformed archives, traversal attempts, or
3896
+ * resource-limit violations.
3897
+ */
3898
+ function extractPackageTarball(params) {
3899
+ const { tarball, onSkippedEntry } = params;
3900
+ const maxFiles = params.maxFiles ?? 1e4;
3901
+ const maxTotalBytes = params.maxTotalBytes ?? 104857600;
3902
+ let tar;
3903
+ try {
3904
+ tar = gunzipSync(tarball, { maxOutputLength: maxTotalBytes + maxFiles * 3 * BLOCK_SIZE + 2 * BLOCK_SIZE });
3905
+ } catch (error) {
3906
+ throw new NpmTarError("Failed to gunzip package tarball", error);
3907
+ }
3908
+ return parseTarBuffer({
3909
+ tar,
3910
+ maxFiles,
3911
+ maxTotalBytes,
3912
+ onSkippedEntry
3913
+ });
3914
+ }
3915
+ function parseTarBuffer(params) {
3916
+ const { tar, maxFiles, maxTotalBytes, onSkippedEntry } = params;
3917
+ const files = [];
3918
+ let totalBytes = 0;
3919
+ let offset = 0;
3920
+ let pendingLongName;
3921
+ let pendingPaxPath;
3922
+ while (offset + BLOCK_SIZE <= tar.length) {
3923
+ const header = tar.subarray(offset, offset + BLOCK_SIZE);
3924
+ if (isZeroBlock(header)) break;
3925
+ verifyHeaderChecksum(header);
3926
+ const size = parseOctalField(header, 124, 12, "size");
3927
+ const typeflag = String.fromCharCode(header[156] ?? 0);
3928
+ const dataStart = offset + BLOCK_SIZE;
3929
+ const dataEnd = dataStart + size;
3930
+ if (dataEnd > tar.length) throw new NpmTarError("Truncated tar archive: entry data extends past end of archive");
3931
+ switch (typeflag) {
3932
+ case "x": {
3933
+ const records = parsePaxRecords(tar.subarray(dataStart, dataEnd));
3934
+ if (records.has("size")) throw new NpmTarError("Unsupported tar archive: pax size override is not supported");
3935
+ pendingPaxPath = records.get("path") ?? pendingPaxPath;
3936
+ break;
3937
+ }
3938
+ case "L":
3939
+ pendingLongName = trimAtFirstNul(tar.toString("utf8", dataStart, dataEnd));
3940
+ break;
3941
+ case "g": {
3942
+ const records = parsePaxRecords(tar.subarray(dataStart, dataEnd));
3943
+ if (records.has("size") || records.has("path")) throw new NpmTarError("Unsupported tar archive: global pax size/path overrides are not supported");
3944
+ break;
3945
+ }
3946
+ case "0":
3947
+ case "\0": {
3948
+ const rawName = resolveEntryName({
3949
+ header,
3950
+ pendingLongName,
3951
+ pendingPaxPath
3952
+ });
3953
+ pendingLongName = void 0;
3954
+ pendingPaxPath = void 0;
3955
+ const relativePath = toSafeRelativePath(rawName);
3956
+ if (relativePath !== null) {
3957
+ if (files.length + 1 > maxFiles) throw new NpmTarError(`Package tarball exceeds max file count of ${maxFiles}. Aborting to prevent resource exhaustion.`);
3958
+ totalBytes += size;
3959
+ if (totalBytes > maxTotalBytes) throw new NpmTarError(`Package tarball exceeds max total size of ${maxTotalBytes / 1024 / 1024}MB. Aborting to prevent resource exhaustion.`);
3960
+ files.push({
3961
+ relativePath,
3962
+ content: Buffer.from(tar.subarray(dataStart, dataEnd))
3963
+ });
3964
+ }
3965
+ break;
3966
+ }
3967
+ case "5":
3968
+ pendingLongName = void 0;
3969
+ pendingPaxPath = void 0;
3970
+ break;
3971
+ default: {
3972
+ const rawName = resolveEntryName({
3973
+ header,
3974
+ pendingLongName,
3975
+ pendingPaxPath
3976
+ });
3977
+ pendingLongName = void 0;
3978
+ pendingPaxPath = void 0;
3979
+ onSkippedEntry?.(`Skipping unsupported tar entry type "${typeflag}" for "${rawName}" (only regular files are extracted).`);
3980
+ break;
3981
+ }
3982
+ }
3983
+ offset = dataStart + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE;
3984
+ }
3985
+ return files;
3986
+ }
3987
+ function isZeroBlock(block) {
3988
+ return block.every((byte) => byte === 0);
3989
+ }
3990
+ /** Cut a string at its first NUL character (tar fields are NUL-terminated). */
3991
+ function trimAtFirstNul(value) {
3992
+ const nulIndex = value.indexOf("\0");
3993
+ return nulIndex === -1 ? value : value.substring(0, nulIndex);
3994
+ }
3995
+ function parseOctalField(block, offset, length, field) {
3996
+ if (((block[offset] ?? 0) & 128) !== 0) throw new NpmTarError(`Unsupported tar archive: base-256 ${field} field is not supported`);
3997
+ const text = trimAtFirstNul(block.toString("latin1", offset, offset + length)).trim();
3998
+ if (text === "") return 0;
3999
+ if (!/^[0-7]+$/.test(text)) throw new NpmTarError(`Invalid tar header: malformed octal ${field} field`);
4000
+ return Number.parseInt(text, 8);
4001
+ }
4002
+ /**
4003
+ * Verify the ustar header checksum: the unsigned byte sum of the 512-byte
4004
+ * header with the checksum field itself treated as ASCII spaces.
4005
+ */
4006
+ function verifyHeaderChecksum(header) {
4007
+ const stored = parseOctalField(header, 148, 8, "checksum");
4008
+ let sum = 0;
4009
+ for (let i = 0; i < BLOCK_SIZE; i++) sum += i >= 148 && i < 156 ? 32 : header[i] ?? 0;
4010
+ if (sum !== stored) throw new NpmTarError("Invalid tar header: checksum mismatch");
4011
+ }
4012
+ function readCString(block, offset, length) {
4013
+ const end = block.indexOf(0, offset);
4014
+ const stop = end === -1 || end > offset + length ? offset + length : end;
4015
+ return block.toString("utf8", offset, stop);
4016
+ }
4017
+ function resolveEntryName(params) {
4018
+ const { header, pendingLongName, pendingPaxPath } = params;
4019
+ if (pendingPaxPath !== void 0) return pendingPaxPath;
4020
+ if (pendingLongName !== void 0) return pendingLongName;
4021
+ const name = readCString(header, 0, 100);
4022
+ const prefix = header.toString("latin1", 257, 262) === "ustar" ? readCString(header, 345, 155) : "";
4023
+ return prefix.length > 0 ? `${prefix}/${name}` : name;
4024
+ }
4025
+ /**
4026
+ * Parse pax extended header records of the form `<len> <key>=<value>\n`,
4027
+ * where `<len>` is the decimal length of the whole record.
4028
+ */
4029
+ function parsePaxRecords(data) {
4030
+ const records = /* @__PURE__ */ new Map();
4031
+ let offset = 0;
4032
+ while (offset < data.length) {
4033
+ if (data[offset] === 0) break;
4034
+ const spaceIndex = data.indexOf(32, offset);
4035
+ if (spaceIndex === -1) throw new NpmTarError("Invalid pax header: missing length delimiter");
4036
+ const recordLength = Number.parseInt(data.toString("utf8", offset, spaceIndex), 10);
4037
+ if (!Number.isInteger(recordLength) || recordLength <= 0 || offset + recordLength > data.length) throw new NpmTarError("Invalid pax header: malformed record length");
4038
+ const record = data.toString("utf8", spaceIndex + 1, offset + recordLength - 1);
4039
+ const equalsIndex = record.indexOf("=");
4040
+ if (equalsIndex !== -1) records.set(record.slice(0, equalsIndex), record.slice(equalsIndex + 1));
4041
+ offset += recordLength;
4042
+ }
4043
+ return records;
4044
+ }
4045
+ /**
4046
+ * Validate a tar entry path and convert it to a package-root-relative path
4047
+ * with the first component stripped. Returns null for entries that resolve to
4048
+ * the package root itself (e.g. the `package/` folder entry). Throws on
4049
+ * traversal attempts.
4050
+ */
4051
+ function toSafeRelativePath(rawPath) {
4052
+ if (rawPath.includes("\0")) throw new NpmTarError(`Unsafe tar entry path (NUL byte): "${rawPath}"`);
4053
+ if (rawPath.includes("\\")) throw new NpmTarError(`Unsafe tar entry path (backslash): "${rawPath}"`);
4054
+ if (rawPath.startsWith("/")) throw new NpmTarError(`Unsafe tar entry path (absolute): "${rawPath}"`);
4055
+ const segments = rawPath.split("/").filter((segment) => segment !== "" && segment !== ".");
4056
+ if (segments.includes("..")) throw new NpmTarError(`Unsafe tar entry path (".." segment): "${rawPath}"`);
4057
+ segments.shift();
4058
+ if (segments.length === 0) return null;
4059
+ return segments.join("/");
4060
+ }
3501
4061
  /**
3502
4062
  * Schema for a single locked skill entry with content integrity.
3503
4063
  */
@@ -3687,37 +4247,41 @@ async function resolveAndFetchSources(params) {
3687
4247
  projectRoot,
3688
4248
  logger
3689
4249
  });
4250
+ let npmLock = options.updateSources ? createEmptyNpmLock() : await readNpmLockFile({
4251
+ projectRoot,
4252
+ logger
4253
+ });
3690
4254
  if (options.frozen) assertFrozenLockCoversSources({
3691
4255
  lock,
4256
+ npmLock,
3692
4257
  sources
3693
4258
  });
3694
4259
  const originalLockJson = JSON.stringify(lock);
4260
+ const originalNpmLockJson = JSON.stringify(npmLock);
3695
4261
  const client = new GitHubClient({ token: GitHubClient.resolveToken(options.token) });
3696
4262
  const localSkillNames = await getLocalSkillDirNames(projectRoot);
3697
4263
  let totalSkillCount = 0;
3698
4264
  const allFetchedSkillNames = /* @__PURE__ */ new Set();
3699
4265
  for (const sourceEntry of sources) try {
3700
- const { skillCount, fetchedSkillNames, updatedLock } = await fetchSourceByTransport({
4266
+ const result = await fetchSingleSource({
3701
4267
  sourceEntry,
3702
4268
  client,
3703
4269
  projectRoot,
3704
4270
  lock,
4271
+ npmLock,
3705
4272
  localSkillNames,
3706
4273
  alreadyFetchedSkillNames: allFetchedSkillNames,
3707
4274
  updateSources: options.updateSources ?? false,
3708
4275
  frozen: options.frozen ?? false,
3709
4276
  logger
3710
4277
  });
3711
- lock = updatedLock;
3712
- totalSkillCount += skillCount;
3713
- for (const name of fetchedSkillNames) allFetchedSkillNames.add(name);
4278
+ lock = result.lock;
4279
+ npmLock = result.npmLock;
4280
+ totalSkillCount += result.skillCount;
4281
+ for (const name of result.fetchedSkillNames) allFetchedSkillNames.add(name);
3714
4282
  } catch (error) {
3715
- logger.error(`Failed to fetch source "${sourceEntry.source}": ${formatError(error)}`);
3716
- if (error instanceof GitHubClientError) logGitHubAuthHints({
3717
- error,
3718
- logger
3719
- });
3720
- else if (error instanceof GitClientError) logGitClientHints({
4283
+ logSourceFetchFailure({
4284
+ sourceEntry,
3721
4285
  error,
3722
4286
  logger
3723
4287
  });
@@ -3727,18 +4291,100 @@ async function resolveAndFetchSources(params) {
3727
4291
  sources,
3728
4292
  logger
3729
4293
  });
3730
- if (!options.frozen && JSON.stringify(lock) !== originalLockJson) await writeLockFile({
4294
+ npmLock = pruneStaleNpmLockEntries({
4295
+ npmLock,
4296
+ sources,
4297
+ logger
4298
+ });
4299
+ await writeLockFilesIfChanged({
3731
4300
  projectRoot,
3732
4301
  lock,
4302
+ npmLock,
4303
+ originalLockJson,
4304
+ originalNpmLockJson,
4305
+ frozen: options.frozen ?? false,
3733
4306
  logger
3734
4307
  });
3735
- else logger.debug("Lockfile unchanged, skipping write.");
3736
4308
  return {
3737
4309
  fetchedSkillCount: totalSkillCount,
3738
4310
  sourcesProcessed: sources.length
3739
4311
  };
3740
4312
  }
3741
4313
  /**
4314
+ * Dispatch a single source to the npm fetcher or the git/github fetcher,
4315
+ * returning the (possibly) updated lock objects for both lockfiles.
4316
+ */
4317
+ async function fetchSingleSource(params) {
4318
+ const { sourceEntry, lock, npmLock } = params;
4319
+ if ((sourceEntry.transport ?? "github") === "npm") {
4320
+ const result = await fetchSourceViaNpm({
4321
+ sourceEntry,
4322
+ projectRoot: params.projectRoot,
4323
+ npmLock,
4324
+ localSkillNames: params.localSkillNames,
4325
+ alreadyFetchedSkillNames: params.alreadyFetchedSkillNames,
4326
+ updateSources: params.updateSources,
4327
+ logger: params.logger
4328
+ });
4329
+ return {
4330
+ skillCount: result.skillCount,
4331
+ fetchedSkillNames: result.fetchedSkillNames,
4332
+ lock,
4333
+ npmLock: result.updatedLock
4334
+ };
4335
+ }
4336
+ const result = await fetchSourceByTransport({
4337
+ sourceEntry,
4338
+ client: params.client,
4339
+ projectRoot: params.projectRoot,
4340
+ lock,
4341
+ localSkillNames: params.localSkillNames,
4342
+ alreadyFetchedSkillNames: params.alreadyFetchedSkillNames,
4343
+ updateSources: params.updateSources,
4344
+ frozen: params.frozen,
4345
+ logger: params.logger
4346
+ });
4347
+ return {
4348
+ skillCount: result.skillCount,
4349
+ fetchedSkillNames: result.fetchedSkillNames,
4350
+ lock: result.updatedLock,
4351
+ npmLock
4352
+ };
4353
+ }
4354
+ /** Log a per-source fetch failure with transport-specific troubleshooting hints. */
4355
+ function logSourceFetchFailure(params) {
4356
+ const { sourceEntry, error, logger } = params;
4357
+ logger.error(`Failed to fetch source "${sourceEntry.source}": ${formatError(error)}`);
4358
+ if (error instanceof GitHubClientError) logGitHubAuthHints({
4359
+ error,
4360
+ logger
4361
+ });
4362
+ else if (error instanceof GitClientError) logGitClientHints({
4363
+ error,
4364
+ logger
4365
+ });
4366
+ else if (error instanceof NpmClientError) logNpmAuthHints({
4367
+ error,
4368
+ logger
4369
+ });
4370
+ }
4371
+ /** Write each lockfile only when it changed (and never in frozen mode). */
4372
+ async function writeLockFilesIfChanged(params) {
4373
+ const { projectRoot, lock, npmLock, originalLockJson, originalNpmLockJson, frozen, logger } = params;
4374
+ if (!frozen && JSON.stringify(lock) !== originalLockJson) await writeLockFile({
4375
+ projectRoot,
4376
+ lock,
4377
+ logger
4378
+ });
4379
+ else logger.debug("Lockfile unchanged, skipping write.");
4380
+ if (!frozen && JSON.stringify(npmLock) !== originalNpmLockJson) await writeNpmLockFile({
4381
+ projectRoot,
4382
+ lock: npmLock,
4383
+ logger
4384
+ });
4385
+ else logger.debug("npm lockfile unchanged, skipping write.");
4386
+ }
4387
+ /**
3742
4388
  * Log contextual hints for GitClientError to help users troubleshoot.
3743
4389
  */
3744
4390
  function logGitClientHints(params) {
@@ -3747,13 +4393,15 @@ function logGitClientHints(params) {
3747
4393
  else logger.info("Hint: Check your git credentials (SSH keys, credential helper, or access token).");
3748
4394
  }
3749
4395
  /**
3750
- * Frozen mode: validate the lockfile covers every declared source. Throws with
3751
- * remediation guidance listing any uncovered source keys.
4396
+ * Frozen mode: validate the lockfiles cover every declared source. Throws with
4397
+ * remediation guidance listing any uncovered source keys. npm-transport
4398
+ * sources are checked against the npm lockfile; everything else against the
4399
+ * main sources lockfile.
3752
4400
  */
3753
4401
  function assertFrozenLockCoversSources(params) {
3754
- const { lock, sources } = params;
4402
+ const { lock, npmLock, sources } = params;
3755
4403
  const missingKeys = [];
3756
- for (const source of sources) if (!getLockedSource(lock, source.source)) missingKeys.push(source.source);
4404
+ for (const source of sources) if (!((source.transport ?? "github") === "npm" ? getNpmLockedSource(npmLock, source.source) : getLockedSource(lock, source.source))) missingKeys.push(source.source);
3757
4405
  if (missingKeys.length > 0) throw new Error(`Frozen install failed: lockfile is missing entries for: ${missingKeys.join(", ")}. Run 'rulesync install' to update the lockfile.`);
3758
4406
  }
3759
4407
  /**
@@ -3789,7 +4437,7 @@ async function fetchSourceByTransport(params) {
3789
4437
  */
3790
4438
  function pruneStaleLockEntries(params) {
3791
4439
  const { lock, sources, logger } = params;
3792
- const sourceKeys = new Set(sources.map((s) => normalizeSourceKey(s.source)));
4440
+ const sourceKeys = new Set(sources.filter((s) => (s.transport ?? "github") !== "npm").map((s) => normalizeSourceKey(s.source)));
3793
4441
  const prunedSources = {};
3794
4442
  for (const [key, value] of Object.entries(lock.sources)) if (sourceKeys.has(normalizeSourceKey(key))) prunedSources[key] = value;
3795
4443
  else logger.debug(`Pruned stale lockfile entry: ${key}`);
@@ -3799,6 +4447,21 @@ function pruneStaleLockEntries(params) {
3799
4447
  };
3800
4448
  }
3801
4449
  /**
4450
+ * Prune stale npm lockfile entries whose keys are not in the current
4451
+ * npm-transport sources (immutable — returns a fresh lock object).
4452
+ */
4453
+ function pruneStaleNpmLockEntries(params) {
4454
+ const { npmLock, sources, logger } = params;
4455
+ const sourceKeys = new Set(sources.filter((s) => (s.transport ?? "github") === "npm").map((s) => normalizeNpmSourceKey(s.source)));
4456
+ const prunedSources = {};
4457
+ for (const [key, value] of Object.entries(npmLock.sources)) if (sourceKeys.has(normalizeNpmSourceKey(key))) prunedSources[key] = value;
4458
+ else logger.debug(`Pruned stale npm lockfile entry: ${key}`);
4459
+ return {
4460
+ lockfileVersion: npmLock.lockfileVersion,
4461
+ sources: prunedSources
4462
+ };
4463
+ }
4464
+ /**
3802
4465
  * Check if all locked skills exist on disk in the curated directory.
3803
4466
  */
3804
4467
  async function checkLockedSkillsExist(curatedDir, skillNames) {
@@ -3866,16 +4529,31 @@ async function writeSkillAndComputeIntegrity(params) {
3866
4529
  return { integrity };
3867
4530
  }
3868
4531
  /**
4532
+ * Merge back locked skills that still exist in the remote but were skipped
4533
+ * during fetching (due to local precedence, already-fetched, etc.). Skills no
4534
+ * longer present in the remote (e.g. renamed or deleted upstream) are
4535
+ * intentionally dropped. Shared by the git/github and npm lock updates.
4536
+ */
4537
+ function mergeFetchedWithLockedSkills(params) {
4538
+ const { fetchedSkills, lockedSkills, remoteSkillNames } = params;
4539
+ const remoteSet = new Set(remoteSkillNames);
4540
+ const mergedSkills = { ...fetchedSkills };
4541
+ if (lockedSkills) {
4542
+ for (const [skillName, skillEntry] of Object.entries(lockedSkills)) if (!(skillName in mergedSkills) && remoteSet.has(skillName)) mergedSkills[skillName] = skillEntry;
4543
+ }
4544
+ return mergedSkills;
4545
+ }
4546
+ /**
3869
4547
  * Merge newly fetched skills with existing locked skills and update the lockfile.
3870
4548
  */
3871
4549
  function buildLockUpdate(params) {
3872
4550
  const { lock, sourceKey, fetchedSkills, locked, requestedRef, resolvedSha, remoteSkillNames, logger } = params;
3873
4551
  const fetchedNames = Object.keys(fetchedSkills);
3874
- const remoteSet = new Set(remoteSkillNames);
3875
- const mergedSkills = { ...fetchedSkills };
3876
- if (locked) {
3877
- for (const [skillName, skillEntry] of Object.entries(locked.skills)) if (!(skillName in mergedSkills) && remoteSet.has(skillName)) mergedSkills[skillName] = skillEntry;
3878
- }
4552
+ const mergedSkills = mergeFetchedWithLockedSkills({
4553
+ fetchedSkills,
4554
+ lockedSkills: locked?.skills,
4555
+ remoteSkillNames
4556
+ });
3879
4557
  const updatedLock = setLockedSource(lock, sourceKey, {
3880
4558
  requestedRef,
3881
4559
  resolvedRef: resolvedSha,
@@ -4316,6 +4994,265 @@ async function fetchSourceViaGit(params) {
4316
4994
  updatedLock: result.updatedLock
4317
4995
  };
4318
4996
  }
4997
+ /**
4998
+ * Select the skill files inside an extracted npm package, mirroring the git
4999
+ * transport's discovery: files under `skills/` (or the configured `path`) are
5000
+ * grouped per subdirectory; a package whose `SKILL.md` sits at the package
5001
+ * root is installed as a single skill (root fallback via
5002
+ * {@link shouldUseRootFallback}), named after the requested skill or, for
5003
+ * wildcard fetches, after the package's base name.
5004
+ */
5005
+ function selectNpmSkillFiles(params) {
5006
+ const { allFiles, skillsPath, skillFilter, isWildcard, packageName } = params;
5007
+ const normalizedBase = posix.normalize(skillsPath.replace(/\\/g, "/")).replace(/\/+$/, "");
5008
+ if (normalizedBase === "" || normalizedBase === ".") return {
5009
+ remoteFiles: allFiles,
5010
+ skillFilter,
5011
+ isWildcard
5012
+ };
5013
+ const prefix = `${normalizedBase}/`;
5014
+ const filesUnderBase = allFiles.filter((file) => file.relativePath.startsWith(prefix)).map((file) => ({
5015
+ relativePath: file.relativePath.substring(prefix.length),
5016
+ content: file.content
5017
+ }));
5018
+ if (filesUnderBase.length > 0) return {
5019
+ remoteFiles: filesUnderBase,
5020
+ skillFilter,
5021
+ isWildcard
5022
+ };
5023
+ const hasRootSkillFile = allFiles.some((file) => file.relativePath === SKILL_FILE_NAME$1);
5024
+ const fallbackFilter = isWildcard ? [npmPackageBaseName(packageName)] : skillFilter;
5025
+ const [singleSkillName] = fallbackFilter;
5026
+ if (fallbackFilter.length === 1 && singleSkillName !== void 0 && shouldUseRootFallback({
5027
+ skillFilter: fallbackFilter,
5028
+ isWildcard: false,
5029
+ hasRootSkillFile,
5030
+ hasRequestedSkillDir: false
5031
+ })) return {
5032
+ remoteFiles: allFiles,
5033
+ skillFilter: fallbackFilter,
5034
+ isWildcard: false
5035
+ };
5036
+ return {
5037
+ remoteFiles: filesUnderBase,
5038
+ skillFilter,
5039
+ isWildcard
5040
+ };
5041
+ }
5042
+ /** Base name of an npm package: `@scope/name` -> `name`. */
5043
+ function npmPackageBaseName(packageName) {
5044
+ const slashIndex = packageName.indexOf("/");
5045
+ return slashIndex === -1 ? packageName : packageName.substring(slashIndex + 1);
5046
+ }
5047
+ /**
5048
+ * Resolve the version to fetch for an npm source: the locked version when
5049
+ * available (deterministic re-fetch), otherwise the declared `ref` (exact
5050
+ * version or dist-tag, defaulting to "latest") resolved via the packument.
5051
+ */
5052
+ function resolveNpmFetchVersion(params) {
5053
+ const { sourceEntry, locked, updateSources } = params;
5054
+ if (locked && !updateSources) return {
5055
+ lockedVersion: locked.resolvedVersion,
5056
+ requestedVersion: locked.requestedVersion
5057
+ };
5058
+ return {
5059
+ lockedVersion: void 0,
5060
+ requestedVersion: sourceEntry.ref ?? "latest"
5061
+ };
5062
+ }
5063
+ /**
5064
+ * Resolve the package version via the registry packument, download the
5065
+ * tarball, and verify it against the registry (and, when re-fetching a locked
5066
+ * version, the locked) integrity metadata.
5067
+ */
5068
+ async function downloadVerifiedNpmTarball(params) {
5069
+ const { packageName, registryUrl, token, lockedVersion, requestedVersion, locked, logger } = params;
5070
+ const packument = await fetchPackument({
5071
+ registryUrl,
5072
+ packageName,
5073
+ token
5074
+ });
5075
+ const resolvedVersion = lockedVersion ?? resolvePackumentVersion({
5076
+ packument,
5077
+ packageName,
5078
+ requested: requestedVersion ?? "latest"
5079
+ });
5080
+ logger.debug(`Resolved ${packageName}@${requestedVersion ?? "latest"} to ${resolvedVersion}`);
5081
+ const dist = getPackumentVersionDist({
5082
+ packument,
5083
+ packageName,
5084
+ version: resolvedVersion
5085
+ });
5086
+ const tarball = await fetchTarball({
5087
+ tarballUrl: dist.tarball,
5088
+ registryUrl,
5089
+ token
5090
+ });
5091
+ const context = `${packageName}@${resolvedVersion}`;
5092
+ verifyTarballIntegrity({
5093
+ tarball,
5094
+ integrity: dist.integrity,
5095
+ shasum: dist.shasum,
5096
+ context,
5097
+ logger
5098
+ });
5099
+ if (locked?.integrity && locked.resolvedVersion === resolvedVersion) verifyTarballIntegrity({
5100
+ tarball,
5101
+ integrity: locked.integrity,
5102
+ context,
5103
+ logger
5104
+ });
5105
+ return {
5106
+ resolvedVersion,
5107
+ dist,
5108
+ tarball
5109
+ };
5110
+ }
5111
+ /**
5112
+ * Extract a verified npm tarball in memory and convert its entries into
5113
+ * remote skill files, skipping any file above MAX_FILE_SIZE.
5114
+ */
5115
+ function extractNpmRemoteFiles(params) {
5116
+ const { tarball, logger } = params;
5117
+ const extracted = extractPackageTarball({
5118
+ tarball,
5119
+ onSkippedEntry: (message) => logger.warn(message)
5120
+ });
5121
+ const allFiles = [];
5122
+ for (const entry of extracted) {
5123
+ if (entry.content.length > 10485760) {
5124
+ logger.warn(`Skipping file "${entry.relativePath}" (${(entry.content.length / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`);
5125
+ continue;
5126
+ }
5127
+ allFiles.push({
5128
+ relativePath: entry.relativePath,
5129
+ content: entry.content.toString("utf8")
5130
+ });
5131
+ }
5132
+ return allFiles;
5133
+ }
5134
+ /** Build the npm lockfile entry for a fetched source. */
5135
+ function buildNpmLockEntry(params) {
5136
+ const { sourceEntry, requestedVersion, resolvedVersion, dist, mergedSkills } = params;
5137
+ const integrity = dist.integrity ?? (dist.shasum !== void 0 ? shasumToSri(dist.shasum) : void 0);
5138
+ return {
5139
+ ...sourceEntry.registry !== void 0 && { registry: sourceEntry.registry },
5140
+ ...requestedVersion !== void 0 && { requestedVersion },
5141
+ resolvedVersion,
5142
+ ...integrity !== void 0 && { integrity },
5143
+ resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
5144
+ skills: mergedSkills
5145
+ };
5146
+ }
5147
+ /**
5148
+ * Fetch skills from a single npm-transport source (EXPERIMENTAL): resolve the
5149
+ * package version via the registry packument, download and verify the
5150
+ * tarball, extract it in-memory with the hardened tar reader, and install the
5151
+ * discovered skills into the curated directory.
5152
+ */
5153
+ async function fetchSourceViaNpm(params) {
5154
+ const { sourceEntry, projectRoot, npmLock, localSkillNames, alreadyFetchedSkillNames, updateSources, logger } = params;
5155
+ const packageName = sourceEntry.source;
5156
+ validateNpmPackageName(packageName);
5157
+ const registryUrl = sourceEntry.registry ?? "https://registry.npmjs.org";
5158
+ validateNpmRegistryUrl(registryUrl, { logger });
5159
+ const token = resolveNpmToken({ tokenEnv: sourceEntry.tokenEnv });
5160
+ const sourceKey = packageName;
5161
+ const locked = getNpmLockedSource(npmLock, sourceKey);
5162
+ const lockedSkillNames = locked ? getNpmLockedSkillNames(locked) : [];
5163
+ const curatedDir = join(projectRoot, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH);
5164
+ const { lockedVersion, requestedVersion } = resolveNpmFetchVersion({
5165
+ sourceEntry,
5166
+ locked,
5167
+ updateSources
5168
+ });
5169
+ if (lockedVersion !== void 0) {
5170
+ if (await checkLockedSkillsExist(curatedDir, lockedSkillNames)) {
5171
+ logger.debug(`Version unchanged for ${sourceKey}, skipping re-fetch.`);
5172
+ return {
5173
+ skillCount: 0,
5174
+ fetchedSkillNames: lockedSkillNames,
5175
+ updatedLock: npmLock
5176
+ };
5177
+ }
5178
+ }
5179
+ const { resolvedVersion, dist, tarball } = await downloadVerifiedNpmTarball({
5180
+ packageName,
5181
+ registryUrl,
5182
+ token,
5183
+ lockedVersion,
5184
+ requestedVersion,
5185
+ locked,
5186
+ logger
5187
+ });
5188
+ const allFiles = extractNpmRemoteFiles({
5189
+ tarball,
5190
+ logger
5191
+ });
5192
+ const declaredFilter = sourceEntry.skills ?? ["*"];
5193
+ const declaredWildcard = declaredFilter.length === 1 && declaredFilter[0] === "*";
5194
+ const { remoteFiles, skillFilter, isWildcard } = selectNpmSkillFiles({
5195
+ allFiles,
5196
+ skillsPath: sourceEntry.path ?? "skills",
5197
+ skillFilter: declaredFilter,
5198
+ isWildcard: declaredWildcard,
5199
+ packageName
5200
+ });
5201
+ const skillFileMap = groupRemoteFilesBySkillRoot({
5202
+ remoteFiles,
5203
+ skillFilter,
5204
+ isWildcard
5205
+ });
5206
+ const allNames = [...skillFileMap.keys()];
5207
+ const filteredNames = isWildcard ? allNames : allNames.filter((n) => skillFilter.includes(n));
5208
+ if (locked) await cleanPreviousCuratedSkills({
5209
+ curatedDir,
5210
+ lockedSkillNames,
5211
+ logger
5212
+ });
5213
+ const lockedForIntegrityCheck = locked ? {
5214
+ resolvedRef: locked.resolvedVersion,
5215
+ skills: locked.skills
5216
+ } : void 0;
5217
+ const fetchedSkills = {};
5218
+ for (const skillName of filteredNames) {
5219
+ if (shouldSkipSkill({
5220
+ skillName,
5221
+ sourceKey,
5222
+ localSkillNames,
5223
+ alreadyFetchedSkillNames,
5224
+ logger
5225
+ })) continue;
5226
+ fetchedSkills[skillName] = await writeSkillAndComputeIntegrity({
5227
+ skillName,
5228
+ files: skillFileMap.get(skillName) ?? [],
5229
+ curatedDir,
5230
+ locked: lockedForIntegrityCheck,
5231
+ resolvedSha: resolvedVersion,
5232
+ sourceKey,
5233
+ logger
5234
+ });
5235
+ logger.debug(`Fetched skill "${skillName}" from ${sourceKey}`);
5236
+ }
5237
+ const fetchedNames = Object.keys(fetchedSkills);
5238
+ const updatedLock = setNpmLockedSource(npmLock, sourceKey, buildNpmLockEntry({
5239
+ sourceEntry,
5240
+ requestedVersion,
5241
+ resolvedVersion,
5242
+ dist,
5243
+ mergedSkills: mergeFetchedWithLockedSkills({
5244
+ fetchedSkills,
5245
+ lockedSkills: locked?.skills,
5246
+ remoteSkillNames: filteredNames
5247
+ })
5248
+ }));
5249
+ logger.info(`Fetched ${fetchedNames.length} skill(s) from ${sourceKey}: ${fetchedNames.join(", ") || "(none)"}`);
5250
+ return {
5251
+ skillCount: fetchedNames.length,
5252
+ fetchedSkillNames: fetchedNames,
5253
+ updatedLock
5254
+ };
5255
+ }
4319
5256
  //#endregion
4320
5257
  //#region src/cli/commands/install.ts
4321
5258
  const INSTALL_MODES = [
@@ -4422,6 +5359,172 @@ async function runGhInstall(logger, options) {
4422
5359
  else logger.success(`All gh sources up to date (${result.sourcesProcessed} checked).`);
4423
5360
  }
4424
5361
  //#endregion
5362
+ //#region src/mcp/checks.ts
5363
+ const logger$4 = new ConsoleLogger({
5364
+ verbose: false,
5365
+ silent: true
5366
+ });
5367
+ const maxCheckSizeBytes = 1024 * 1024;
5368
+ const maxChecksCount = 1e3;
5369
+ /**
5370
+ * Tool to list all checks from .rulesync/checks/*.md
5371
+ */
5372
+ async function listChecks() {
5373
+ const checksDir = join(process.cwd(), RULESYNC_CHECKS_RELATIVE_DIR_PATH);
5374
+ try {
5375
+ const mdFiles = (await listDirectoryFiles(checksDir)).filter((file) => file.endsWith(".md"));
5376
+ return (await Promise.all(mdFiles.map(async (file) => {
5377
+ try {
5378
+ const check = await RulesyncCheck.fromFile({
5379
+ relativeFilePath: file,
5380
+ validate: true
5381
+ });
5382
+ return {
5383
+ relativePathFromCwd: join(RULESYNC_CHECKS_RELATIVE_DIR_PATH, file),
5384
+ frontmatter: check.getFrontmatter()
5385
+ };
5386
+ } catch (error) {
5387
+ logger$4.error(`Failed to read check file ${file}: ${formatError(error)}`);
5388
+ return null;
5389
+ }
5390
+ }))).filter((check) => check !== null);
5391
+ } catch (error) {
5392
+ logger$4.error(`Failed to read checks directory (${RULESYNC_CHECKS_RELATIVE_DIR_PATH}): ${formatError(error)}`);
5393
+ return [];
5394
+ }
5395
+ }
5396
+ /**
5397
+ * Tool to get detailed information about a specific check
5398
+ */
5399
+ async function getCheck({ relativePathFromCwd }) {
5400
+ checkPathTraversal({
5401
+ relativePath: relativePathFromCwd,
5402
+ intendedRootDir: process.cwd()
5403
+ });
5404
+ const filename = basename(relativePathFromCwd);
5405
+ try {
5406
+ const check = await RulesyncCheck.fromFile({
5407
+ relativeFilePath: filename,
5408
+ validate: true
5409
+ });
5410
+ return {
5411
+ relativePathFromCwd: join(RULESYNC_CHECKS_RELATIVE_DIR_PATH, filename),
5412
+ frontmatter: check.getFrontmatter(),
5413
+ body: check.getBody()
5414
+ };
5415
+ } catch (error) {
5416
+ throw new Error(`Failed to read check file ${relativePathFromCwd}: ${formatError(error)}`, { cause: error });
5417
+ }
5418
+ }
5419
+ /**
5420
+ * Tool to create or update a check (upsert operation)
5421
+ */
5422
+ async function putCheck({ relativePathFromCwd, frontmatter, body }) {
5423
+ checkPathTraversal({
5424
+ relativePath: relativePathFromCwd,
5425
+ intendedRootDir: process.cwd()
5426
+ });
5427
+ const filename = basename(relativePathFromCwd);
5428
+ const estimatedSize = JSON.stringify(frontmatter).length + body.length;
5429
+ if (estimatedSize > maxCheckSizeBytes) throw new Error(`Check size ${estimatedSize} bytes exceeds maximum ${maxCheckSizeBytes} bytes (1MB) for ${relativePathFromCwd}`);
5430
+ try {
5431
+ const existingChecks = await listChecks();
5432
+ if (!existingChecks.some((check) => check.relativePathFromCwd === join(RULESYNC_CHECKS_RELATIVE_DIR_PATH, filename)) && existingChecks.length >= maxChecksCount) throw new Error(`Maximum number of checks (${maxChecksCount}) reached in ${RULESYNC_CHECKS_RELATIVE_DIR_PATH}`);
5433
+ const check = new RulesyncCheck({
5434
+ outputRoot: process.cwd(),
5435
+ relativeDirPath: RULESYNC_CHECKS_RELATIVE_DIR_PATH,
5436
+ relativeFilePath: filename,
5437
+ frontmatter,
5438
+ body,
5439
+ validate: true
5440
+ });
5441
+ await ensureDir(join(process.cwd(), RULESYNC_CHECKS_RELATIVE_DIR_PATH));
5442
+ await writeFileContent(check.getFilePath(), check.getFileContent());
5443
+ return {
5444
+ relativePathFromCwd: join(RULESYNC_CHECKS_RELATIVE_DIR_PATH, filename),
5445
+ frontmatter: check.getFrontmatter(),
5446
+ body: check.getBody()
5447
+ };
5448
+ } catch (error) {
5449
+ throw new Error(`Failed to write check file ${relativePathFromCwd}: ${formatError(error)}`, { cause: error });
5450
+ }
5451
+ }
5452
+ /**
5453
+ * Tool to delete a check
5454
+ */
5455
+ async function deleteCheck({ relativePathFromCwd }) {
5456
+ checkPathTraversal({
5457
+ relativePath: relativePathFromCwd,
5458
+ intendedRootDir: process.cwd()
5459
+ });
5460
+ const filename = basename(relativePathFromCwd);
5461
+ const fullPath = join(process.cwd(), RULESYNC_CHECKS_RELATIVE_DIR_PATH, filename);
5462
+ try {
5463
+ await removeFile(fullPath);
5464
+ return { relativePathFromCwd: join(RULESYNC_CHECKS_RELATIVE_DIR_PATH, filename) };
5465
+ } catch (error) {
5466
+ throw new Error(`Failed to delete check file ${relativePathFromCwd}: ${formatError(error)}`, { cause: error });
5467
+ }
5468
+ }
5469
+ /**
5470
+ * Schema for check-related tool parameters
5471
+ */
5472
+ const checkToolSchemas = {
5473
+ listChecks: z.object({}),
5474
+ getCheck: z.object({ relativePathFromCwd: z.string() }),
5475
+ putCheck: z.object({
5476
+ relativePathFromCwd: z.string(),
5477
+ frontmatter: RulesyncCheckFrontmatterSchema,
5478
+ body: z.string()
5479
+ }),
5480
+ deleteCheck: z.object({ relativePathFromCwd: z.string() })
5481
+ };
5482
+ /**
5483
+ * Tool definitions for check-related operations
5484
+ */
5485
+ const checkTools = {
5486
+ listChecks: {
5487
+ name: "listChecks",
5488
+ description: `List all checks from ${join(RULESYNC_CHECKS_RELATIVE_DIR_PATH, "*.md")} with their frontmatter.`,
5489
+ parameters: checkToolSchemas.listChecks,
5490
+ execute: async () => {
5491
+ const output = { checks: await listChecks() };
5492
+ return JSON.stringify(output, null, 2);
5493
+ }
5494
+ },
5495
+ getCheck: {
5496
+ name: "getCheck",
5497
+ description: "Get detailed information about a specific check. relativePathFromCwd parameter is required.",
5498
+ parameters: checkToolSchemas.getCheck,
5499
+ execute: async (args) => {
5500
+ const result = await getCheck({ relativePathFromCwd: args.relativePathFromCwd });
5501
+ return JSON.stringify(result, null, 2);
5502
+ }
5503
+ },
5504
+ putCheck: {
5505
+ name: "putCheck",
5506
+ description: "Create or update a check (upsert operation). relativePathFromCwd, frontmatter, and body parameters are required.",
5507
+ parameters: checkToolSchemas.putCheck,
5508
+ execute: async (args) => {
5509
+ const result = await putCheck({
5510
+ relativePathFromCwd: args.relativePathFromCwd,
5511
+ frontmatter: args.frontmatter,
5512
+ body: args.body
5513
+ });
5514
+ return JSON.stringify(result, null, 2);
5515
+ }
5516
+ },
5517
+ deleteCheck: {
5518
+ name: "deleteCheck",
5519
+ description: "Delete a check file. relativePathFromCwd parameter is required.",
5520
+ parameters: checkToolSchemas.deleteCheck,
5521
+ execute: async (args) => {
5522
+ const result = await deleteCheck({ relativePathFromCwd: args.relativePathFromCwd });
5523
+ return JSON.stringify(result, null, 2);
5524
+ }
5525
+ }
5526
+ };
5527
+ //#endregion
4425
5528
  //#region src/mcp/commands.ts
4426
5529
  const logger$3 = new ConsoleLogger({
4427
5530
  verbose: false,
@@ -4673,6 +5776,7 @@ function buildSuccessResponse$2(params) {
4673
5776
  skillsCount: convertResult.skillsCount,
4674
5777
  hooksCount: convertResult.hooksCount,
4675
5778
  permissionsCount: convertResult.permissionsCount,
5779
+ checksCount: convertResult.checksCount,
4676
5780
  totalCount
4677
5781
  },
4678
5782
  config: {
@@ -4783,6 +5887,7 @@ function buildSuccessResponse$1(params) {
4783
5887
  skillsCount: generateResult.skillsCount,
4784
5888
  hooksCount: generateResult.hooksCount,
4785
5889
  permissionsCount: generateResult.permissionsCount,
5890
+ checksCount: generateResult.checksCount,
4786
5891
  totalCount
4787
5892
  },
4788
5893
  config: {
@@ -5068,6 +6173,7 @@ function buildSuccessResponse(params) {
5068
6173
  skillsCount: importResult.skillsCount,
5069
6174
  hooksCount: importResult.hooksCount,
5070
6175
  permissionsCount: importResult.permissionsCount,
6176
+ checksCount: importResult.checksCount,
5071
6177
  totalCount
5072
6178
  },
5073
6179
  config: {
@@ -5853,6 +6959,7 @@ const rulesyncFeatureSchema = z.enum([
5853
6959
  "command",
5854
6960
  "subagent",
5855
6961
  "skill",
6962
+ "check",
5856
6963
  "ignore",
5857
6964
  "mcp",
5858
6965
  "permissions",
@@ -5909,6 +7016,12 @@ const supportedOperationsByFeature = {
5909
7016
  "put",
5910
7017
  "delete"
5911
7018
  ],
7019
+ check: [
7020
+ "list",
7021
+ "get",
7022
+ "put",
7023
+ "delete"
7024
+ ],
5912
7025
  ignore: [
5913
7026
  "get",
5914
7027
  "put",
@@ -5947,6 +7060,7 @@ function parseFrontmatter({ feature, frontmatter }) {
5947
7060
  case "command": return RulesyncCommandFrontmatterSchema.parse(frontmatter);
5948
7061
  case "subagent": return RulesyncSubagentFrontmatterSchema.parse(frontmatter);
5949
7062
  case "skill": return RulesyncSkillFrontmatterSchema.parse(frontmatter);
7063
+ case "check": return RulesyncCheckFrontmatterSchema.parse(frontmatter);
5950
7064
  }
5951
7065
  }
5952
7066
  function ensureBody({ body, feature, operation }) {
@@ -6010,6 +7124,19 @@ function executeSkill(parsed) {
6010
7124
  });
6011
7125
  return skillTools.deleteSkill.execute({ relativeDirPathFromCwd: requireTargetPath(parsed) });
6012
7126
  }
7127
+ function executeCheck(parsed) {
7128
+ if (parsed.operation === "list") return checkTools.listChecks.execute();
7129
+ if (parsed.operation === "get") return checkTools.getCheck.execute({ relativePathFromCwd: requireTargetPath(parsed) });
7130
+ if (parsed.operation === "put") return checkTools.putCheck.execute({
7131
+ relativePathFromCwd: requireTargetPath(parsed),
7132
+ frontmatter: parseFrontmatter({
7133
+ feature: "check",
7134
+ frontmatter: parsed.frontmatter ?? {}
7135
+ }),
7136
+ body: ensureBody(parsed)
7137
+ });
7138
+ return checkTools.deleteCheck.execute({ relativePathFromCwd: requireTargetPath(parsed) });
7139
+ }
6013
7140
  function executeIgnore(parsed) {
6014
7141
  if (parsed.operation === "get") return ignoreTools.getIgnoreFile.execute();
6015
7142
  if (parsed.operation === "put") return ignoreTools.putIgnoreFile.execute({ content: requireContent({
@@ -6058,6 +7185,7 @@ const featureExecutors = {
6058
7185
  command: executeCommand,
6059
7186
  subagent: executeSubagent,
6060
7187
  skill: executeSkill,
7188
+ check: executeCheck,
6061
7189
  ignore: executeIgnore,
6062
7190
  mcp: executeMcp,
6063
7191
  permissions: executePermissions,
@@ -6068,7 +7196,7 @@ const featureExecutors = {
6068
7196
  };
6069
7197
  const rulesyncTool = {
6070
7198
  name: "rulesyncTool",
6071
- description: "Manage Rulesync files through a single MCP tool. Features: rule/command/subagent/skill support list/get/put/delete; ignore/mcp/permissions/hooks support get/put/delete only; generate supports run only; import supports run only; convert supports run only. Parameters: list requires no targetPathFromCwd (lists all items); get/delete require targetPathFromCwd; put requires targetPathFromCwd, frontmatter, and body (or content for ignore/mcp/permissions/hooks); generate/run uses generateOptions to configure generation; import/run uses importOptions to configure import; convert/run uses convertOptions to configure conversion.",
7199
+ description: "Manage Rulesync files through a single MCP tool. Features: rule/command/subagent/skill/check support list/get/put/delete; ignore/mcp/permissions/hooks support get/put/delete only; generate supports run only; import supports run only; convert supports run only. Parameters: list requires no targetPathFromCwd (lists all items); get/delete require targetPathFromCwd; put requires targetPathFromCwd, frontmatter, and body (or content for ignore/mcp/permissions/hooks); generate/run uses generateOptions to configure generation; import/run uses importOptions to configure import; convert/run uses convertOptions to configure conversion.",
6072
7200
  parameters: rulesyncToolSchema,
6073
7201
  execute: async (args) => {
6074
7202
  const parsed = rulesyncToolSchema.parse(args);
@@ -6554,7 +7682,7 @@ function wrapCommand$1({ name, errorCode, handler, getVersion, loggerFactory = c
6554
7682
  }
6555
7683
  //#endregion
6556
7684
  //#region src/cli/index.ts
6557
- const getVersion = () => "12.0.0";
7685
+ const getVersion = () => "14.0.0";
6558
7686
  function wrapCommand(name, errorCode, handler) {
6559
7687
  return wrapCommand$1({
6560
7688
  name,