rulesync 8.30.1 → 8.32.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
@@ -4,28 +4,29 @@ import {
4
4
  ALL_FEATURES_WITH_WILDCARD,
5
5
  ALL_TOOL_TARGETS,
6
6
  ALL_TOOL_TARGETS_WITH_WILDCARD,
7
- CLAUDECODE_AGENTS_DIR_PATH,
8
- CLAUDECODE_COMMANDS_DIR_PATH,
9
7
  CLAUDECODE_DIR,
10
8
  CLAUDECODE_LOCAL_RULE_FILE_NAME,
11
- CLAUDECODE_MCP_FILE_NAME,
12
9
  CLAUDECODE_MEMORIES_DIR_NAME,
13
- CLAUDECODE_RULES_DIR_NAME,
14
- CLAUDECODE_RULE_FILE_NAME,
15
10
  CLAUDECODE_SETTINGS_LOCAL_FILE_NAME,
16
11
  CLAUDECODE_SKILLS_DIR_PATH,
17
12
  CLIError,
18
13
  CommandsProcessor,
14
+ CommandsProcessorToolTargetSchema,
19
15
  ConfigResolver,
20
16
  ConsoleLogger,
21
17
  ErrorCodes,
22
18
  FETCH_CONCURRENCY_LIMIT,
23
19
  GITIGNORE_DESTINATION_KEY,
24
20
  HooksProcessor,
21
+ HooksProcessorToolTargetSchema,
25
22
  IgnoreProcessor,
23
+ IgnoreProcessorToolTargetSchema,
26
24
  JsonLogger,
27
25
  MAX_FILE_SIZE,
28
26
  McpProcessor,
27
+ McpProcessorToolTargetSchema,
28
+ PermissionsProcessor,
29
+ PermissionsProcessorToolTargetSchema,
29
30
  RULESYNC_AIIGNORE_FILE_NAME,
30
31
  RULESYNC_AIIGNORE_RELATIVE_FILE_PATH,
31
32
  RULESYNC_COMMANDS_RELATIVE_DIR_PATH,
@@ -48,6 +49,7 @@ import {
48
49
  RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH,
49
50
  RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
50
51
  RulesProcessor,
52
+ RulesProcessorToolTargetSchema,
51
53
  RulesyncCommand,
52
54
  RulesyncCommandFrontmatterSchema,
53
55
  RulesyncHooks,
@@ -62,7 +64,9 @@ import {
62
64
  RulesyncSubagentFrontmatterSchema,
63
65
  SKILL_FILE_NAME,
64
66
  SkillsProcessor,
67
+ SkillsProcessorToolTargetSchema,
65
68
  SubagentsProcessor,
69
+ SubagentsProcessorToolTargetSchema,
66
70
  ToolTargetSchema,
67
71
  checkPathTraversal,
68
72
  checkRulesyncDirExists,
@@ -88,8 +92,16 @@ import {
88
92
  removeTempDirectory,
89
93
  stringifyFrontmatter,
90
94
  toPosixPath,
95
+ toolCommandFactories,
96
+ toolHooksFactories,
97
+ toolIgnoreFactories,
98
+ toolMcpFactories,
99
+ toolPermissionsFactories,
100
+ toolRuleFactories,
101
+ toolSkillFactories,
102
+ toolSubagentFactories,
91
103
  writeFileContent
92
- } from "../chunk-DF7KWJTN.js";
104
+ } from "../chunk-3SMTK3M3.js";
93
105
 
94
106
  // src/cli/index.ts
95
107
  import { Command } from "commander";
@@ -1142,8 +1154,11 @@ function logFeatureResult(logger5, params) {
1142
1154
  }
1143
1155
  }
1144
1156
  }
1145
- async function generateCommand(logger5, options) {
1146
- const { baseDir, outputRoots, ...rest } = options;
1157
+ function resolveOutputRoots({
1158
+ logger: logger5,
1159
+ baseDir,
1160
+ outputRoots
1161
+ }) {
1147
1162
  if (baseDir !== void 0) {
1148
1163
  logger5.warn(
1149
1164
  "--base-dir is deprecated; use --output-roots instead. It will be removed in a future major release."
@@ -1155,6 +1170,53 @@ async function generateCommand(logger5, options) {
1155
1170
  `Both '--output-roots' and '--base-dir' were provided with differing values; using '--output-roots' (${JSON.stringify(outputRoots)}) and ignoring '--base-dir' (${JSON.stringify(baseDir)}).`
1156
1171
  );
1157
1172
  }
1173
+ return outputRootsResolved;
1174
+ }
1175
+ var FEATURE_DEBUG_MESSAGES = {
1176
+ ignore: "Generating ignore files...",
1177
+ mcp: "Generating MCP files...",
1178
+ commands: "Generating command files...",
1179
+ subagents: "Generating subagent files...",
1180
+ skills: "Generating skill files...",
1181
+ hooks: "Generating hooks...",
1182
+ rules: "Generating rule files..."
1183
+ };
1184
+ var FEATURE_DEBUG_ORDER = [
1185
+ "ignore",
1186
+ "mcp",
1187
+ "commands",
1188
+ "subagents",
1189
+ "skills",
1190
+ "hooks",
1191
+ "rules"
1192
+ ];
1193
+ function logFeatureDebugMessages(logger5, features) {
1194
+ for (const feature of FEATURE_DEBUG_ORDER) {
1195
+ if (features.includes(feature)) {
1196
+ logger5.debug(FEATURE_DEBUG_MESSAGES[feature] ?? "");
1197
+ }
1198
+ }
1199
+ }
1200
+ function buildSummaryParts(result) {
1201
+ const summarySpecs = [
1202
+ { count: result.rulesCount, label: "rules" },
1203
+ { count: result.ignoreCount, label: "ignore files" },
1204
+ { count: result.mcpCount, label: "MCP files" },
1205
+ { count: result.commandsCount, label: "commands" },
1206
+ { count: result.subagentsCount, label: "subagents" },
1207
+ { count: result.skillsCount, label: "skills" },
1208
+ { count: result.hooksCount, label: "hooks" },
1209
+ { count: result.permissionsCount, label: "permissions" }
1210
+ ];
1211
+ const parts = [];
1212
+ for (const { count, label } of summarySpecs) {
1213
+ if (count > 0) parts.push(`${count} ${label}`);
1214
+ }
1215
+ return parts;
1216
+ }
1217
+ async function generateCommand(logger5, options) {
1218
+ const { baseDir, outputRoots, ...rest } = options;
1219
+ const outputRootsResolved = resolveOutputRoots({ logger: logger5, baseDir, outputRoots });
1158
1220
  const config = await ConfigResolver.resolve(
1159
1221
  { ...rest, outputRoots: outputRootsResolved },
1160
1222
  { logger: logger5 }
@@ -1171,27 +1233,7 @@ async function generateCommand(logger5, options) {
1171
1233
  }
1172
1234
  logger5.debug(`Output roots: ${config.getOutputRoots().join(", ")}`);
1173
1235
  const features = config.getFeatures();
1174
- if (features.includes("ignore")) {
1175
- logger5.debug("Generating ignore files...");
1176
- }
1177
- if (features.includes("mcp")) {
1178
- logger5.debug("Generating MCP files...");
1179
- }
1180
- if (features.includes("commands")) {
1181
- logger5.debug("Generating command files...");
1182
- }
1183
- if (features.includes("subagents")) {
1184
- logger5.debug("Generating subagent files...");
1185
- }
1186
- if (features.includes("skills")) {
1187
- logger5.debug("Generating skill files...");
1188
- }
1189
- if (features.includes("hooks")) {
1190
- logger5.debug("Generating hooks...");
1191
- }
1192
- if (features.includes("rules")) {
1193
- logger5.debug("Generating rule files...");
1194
- }
1236
+ logFeatureDebugMessages(logger5, features);
1195
1237
  const result = await generate({ config, logger: logger5 });
1196
1238
  const totalGenerated = calculateTotalCount(result);
1197
1239
  const featureResults = {
@@ -1244,15 +1286,7 @@ async function generateCommand(logger5, options) {
1244
1286
  logger5.info(`\u2713 All files are up to date (${enabledFeatures})`);
1245
1287
  return;
1246
1288
  }
1247
- const parts = [];
1248
- if (result.rulesCount > 0) parts.push(`${result.rulesCount} rules`);
1249
- if (result.ignoreCount > 0) parts.push(`${result.ignoreCount} ignore files`);
1250
- if (result.mcpCount > 0) parts.push(`${result.mcpCount} MCP files`);
1251
- if (result.commandsCount > 0) parts.push(`${result.commandsCount} commands`);
1252
- if (result.subagentsCount > 0) parts.push(`${result.subagentsCount} subagents`);
1253
- if (result.skillsCount > 0) parts.push(`${result.skillsCount} skills`);
1254
- if (result.hooksCount > 0) parts.push(`${result.hooksCount} hooks`);
1255
- if (result.permissionsCount > 0) parts.push(`${result.permissionsCount} permissions`);
1289
+ const parts = buildSummaryParts(result);
1256
1290
  if (isPreview) {
1257
1291
  logger5.info(`${modePrefix} Would write ${totalGenerated} file(s) total (${parts.join(" + ")})`);
1258
1292
  } else {
@@ -1263,371 +1297,230 @@ async function generateCommand(logger5, options) {
1263
1297
  // src/cli/commands/gitignore.ts
1264
1298
  import { join as join2 } from "path";
1265
1299
 
1266
- // src/cli/commands/gitignore-entries.ts
1267
- var normalizeGitignoreEntryTargets = (target) => {
1268
- return typeof target === "string" ? [target] : target;
1269
- };
1270
- var GITIGNORE_ENTRY_REGISTRY = [
1271
- // Common / general
1272
- {
1273
- target: "common",
1274
- feature: "general",
1275
- entry: `${RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH}/`
1276
- },
1277
- { target: "common", feature: "general", entry: ".rulesync/rules/*.local.md" },
1278
- { target: "common", feature: "general", entry: "rulesync.local.jsonc" },
1279
- // AGENTS.local.md is placed in common scope (not rovodev-only) so that
1280
- // local rule files are always gitignored regardless of which targets are enabled.
1281
- // This prevents accidental commits when a user disables the rovodev target.
1282
- { target: "common", feature: "general", entry: "**/AGENTS.local.md" },
1283
- // AGENTS.md
1284
- {
1285
- target: ["agentsmd", "amp", "antigravity-cli", "antigravity-ide", "pi", "vibe", "warp"],
1286
- feature: "rules",
1287
- entry: "**/AGENTS.md"
1288
- },
1289
- { target: "agentsmd", feature: "rules", entry: "**/.agents/" },
1290
- // Amp
1291
- // Amp reads project rules from a root `AGENTS.md` (handled by the shared
1292
- // AGENTS.md entry above) plus `.agents/memories/*.md` non-root rules. Agent
1293
- // Skills come from the shared `.agents/skills/` project tree. The
1294
- // `.amp/settings.json` permissions file is a user-managed file (like other
1295
- // tools' settings.json), so it is intentionally not gitignored.
1296
- { target: "amp", feature: "rules", entry: "**/.agents/memories/" },
1297
- { target: "amp", feature: "skills", entry: "**/.agents/skills/" },
1298
- // Antigravity (IDE + CLI, Antigravity 2.0)
1299
- // Both targets share the `.agents/` project tree and both write a root
1300
- // `AGENTS.md` (handled by the shared AGENTS.md entry above). Global-scope
1301
- // paths (under the home directory, e.g. `~/.gemini/GEMINI.md`) are
1302
- // intentionally not gitignored.
1300
+ // src/types/processor-registry.ts
1301
+ var PROCESSOR_REGISTRY = [
1303
1302
  {
1304
- target: ["antigravity-ide", "antigravity-cli"],
1305
1303
  feature: "rules",
1306
- entry: "**/.agents/rules/"
1307
- },
1308
- { target: "antigravity-cli", feature: "ignore", entry: "**/.geminiignore" },
1309
- {
1310
- target: "antigravity-ide",
1311
- feature: "commands",
1312
- entry: "**/.agents/workflows/"
1304
+ processor: RulesProcessor,
1305
+ schema: RulesProcessorToolTargetSchema,
1306
+ factory: toolRuleFactories
1313
1307
  },
1314
1308
  {
1315
- target: ["antigravity-ide", "antigravity-cli"],
1316
- feature: "skills",
1317
- entry: "**/.agents/skills/"
1309
+ feature: "ignore",
1310
+ processor: IgnoreProcessor,
1311
+ schema: IgnoreProcessorToolTargetSchema,
1312
+ factory: toolIgnoreFactories
1318
1313
  },
1319
1314
  {
1320
- target: ["antigravity-ide", "antigravity-cli"],
1321
1315
  feature: "mcp",
1322
- entry: "**/.agents/mcp_config.json"
1323
- },
1324
- {
1325
- target: ["antigravity-ide", "antigravity-cli"],
1326
- feature: "hooks",
1327
- entry: "**/.agents/hooks.json"
1328
- },
1329
- // Augment Code
1330
- { target: "augmentcode", feature: "rules", entry: "**/.augment/rules/" },
1331
- { target: "augmentcode", feature: "rules", entry: "**/.augment-guidelines" },
1332
- { target: "augmentcode", feature: "commands", entry: "**/.augment/commands/" },
1333
- { target: "augmentcode", feature: "subagents", entry: "**/.augment/agents/" },
1334
- { target: "augmentcode", feature: "skills", entry: "**/.augment/skills/" },
1335
- { target: "augmentcode", feature: "ignore", entry: "**/.augmentignore" },
1336
- { target: "augmentcode", feature: "permissions", entry: "**/.augment/settings.json" },
1337
- { target: "augmentcode", feature: "hooks", entry: "**/.augment/settings.json" },
1338
- // settings.json is shared with permissions/hooks; re-tag it under `mcp` so
1339
- // target+feature filtering still resolves the file for MCP runs.
1340
- { target: "augmentcode", feature: "mcp", entry: "**/.augment/settings.json" },
1341
- // Claude Code
1342
- { target: "claudecode", feature: "rules", entry: `**/${CLAUDECODE_RULE_FILE_NAME}` },
1343
- { target: "claudecode", feature: "rules", entry: `**/${CLAUDECODE_LOCAL_RULE_FILE_NAME}` },
1344
- {
1345
- target: "claudecode",
1346
- feature: "rules",
1347
- entry: `**/${CLAUDECODE_DIR}/${CLAUDECODE_RULE_FILE_NAME}`
1348
- },
1349
- {
1350
- target: "claudecode",
1351
- feature: "rules",
1352
- entry: `**/${CLAUDECODE_DIR}/${CLAUDECODE_LOCAL_RULE_FILE_NAME}`
1353
- },
1354
- {
1355
- target: "claudecode",
1356
- feature: "rules",
1357
- entry: `**/${CLAUDECODE_DIR}/${CLAUDECODE_RULES_DIR_NAME}/`
1316
+ processor: McpProcessor,
1317
+ schema: McpProcessorToolTargetSchema,
1318
+ factory: toolMcpFactories
1358
1319
  },
1359
1320
  {
1360
- target: "claudecode",
1361
1321
  feature: "commands",
1362
- entry: `**/${toPosixPath(CLAUDECODE_COMMANDS_DIR_PATH)}/`
1322
+ processor: CommandsProcessor,
1323
+ schema: CommandsProcessorToolTargetSchema,
1324
+ factory: toolCommandFactories
1363
1325
  },
1364
1326
  {
1365
- target: "claudecode",
1366
1327
  feature: "subagents",
1367
- entry: `**/${toPosixPath(CLAUDECODE_AGENTS_DIR_PATH)}/`
1328
+ processor: SubagentsProcessor,
1329
+ schema: SubagentsProcessorToolTargetSchema,
1330
+ factory: toolSubagentFactories
1368
1331
  },
1369
1332
  {
1370
- target: "claudecode",
1371
1333
  feature: "skills",
1372
- entry: `**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}/`
1373
- },
1374
- { target: "claudecode", feature: "mcp", entry: `**/${CLAUDECODE_MCP_FILE_NAME}` },
1375
- {
1376
- target: "claudecode",
1377
- feature: "general",
1378
- entry: `**/${CLAUDECODE_DIR}/${CLAUDECODE_MEMORIES_DIR_NAME}/`
1334
+ processor: SkillsProcessor,
1335
+ schema: SkillsProcessorToolTargetSchema,
1336
+ factory: toolSkillFactories
1379
1337
  },
1380
1338
  {
1381
- target: "claudecode",
1382
- feature: "general",
1383
- entry: `**/${CLAUDECODE_DIR}/${CLAUDECODE_SETTINGS_LOCAL_FILE_NAME}`
1384
- },
1385
- { target: "claudecode", feature: "general", entry: `**/${CLAUDECODE_DIR}/*.lock` },
1386
- // Cline
1387
- { target: "cline", feature: "rules", entry: "**/.clinerules/" },
1388
- { target: "cline", feature: "commands", entry: "**/.clinerules/workflows/" },
1389
- { target: "cline", feature: "ignore", entry: "**/.clineignore" },
1390
- // Cline reads MCP only from a global settings file
1391
- // (`~/.cline/data/settings/cline_mcp_settings.json`), which lives under the
1392
- // home directory and is not gitignored at the project level.
1393
- { target: "cline", feature: "subagents", entry: "**/.cline/agents/" },
1394
- { target: "cline", feature: "permissions", entry: "**/.cline/command-permissions.json" },
1395
- // Codex CLI
1396
- { target: "codexcli", feature: "ignore", entry: "**/.codexignore" },
1397
- { target: "codexcli", feature: "skills", entry: "**/.agents/skills/" },
1398
- { target: "codexcli", feature: "subagents", entry: "**/.codex/agents/" },
1399
- { target: "codexcli", feature: "general", entry: "**/.codex/memories/" },
1400
- { target: "codexcli", feature: "general", entry: "**/.codex/config.toml" },
1401
- { target: "codexcli", feature: "hooks", entry: "**/.codex/hooks.json" },
1402
- // Cursor
1403
- { target: "cursor", feature: "rules", entry: "**/.cursor/" },
1404
- { target: "cursor", feature: "ignore", entry: "**/.cursorignore" },
1405
- // .cursor/cli.json (project) and .cursor/cli-config.json (global) are
1406
- // already covered by the broader **/.cursor/ entry above; the additional
1407
- // `permissions` and `hooks` tags below register the same prefix under the
1408
- // matching feature so target+feature filtering still resolves correctly.
1409
- { target: "cursor", feature: "permissions", entry: "**/.cursor/" },
1410
- { target: "cursor", feature: "hooks", entry: "**/.cursor/" },
1411
- // deepagents-cli
1412
- // All rule content is folded into the single `.deepagents/AGENTS.md`; there is
1413
- // no `.deepagents/memories/` directory.
1414
- { target: "deepagents", feature: "rules", entry: "**/.deepagents/AGENTS.md" },
1415
- { target: "deepagents", feature: "mcp", entry: "**/.deepagents/.mcp.json" },
1416
- { target: "deepagents", feature: "skills", entry: "**/.deepagents/skills/" },
1417
- {
1418
- target: "deepagents",
1419
- feature: "subagents",
1420
- entry: "**/.deepagents/agents/"
1421
- },
1422
- {
1423
- target: "deepagents",
1424
1339
  feature: "hooks",
1425
- entry: "**/.deepagents/hooks.json"
1426
- },
1427
- // Factory Droid
1428
- { target: "factorydroid", feature: "rules", entry: "**/.factory/rules/" },
1429
- {
1430
- target: "factorydroid",
1431
- feature: "commands",
1432
- entry: "**/.factory/commands/"
1340
+ processor: HooksProcessor,
1341
+ schema: HooksProcessorToolTargetSchema,
1342
+ factory: toolHooksFactories
1433
1343
  },
1434
1344
  {
1435
- target: "factorydroid",
1436
- feature: "subagents",
1437
- entry: "**/.factory/droids/"
1438
- },
1439
- { target: "factorydroid", feature: "skills", entry: "**/.factory/skills/" },
1440
- { target: "factorydroid", feature: "mcp", entry: "**/.factory/mcp.json" },
1441
- {
1442
- target: "factorydroid",
1443
- feature: "hooks",
1444
- entry: "**/.factory/hooks.json"
1445
- },
1446
- // Gemini CLI
1447
- { target: "geminicli", feature: "rules", entry: "**/GEMINI.md" },
1448
- { target: "geminicli", feature: "commands", entry: "**/.gemini/commands/" },
1449
- { target: "geminicli", feature: "subagents", entry: "**/.gemini/agents/" },
1450
- { target: "geminicli", feature: "skills", entry: "**/.gemini/skills/" },
1451
- { target: "geminicli", feature: "ignore", entry: "**/.geminiignore" },
1452
- { target: "geminicli", feature: "general", entry: "**/.gemini/memories/" },
1453
- // Goose
1454
- { target: "goose", feature: "rules", entry: "**/.goosehints" },
1455
- { target: "goose", feature: "rules", entry: "**/.goose/" },
1456
- { target: "goose", feature: "ignore", entry: "**/.gooseignore" },
1457
- // Goose recipes: commands map to top-level recipes (`.goose/recipes/*.yaml`)
1458
- // and subagents to sub-recipes (`.goose/recipes/subagents/*.yaml`). These are
1459
- // already covered by the broad `**/.goose/` rules entry above, but tagging
1460
- // them per-feature keeps coverage correct under feature-filtered gitignore.
1461
- { target: "goose", feature: "commands", entry: "**/.goose/recipes/" },
1462
- { target: "goose", feature: "subagents", entry: "**/.goose/recipes/subagents/" },
1463
- // Goose lifecycle hooks plugin (.agents/plugins/<name>/hooks/hooks.json)
1464
- { target: "goose", feature: "hooks", entry: "**/.agents/plugins/" },
1465
- // Goose reads MCP "extensions" only from the global user config
1466
- // (`~/.config/goose/config.yaml`), which lives under the home directory and is
1467
- // not gitignored at the project level (mirrors Cline's global-only MCP).
1468
- // Grok Build
1469
- { target: "grokcli", feature: "general", entry: "**/.grok/config.toml" },
1470
- // GitHub Copilot
1345
+ feature: "permissions",
1346
+ processor: PermissionsProcessor,
1347
+ schema: PermissionsProcessorToolTargetSchema,
1348
+ factory: toolPermissionsFactories
1349
+ }
1350
+ ];
1351
+ var getProcessorRegistryEntry = (feature) => {
1352
+ const entry = PROCESSOR_REGISTRY.find((e) => e.feature === feature);
1353
+ if (!entry) {
1354
+ throw new Error(`No processor registered for feature: ${feature}`);
1355
+ }
1356
+ return entry;
1357
+ };
1358
+
1359
+ // src/cli/commands/gitignore-derive.ts
1360
+ var TARGETS_NOT_DERIVED = /* @__PURE__ */ new Set([
1361
+ "agentsskills",
1362
+ "antigravity",
1363
+ "augmentcode-legacy",
1364
+ "claudecode-legacy"
1365
+ ]);
1366
+ var DERIVED_PATHS_NOT_GITIGNORED = /* @__PURE__ */ new Set([
1367
+ "**/.amp/settings.json",
1368
+ "**/.amp/settings.jsonc",
1369
+ "**/.antigravity/settings.json",
1370
+ "**/.claude/settings.json",
1371
+ "**/.claude/settings.local.json",
1372
+ "**/.factory/settings.json",
1373
+ "**/.gemini/settings.json",
1374
+ "**/.zed/settings.json",
1375
+ "**/.warp/settings.toml",
1376
+ "**/kilo.json",
1377
+ "**/kilo.jsonc",
1378
+ "**/opencode.json"
1379
+ ]);
1380
+ var toPosix = (path2) => path2.replace(/\\/g, "/");
1381
+ var dirToGlob = (relativeDirPath) => `**/${toPosix(relativeDirPath).replace(/\/$/, "")}/`;
1382
+ var fileToGlob = (relativeDirPath, relativeFilePath) => {
1383
+ const hasDir = relativeDirPath && relativeDirPath !== ".";
1384
+ return `**/${toPosix(hasDir ? `${relativeDirPath}/${relativeFilePath}` : relativeFilePath)}`;
1385
+ };
1386
+ var supportsProject = (factory) => {
1387
+ if (typeof factory !== "object" || factory === null || !("meta" in factory)) return true;
1388
+ const meta = factory.meta;
1389
+ return meta?.supportsProject !== false;
1390
+ };
1391
+ var getProjectPaths = (factory) => factory.class.getSettablePaths({ global: false });
1392
+ var pushEntry = (entries, target, feature, entry) => {
1393
+ if (DERIVED_PATHS_NOT_GITIGNORED.has(entry)) return;
1394
+ entries.push({ target, feature, entry });
1395
+ };
1396
+ var deriveDirEntries = (factories, feature) => {
1397
+ const entries = [];
1398
+ for (const [target, factory] of factories) {
1399
+ if (TARGETS_NOT_DERIVED.has(target)) continue;
1400
+ if (!supportsProject(factory)) continue;
1401
+ const paths = getProjectPaths(factory);
1402
+ const dir = paths.relativeDirPath;
1403
+ if (!dir || dir === ".") continue;
1404
+ pushEntry(entries, target, feature, dirToGlob(dir));
1405
+ }
1406
+ return entries;
1407
+ };
1408
+ var deriveFileEntries = (factories, feature) => {
1409
+ const entries = [];
1410
+ for (const [target, factory] of factories) {
1411
+ if (TARGETS_NOT_DERIVED.has(target)) continue;
1412
+ if (!supportsProject(factory)) continue;
1413
+ const paths = getProjectPaths(factory);
1414
+ if (!paths.relativeFilePath) continue;
1415
+ pushEntry(entries, target, feature, fileToGlob(paths.relativeDirPath, paths.relativeFilePath));
1416
+ }
1417
+ return entries;
1418
+ };
1419
+ var deriveRulesEntries = () => {
1420
+ const entries = [];
1421
+ const factories = getProcessorRegistryEntry("rules").factory;
1422
+ for (const [target, factory] of factories) {
1423
+ if (TARGETS_NOT_DERIVED.has(target)) continue;
1424
+ const paths = getProjectPaths(factory);
1425
+ for (const root of [paths.root, ...paths.alternativeRoots ?? []]) {
1426
+ if (root)
1427
+ pushEntry(
1428
+ entries,
1429
+ target,
1430
+ "rules",
1431
+ fileToGlob(root.relativeDirPath, root.relativeFilePath)
1432
+ );
1433
+ }
1434
+ const nonRootDir = paths.nonRoot?.relativeDirPath;
1435
+ if (nonRootDir && nonRootDir !== ".") {
1436
+ pushEntry(entries, target, "rules", dirToGlob(nonRootDir));
1437
+ }
1438
+ }
1439
+ return entries;
1440
+ };
1441
+ var DIR_FEATURES = /* @__PURE__ */ new Set(["commands", "skills", "subagents"]);
1442
+ var FILE_FEATURES = /* @__PURE__ */ new Set(["mcp", "hooks", "permissions", "ignore"]);
1443
+ var deriveFeatureGitignoreEntries = (feature) => {
1444
+ if (feature === "rules") return deriveRulesEntries();
1445
+ const factory = getProcessorRegistryEntry(feature).factory;
1446
+ if (DIR_FEATURES.has(feature)) return deriveDirEntries(factory, feature);
1447
+ if (FILE_FEATURES.has(feature)) return deriveFileEntries(factory, feature);
1448
+ return [];
1449
+ };
1450
+ var DERIVED_FEATURES = [
1451
+ "rules",
1452
+ "commands",
1453
+ "skills",
1454
+ "subagents",
1455
+ "mcp",
1456
+ "hooks",
1457
+ "permissions",
1458
+ "ignore"
1459
+ ];
1460
+ var deriveAllGitignoreEntries = () => DERIVED_FEATURES.flatMap((feature) => deriveFeatureGitignoreEntries(feature));
1461
+
1462
+ // src/cli/commands/gitignore-entries.ts
1463
+ var normalizeGitignoreEntryTargets = (target) => {
1464
+ return typeof target === "string" ? [target] : target;
1465
+ };
1466
+ var HAND_MAINTAINED_GITIGNORE_ENTRIES = [
1467
+ // rulesync's own meta files (common scope).
1471
1468
  {
1472
- target: ["copilot", "copilotcli"],
1473
- feature: "rules",
1474
- entry: "**/.github/copilot-instructions.md"
1469
+ target: "common",
1470
+ feature: "general",
1471
+ entry: `${RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH}/`
1475
1472
  },
1473
+ { target: "common", feature: "general", entry: ".rulesync/rules/*.local.md" },
1474
+ { target: "common", feature: "general", entry: "rulesync.local.jsonc" },
1475
+ // AGENTS.local.md is placed in common scope (not rovodev-only) so that
1476
+ // local rule files are always gitignored regardless of which targets are enabled.
1477
+ { target: "common", feature: "general", entry: "**/AGENTS.local.md" },
1478
+ // Local-root rule files: materialized outside getSettablePaths.
1479
+ { target: "claudecode", feature: "rules", entry: `**/${CLAUDECODE_LOCAL_RULE_FILE_NAME}` },
1476
1480
  {
1477
- target: ["copilot", "copilotcli"],
1481
+ target: "claudecode",
1478
1482
  feature: "rules",
1479
- entry: "**/.github/instructions/"
1480
- },
1481
- { target: "copilot", feature: "commands", entry: "**/.github/prompts/" },
1482
- { target: "copilot", feature: "subagents", entry: "**/.github/agents/" },
1483
- // Copilot CLI shares the project `.github/skills/` location with the Copilot
1484
- // IDE target; its personal skills live under `~/.copilot/skills/` (home dir,
1485
- // not gitignored at the project level).
1486
- { target: ["copilot", "copilotcli"], feature: "skills", entry: "**/.github/skills/" },
1487
- { target: "copilot", feature: "hooks", entry: "**/.github/hooks/" },
1488
- { target: "copilot", feature: "mcp", entry: "**/.vscode/mcp.json" },
1489
- // GitHub Copilot CLI
1490
- {
1491
- target: "copilotcli",
1492
- feature: "mcp",
1493
- entry: "**/.copilot/mcp-config.json"
1494
- },
1495
- // Project: <project>/.github/agents/*.agent.md
1496
- // Global: ~/.copilot/agents/
1497
- { target: "copilotcli", feature: "subagents", entry: "**/.github/agents/" },
1498
- { target: "copilotcli", feature: "subagents", entry: "**/.copilot/agents/" },
1499
- // Project: <project>/.github/hooks/copilotcli-hooks.json
1500
- // Global: ~/.copilot/hooks/copilot-hooks.json (rulesync convention pending
1501
- // official documentation of a global hooks location)
1502
- { target: "copilotcli", feature: "hooks", entry: "**/.github/hooks/" },
1503
- { target: "copilotcli", feature: "hooks", entry: "**/.copilot/hooks/" },
1504
- // Junie
1505
- { target: "junie", feature: "rules", entry: "**/.junie/guidelines.md" },
1506
- { target: "junie", feature: "mcp", entry: "**/.junie/mcp/mcp.json" },
1507
- { target: "junie", feature: "skills", entry: "**/.junie/skills/" },
1508
- { target: "junie", feature: "subagents", entry: "**/.junie/agents/" },
1509
- // Kilo Code
1510
- { target: "kilo", feature: "rules", entry: "**/.kilo/rules/" },
1511
- { target: "kilo", feature: "skills", entry: "**/.kilo/skills/" },
1512
- { target: "kilo", feature: "commands", entry: "**/.kilo/workflows/" },
1513
- { target: "kilo", feature: "mcp", entry: "**/.kilo/mcp.json" },
1514
- { target: "kilo", feature: "ignore", entry: "**/.kilocodeignore" },
1515
- // No `**/kilo.jsonc` entry: structurally identical to `opencode.jsonc` (no
1516
- // entry). The Kilo translator preserves non-permissions Kilo settings on
1517
- // round-trip, so the file is intended to be checked in by the user — adding
1518
- // `**/kilo.jsonc` would be too aggressive.
1519
- // Kiro
1520
- { target: "kiro", feature: "rules", entry: "**/.kiro/steering/" },
1521
- { target: "kiro", feature: "commands", entry: "**/.kiro/prompts/" },
1522
- { target: "kiro", feature: "skills", entry: "**/.kiro/skills/" },
1523
- { target: "kiro", feature: "subagents", entry: "**/.kiro/agents/" },
1524
- { target: "kiro", feature: "mcp", entry: "**/.kiro/settings/mcp.json" },
1525
- { target: "kiro", feature: "ignore", entry: "**/.aiignore" },
1526
- // Kiro IDE and CLI write to the same `.kiro/` tree as the legacy `kiro` alias.
1527
- // (Kiro IDE subagents are Markdown under `.kiro/agents/`, the CLI's are JSON —
1528
- // both covered by the shared `**/.kiro/agents/` entry.)
1529
- { target: ["kiro-cli", "kiro-ide"], feature: "rules", entry: "**/.kiro/steering/" },
1530
- { target: ["kiro-cli", "kiro-ide"], feature: "commands", entry: "**/.kiro/prompts/" },
1531
- { target: ["kiro-cli", "kiro-ide"], feature: "skills", entry: "**/.kiro/skills/" },
1532
- { target: ["kiro-cli", "kiro-ide"], feature: "subagents", entry: "**/.kiro/agents/" },
1533
- { target: ["kiro-cli", "kiro-ide"], feature: "mcp", entry: "**/.kiro/settings/mcp.json" },
1534
- { target: ["kiro-cli", "kiro-ide"], feature: "ignore", entry: "**/.aiignore" },
1535
- // Keep this after ignore entries like "**/.aiignore" so the exception remains effective.
1536
- { target: "common", feature: "general", entry: "!.rulesync/.aiignore" },
1537
- // OpenCode
1538
- { target: "opencode", feature: "commands", entry: "**/.opencode/commands/" },
1539
- { target: "opencode", feature: "subagents", entry: "**/.opencode/agents/" },
1540
- { target: "opencode", feature: "skills", entry: "**/.opencode/skills/" },
1541
- { target: "opencode", feature: "mcp", entry: "**/.opencode/plugins/" },
1542
- { target: "opencode", feature: "general", entry: "**/.opencode/memories/" },
1543
- {
1544
- target: "opencode",
1545
- feature: "general",
1546
- entry: "**/.opencode/package-lock.json"
1483
+ entry: `**/${CLAUDECODE_DIR}/${CLAUDECODE_LOCAL_RULE_FILE_NAME}`
1547
1484
  },
1548
- // Pi Coding Agent
1549
- { target: "pi", feature: "rules", entry: "**/.agents/memories/" },
1550
- { target: "pi", feature: "commands", entry: "**/.pi/prompts/" },
1551
- { target: "pi", feature: "skills", entry: "**/.pi/skills/" },
1552
- // Qwen Code
1553
- { target: "qwencode", feature: "rules", entry: "**/QWEN.md" },
1554
- { target: "qwencode", feature: "commands", entry: "**/.qwen/commands/" },
1555
- { target: "qwencode", feature: "subagents", entry: "**/.qwen/agents/" },
1556
- { target: "qwencode", feature: "skills", entry: "**/.qwen/skills/" },
1557
- { target: "qwencode", feature: "ignore", entry: "**/.qwenignore" },
1558
- { target: "qwencode", feature: "general", entry: "**/.qwen/memories/" },
1559
- // mcp + hooks both write to `.qwen/settings.json`, shared with permissions.
1560
- { target: "qwencode", feature: "permissions", entry: "**/.qwen/settings.json" },
1561
- // Replit
1562
- { target: "replit", feature: "rules", entry: "**/replit.md" },
1563
- // Roo
1564
- { target: "roo", feature: "rules", entry: "**/.roo/rules/" },
1565
- { target: "roo", feature: "skills", entry: "**/.roo/skills/" },
1566
- { target: "roo", feature: "ignore", entry: "**/.rooignore" },
1567
- { target: "roo", feature: "mcp", entry: "**/.roo/mcp.json" },
1568
- { target: "roo", feature: "subagents", entry: "**/.roomodes" },
1569
- // Rovodev
1485
+ // Third-party tool by-products rulesync gitignores but never writes itself.
1486
+ { target: "claudecode", feature: "general", entry: `**/${CLAUDECODE_DIR}/*.lock` },
1570
1487
  {
1571
- target: "rovodev",
1488
+ target: "claudecode",
1572
1489
  feature: "general",
1573
- entry: "**/.rovodev/AGENTS.md"
1490
+ entry: `**/${CLAUDECODE_DIR}/${CLAUDECODE_SETTINGS_LOCAL_FILE_NAME}`
1574
1491
  },
1575
- { target: "rovodev", feature: "subagents", entry: "**/.rovodev/subagents/" },
1576
- { target: "rovodev", feature: "skills", entry: "**/.rovodev/skills/" },
1577
1492
  {
1578
- target: "rovodev",
1493
+ target: "claudecode",
1579
1494
  feature: "general",
1580
- entry: "**/.rovodev/.rulesync/"
1495
+ entry: `**/${CLAUDECODE_DIR}/${CLAUDECODE_MEMORIES_DIR_NAME}/`
1581
1496
  },
1582
- { target: "rovodev", feature: "skills", entry: "**/.agents/skills/" },
1583
- // TAKT
1584
- // Each rulesync feature maps one-to-one onto a TAKT facet directory.
1585
- { target: "takt", feature: "rules", entry: "**/.takt/facets/policies/" },
1586
- { target: "takt", feature: "skills", entry: "**/.takt/facets/knowledge/" },
1587
- { target: "takt", feature: "subagents", entry: "**/.takt/facets/personas/" },
1588
- { target: "takt", feature: "commands", entry: "**/.takt/facets/instructions/" },
1497
+ { target: "opencode", feature: "general", entry: "**/.opencode/package-lock.json" },
1498
+ { target: "rovodev", feature: "general", entry: "**/.rovodev/.rulesync/" },
1589
1499
  { target: "takt", feature: "general", entry: "**/.takt/runs/" },
1590
1500
  { target: "takt", feature: "general", entry: "**/.takt/tasks/" },
1591
1501
  { target: "takt", feature: "general", entry: "**/.takt/.cache/" },
1592
1502
  { target: "takt", feature: "general", entry: "**/.takt/config.yaml" },
1593
- // Devin (Devin Desktop). Since the rebrand (v3.0.12, 2026-06-02), project
1594
- // rules/workflows/skills live under `.devin/`; MCP and hooks keep their
1595
- // `.devin/` paths and the global skills path is unchanged.
1596
- { target: "devin", feature: "rules", entry: "**/.devin/rules/" },
1597
- { target: "devin", feature: "commands", entry: "**/.devin/workflows/" },
1598
- { target: "devin", feature: "ignore", entry: "**/.devinignore" },
1599
- { target: "devin", feature: "mcp", entry: "**/.windsurf/mcp_config.json" },
1600
- { target: "devin", feature: "hooks", entry: "**/.windsurf/hooks.json" },
1601
- { target: "devin", feature: "skills", entry: "**/.devin/skills/" },
1503
+ // Augment Code's legacy single-file rules path: accepted on import but never
1504
+ // generated (so not in getSettablePaths), gitignored as a convenience.
1505
+ { target: "augmentcode", feature: "rules", entry: "**/.augment-guidelines" },
1506
+ // Shared trees and global-scope outputs not produced via project getSettablePaths.
1507
+ { target: "rovodev", feature: "skills", entry: "**/.agents/skills/" },
1602
1508
  { target: "devin", feature: "skills", entry: "**/.codeium/windsurf/skills/" },
1603
- // Devin Local custom subagent profiles: `.devin/agents/<name>/AGENT.md`
1604
- // (project). The global path `~/.config/devin/agents/` lives under the home
1605
- // directory and is not gitignored at the project level.
1606
- { target: "devin", feature: "subagents", entry: "**/.devin/agents/" },
1607
- // Vibe
1608
- { target: "vibe", feature: "ignore", entry: "**/.vibeignore" },
1609
- { target: "vibe", feature: "skills", entry: "**/.vibe/skills/" },
1610
- { target: "vibe", feature: "subagents", entry: "**/.vibe/agents/" },
1611
- { target: "vibe", feature: "mcp", entry: "**/.vibe/config.toml" },
1612
- { target: "vibe", feature: "permissions", entry: "**/.vibe/config.toml" },
1613
- // Experimental hooks live in `.vibe/hooks.toml`; the `enable_experimental_hooks`
1614
- // flag is merged into the shared `.vibe/config.toml` (already covered above).
1615
- { target: "vibe", feature: "hooks", entry: "**/.vibe/hooks.toml" },
1616
- { target: "vibe", feature: "hooks", entry: "**/.vibe/config.toml" },
1617
- // Warp
1618
- // Warp reads project rules only from the root `AGENTS.md` (handled by the
1619
- // shared AGENTS.md entry above); it does not read `.warp/memories/`, so no
1620
- // rules entry under `.warp/` is emitted.
1621
- { target: "warp", feature: "mcp", entry: "**/.warp/.mcp.json" },
1622
- { target: "warp", feature: "skills", entry: "**/.warp/skills/" },
1623
- // Zed
1624
- // `.rules` is the project rules file. `.agents/skills/` is shared with the
1625
- // antigravity targets (already registered above); re-tagging it for `zed`
1626
- // ensures target-filtered gitignore runs still include it.
1627
- // No entry for `.zed/settings.json` (MCP / ignore): it is a user-managed
1628
- // file, like other tools' settings.json, so it is intentionally not ignored.
1629
- { target: "zed", feature: "rules", entry: "**/.rules" },
1630
- { target: "zed", feature: "skills", entry: "**/.agents/skills/" }
1509
+ { target: "copilotcli", feature: "subagents", entry: "**/.copilot/agents/" },
1510
+ { target: "copilotcli", feature: "mcp", entry: "**/.copilot/mcp-config.json" },
1511
+ { target: "copilotcli", feature: "hooks", entry: "**/.copilot/hooks/" },
1512
+ { target: "deepagents", feature: "hooks", entry: "**/.deepagents/hooks.json" },
1513
+ // Roo aggregates subagents into a single `.roomodes` file (no settable path).
1514
+ { target: "roo", feature: "subagents", entry: "**/.roomodes" },
1515
+ // codexcli has no ignore processor; its `.codexignore` is a ghost entry.
1516
+ { target: "codexcli", feature: "ignore", entry: "**/.codexignore" }
1517
+ ];
1518
+ var GITIGNORE_ENTRY_REGISTRY = [
1519
+ ...HAND_MAINTAINED_GITIGNORE_ENTRIES,
1520
+ // Every entry a tool actually emits, derived from its getSettablePaths.
1521
+ ...deriveAllGitignoreEntries(),
1522
+ // Keep this after ignore entries like Junie's "**/.aiignore" so the exception remains effective.
1523
+ { target: "common", feature: "general", entry: "!.rulesync/.aiignore" }
1631
1524
  ];
1632
1525
  var ALL_GITIGNORE_ENTRIES = (() => {
1633
1526
  const seen = /* @__PURE__ */ new Set();
@@ -2340,7 +2233,7 @@ function findApmLockDependency(lock, repoUrl) {
2340
2233
 
2341
2234
  // src/lib/apm/apm-manifest.ts
2342
2235
  import { join as join5 } from "path";
2343
- import { dump as dump2, load as load2 } from "js-yaml";
2236
+ import { load as load2 } from "js-yaml";
2344
2237
  import { optional as optional2, z as z5 } from "zod/mini";
2345
2238
  var APM_MANIFEST_FILE_NAME = "apm.yml";
2346
2239
  var ApmObjectDependencySchema = z5.looseObject({
@@ -2548,34 +2441,7 @@ async function installApm(params) {
2548
2441
  }
2549
2442
  const existingLock = await readApmLock(projectRoot);
2550
2443
  if (options.frozen) {
2551
- if (!existingLock) {
2552
- throw new Error(
2553
- "Frozen install failed: rulesync-apm.lock.yaml is missing. Run 'rulesync install --mode apm' to create it."
2554
- );
2555
- }
2556
- const missing = manifest.dependencies.filter(
2557
- (dep) => !findApmLockDependency(existingLock, canonicalRepoUrl(dep))
2558
- );
2559
- if (missing.length > 0) {
2560
- const names = missing.map((d) => d.gitUrl).join(", ");
2561
- throw new Error(
2562
- `Frozen install failed: rulesync-apm.lock.yaml is missing entries for: ${names}. Run 'rulesync install --mode apm' to update the lockfile.`
2563
- );
2564
- }
2565
- const drifted = manifest.dependencies.filter((dep) => {
2566
- if (dep.ref === void 0) return false;
2567
- const locked = findApmLockDependency(existingLock, canonicalRepoUrl(dep));
2568
- return locked?.resolved_ref !== void 0 && locked.resolved_ref !== dep.ref;
2569
- });
2570
- if (drifted.length > 0) {
2571
- const names = drifted.map((d) => {
2572
- const locked = findApmLockDependency(existingLock, canonicalRepoUrl(d));
2573
- return `${d.gitUrl} (manifest=${d.ref}, lock=${locked?.resolved_ref})`;
2574
- }).join(", ");
2575
- throw new Error(
2576
- `Frozen install failed: manifest ref does not match rulesync-apm.lock.yaml for: ${names}. Run 'rulesync install --mode apm' to update the lockfile.`
2577
- );
2578
- }
2444
+ assertFrozenLockCoversManifest({ existingLock, dependencies: manifest.dependencies });
2579
2445
  }
2580
2446
  const token = GitHubClient.resolveToken(options.token);
2581
2447
  const client = new GitHubClient({ token });
@@ -2630,30 +2496,7 @@ async function installApm(params) {
2630
2496
  }
2631
2497
  }
2632
2498
  if (existingLock) {
2633
- const newDeployedFiles = new Set(newLock.dependencies.flatMap((d) => d.deployed_files));
2634
- const toDelete = [];
2635
- for (const prev of existingLock.dependencies) {
2636
- for (const deployed of prev.deployed_files) {
2637
- if (!newDeployedFiles.has(deployed)) {
2638
- toDelete.push(deployed);
2639
- }
2640
- }
2641
- }
2642
- for (const relativePath of toDelete) {
2643
- if (posix2.isAbsolute(relativePath) || relativePath.split(/[/\\]/).includes("..")) {
2644
- logger5.warn(`Refusing to remove stale apm file with suspicious path: "${relativePath}".`);
2645
- continue;
2646
- }
2647
- try {
2648
- checkPathTraversal({ relativePath, intendedRootDir: projectRoot });
2649
- } catch {
2650
- logger5.warn(`Refusing to remove stale apm file outside projectRoot: "${relativePath}".`);
2651
- continue;
2652
- }
2653
- const absolute = join6(projectRoot, relativePath);
2654
- await removeFile(absolute);
2655
- logger5.debug(`Removed stale apm file: ${relativePath}`);
2656
- }
2499
+ await removeStaleApmFiles({ existingLock, newLock, projectRoot, logger: logger5 });
2657
2500
  }
2658
2501
  if (!frozen) {
2659
2502
  newLock.generated_at = (/* @__PURE__ */ new Date()).toISOString();
@@ -2672,6 +2515,64 @@ async function installApm(params) {
2672
2515
  failedDependencyCount: failedCount
2673
2516
  };
2674
2517
  }
2518
+ function assertFrozenLockCoversManifest(params) {
2519
+ const { existingLock, dependencies } = params;
2520
+ if (!existingLock) {
2521
+ throw new Error(
2522
+ "Frozen install failed: rulesync-apm.lock.yaml is missing. Run 'rulesync install --mode apm' to create it."
2523
+ );
2524
+ }
2525
+ const missing = dependencies.filter(
2526
+ (dep) => !findApmLockDependency(existingLock, canonicalRepoUrl(dep))
2527
+ );
2528
+ if (missing.length > 0) {
2529
+ const names = missing.map((d) => d.gitUrl).join(", ");
2530
+ throw new Error(
2531
+ `Frozen install failed: rulesync-apm.lock.yaml is missing entries for: ${names}. Run 'rulesync install --mode apm' to update the lockfile.`
2532
+ );
2533
+ }
2534
+ const drifted = dependencies.filter((dep) => {
2535
+ if (dep.ref === void 0) return false;
2536
+ const locked = findApmLockDependency(existingLock, canonicalRepoUrl(dep));
2537
+ return locked?.resolved_ref !== void 0 && locked.resolved_ref !== dep.ref;
2538
+ });
2539
+ if (drifted.length > 0) {
2540
+ const names = drifted.map((d) => {
2541
+ const locked = findApmLockDependency(existingLock, canonicalRepoUrl(d));
2542
+ return `${d.gitUrl} (manifest=${d.ref}, lock=${locked?.resolved_ref})`;
2543
+ }).join(", ");
2544
+ throw new Error(
2545
+ `Frozen install failed: manifest ref does not match rulesync-apm.lock.yaml for: ${names}. Run 'rulesync install --mode apm' to update the lockfile.`
2546
+ );
2547
+ }
2548
+ }
2549
+ async function removeStaleApmFiles(params) {
2550
+ const { existingLock, newLock, projectRoot, logger: logger5 } = params;
2551
+ const newDeployedFiles = new Set(newLock.dependencies.flatMap((d) => d.deployed_files));
2552
+ const toDelete = [];
2553
+ for (const prev of existingLock.dependencies) {
2554
+ for (const deployed of prev.deployed_files) {
2555
+ if (!newDeployedFiles.has(deployed)) {
2556
+ toDelete.push(deployed);
2557
+ }
2558
+ }
2559
+ }
2560
+ for (const relativePath of toDelete) {
2561
+ if (posix2.isAbsolute(relativePath) || relativePath.split(/[/\\]/).includes("..")) {
2562
+ logger5.warn(`Refusing to remove stale apm file with suspicious path: "${relativePath}".`);
2563
+ continue;
2564
+ }
2565
+ try {
2566
+ checkPathTraversal({ relativePath, intendedRootDir: projectRoot });
2567
+ } catch {
2568
+ logger5.warn(`Refusing to remove stale apm file outside projectRoot: "${relativePath}".`);
2569
+ continue;
2570
+ }
2571
+ const absolute = join6(projectRoot, relativePath);
2572
+ await removeFile(absolute);
2573
+ logger5.debug(`Removed stale apm file: ${relativePath}`);
2574
+ }
2575
+ }
2675
2576
  async function installDependency(params) {
2676
2577
  const { dep, client, semaphore, projectRoot, existingLock, frozen, update, logger: logger5 } = params;
2677
2578
  const repoUrl = canonicalRepoUrl(dep);
@@ -2700,58 +2601,25 @@ async function installDependency(params) {
2700
2601
  logger: logger5
2701
2602
  });
2702
2603
  if (files.length === 0) continue;
2703
- for (const file of files) {
2704
- if (file.size > MAX_FILE_SIZE) {
2705
- logger5.warn(
2706
- `Skipping "${file.path}" from ${repoUrl}: ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
2707
- );
2708
- continue;
2709
- }
2710
- const relativeToBase = posix2.relative(remoteBase, toPosixPath(file.path));
2711
- if (!relativeToBase || relativeToBase.startsWith("..") || posix2.isAbsolute(relativeToBase)) {
2712
- logger5.warn(
2713
- `Skipping "${file.path}" from ${repoUrl}: resolved outside of "${remoteBase}".`
2714
- );
2715
- continue;
2716
- }
2717
- const deployRelative = toPosixPath(join6(primitive.deployDir, relativeToBase));
2718
- checkPathTraversal({
2719
- relativePath: deployRelative,
2720
- intendedRootDir: projectRoot
2721
- });
2722
- const content = await withSemaphore(
2723
- semaphore,
2724
- () => client.getFileContent(dep.owner, dep.repo, file.path, resolvedSha)
2725
- );
2726
- const byteLength = Buffer.byteLength(content, "utf8");
2727
- if (byteLength > MAX_FILE_SIZE) {
2728
- logger5.warn(
2729
- `Skipping "${file.path}" from ${repoUrl}: fetched ${(byteLength / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
2730
- );
2731
- continue;
2732
- }
2733
- deployed.push({ path: deployRelative, content });
2734
- if (!frozen) {
2735
- await writeFileContent(join6(projectRoot, deployRelative), content);
2736
- }
2737
- }
2604
+ await collectPrimitiveDeployments({
2605
+ dep,
2606
+ client,
2607
+ semaphore,
2608
+ projectRoot,
2609
+ primitive,
2610
+ remoteBase,
2611
+ files,
2612
+ resolvedSha,
2613
+ repoUrl,
2614
+ frozen,
2615
+ deployed,
2616
+ logger: logger5
2617
+ });
2738
2618
  }
2739
2619
  deployed.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2740
2620
  const deployedFiles = deployed.map((d) => d.path);
2741
2621
  const contentHash = computeContentHash(deployed);
2742
- if (frozen && locked?.content_hash) {
2743
- if (RULESYNC_CONTENT_HASH_REGEX.test(locked.content_hash)) {
2744
- if (locked.content_hash !== contentHash) {
2745
- throw new Error(
2746
- `content_hash mismatch for ${repoUrl}: lock=${locked.content_hash} computed=${contentHash}. Refuse to trust the deployment under --frozen.`
2747
- );
2748
- }
2749
- } else {
2750
- logger5.debug(
2751
- `Skipping content_hash integrity check for ${repoUrl}: recorded hash "${locked.content_hash}" was not written by rulesync.`
2752
- );
2753
- }
2754
- }
2622
+ assertFrozenContentHashMatches({ frozen, locked, contentHash, repoUrl, logger: logger5 });
2755
2623
  if (frozen) {
2756
2624
  for (const { path: deployRelative, content } of deployed) {
2757
2625
  await writeFileContent(join6(projectRoot, deployRelative), content);
@@ -2772,20 +2640,85 @@ async function installDependency(params) {
2772
2640
  logger5.info(`Installed ${deployedFiles.length} file(s) from ${repoUrl}@${shortSha(resolvedSha)}`);
2773
2641
  return { lockEntry, deployedFiles };
2774
2642
  }
2775
- function computeContentHash(files) {
2776
- const hash = createHash("sha256");
2777
- for (const { path: path2, content } of files) {
2778
- hash.update(path2);
2779
- hash.update("\0");
2780
- hash.update(content);
2781
- hash.update("\0");
2782
- }
2783
- return `sha256:${hash.digest("hex")}`;
2784
- }
2785
- async function listPrimitiveFiles(params) {
2786
- const { client, semaphore, owner, repo, ref, remoteBase, logger: logger5 } = params;
2787
- try {
2788
- return await listDirectoryRecursive({
2643
+ async function collectPrimitiveDeployments(params) {
2644
+ const {
2645
+ dep,
2646
+ client,
2647
+ semaphore,
2648
+ projectRoot,
2649
+ primitive,
2650
+ remoteBase,
2651
+ files,
2652
+ resolvedSha,
2653
+ repoUrl,
2654
+ frozen,
2655
+ deployed,
2656
+ logger: logger5
2657
+ } = params;
2658
+ for (const file of files) {
2659
+ if (file.size > MAX_FILE_SIZE) {
2660
+ logger5.warn(
2661
+ `Skipping "${file.path}" from ${repoUrl}: ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
2662
+ );
2663
+ continue;
2664
+ }
2665
+ const relativeToBase = posix2.relative(remoteBase, toPosixPath(file.path));
2666
+ if (!relativeToBase || relativeToBase.startsWith("..") || posix2.isAbsolute(relativeToBase)) {
2667
+ logger5.warn(`Skipping "${file.path}" from ${repoUrl}: resolved outside of "${remoteBase}".`);
2668
+ continue;
2669
+ }
2670
+ const deployRelative = toPosixPath(join6(primitive.deployDir, relativeToBase));
2671
+ checkPathTraversal({
2672
+ relativePath: deployRelative,
2673
+ intendedRootDir: projectRoot
2674
+ });
2675
+ const content = await withSemaphore(
2676
+ semaphore,
2677
+ () => client.getFileContent(dep.owner, dep.repo, file.path, resolvedSha)
2678
+ );
2679
+ const byteLength = Buffer.byteLength(content, "utf8");
2680
+ if (byteLength > MAX_FILE_SIZE) {
2681
+ logger5.warn(
2682
+ `Skipping "${file.path}" from ${repoUrl}: fetched ${(byteLength / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
2683
+ );
2684
+ continue;
2685
+ }
2686
+ deployed.push({ path: deployRelative, content });
2687
+ if (!frozen) {
2688
+ await writeFileContent(join6(projectRoot, deployRelative), content);
2689
+ }
2690
+ }
2691
+ }
2692
+ function assertFrozenContentHashMatches(params) {
2693
+ const { frozen, locked, contentHash, repoUrl, logger: logger5 } = params;
2694
+ if (frozen && locked?.content_hash) {
2695
+ if (RULESYNC_CONTENT_HASH_REGEX.test(locked.content_hash)) {
2696
+ if (locked.content_hash !== contentHash) {
2697
+ throw new Error(
2698
+ `content_hash mismatch for ${repoUrl}: lock=${locked.content_hash} computed=${contentHash}. Refuse to trust the deployment under --frozen.`
2699
+ );
2700
+ }
2701
+ } else {
2702
+ logger5.debug(
2703
+ `Skipping content_hash integrity check for ${repoUrl}: recorded hash "${locked.content_hash}" was not written by rulesync.`
2704
+ );
2705
+ }
2706
+ }
2707
+ }
2708
+ function computeContentHash(files) {
2709
+ const hash = createHash("sha256");
2710
+ for (const { path: path2, content } of files) {
2711
+ hash.update(path2);
2712
+ hash.update("\0");
2713
+ hash.update(content);
2714
+ hash.update("\0");
2715
+ }
2716
+ return `sha256:${hash.digest("hex")}`;
2717
+ }
2718
+ async function listPrimitiveFiles(params) {
2719
+ const { client, semaphore, owner, repo, ref, remoteBase, logger: logger5 } = params;
2720
+ try {
2721
+ return await listDirectoryRecursive({
2789
2722
  client,
2790
2723
  owner,
2791
2724
  repo,
@@ -2814,7 +2747,7 @@ import { basename, join as join9, posix as posix3 } from "path";
2814
2747
  import { Semaphore as Semaphore3 } from "es-toolkit/promise";
2815
2748
 
2816
2749
  // src/lib/gh/gh-frontmatter.ts
2817
- import { dump as dump3, load as load3 } from "js-yaml";
2750
+ import { dump as dump2, load as load3 } from "js-yaml";
2818
2751
  var FRONTMATTER_FENCE = "---";
2819
2752
  function injectSourceMetadata(params) {
2820
2753
  const { content, source, repository, ref } = params;
@@ -2829,7 +2762,7 @@ function injectSourceMetadata(params) {
2829
2762
  } else if (content === FRONTMATTER_FENCE) {
2830
2763
  openFenceLen = 3;
2831
2764
  } else {
2832
- const yaml2 = dump3(provenance, { noRefs: true, lineWidth: -1, sortKeys: false });
2765
+ const yaml2 = dump2(provenance, { noRefs: true, lineWidth: -1, sortKeys: false });
2833
2766
  return `${FRONTMATTER_FENCE}
2834
2767
  ${yaml2}${FRONTMATTER_FENCE}
2835
2768
  ${content}`;
@@ -2856,7 +2789,7 @@ ${content}`;
2856
2789
  throw new Error("invalid frontmatter");
2857
2790
  }
2858
2791
  if (loaded === null || loaded === void 0) {
2859
- const yaml2 = dump3(provenance, { noRefs: true, lineWidth: -1, sortKeys: false });
2792
+ const yaml2 = dump2(provenance, { noRefs: true, lineWidth: -1, sortKeys: false });
2860
2793
  return `${FRONTMATTER_FENCE}
2861
2794
  ${yaml2}${FRONTMATTER_FENCE}
2862
2795
  ${rest}`;
@@ -2869,7 +2802,7 @@ ${rest}`;
2869
2802
  ...existing,
2870
2803
  ...provenance
2871
2804
  };
2872
- const yaml = dump3(merged, { noRefs: true, lineWidth: -1, sortKeys: false });
2805
+ const yaml = dump2(merged, { noRefs: true, lineWidth: -1, sortKeys: false });
2873
2806
  return `${FRONTMATTER_FENCE}
2874
2807
  ${yaml}${FRONTMATTER_FENCE}
2875
2808
  ${rest}`;
@@ -2877,7 +2810,7 @@ ${rest}`;
2877
2810
 
2878
2811
  // src/lib/gh/gh-lock.ts
2879
2812
  import { join as join7 } from "path";
2880
- import { dump as dump4, load as load4 } from "js-yaml";
2813
+ import { dump as dump3, load as load4 } from "js-yaml";
2881
2814
  import { optional as optional3, refine as refine2, z as z6 } from "zod/mini";
2882
2815
  var GH_LOCKFILE_FILE_NAME = "rulesync-gh.lock.yaml";
2883
2816
  var GH_LOCKFILE_VERSION = "1";
@@ -2949,7 +2882,7 @@ async function writeGhLock(params) {
2949
2882
  await writeFileContent(path2, content);
2950
2883
  }
2951
2884
  function serializeGhLock(lock) {
2952
- return dump4(lock, { noRefs: true, lineWidth: -1, sortKeys: false });
2885
+ return dump3(lock, { noRefs: true, lineWidth: -1, sortKeys: false });
2953
2886
  }
2954
2887
  function findGhLockInstallation(lock, params) {
2955
2888
  const target = params.source.toLowerCase();
@@ -3000,39 +2933,7 @@ async function installGh(params) {
3000
2933
  if (sources.length === 0) {
3001
2934
  return { sourcesProcessed: 0, installedSkillCount: 0, failedSourceCount: 0 };
3002
2935
  }
3003
- const resolvedSources = sources.map((entry) => {
3004
- const parsed = parseSource(entry.source);
3005
- if (parsed.provider !== "github") {
3006
- throw new Error(
3007
- `--mode gh only supports GitHub sources. "${entry.source}" resolves to provider "${parsed.provider}".`
3008
- );
3009
- }
3010
- if (entry.transport !== void 0 && entry.transport !== "github") {
3011
- throw new Error(
3012
- `--mode gh: field "transport" is not supported (got "${entry.transport}" for source "${entry.source}"). Drop the field or switch to --mode rulesync.`
3013
- );
3014
- }
3015
- if (entry.path !== void 0) {
3016
- throw new Error(
3017
- `--mode gh: field "path" is not supported for source "${entry.source}". The remote layout is fixed to "skills/<name>/SKILL.md".`
3018
- );
3019
- }
3020
- const agent = entry.agent ?? "github-copilot";
3021
- if (!GH_AGENTS.includes(agent)) {
3022
- throw new Error(
3023
- `--mode gh: unknown agent "${agent}" for source "${entry.source}". Valid agents: ${GH_AGENTS.join(", ")}.`
3024
- );
3025
- }
3026
- const scope = entry.scope ?? "project";
3027
- return {
3028
- entry,
3029
- owner: parsed.owner,
3030
- repo: parsed.repo,
3031
- ref: entry.ref ?? parsed.ref,
3032
- agent,
3033
- scope
3034
- };
3035
- });
2936
+ const resolvedSources = sources.map(resolveGhSource);
3036
2937
  const existingLock = await readGhLock(projectRoot);
3037
2938
  const frozen = options.frozen ?? false;
3038
2939
  const update = options.update ?? false;
@@ -3042,38 +2943,7 @@ async function installGh(params) {
3042
2943
  );
3043
2944
  }
3044
2945
  if (frozen && existingLock) {
3045
- const uncovered = [];
3046
- for (const rs of resolvedSources) {
3047
- const hasAny = existingLock.installations.some(
3048
- (i) => i.source.toLowerCase() === rs.entry.source.toLowerCase() && i.agent === rs.agent && i.scope === rs.scope
3049
- );
3050
- if (!hasAny) {
3051
- uncovered.push(`${rs.entry.source} (agent=${rs.agent}, scope=${rs.scope})`);
3052
- }
3053
- }
3054
- if (uncovered.length > 0) {
3055
- throw new Error(
3056
- `Frozen install failed: rulesync-gh.lock.yaml is missing entries for: ${uncovered.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3057
- );
3058
- }
3059
- const drifted = [];
3060
- for (const rs of resolvedSources) {
3061
- if (!rs.ref) continue;
3062
- const matches = existingLock.installations.filter(
3063
- (i) => i.source.toLowerCase() === rs.entry.source.toLowerCase()
3064
- );
3065
- for (const m of matches) {
3066
- if (m.requested_ref !== void 0 && m.requested_ref !== rs.ref) {
3067
- drifted.push(`${rs.entry.source} (manifest=${rs.ref}, lock=${m.requested_ref})`);
3068
- break;
3069
- }
3070
- }
3071
- }
3072
- if (drifted.length > 0) {
3073
- throw new Error(
3074
- `Frozen install failed: manifest ref does not match rulesync-gh.lock.yaml for: ${drifted.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3075
- );
3076
- }
2946
+ assertFrozenLockCoversSources({ existingLock, resolvedSources });
3077
2947
  }
3078
2948
  const token = GitHubClient.resolveToken(options.token);
3079
2949
  const client = new GitHubClient({ token });
@@ -3109,15 +2979,109 @@ async function installGh(params) {
3109
2979
  })
3110
2980
  );
3111
2981
  if (frozen) {
3112
- for (const result of results) {
3113
- if (result.status !== "ok") continue;
3114
- for (const inst of result.installations) {
3115
- for (const d of inst.deployed) {
3116
- await writeFileContent(d.absolutePath, d.content);
3117
- }
2982
+ await writeDeferredFrozenFiles(results);
2983
+ }
2984
+ const { totalInstalled, failedCount } = aggregateSourceResults({ results, newLock });
2985
+ if (existingLock) {
2986
+ await removeStaleGhFiles({ existingLock, newLock, projectRoot, logger: logger5 });
2987
+ }
2988
+ if (!frozen) {
2989
+ newLock.generated_at = (/* @__PURE__ */ new Date()).toISOString();
2990
+ await writeGhLock({ projectRoot, lock: newLock });
2991
+ if (failedCount === 0) {
2992
+ logger5.debug("rulesync-gh.lock.yaml updated.");
2993
+ } else {
2994
+ logger5.warn(
2995
+ `rulesync-gh.lock.yaml written with partially successful installs (${failedCount} source(s) failed).`
2996
+ );
2997
+ }
2998
+ }
2999
+ return {
3000
+ sourcesProcessed: sources.length,
3001
+ installedSkillCount: totalInstalled,
3002
+ failedSourceCount: failedCount
3003
+ };
3004
+ }
3005
+ function resolveGhSource(entry) {
3006
+ const parsed = parseSource(entry.source);
3007
+ if (parsed.provider !== "github") {
3008
+ throw new Error(
3009
+ `--mode gh only supports GitHub sources. "${entry.source}" resolves to provider "${parsed.provider}".`
3010
+ );
3011
+ }
3012
+ if (entry.transport !== void 0 && entry.transport !== "github") {
3013
+ throw new Error(
3014
+ `--mode gh: field "transport" is not supported (got "${entry.transport}" for source "${entry.source}"). Drop the field or switch to --mode rulesync.`
3015
+ );
3016
+ }
3017
+ if (entry.path !== void 0) {
3018
+ throw new Error(
3019
+ `--mode gh: field "path" is not supported for source "${entry.source}". The remote layout is fixed to "skills/<name>/SKILL.md".`
3020
+ );
3021
+ }
3022
+ const agent = entry.agent ?? "github-copilot";
3023
+ if (!GH_AGENTS.includes(agent)) {
3024
+ throw new Error(
3025
+ `--mode gh: unknown agent "${agent}" for source "${entry.source}". Valid agents: ${GH_AGENTS.join(", ")}.`
3026
+ );
3027
+ }
3028
+ const scope = entry.scope ?? "project";
3029
+ return {
3030
+ entry,
3031
+ owner: parsed.owner,
3032
+ repo: parsed.repo,
3033
+ ref: entry.ref ?? parsed.ref,
3034
+ agent,
3035
+ scope
3036
+ };
3037
+ }
3038
+ function assertFrozenLockCoversSources(params) {
3039
+ const { existingLock, resolvedSources } = params;
3040
+ const uncovered = [];
3041
+ for (const rs of resolvedSources) {
3042
+ const hasAny = existingLock.installations.some(
3043
+ (i) => i.source.toLowerCase() === rs.entry.source.toLowerCase() && i.agent === rs.agent && i.scope === rs.scope
3044
+ );
3045
+ if (!hasAny) {
3046
+ uncovered.push(`${rs.entry.source} (agent=${rs.agent}, scope=${rs.scope})`);
3047
+ }
3048
+ }
3049
+ if (uncovered.length > 0) {
3050
+ throw new Error(
3051
+ `Frozen install failed: rulesync-gh.lock.yaml is missing entries for: ${uncovered.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3052
+ );
3053
+ }
3054
+ const drifted = [];
3055
+ for (const rs of resolvedSources) {
3056
+ if (!rs.ref) continue;
3057
+ const matches = existingLock.installations.filter(
3058
+ (i) => i.source.toLowerCase() === rs.entry.source.toLowerCase()
3059
+ );
3060
+ for (const m of matches) {
3061
+ if (m.requested_ref !== void 0 && m.requested_ref !== rs.ref) {
3062
+ drifted.push(`${rs.entry.source} (manifest=${rs.ref}, lock=${m.requested_ref})`);
3063
+ break;
3064
+ }
3065
+ }
3066
+ }
3067
+ if (drifted.length > 0) {
3068
+ throw new Error(
3069
+ `Frozen install failed: manifest ref does not match rulesync-gh.lock.yaml for: ${drifted.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3070
+ );
3071
+ }
3072
+ }
3073
+ async function writeDeferredFrozenFiles(results) {
3074
+ for (const result of results) {
3075
+ if (result.status !== "ok") continue;
3076
+ for (const inst of result.installations) {
3077
+ for (const d of inst.deployed) {
3078
+ await writeFileContent(d.absolutePath, d.content);
3118
3079
  }
3119
3080
  }
3120
3081
  }
3082
+ }
3083
+ function aggregateSourceResults(params) {
3084
+ const { results, newLock } = params;
3121
3085
  let totalInstalled = 0;
3122
3086
  let failedCount = 0;
3123
3087
  for (const result of results) {
@@ -3133,47 +3097,135 @@ async function installGh(params) {
3133
3097
  }
3134
3098
  }
3135
3099
  }
3136
- if (existingLock) {
3137
- const newDeployed = /* @__PURE__ */ new Set();
3138
- for (const inst of newLock.installations) {
3139
- for (const file of inst.deployed_files) {
3140
- newDeployed.add(`${inst.scope}::${file}`);
3141
- }
3142
- }
3143
- for (const prev of existingLock.installations) {
3144
- for (const deployed of prev.deployed_files) {
3145
- const key = `${prev.scope}::${deployed}`;
3146
- if (newDeployed.has(key)) continue;
3147
- await removeStaleFile({
3148
- relativePath: deployed,
3149
- scope: prev.scope === "user" ? "user" : "project",
3150
- projectRoot,
3151
- logger: logger5
3152
- });
3153
- }
3100
+ return { totalInstalled, failedCount };
3101
+ }
3102
+ async function removeStaleGhFiles(params) {
3103
+ const { existingLock, newLock, projectRoot, logger: logger5 } = params;
3104
+ const newDeployed = /* @__PURE__ */ new Set();
3105
+ for (const inst of newLock.installations) {
3106
+ for (const file of inst.deployed_files) {
3107
+ newDeployed.add(`${inst.scope}::${file}`);
3154
3108
  }
3155
3109
  }
3156
- if (!frozen) {
3157
- newLock.generated_at = (/* @__PURE__ */ new Date()).toISOString();
3158
- await writeGhLock({ projectRoot, lock: newLock });
3159
- if (failedCount === 0) {
3160
- logger5.debug("rulesync-gh.lock.yaml updated.");
3161
- } else {
3162
- logger5.warn(
3163
- `rulesync-gh.lock.yaml written with partially successful installs (${failedCount} source(s) failed).`
3164
- );
3110
+ for (const prev of existingLock.installations) {
3111
+ for (const deployed of prev.deployed_files) {
3112
+ const key = `${prev.scope}::${deployed}`;
3113
+ if (newDeployed.has(key)) continue;
3114
+ await removeStaleFile({
3115
+ relativePath: deployed,
3116
+ scope: prev.scope === "user" ? "user" : "project",
3117
+ projectRoot,
3118
+ logger: logger5
3119
+ });
3165
3120
  }
3166
3121
  }
3167
- return {
3168
- sourcesProcessed: sources.length,
3169
- installedSkillCount: totalInstalled,
3170
- failedSourceCount: failedCount
3171
- };
3172
3122
  }
3173
3123
  async function installSource(params) {
3174
3124
  const { rs, client, semaphore, projectRoot, existingLock, frozen, update, logger: logger5 } = params;
3175
3125
  const { entry, owner, repo, agent, scope } = rs;
3176
3126
  const sourceKey = entry.source;
3127
+ const { resolvedRef, resolvedSha, usedTag } = await resolveGhRef({
3128
+ rs,
3129
+ client,
3130
+ owner,
3131
+ repo,
3132
+ sourceKey,
3133
+ logger: logger5
3134
+ });
3135
+ const validatedSkills = await discoverValidatedSkills({
3136
+ client,
3137
+ semaphore,
3138
+ owner,
3139
+ repo,
3140
+ resolvedSha,
3141
+ sourceKey,
3142
+ logger: logger5
3143
+ });
3144
+ if (validatedSkills === null) {
3145
+ return [];
3146
+ }
3147
+ const selected = selectSkills({ validatedSkills, entry, sourceKey, logger: logger5 });
3148
+ if (frozen && existingLock) {
3149
+ assertFrozenSkillCoverage({ selected, existingLock, sourceKey, agent, scope });
3150
+ }
3151
+ const results = [];
3152
+ const installRelDir = relativeInstallDirFor({ agent, scope });
3153
+ const scopeRoot = scope === "user" ? getHomeDirectory() : projectRoot;
3154
+ const sourceUrl = `https://github.com/${owner}/${repo}`;
3155
+ const repository = `${owner}/${repo}`;
3156
+ const provenanceRef = usedTag ? resolvedRef : resolvedSha;
3157
+ for (const sk of selected) {
3158
+ const locked = existingLock && !update ? findGhLockInstallation(existingLock, {
3159
+ source: sourceKey,
3160
+ agent,
3161
+ scope,
3162
+ skill: sk.name
3163
+ }) : void 0;
3164
+ const allFiles = await listDirectoryRecursive({
3165
+ client,
3166
+ owner,
3167
+ repo,
3168
+ path: sk.path,
3169
+ ref: resolvedSha,
3170
+ semaphore
3171
+ });
3172
+ const deployed = await buildSkillDeployment({
3173
+ sk,
3174
+ allFiles,
3175
+ client,
3176
+ semaphore,
3177
+ owner,
3178
+ repo,
3179
+ resolvedSha,
3180
+ installRelDir,
3181
+ scopeRoot,
3182
+ sourceUrl,
3183
+ repository,
3184
+ provenanceRef,
3185
+ sourceKey,
3186
+ frozen,
3187
+ logger: logger5
3188
+ });
3189
+ deployed.sort(
3190
+ (a, b) => a.relativeToScopeRoot < b.relativeToScopeRoot ? -1 : a.relativeToScopeRoot > b.relativeToScopeRoot ? 1 : 0
3191
+ );
3192
+ const deployedFiles = deployed.map((d) => d.relativeToScopeRoot);
3193
+ const contentHash = computeContentHash2(deployed);
3194
+ assertFrozenSkillIntegrity({
3195
+ frozen,
3196
+ locked,
3197
+ contentHash,
3198
+ sourceKey,
3199
+ skillName: sk.name,
3200
+ agent,
3201
+ scope,
3202
+ logger: logger5
3203
+ });
3204
+ const installation = {
3205
+ source: sourceKey,
3206
+ owner,
3207
+ repo,
3208
+ agent,
3209
+ scope,
3210
+ skill: sk.name,
3211
+ resolved_ref: resolvedRef,
3212
+ resolved_commit: resolvedSha,
3213
+ install_dir: toPosixPath(installRelDir),
3214
+ deployed_files: deployedFiles,
3215
+ content_hash: contentHash
3216
+ };
3217
+ if (rs.ref !== void 0) {
3218
+ installation.requested_ref = rs.ref;
3219
+ }
3220
+ results.push({ installation, deployed });
3221
+ logger5.info(
3222
+ `Installed gh skill "${sk.name}" from ${sourceKey} (agent=${agent}, scope=${scope}, ref=${resolvedRef})`
3223
+ );
3224
+ }
3225
+ return results;
3226
+ }
3227
+ async function resolveGhRef(params) {
3228
+ const { rs, client, owner, repo, sourceKey, logger: logger5 } = params;
3177
3229
  let resolvedRef;
3178
3230
  let usedTag = false;
3179
3231
  if (rs.ref) {
@@ -3193,13 +3245,17 @@ async function installSource(params) {
3193
3245
  }
3194
3246
  const resolvedSha = await client.resolveRefToSha(owner, repo, resolvedRef);
3195
3247
  logger5.debug(`Resolved ${sourceKey} -> ref=${resolvedRef} sha=${resolvedSha}`);
3248
+ return { resolvedRef, resolvedSha, usedTag };
3249
+ }
3250
+ async function discoverValidatedSkills(params) {
3251
+ const { client, semaphore, owner, repo, resolvedSha, sourceKey, logger: logger5 } = params;
3196
3252
  let topLevel;
3197
3253
  try {
3198
3254
  topLevel = await client.listDirectory(owner, repo, SKILLS_REMOTE_DIR, resolvedSha);
3199
3255
  } catch (error) {
3200
3256
  if (is404(error)) {
3201
3257
  logger5.warn(`No skills/ directory found in ${sourceKey}. Skipping.`);
3202
- return [];
3258
+ return null;
3203
3259
  }
3204
3260
  throw error;
3205
3261
  }
@@ -3214,152 +3270,133 @@ async function installSource(params) {
3214
3270
  validatedSkills.push(sk);
3215
3271
  }
3216
3272
  }
3217
- let selected = validatedSkills;
3218
- if (entry.skills && entry.skills.length > 0) {
3219
- const requested = new Set(entry.skills);
3220
- selected = validatedSkills.filter((s) => requested.has(s.name));
3221
- const presentNames = new Set(validatedSkills.map((s) => s.name));
3222
- for (const want of entry.skills) {
3223
- if (!presentNames.has(want)) {
3224
- logger5.warn(`Requested skill "${want}" not found in ${sourceKey} under skills/. Skipping.`);
3225
- }
3226
- }
3273
+ return validatedSkills;
3274
+ }
3275
+ function selectSkills(params) {
3276
+ const { validatedSkills, entry, sourceKey, logger: logger5 } = params;
3277
+ if (!entry.skills || entry.skills.length === 0) {
3278
+ return validatedSkills;
3227
3279
  }
3228
- if (frozen && existingLock) {
3229
- const missing = [];
3230
- for (const sk of selected) {
3231
- const locked = findGhLockInstallation(existingLock, {
3232
- source: sourceKey,
3233
- agent,
3234
- scope,
3235
- skill: sk.name
3236
- });
3237
- if (!locked) {
3238
- missing.push(sk.name);
3239
- }
3240
- }
3241
- if (missing.length > 0) {
3242
- throw new Error(
3243
- `Frozen install failed: rulesync-gh.lock.yaml is missing entries for ${sourceKey} (agent=${agent}, scope=${scope}) skills: ${missing.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3244
- );
3280
+ const requested = new Set(entry.skills);
3281
+ const selected = validatedSkills.filter((s) => requested.has(s.name));
3282
+ const presentNames = new Set(validatedSkills.map((s) => s.name));
3283
+ for (const want of entry.skills) {
3284
+ if (!presentNames.has(want)) {
3285
+ logger5.warn(`Requested skill "${want}" not found in ${sourceKey} under skills/. Skipping.`);
3245
3286
  }
3246
3287
  }
3247
- const results = [];
3248
- const installRelDir = relativeInstallDirFor({ agent, scope });
3249
- const scopeRoot = scope === "user" ? getHomeDirectory() : projectRoot;
3250
- const sourceUrl = `https://github.com/${owner}/${repo}`;
3251
- const repository = `${owner}/${repo}`;
3252
- const provenanceRef = usedTag ? resolvedRef : resolvedSha;
3288
+ return selected;
3289
+ }
3290
+ function assertFrozenSkillCoverage(params) {
3291
+ const { selected, existingLock, sourceKey, agent, scope } = params;
3292
+ const missing = [];
3253
3293
  for (const sk of selected) {
3254
- const locked = existingLock && !update ? findGhLockInstallation(existingLock, {
3294
+ const locked = findGhLockInstallation(existingLock, {
3255
3295
  source: sourceKey,
3256
3296
  agent,
3257
3297
  scope,
3258
3298
  skill: sk.name
3259
- }) : void 0;
3260
- const allFiles = await listDirectoryRecursive({
3261
- client,
3262
- owner,
3263
- repo,
3264
- path: sk.path,
3265
- ref: resolvedSha,
3266
- semaphore
3267
3299
  });
3268
- const deployed = [];
3269
- for (const file of allFiles) {
3270
- if (file.size > MAX_FILE_SIZE) {
3271
- logger5.warn(
3272
- `Skipping "${file.path}" from ${sourceKey}: ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
3273
- );
3274
- continue;
3275
- }
3276
- const relativeToSkill = posix3.relative(sk.path, toPosixPath(file.path));
3277
- if (!relativeToSkill || relativeToSkill.startsWith("..") || posix3.isAbsolute(relativeToSkill)) {
3278
- logger5.warn(`Skipping "${file.path}" from ${sourceKey}: resolved outside of "${sk.path}".`);
3279
- continue;
3280
- }
3281
- const deployRelative = toPosixPath(join9(installRelDir, sk.name, relativeToSkill));
3282
- checkPathTraversal({ relativePath: deployRelative, intendedRootDir: scopeRoot });
3283
- const installAbs = join9(scopeRoot, installRelDir);
3284
- const withinInstallDir = toPosixPath(join9(sk.name, relativeToSkill));
3285
- checkPathTraversal({ relativePath: withinInstallDir, intendedRootDir: installAbs });
3286
- let content = await withSemaphore(
3287
- semaphore,
3288
- () => client.getFileContent(owner, repo, file.path, resolvedSha)
3300
+ if (!locked) {
3301
+ missing.push(sk.name);
3302
+ }
3303
+ }
3304
+ if (missing.length > 0) {
3305
+ throw new Error(
3306
+ `Frozen install failed: rulesync-gh.lock.yaml is missing entries for ${sourceKey} (agent=${agent}, scope=${scope}) skills: ${missing.join(", ")}. Run 'rulesync install --mode gh' to update the lockfile.`
3307
+ );
3308
+ }
3309
+ }
3310
+ async function buildSkillDeployment(params) {
3311
+ const {
3312
+ sk,
3313
+ allFiles,
3314
+ client,
3315
+ semaphore,
3316
+ owner,
3317
+ repo,
3318
+ resolvedSha,
3319
+ installRelDir,
3320
+ scopeRoot,
3321
+ sourceUrl,
3322
+ repository,
3323
+ provenanceRef,
3324
+ sourceKey,
3325
+ frozen,
3326
+ logger: logger5
3327
+ } = params;
3328
+ const deployed = [];
3329
+ for (const file of allFiles) {
3330
+ if (file.size > MAX_FILE_SIZE) {
3331
+ logger5.warn(
3332
+ `Skipping "${file.path}" from ${sourceKey}: ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
3333
+ );
3334
+ continue;
3335
+ }
3336
+ const relativeToSkill = posix3.relative(sk.path, toPosixPath(file.path));
3337
+ if (!relativeToSkill || relativeToSkill.startsWith("..") || posix3.isAbsolute(relativeToSkill)) {
3338
+ logger5.warn(`Skipping "${file.path}" from ${sourceKey}: resolved outside of "${sk.path}".`);
3339
+ continue;
3340
+ }
3341
+ const deployRelative = toPosixPath(join9(installRelDir, sk.name, relativeToSkill));
3342
+ checkPathTraversal({ relativePath: deployRelative, intendedRootDir: scopeRoot });
3343
+ const installAbs = join9(scopeRoot, installRelDir);
3344
+ const withinInstallDir = toPosixPath(join9(sk.name, relativeToSkill));
3345
+ checkPathTraversal({ relativePath: withinInstallDir, intendedRootDir: installAbs });
3346
+ let content = await withSemaphore(
3347
+ semaphore,
3348
+ () => client.getFileContent(owner, repo, file.path, resolvedSha)
3349
+ );
3350
+ const byteLength = Buffer.byteLength(content, "utf8");
3351
+ if (byteLength > MAX_FILE_SIZE) {
3352
+ logger5.warn(
3353
+ `Skipping "${file.path}" from ${sourceKey}: fetched ${(byteLength / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
3289
3354
  );
3290
- const byteLength = Buffer.byteLength(content, "utf8");
3291
- if (byteLength > MAX_FILE_SIZE) {
3355
+ continue;
3356
+ }
3357
+ if (basename(file.path) === SKILL_FILE_NAME2) {
3358
+ try {
3359
+ content = injectSourceMetadata({
3360
+ content,
3361
+ source: sourceUrl,
3362
+ repository,
3363
+ ref: provenanceRef
3364
+ });
3365
+ } catch {
3292
3366
  logger5.warn(
3293
- `Skipping "${file.path}" from ${sourceKey}: fetched ${(byteLength / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`
3367
+ `Frontmatter in ${file.path} (${sourceKey}) is invalid. Prepending a fresh provenance block.`
3294
3368
  );
3295
- continue;
3296
- }
3297
- if (basename(file.path) === SKILL_FILE_NAME2) {
3298
- try {
3299
- content = injectSourceMetadata({
3300
- content,
3301
- source: sourceUrl,
3302
- repository,
3303
- ref: provenanceRef
3304
- });
3305
- } catch {
3306
- logger5.warn(
3307
- `Frontmatter in ${file.path} (${sourceKey}) is invalid. Prepending a fresh provenance block.`
3308
- );
3309
- content = `---
3369
+ content = `---
3310
3370
  source: ${sourceUrl}
3311
3371
  repository: ${repository}
3312
3372
  ref: ${provenanceRef}
3313
3373
  ---
3314
3374
  ${content}`;
3315
- }
3316
- }
3317
- const absolutePath = join9(scopeRoot, deployRelative);
3318
- deployed.push({ relativeToScopeRoot: deployRelative, absolutePath, content });
3319
- if (!frozen) {
3320
- await writeFileContent(absolutePath, content);
3321
3375
  }
3322
3376
  }
3323
- deployed.sort(
3324
- (a, b) => a.relativeToScopeRoot < b.relativeToScopeRoot ? -1 : a.relativeToScopeRoot > b.relativeToScopeRoot ? 1 : 0
3325
- );
3326
- const deployedFiles = deployed.map((d) => d.relativeToScopeRoot);
3327
- const contentHash = computeContentHash2(deployed);
3328
- if (frozen && locked?.content_hash) {
3329
- if (RULESYNC_CONTENT_HASH_REGEX2.test(locked.content_hash)) {
3330
- if (locked.content_hash !== contentHash) {
3331
- throw new Error(
3332
- `content_hash mismatch for ${sourceKey} skill "${sk.name}" (agent=${agent}, scope=${scope}): lock=${locked.content_hash} computed=${contentHash}. Refuse to trust the deployment under --frozen.`
3333
- );
3334
- }
3335
- } else {
3336
- logger5.debug(
3337
- `Skipping content_hash integrity check for ${sourceKey} skill "${sk.name}": recorded hash "${locked.content_hash}" was not written by rulesync.`
3377
+ const absolutePath = join9(scopeRoot, deployRelative);
3378
+ deployed.push({ relativeToScopeRoot: deployRelative, absolutePath, content });
3379
+ if (!frozen) {
3380
+ await writeFileContent(absolutePath, content);
3381
+ }
3382
+ }
3383
+ return deployed;
3384
+ }
3385
+ function assertFrozenSkillIntegrity(params) {
3386
+ const { frozen, locked, contentHash, sourceKey, skillName, agent, scope, logger: logger5 } = params;
3387
+ if (frozen && locked?.content_hash) {
3388
+ if (RULESYNC_CONTENT_HASH_REGEX2.test(locked.content_hash)) {
3389
+ if (locked.content_hash !== contentHash) {
3390
+ throw new Error(
3391
+ `content_hash mismatch for ${sourceKey} skill "${skillName}" (agent=${agent}, scope=${scope}): lock=${locked.content_hash} computed=${contentHash}. Refuse to trust the deployment under --frozen.`
3338
3392
  );
3339
3393
  }
3394
+ } else {
3395
+ logger5.debug(
3396
+ `Skipping content_hash integrity check for ${sourceKey} skill "${skillName}": recorded hash "${locked.content_hash}" was not written by rulesync.`
3397
+ );
3340
3398
  }
3341
- const installation = {
3342
- source: sourceKey,
3343
- owner,
3344
- repo,
3345
- agent,
3346
- scope,
3347
- skill: sk.name,
3348
- resolved_ref: resolvedRef,
3349
- resolved_commit: resolvedSha,
3350
- install_dir: toPosixPath(installRelDir),
3351
- deployed_files: deployedFiles,
3352
- content_hash: contentHash
3353
- };
3354
- if (rs.ref !== void 0) {
3355
- installation.requested_ref = rs.ref;
3356
- }
3357
- results.push({ installation, deployed });
3358
- logger5.info(
3359
- `Installed gh skill "${sk.name}" from ${sourceKey} (agent=${agent}, scope=${scope}, ref=${resolvedRef})`
3360
- );
3361
3399
  }
3362
- return results;
3363
3400
  }
3364
3401
  async function removeStaleFile(params) {
3365
3402
  const { relativePath, scope, projectRoot, logger: logger5 } = params;
@@ -3752,18 +3789,7 @@ async function resolveAndFetchSources(params) {
3752
3789
  }
3753
3790
  let lock = options.updateSources ? createEmptyLock() : await readLockFile({ projectRoot, logger: logger5 });
3754
3791
  if (options.frozen) {
3755
- const missingKeys = [];
3756
- for (const source of sources) {
3757
- const locked = getLockedSource(lock, source.source);
3758
- if (!locked) {
3759
- missingKeys.push(source.source);
3760
- }
3761
- }
3762
- if (missingKeys.length > 0) {
3763
- throw new Error(
3764
- `Frozen install failed: lockfile is missing entries for: ${missingKeys.join(", ")}. Run 'rulesync install' to update the lockfile.`
3765
- );
3766
- }
3792
+ assertFrozenLockCoversSources2({ lock, sources });
3767
3793
  }
3768
3794
  const originalLockJson = JSON.stringify(lock);
3769
3795
  const token = GitHubClient.resolveToken(options.token);
@@ -3773,31 +3799,17 @@ async function resolveAndFetchSources(params) {
3773
3799
  const allFetchedSkillNames = /* @__PURE__ */ new Set();
3774
3800
  for (const sourceEntry of sources) {
3775
3801
  try {
3776
- const transport = sourceEntry.transport ?? "github";
3777
- let result;
3778
- if (transport === "git") {
3779
- result = await fetchSourceViaGit({
3780
- sourceEntry,
3781
- projectRoot,
3782
- lock,
3783
- localSkillNames,
3784
- alreadyFetchedSkillNames: allFetchedSkillNames,
3785
- updateSources: options.updateSources ?? false,
3786
- frozen: options.frozen ?? false,
3787
- logger: logger5
3788
- });
3789
- } else {
3790
- result = await fetchSource({
3791
- sourceEntry,
3792
- client,
3793
- projectRoot,
3794
- lock,
3795
- localSkillNames,
3796
- alreadyFetchedSkillNames: allFetchedSkillNames,
3797
- updateSources: options.updateSources ?? false,
3798
- logger: logger5
3799
- });
3800
- }
3802
+ const result = await fetchSourceByTransport({
3803
+ sourceEntry,
3804
+ client,
3805
+ projectRoot,
3806
+ lock,
3807
+ localSkillNames,
3808
+ alreadyFetchedSkillNames: allFetchedSkillNames,
3809
+ updateSources: options.updateSources ?? false,
3810
+ frozen: options.frozen ?? false,
3811
+ logger: logger5
3812
+ });
3801
3813
  const { skillCount, fetchedSkillNames, updatedLock } = result;
3802
3814
  lock = updatedLock;
3803
3815
  totalSkillCount += skillCount;
@@ -3813,16 +3825,7 @@ async function resolveAndFetchSources(params) {
3813
3825
  }
3814
3826
  }
3815
3827
  }
3816
- const sourceKeys = new Set(sources.map((s) => normalizeSourceKey(s.source)));
3817
- const prunedSources = {};
3818
- for (const [key, value] of Object.entries(lock.sources)) {
3819
- if (sourceKeys.has(normalizeSourceKey(key))) {
3820
- prunedSources[key] = value;
3821
- } else {
3822
- logger5.debug(`Pruned stale lockfile entry: ${key}`);
3823
- }
3824
- }
3825
- lock = { lockfileVersion: lock.lockfileVersion, sources: prunedSources };
3828
+ lock = pruneStaleLockEntries({ lock, sources, logger: logger5 });
3826
3829
  if (!options.frozen && JSON.stringify(lock) !== originalLockJson) {
3827
3830
  await writeLockFile({ projectRoot, lock, logger: logger5 });
3828
3831
  } else {
@@ -3838,6 +3841,70 @@ function logGitClientHints(params) {
3838
3841
  logger5.info("Hint: Check your git credentials (SSH keys, credential helper, or access token).");
3839
3842
  }
3840
3843
  }
3844
+ function assertFrozenLockCoversSources2(params) {
3845
+ const { lock, sources } = params;
3846
+ const missingKeys = [];
3847
+ for (const source of sources) {
3848
+ const locked = getLockedSource(lock, source.source);
3849
+ if (!locked) {
3850
+ missingKeys.push(source.source);
3851
+ }
3852
+ }
3853
+ if (missingKeys.length > 0) {
3854
+ throw new Error(
3855
+ `Frozen install failed: lockfile is missing entries for: ${missingKeys.join(", ")}. Run 'rulesync install' to update the lockfile.`
3856
+ );
3857
+ }
3858
+ }
3859
+ async function fetchSourceByTransport(params) {
3860
+ const {
3861
+ sourceEntry,
3862
+ client,
3863
+ projectRoot,
3864
+ lock,
3865
+ localSkillNames,
3866
+ alreadyFetchedSkillNames,
3867
+ updateSources,
3868
+ frozen,
3869
+ logger: logger5
3870
+ } = params;
3871
+ const transport = sourceEntry.transport ?? "github";
3872
+ if (transport === "git") {
3873
+ return fetchSourceViaGit({
3874
+ sourceEntry,
3875
+ projectRoot,
3876
+ lock,
3877
+ localSkillNames,
3878
+ alreadyFetchedSkillNames,
3879
+ updateSources,
3880
+ frozen,
3881
+ logger: logger5
3882
+ });
3883
+ }
3884
+ return fetchSource({
3885
+ sourceEntry,
3886
+ client,
3887
+ projectRoot,
3888
+ lock,
3889
+ localSkillNames,
3890
+ alreadyFetchedSkillNames,
3891
+ updateSources,
3892
+ logger: logger5
3893
+ });
3894
+ }
3895
+ function pruneStaleLockEntries(params) {
3896
+ const { lock, sources, logger: logger5 } = params;
3897
+ const sourceKeys = new Set(sources.map((s) => normalizeSourceKey(s.source)));
3898
+ const prunedSources = {};
3899
+ for (const [key, value] of Object.entries(lock.sources)) {
3900
+ if (sourceKeys.has(normalizeSourceKey(key))) {
3901
+ prunedSources[key] = value;
3902
+ } else {
3903
+ logger5.debug(`Pruned stale lockfile entry: ${key}`);
3904
+ }
3905
+ }
3906
+ return { lockfileVersion: lock.lockfileVersion, sources: prunedSources };
3907
+ }
3841
3908
  async function checkLockedSkillsExist(curatedDir, skillNames) {
3842
3909
  if (skillNames.length === 0) return true;
3843
3910
  for (const name of skillNames) {
@@ -3969,7 +4036,189 @@ function groupRemoteFilesBySkillRoot(params) {
3969
4036
  grouped.set(singleSkillName, rootLevelFiles);
3970
4037
  }
3971
4038
  }
3972
- return grouped;
4039
+ return grouped;
4040
+ }
4041
+ async function resolveGithubFetchRef(params) {
4042
+ const { parsed, locked, updateSources, sourceKey, client, logger: logger5 } = params;
4043
+ if (locked && !updateSources) {
4044
+ logger5.debug(`Using locked ref for ${sourceKey}: ${locked.resolvedRef}`);
4045
+ return {
4046
+ ref: locked.resolvedRef,
4047
+ resolvedSha: locked.resolvedRef,
4048
+ requestedRef: locked.requestedRef
4049
+ };
4050
+ }
4051
+ const requestedRef = parsed.ref ?? await client.getDefaultBranch(parsed.owner, parsed.repo);
4052
+ const resolvedSha = await client.resolveRefToSha(parsed.owner, parsed.repo, requestedRef);
4053
+ logger5.debug(`Resolved ${sourceKey} ref "${requestedRef}" to SHA: ${resolvedSha}`);
4054
+ return { ref: resolvedSha, resolvedSha, requestedRef };
4055
+ }
4056
+ async function fetchRootLevelFallbackSkill(params) {
4057
+ const {
4058
+ entries,
4059
+ parsed,
4060
+ ref,
4061
+ resolvedSha,
4062
+ skillFilter,
4063
+ isWildcard,
4064
+ curatedDir,
4065
+ locked,
4066
+ sourceKey,
4067
+ localSkillNames,
4068
+ alreadyFetchedSkillNames,
4069
+ client,
4070
+ semaphore,
4071
+ fetchedSkills,
4072
+ logger: logger5
4073
+ } = params;
4074
+ const rootFiles = entries.filter((entry) => entry.type === "file");
4075
+ const rootSkillFiles = [];
4076
+ for (const file of rootFiles) {
4077
+ if (file.size > MAX_FILE_SIZE) {
4078
+ logger5.warn(
4079
+ `Skipping file "${file.path}" (${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`
4080
+ );
4081
+ continue;
4082
+ }
4083
+ const content = await withSemaphore(
4084
+ semaphore,
4085
+ () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
4086
+ );
4087
+ rootSkillFiles.push({ relativePath: file.name, content });
4088
+ }
4089
+ const groupedRootFiles = groupRemoteFilesBySkillRoot({
4090
+ remoteFiles: rootSkillFiles,
4091
+ skillFilter,
4092
+ isWildcard
4093
+ });
4094
+ const [fallbackSkillName] = groupedRootFiles.keys();
4095
+ if (fallbackSkillName === void 0) {
4096
+ return { handled: false, remoteSkillNames: [] };
4097
+ }
4098
+ if (!shouldSkipSkill({
4099
+ skillName: fallbackSkillName,
4100
+ sourceKey,
4101
+ localSkillNames,
4102
+ alreadyFetchedSkillNames,
4103
+ logger: logger5
4104
+ })) {
4105
+ fetchedSkills[fallbackSkillName] = await writeSkillAndComputeIntegrity({
4106
+ skillName: fallbackSkillName,
4107
+ files: groupedRootFiles.get(fallbackSkillName) ?? [],
4108
+ curatedDir,
4109
+ locked,
4110
+ resolvedSha,
4111
+ sourceKey,
4112
+ logger: logger5
4113
+ });
4114
+ logger5.debug(`Fetched skill "${fallbackSkillName}" from ${sourceKey}`);
4115
+ }
4116
+ return { handled: true, remoteSkillNames: [fallbackSkillName] };
4117
+ }
4118
+ async function fetchGithubSkillDir(params) {
4119
+ const {
4120
+ skillDir,
4121
+ parsed,
4122
+ ref,
4123
+ resolvedSha,
4124
+ curatedDir,
4125
+ locked,
4126
+ sourceKey,
4127
+ client,
4128
+ semaphore,
4129
+ logger: logger5
4130
+ } = params;
4131
+ const allFiles = await listDirectoryRecursive({
4132
+ client,
4133
+ owner: parsed.owner,
4134
+ repo: parsed.repo,
4135
+ path: skillDir.path,
4136
+ ref,
4137
+ semaphore
4138
+ });
4139
+ const files = allFiles.filter((file) => {
4140
+ if (file.size > MAX_FILE_SIZE) {
4141
+ logger5.warn(
4142
+ `Skipping file "${file.path}" (${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`
4143
+ );
4144
+ return false;
4145
+ }
4146
+ return true;
4147
+ });
4148
+ const skillFiles = [];
4149
+ for (const file of files) {
4150
+ const relativeToSkill = file.path.substring(skillDir.path.length + 1);
4151
+ const content = await withSemaphore(
4152
+ semaphore,
4153
+ () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
4154
+ );
4155
+ skillFiles.push({ relativePath: relativeToSkill, content });
4156
+ }
4157
+ return writeSkillAndComputeIntegrity({
4158
+ skillName: skillDir.name,
4159
+ files: skillFiles,
4160
+ curatedDir,
4161
+ locked,
4162
+ resolvedSha,
4163
+ sourceKey,
4164
+ logger: logger5
4165
+ });
4166
+ }
4167
+ async function discoverGithubSkillDirs(params) {
4168
+ const {
4169
+ parsed,
4170
+ ref,
4171
+ resolvedSha,
4172
+ skillFilter,
4173
+ isWildcard,
4174
+ curatedDir,
4175
+ locked,
4176
+ sourceKey,
4177
+ localSkillNames,
4178
+ alreadyFetchedSkillNames,
4179
+ client,
4180
+ semaphore,
4181
+ fetchedSkills,
4182
+ logger: logger5
4183
+ } = params;
4184
+ const skillsBasePath = parsed.path ?? "skills";
4185
+ try {
4186
+ const entries = await client.listDirectory(parsed.owner, parsed.repo, skillsBasePath, ref);
4187
+ const remoteSkillDirs = entries.filter((e) => e.type === "dir").map((e) => ({ name: e.name, path: e.path }));
4188
+ if (remoteSkillDirs.length === 0 && !isWildcard && skillFilter.length === 1) {
4189
+ const fallback = await fetchRootLevelFallbackSkill({
4190
+ entries,
4191
+ parsed,
4192
+ ref,
4193
+ resolvedSha,
4194
+ skillFilter,
4195
+ isWildcard,
4196
+ curatedDir,
4197
+ locked,
4198
+ sourceKey,
4199
+ localSkillNames,
4200
+ alreadyFetchedSkillNames,
4201
+ client,
4202
+ semaphore,
4203
+ fetchedSkills,
4204
+ logger: logger5
4205
+ });
4206
+ if (fallback.handled) {
4207
+ return {
4208
+ status: "ok",
4209
+ remoteSkillDirs,
4210
+ fallbackHandled: true,
4211
+ remoteSkillNames: fallback.remoteSkillNames
4212
+ };
4213
+ }
4214
+ }
4215
+ return { status: "ok", remoteSkillDirs, fallbackHandled: false, remoteSkillNames: [] };
4216
+ } catch (error) {
4217
+ if (error instanceof GitHubClientError && error.statusCode === 404) {
4218
+ return { status: "notFound" };
4219
+ }
4220
+ throw error;
4221
+ }
3973
4222
  }
3974
4223
  async function fetchSource(params) {
3975
4224
  const {
@@ -3990,20 +4239,14 @@ async function fetchSource(params) {
3990
4239
  const sourceKey = sourceEntry.source;
3991
4240
  const locked = getLockedSource(lock, sourceKey);
3992
4241
  const lockedSkillNames = locked ? getLockedSkillNames(locked) : [];
3993
- let ref;
3994
- let resolvedSha;
3995
- let requestedRef;
3996
- if (locked && !updateSources) {
3997
- ref = locked.resolvedRef;
3998
- resolvedSha = locked.resolvedRef;
3999
- requestedRef = locked.requestedRef;
4000
- logger5.debug(`Using locked ref for ${sourceKey}: ${resolvedSha}`);
4001
- } else {
4002
- requestedRef = parsed.ref ?? await client.getDefaultBranch(parsed.owner, parsed.repo);
4003
- resolvedSha = await client.resolveRefToSha(parsed.owner, parsed.repo, requestedRef);
4004
- ref = resolvedSha;
4005
- logger5.debug(`Resolved ${sourceKey} ref "${requestedRef}" to SHA: ${resolvedSha}`);
4006
- }
4242
+ const { ref, resolvedSha, requestedRef } = await resolveGithubFetchRef({
4243
+ parsed,
4244
+ locked,
4245
+ updateSources,
4246
+ sourceKey,
4247
+ client,
4248
+ logger: logger5
4249
+ });
4007
4250
  const curatedDir = join12(projectRoot, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH);
4008
4251
  if (locked && resolvedSha === locked.resolvedRef && !updateSources) {
4009
4252
  const allExist = await checkLockedSkillsExist(curatedDir, lockedSkillNames);
@@ -4020,69 +4263,29 @@ async function fetchSource(params) {
4020
4263
  const isWildcard = skillFilter.length === 1 && skillFilter[0] === "*";
4021
4264
  const semaphore = new Semaphore4(FETCH_CONCURRENCY_LIMIT);
4022
4265
  const fetchedSkills = {};
4023
- const skillsBasePath = parsed.path ?? "skills";
4024
- let remoteSkillDirs;
4025
- let remoteSkillNames = [];
4026
- let fallbackHandled = false;
4027
- try {
4028
- const entries = await client.listDirectory(parsed.owner, parsed.repo, skillsBasePath, ref);
4029
- remoteSkillDirs = entries.filter((e) => e.type === "dir").map((e) => ({ name: e.name, path: e.path }));
4030
- if (remoteSkillDirs.length === 0 && !isWildcard && skillFilter.length === 1) {
4031
- const rootFiles = entries.filter((entry) => entry.type === "file");
4032
- const rootSkillFiles = [];
4033
- for (const file of rootFiles) {
4034
- if (file.size > MAX_FILE_SIZE) {
4035
- logger5.warn(
4036
- `Skipping file "${file.path}" (${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`
4037
- );
4038
- continue;
4039
- }
4040
- const content = await withSemaphore(
4041
- semaphore,
4042
- () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
4043
- );
4044
- rootSkillFiles.push({ relativePath: file.name, content });
4045
- }
4046
- const groupedRootFiles = groupRemoteFilesBySkillRoot({
4047
- remoteFiles: rootSkillFiles,
4048
- skillFilter,
4049
- isWildcard
4050
- });
4051
- const [fallbackSkillName] = groupedRootFiles.keys();
4052
- if (fallbackSkillName !== void 0) {
4053
- fallbackHandled = true;
4054
- remoteSkillNames = [fallbackSkillName];
4055
- if (!shouldSkipSkill({
4056
- skillName: fallbackSkillName,
4057
- sourceKey,
4058
- localSkillNames,
4059
- alreadyFetchedSkillNames,
4060
- logger: logger5
4061
- })) {
4062
- fetchedSkills[fallbackSkillName] = await writeSkillAndComputeIntegrity({
4063
- skillName: fallbackSkillName,
4064
- files: groupedRootFiles.get(fallbackSkillName) ?? [],
4065
- curatedDir,
4066
- locked,
4067
- resolvedSha,
4068
- sourceKey,
4069
- logger: logger5
4070
- });
4071
- logger5.debug(`Fetched skill "${fallbackSkillName}" from ${sourceKey}`);
4072
- }
4073
- }
4074
- }
4075
- } catch (error) {
4076
- if (error instanceof GitHubClientError && error.statusCode === 404) {
4077
- logger5.warn(`No skills/ directory found in ${sourceKey}. Skipping.`);
4078
- return { skillCount: 0, fetchedSkillNames: [], updatedLock: lock };
4079
- }
4080
- throw error;
4266
+ const discovery = await discoverGithubSkillDirs({
4267
+ parsed,
4268
+ ref,
4269
+ resolvedSha,
4270
+ skillFilter,
4271
+ isWildcard,
4272
+ curatedDir,
4273
+ locked,
4274
+ sourceKey,
4275
+ localSkillNames,
4276
+ alreadyFetchedSkillNames,
4277
+ client,
4278
+ semaphore,
4279
+ fetchedSkills,
4280
+ logger: logger5
4281
+ });
4282
+ if (discovery.status === "notFound") {
4283
+ logger5.warn(`No skills/ directory found in ${sourceKey}. Skipping.`);
4284
+ return { skillCount: 0, fetchedSkillNames: [], updatedLock: lock };
4081
4285
  }
4286
+ const { remoteSkillDirs, fallbackHandled, remoteSkillNames: fallbackSkillNames } = discovery;
4082
4287
  const filteredDirs = isWildcard ? remoteSkillDirs : remoteSkillDirs.filter((d) => skillFilter.includes(d.name));
4083
- if (!fallbackHandled) {
4084
- remoteSkillNames = filteredDirs.map((d) => d.name);
4085
- }
4288
+ const remoteSkillNames = fallbackHandled ? fallbackSkillNames : filteredDirs.map((d) => d.name);
4086
4289
  if (locked) {
4087
4290
  await cleanPreviousCuratedSkills({ curatedDir, lockedSkillNames, logger: logger5 });
4088
4291
  }
@@ -4096,39 +4299,16 @@ async function fetchSource(params) {
4096
4299
  })) {
4097
4300
  continue;
4098
4301
  }
4099
- const allFiles = await listDirectoryRecursive({
4100
- client,
4101
- owner: parsed.owner,
4102
- repo: parsed.repo,
4103
- path: skillDir.path,
4302
+ fetchedSkills[skillDir.name] = await fetchGithubSkillDir({
4303
+ skillDir,
4304
+ parsed,
4104
4305
  ref,
4105
- semaphore
4106
- });
4107
- const files = allFiles.filter((file) => {
4108
- if (file.size > MAX_FILE_SIZE) {
4109
- logger5.warn(
4110
- `Skipping file "${file.path}" (${(file.size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`
4111
- );
4112
- return false;
4113
- }
4114
- return true;
4115
- });
4116
- const skillFiles = [];
4117
- for (const file of files) {
4118
- const relativeToSkill = file.path.substring(skillDir.path.length + 1);
4119
- const content = await withSemaphore(
4120
- semaphore,
4121
- () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
4122
- );
4123
- skillFiles.push({ relativePath: relativeToSkill, content });
4124
- }
4125
- fetchedSkills[skillDir.name] = await writeSkillAndComputeIntegrity({
4126
- skillName: skillDir.name,
4127
- files: skillFiles,
4306
+ resolvedSha,
4128
4307
  curatedDir,
4129
4308
  locked,
4130
- resolvedSha,
4131
4309
  sourceKey,
4310
+ client,
4311
+ semaphore,
4132
4312
  logger: logger5
4133
4313
  });
4134
4314
  logger5.debug(`Fetched skill "${skillDir.name}" from ${sourceKey}`);
@@ -6037,6 +6217,174 @@ function ensureBody({ body, feature, operation }) {
6037
6217
  }
6038
6218
  return body;
6039
6219
  }
6220
+ function requireContent({
6221
+ content,
6222
+ feature
6223
+ }) {
6224
+ if (!content) {
6225
+ throw new Error(`content is required for ${feature} put operation`);
6226
+ }
6227
+ return content;
6228
+ }
6229
+ function executeRule(parsed) {
6230
+ if (parsed.operation === "list") {
6231
+ return ruleTools.listRules.execute();
6232
+ }
6233
+ if (parsed.operation === "get") {
6234
+ return ruleTools.getRule.execute({ relativePathFromCwd: requireTargetPath(parsed) });
6235
+ }
6236
+ if (parsed.operation === "put") {
6237
+ return ruleTools.putRule.execute({
6238
+ relativePathFromCwd: requireTargetPath(parsed),
6239
+ frontmatter: parseFrontmatter({
6240
+ feature: "rule",
6241
+ frontmatter: parsed.frontmatter ?? {}
6242
+ }),
6243
+ body: ensureBody(parsed)
6244
+ });
6245
+ }
6246
+ return ruleTools.deleteRule.execute({ relativePathFromCwd: requireTargetPath(parsed) });
6247
+ }
6248
+ function executeCommand(parsed) {
6249
+ if (parsed.operation === "list") {
6250
+ return commandTools.listCommands.execute();
6251
+ }
6252
+ if (parsed.operation === "get") {
6253
+ return commandTools.getCommand.execute({
6254
+ relativePathFromCwd: requireTargetPath(parsed)
6255
+ });
6256
+ }
6257
+ if (parsed.operation === "put") {
6258
+ return commandTools.putCommand.execute({
6259
+ relativePathFromCwd: requireTargetPath(parsed),
6260
+ frontmatter: parseFrontmatter({
6261
+ feature: "command",
6262
+ frontmatter: parsed.frontmatter ?? {}
6263
+ }),
6264
+ body: ensureBody(parsed)
6265
+ });
6266
+ }
6267
+ return commandTools.deleteCommand.execute({
6268
+ relativePathFromCwd: requireTargetPath(parsed)
6269
+ });
6270
+ }
6271
+ function executeSubagent(parsed) {
6272
+ if (parsed.operation === "list") {
6273
+ return subagentTools.listSubagents.execute();
6274
+ }
6275
+ if (parsed.operation === "get") {
6276
+ return subagentTools.getSubagent.execute({
6277
+ relativePathFromCwd: requireTargetPath(parsed)
6278
+ });
6279
+ }
6280
+ if (parsed.operation === "put") {
6281
+ return subagentTools.putSubagent.execute({
6282
+ relativePathFromCwd: requireTargetPath(parsed),
6283
+ frontmatter: parseFrontmatter({
6284
+ feature: "subagent",
6285
+ frontmatter: parsed.frontmatter ?? {}
6286
+ }),
6287
+ body: ensureBody(parsed)
6288
+ });
6289
+ }
6290
+ return subagentTools.deleteSubagent.execute({
6291
+ relativePathFromCwd: requireTargetPath(parsed)
6292
+ });
6293
+ }
6294
+ function executeSkill(parsed) {
6295
+ if (parsed.operation === "list") {
6296
+ return skillTools.listSkills.execute();
6297
+ }
6298
+ if (parsed.operation === "get") {
6299
+ return skillTools.getSkill.execute({ relativeDirPathFromCwd: requireTargetPath(parsed) });
6300
+ }
6301
+ if (parsed.operation === "put") {
6302
+ return skillTools.putSkill.execute({
6303
+ relativeDirPathFromCwd: requireTargetPath(parsed),
6304
+ frontmatter: parseFrontmatter({
6305
+ feature: "skill",
6306
+ frontmatter: parsed.frontmatter ?? {}
6307
+ }),
6308
+ body: ensureBody(parsed),
6309
+ otherFiles: parsed.otherFiles ?? []
6310
+ });
6311
+ }
6312
+ return skillTools.deleteSkill.execute({
6313
+ relativeDirPathFromCwd: requireTargetPath(parsed)
6314
+ });
6315
+ }
6316
+ function executeIgnore(parsed) {
6317
+ if (parsed.operation === "get") {
6318
+ return ignoreTools.getIgnoreFile.execute();
6319
+ }
6320
+ if (parsed.operation === "put") {
6321
+ return ignoreTools.putIgnoreFile.execute({
6322
+ content: requireContent({ content: parsed.content, feature: "ignore" })
6323
+ });
6324
+ }
6325
+ return ignoreTools.deleteIgnoreFile.execute();
6326
+ }
6327
+ function executeMcp(parsed) {
6328
+ if (parsed.operation === "get") {
6329
+ return mcpTools.getMcpFile.execute();
6330
+ }
6331
+ if (parsed.operation === "put") {
6332
+ return mcpTools.putMcpFile.execute({
6333
+ content: requireContent({ content: parsed.content, feature: "mcp" })
6334
+ });
6335
+ }
6336
+ return mcpTools.deleteMcpFile.execute();
6337
+ }
6338
+ function executePermissions(parsed) {
6339
+ if (parsed.operation === "get") {
6340
+ return permissionsTools.getPermissionsFile.execute();
6341
+ }
6342
+ if (parsed.operation === "put") {
6343
+ return permissionsTools.putPermissionsFile.execute({
6344
+ content: requireContent({ content: parsed.content, feature: "permissions" })
6345
+ });
6346
+ }
6347
+ return permissionsTools.deletePermissionsFile.execute();
6348
+ }
6349
+ function executeHooks(parsed) {
6350
+ if (parsed.operation === "get") {
6351
+ return hooksTools.getHooksFile.execute();
6352
+ }
6353
+ if (parsed.operation === "put") {
6354
+ return hooksTools.putHooksFile.execute({
6355
+ content: requireContent({ content: parsed.content, feature: "hooks" })
6356
+ });
6357
+ }
6358
+ return hooksTools.deleteHooksFile.execute();
6359
+ }
6360
+ function executeGenerate2(parsed) {
6361
+ return generateTools.executeGenerate.execute(parsed.generateOptions ?? {});
6362
+ }
6363
+ function executeImport2(parsed) {
6364
+ if (!parsed.importOptions) {
6365
+ throw new Error("importOptions is required for import feature");
6366
+ }
6367
+ return importTools.executeImport.execute(parsed.importOptions);
6368
+ }
6369
+ function executeConvert2(parsed) {
6370
+ if (!parsed.convertOptions) {
6371
+ throw new Error("convertOptions is required for convert feature");
6372
+ }
6373
+ return convertTools.executeConvert.execute(parsed.convertOptions);
6374
+ }
6375
+ var featureExecutors = {
6376
+ rule: executeRule,
6377
+ command: executeCommand,
6378
+ subagent: executeSubagent,
6379
+ skill: executeSkill,
6380
+ ignore: executeIgnore,
6381
+ mcp: executeMcp,
6382
+ permissions: executePermissions,
6383
+ hooks: executeHooks,
6384
+ generate: executeGenerate2,
6385
+ import: executeImport2,
6386
+ convert: executeConvert2
6387
+ };
6040
6388
  var rulesyncTool = {
6041
6389
  name: "rulesyncTool",
6042
6390
  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.",
@@ -6044,161 +6392,11 @@ var rulesyncTool = {
6044
6392
  execute: async (args) => {
6045
6393
  const parsed = rulesyncToolSchema.parse(args);
6046
6394
  assertSupported({ feature: parsed.feature, operation: parsed.operation });
6047
- switch (parsed.feature) {
6048
- case "rule": {
6049
- if (parsed.operation === "list") {
6050
- return ruleTools.listRules.execute();
6051
- }
6052
- if (parsed.operation === "get") {
6053
- return ruleTools.getRule.execute({ relativePathFromCwd: requireTargetPath(parsed) });
6054
- }
6055
- if (parsed.operation === "put") {
6056
- return ruleTools.putRule.execute({
6057
- relativePathFromCwd: requireTargetPath(parsed),
6058
- frontmatter: parseFrontmatter({
6059
- feature: "rule",
6060
- frontmatter: parsed.frontmatter ?? {}
6061
- }),
6062
- body: ensureBody(parsed)
6063
- });
6064
- }
6065
- return ruleTools.deleteRule.execute({ relativePathFromCwd: requireTargetPath(parsed) });
6066
- }
6067
- case "command": {
6068
- if (parsed.operation === "list") {
6069
- return commandTools.listCommands.execute();
6070
- }
6071
- if (parsed.operation === "get") {
6072
- return commandTools.getCommand.execute({
6073
- relativePathFromCwd: requireTargetPath(parsed)
6074
- });
6075
- }
6076
- if (parsed.operation === "put") {
6077
- return commandTools.putCommand.execute({
6078
- relativePathFromCwd: requireTargetPath(parsed),
6079
- frontmatter: parseFrontmatter({
6080
- feature: "command",
6081
- frontmatter: parsed.frontmatter ?? {}
6082
- }),
6083
- body: ensureBody(parsed)
6084
- });
6085
- }
6086
- return commandTools.deleteCommand.execute({
6087
- relativePathFromCwd: requireTargetPath(parsed)
6088
- });
6089
- }
6090
- case "subagent": {
6091
- if (parsed.operation === "list") {
6092
- return subagentTools.listSubagents.execute();
6093
- }
6094
- if (parsed.operation === "get") {
6095
- return subagentTools.getSubagent.execute({
6096
- relativePathFromCwd: requireTargetPath(parsed)
6097
- });
6098
- }
6099
- if (parsed.operation === "put") {
6100
- return subagentTools.putSubagent.execute({
6101
- relativePathFromCwd: requireTargetPath(parsed),
6102
- frontmatter: parseFrontmatter({
6103
- feature: "subagent",
6104
- frontmatter: parsed.frontmatter ?? {}
6105
- }),
6106
- body: ensureBody(parsed)
6107
- });
6108
- }
6109
- return subagentTools.deleteSubagent.execute({
6110
- relativePathFromCwd: requireTargetPath(parsed)
6111
- });
6112
- }
6113
- case "skill": {
6114
- if (parsed.operation === "list") {
6115
- return skillTools.listSkills.execute();
6116
- }
6117
- if (parsed.operation === "get") {
6118
- return skillTools.getSkill.execute({ relativeDirPathFromCwd: requireTargetPath(parsed) });
6119
- }
6120
- if (parsed.operation === "put") {
6121
- return skillTools.putSkill.execute({
6122
- relativeDirPathFromCwd: requireTargetPath(parsed),
6123
- frontmatter: parseFrontmatter({
6124
- feature: "skill",
6125
- frontmatter: parsed.frontmatter ?? {}
6126
- }),
6127
- body: ensureBody(parsed),
6128
- otherFiles: parsed.otherFiles ?? []
6129
- });
6130
- }
6131
- return skillTools.deleteSkill.execute({
6132
- relativeDirPathFromCwd: requireTargetPath(parsed)
6133
- });
6134
- }
6135
- case "ignore": {
6136
- if (parsed.operation === "get") {
6137
- return ignoreTools.getIgnoreFile.execute();
6138
- }
6139
- if (parsed.operation === "put") {
6140
- if (!parsed.content) {
6141
- throw new Error("content is required for ignore put operation");
6142
- }
6143
- return ignoreTools.putIgnoreFile.execute({ content: parsed.content });
6144
- }
6145
- return ignoreTools.deleteIgnoreFile.execute();
6146
- }
6147
- case "mcp": {
6148
- if (parsed.operation === "get") {
6149
- return mcpTools.getMcpFile.execute();
6150
- }
6151
- if (parsed.operation === "put") {
6152
- if (!parsed.content) {
6153
- throw new Error("content is required for mcp put operation");
6154
- }
6155
- return mcpTools.putMcpFile.execute({ content: parsed.content });
6156
- }
6157
- return mcpTools.deleteMcpFile.execute();
6158
- }
6159
- case "permissions": {
6160
- if (parsed.operation === "get") {
6161
- return permissionsTools.getPermissionsFile.execute();
6162
- }
6163
- if (parsed.operation === "put") {
6164
- if (!parsed.content) {
6165
- throw new Error("content is required for permissions put operation");
6166
- }
6167
- return permissionsTools.putPermissionsFile.execute({ content: parsed.content });
6168
- }
6169
- return permissionsTools.deletePermissionsFile.execute();
6170
- }
6171
- case "hooks": {
6172
- if (parsed.operation === "get") {
6173
- return hooksTools.getHooksFile.execute();
6174
- }
6175
- if (parsed.operation === "put") {
6176
- if (!parsed.content) {
6177
- throw new Error("content is required for hooks put operation");
6178
- }
6179
- return hooksTools.putHooksFile.execute({ content: parsed.content });
6180
- }
6181
- return hooksTools.deleteHooksFile.execute();
6182
- }
6183
- case "generate": {
6184
- return generateTools.executeGenerate.execute(parsed.generateOptions ?? {});
6185
- }
6186
- case "import": {
6187
- if (!parsed.importOptions) {
6188
- throw new Error("importOptions is required for import feature");
6189
- }
6190
- return importTools.executeImport.execute(parsed.importOptions);
6191
- }
6192
- case "convert": {
6193
- if (!parsed.convertOptions) {
6194
- throw new Error("convertOptions is required for convert feature");
6195
- }
6196
- return convertTools.executeConvert.execute(parsed.convertOptions);
6197
- }
6198
- default: {
6199
- throw new Error(`Unknown feature: ${parsed.feature}`);
6200
- }
6395
+ const executor = featureExecutors[parsed.feature];
6396
+ if (!executor) {
6397
+ throw new Error(`Unknown feature: ${parsed.feature}`);
6201
6398
  }
6399
+ return executor(parsed);
6202
6400
  }
6203
6401
  };
6204
6402
 
@@ -6417,103 +6615,150 @@ function parseSha256Sums(content) {
6417
6615
  }
6418
6616
  return result;
6419
6617
  }
6420
- async function performBinaryUpdate(currentVersion, options = {}) {
6421
- const { force = false, token } = options;
6422
- const updateCheck = await checkForUpdate(currentVersion, token);
6423
- if (!updateCheck.hasUpdate && !force) {
6424
- return `Already at the latest version (${currentVersion})`;
6425
- }
6618
+ function resolveUpdateAssets(release) {
6426
6619
  const assetName = getPlatformAssetName();
6427
6620
  if (!assetName) {
6428
6621
  throw new Error(
6429
6622
  `Unsupported platform: ${os.platform()} ${os.arch()}. Please download manually from ${RELEASES_URL}`
6430
6623
  );
6431
6624
  }
6432
- const binaryAsset = findAsset(updateCheck.release, assetName);
6625
+ const binaryAsset = findAsset(release, assetName);
6433
6626
  if (!binaryAsset) {
6434
6627
  throw new Error(
6435
6628
  `Binary for ${assetName} not found in release. Please download manually from ${RELEASES_URL}`
6436
6629
  );
6437
6630
  }
6438
- const checksumAsset = findAsset(updateCheck.release, "SHA256SUMS");
6631
+ const checksumAsset = findAsset(release, "SHA256SUMS");
6439
6632
  if (!checksumAsset) {
6440
6633
  throw new Error(
6441
6634
  `SHA256SUMS not found in release. Cannot verify download integrity. Please download manually from ${RELEASES_URL}`
6442
6635
  );
6443
6636
  }
6444
- const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "rulesync-update-"));
6445
- let restoreFailed = false;
6637
+ return { assetName, binaryAsset, checksumAsset };
6638
+ }
6639
+ async function downloadAndVerifyBinary(params) {
6640
+ const { tempDir, assetName, binaryAsset, checksumAsset } = params;
6641
+ const tempBinaryPath = path.join(tempDir, assetName);
6642
+ await downloadFile(binaryAsset.browser_download_url, tempBinaryPath);
6643
+ const checksumsPath = path.join(tempDir, "SHA256SUMS");
6644
+ await downloadFile(checksumAsset.browser_download_url, checksumsPath);
6645
+ const checksumsContent = await fs.promises.readFile(checksumsPath, "utf-8");
6646
+ const checksums = parseSha256Sums(checksumsContent);
6647
+ const expectedChecksum = checksums.get(assetName);
6648
+ if (!expectedChecksum) {
6649
+ throw new Error(
6650
+ `Checksum entry for "${assetName}" not found in SHA256SUMS. Cannot verify download integrity.`
6651
+ );
6652
+ }
6653
+ const actualChecksum = await calculateSha256(tempBinaryPath);
6654
+ if (actualChecksum !== expectedChecksum) {
6655
+ throw new Error(
6656
+ `Checksum verification failed. Expected: ${expectedChecksum}, Got: ${actualChecksum}. The download may be corrupted.`
6657
+ );
6658
+ }
6659
+ return tempBinaryPath;
6660
+ }
6661
+ async function replaceCurrentBinary(params) {
6662
+ const { tempBinaryPath, currentExePath, currentDir } = params;
6663
+ const tempInPlace = path.join(currentDir, `.rulesync-update-${crypto.randomUUID()}`);
6446
6664
  try {
6665
+ await fs.promises.copyFile(tempBinaryPath, tempInPlace);
6447
6666
  if (os.platform() !== "win32") {
6448
- await fs.promises.chmod(tempDir, 448);
6667
+ await fs.promises.chmod(tempInPlace, 493);
6449
6668
  }
6450
- const tempBinaryPath = path.join(tempDir, assetName);
6451
- await downloadFile(binaryAsset.browser_download_url, tempBinaryPath);
6452
- const checksumsPath = path.join(tempDir, "SHA256SUMS");
6453
- await downloadFile(checksumAsset.browser_download_url, checksumsPath);
6454
- const checksumsContent = await fs.promises.readFile(checksumsPath, "utf-8");
6455
- const checksums = parseSha256Sums(checksumsContent);
6456
- const expectedChecksum = checksums.get(assetName);
6457
- if (!expectedChecksum) {
6458
- throw new Error(
6459
- `Checksum entry for "${assetName}" not found in SHA256SUMS. Cannot verify download integrity.`
6460
- );
6669
+ await fs.promises.rename(tempInPlace, currentExePath);
6670
+ } catch {
6671
+ try {
6672
+ await fs.promises.unlink(tempInPlace);
6673
+ } catch {
6461
6674
  }
6462
- const actualChecksum = await calculateSha256(tempBinaryPath);
6463
- if (actualChecksum !== expectedChecksum) {
6464
- throw new Error(
6465
- `Checksum verification failed. Expected: ${expectedChecksum}, Got: ${actualChecksum}. The download may be corrupted.`
6466
- );
6675
+ await fs.promises.copyFile(tempBinaryPath, currentExePath);
6676
+ if (os.platform() !== "win32") {
6677
+ await fs.promises.chmod(currentExePath, 493);
6467
6678
  }
6468
- const currentExePath = await fs.promises.realpath(process.execPath);
6469
- const currentDir = path.dirname(currentExePath);
6470
- const backupPath = path.join(tempDir, "rulesync.backup");
6471
- try {
6472
- await fs.promises.copyFile(currentExePath, backupPath);
6473
- } catch (error) {
6474
- if (isPermissionError(error)) {
6475
- throw new UpdatePermissionError(
6476
- `Permission denied: Cannot read ${currentExePath}. Try running with sudo.`
6477
- );
6478
- }
6479
- throw error;
6679
+ }
6680
+ }
6681
+ async function installVerifiedBinary(params) {
6682
+ const { tempDir, tempBinaryPath, currentVersion, latestVersion } = params;
6683
+ const currentExePath = await fs.promises.realpath(process.execPath);
6684
+ const currentDir = path.dirname(currentExePath);
6685
+ const backupPath = path.join(tempDir, "rulesync.backup");
6686
+ try {
6687
+ await fs.promises.copyFile(currentExePath, backupPath);
6688
+ } catch (error) {
6689
+ if (isPermissionError(error)) {
6690
+ throw new UpdatePermissionError(
6691
+ `Permission denied: Cannot read ${currentExePath}. Try running with sudo.`
6692
+ );
6480
6693
  }
6694
+ throw error;
6695
+ }
6696
+ try {
6697
+ await replaceCurrentBinary({ tempBinaryPath, currentExePath, currentDir });
6698
+ return {
6699
+ message: `Successfully updated from ${currentVersion} to ${latestVersion}`,
6700
+ restoreFailed: false
6701
+ };
6702
+ } catch (error) {
6481
6703
  try {
6482
- const tempInPlace = path.join(currentDir, `.rulesync-update-${crypto.randomUUID()}`);
6483
- try {
6484
- await fs.promises.copyFile(tempBinaryPath, tempInPlace);
6485
- if (os.platform() !== "win32") {
6486
- await fs.promises.chmod(tempInPlace, 493);
6487
- }
6488
- await fs.promises.rename(tempInPlace, currentExePath);
6489
- } catch {
6490
- try {
6491
- await fs.promises.unlink(tempInPlace);
6492
- } catch {
6493
- }
6494
- await fs.promises.copyFile(tempBinaryPath, currentExePath);
6495
- if (os.platform() !== "win32") {
6496
- await fs.promises.chmod(currentExePath, 493);
6497
- }
6498
- }
6499
- return `Successfully updated from ${currentVersion} to ${updateCheck.latestVersion}`;
6500
- } catch (error) {
6501
- try {
6502
- await fs.promises.copyFile(backupPath, currentExePath);
6503
- } catch {
6504
- restoreFailed = true;
6505
- throw new Error(
6704
+ await fs.promises.copyFile(backupPath, currentExePath);
6705
+ } catch {
6706
+ throw new RestoreFailedError(
6707
+ new Error(
6506
6708
  `Failed to replace binary and restore failed. Backup is preserved at: ${backupPath} (in ${tempDir}). Please manually copy it to ${currentExePath}. Original error: ${error instanceof Error ? error.message : String(error)}`,
6507
6709
  { cause: error }
6508
- );
6509
- }
6510
- if (isPermissionError(error)) {
6511
- throw new UpdatePermissionError(
6512
- `Permission denied: Cannot write to ${path.dirname(currentExePath)}. Try running with sudo.`
6513
- );
6514
- }
6515
- throw error;
6710
+ )
6711
+ );
6712
+ }
6713
+ if (isPermissionError(error)) {
6714
+ throw new UpdatePermissionError(
6715
+ `Permission denied: Cannot write to ${path.dirname(currentExePath)}. Try running with sudo.`
6716
+ );
6717
+ }
6718
+ throw error;
6719
+ }
6720
+ }
6721
+ var RestoreFailedError = class extends Error {
6722
+ cause;
6723
+ constructor(cause) {
6724
+ super(cause.message);
6725
+ this.name = "RestoreFailedError";
6726
+ this.cause = cause;
6727
+ }
6728
+ };
6729
+ async function performBinaryUpdate(currentVersion, options = {}) {
6730
+ const { force = false, token } = options;
6731
+ const updateCheck = await checkForUpdate(currentVersion, token);
6732
+ if (!updateCheck.hasUpdate && !force) {
6733
+ return `Already at the latest version (${currentVersion})`;
6734
+ }
6735
+ const { assetName, binaryAsset, checksumAsset } = resolveUpdateAssets(updateCheck.release);
6736
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "rulesync-update-"));
6737
+ let restoreFailed = false;
6738
+ try {
6739
+ if (os.platform() !== "win32") {
6740
+ await fs.promises.chmod(tempDir, 448);
6741
+ }
6742
+ const tempBinaryPath = await downloadAndVerifyBinary({
6743
+ tempDir,
6744
+ assetName,
6745
+ binaryAsset,
6746
+ checksumAsset
6747
+ });
6748
+ const installed = await installVerifiedBinary({
6749
+ tempDir,
6750
+ tempBinaryPath,
6751
+ currentVersion,
6752
+ latestVersion: updateCheck.latestVersion
6753
+ });
6754
+ restoreFailed = installed.restoreFailed;
6755
+ return installed.message;
6756
+ } catch (error) {
6757
+ if (error instanceof RestoreFailedError) {
6758
+ restoreFailed = true;
6759
+ throw error.cause;
6516
6760
  }
6761
+ throw error;
6517
6762
  } finally {
6518
6763
  if (!restoreFailed) {
6519
6764
  try {
@@ -6644,7 +6889,7 @@ function wrapCommand({
6644
6889
  }
6645
6890
 
6646
6891
  // src/cli/index.ts
6647
- var getVersion = () => "8.30.1";
6892
+ var getVersion = () => "8.32.0";
6648
6893
  function wrapCommand2(name, errorCode, handler) {
6649
6894
  return wrapCommand({ name, errorCode, handler, getVersion });
6650
6895
  }