rulesync 16.15.0 → 16.17.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.cjs +208 -60
- package/dist/cli/index.js +208 -60
- package/dist/cli/index.js.map +1 -1
- package/dist/{import-nfDFI5X1.cjs → import-DDPTPxOX.cjs} +2676 -450
- package/dist/{import-BPTCMtUS.js → import-u8yswsGB.js} +2641 -439
- package/dist/import-u8yswsGB.js.map +1 -0
- package/dist/index.cjs +4 -3
- package/dist/index.d.cts +103 -4
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +103 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/import-BPTCMtUS.js.map +0 -1
|
@@ -34,6 +34,7 @@ let gray_matter = require("gray-matter");
|
|
|
34
34
|
gray_matter = __toESM(gray_matter, 1);
|
|
35
35
|
let js_yaml = require("js-yaml");
|
|
36
36
|
let es_toolkit_object = require("es-toolkit/object");
|
|
37
|
+
let node_fs = require("node:fs");
|
|
37
38
|
let node_crypto = require("node:crypto");
|
|
38
39
|
let node_util = require("node:util");
|
|
39
40
|
let smol_toml = require("smol-toml");
|
|
@@ -134,27 +135,34 @@ const isFeatureValueEnabled = (value) => {
|
|
|
134
135
|
};
|
|
135
136
|
//#endregion
|
|
136
137
|
//#region src/constants/rulesync-paths.ts
|
|
137
|
-
const { join: join$
|
|
138
|
+
const { join: join$291 } = node_path.posix;
|
|
138
139
|
const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
|
|
139
140
|
const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
|
|
140
141
|
const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
join$
|
|
150
|
-
const
|
|
151
|
-
const
|
|
142
|
+
const RULES_FEATURE_SUBDIR = "rules";
|
|
143
|
+
const CURATED_RULES_FEATURE_SUBDIR = join$291(RULES_FEATURE_SUBDIR, ".curated");
|
|
144
|
+
const COMMANDS_FEATURE_SUBDIR = "commands";
|
|
145
|
+
const SUBAGENTS_FEATURE_SUBDIR = "subagents";
|
|
146
|
+
const CHECKS_FEATURE_SUBDIR = "checks";
|
|
147
|
+
const SKILLS_FEATURE_SUBDIR = "skills";
|
|
148
|
+
const CURATED_SKILLS_FEATURE_SUBDIR = join$291(SKILLS_FEATURE_SUBDIR, ".curated");
|
|
149
|
+
const RULESYNC_RULES_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
|
|
150
|
+
const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
|
|
151
|
+
const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
|
|
152
|
+
const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
|
|
153
|
+
const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
|
|
154
|
+
const RULESYNC_MCP_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
|
|
155
|
+
const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
|
|
156
|
+
const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
|
|
157
|
+
join$291(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
|
|
158
|
+
const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
|
|
159
|
+
const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
|
|
152
160
|
const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
|
|
153
|
-
const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$
|
|
161
|
+
const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
|
|
154
162
|
const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
|
|
155
163
|
const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
|
|
156
|
-
const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$
|
|
157
|
-
const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$
|
|
164
|
+
const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
|
|
165
|
+
const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
|
|
158
166
|
const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
|
|
159
167
|
const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
|
|
160
168
|
const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
|
|
@@ -488,9 +496,22 @@ function isEnvTest() {
|
|
|
488
496
|
}
|
|
489
497
|
//#endregion
|
|
490
498
|
//#region src/utils/file.ts
|
|
499
|
+
/**
|
|
500
|
+
* Whether a relative path leads out of the root it is relative to. Matching
|
|
501
|
+
* whole segments matters: a directory really named `..cache` relatively
|
|
502
|
+
* resolves to `..cache/file`, which a prefix test would report as an escape.
|
|
503
|
+
*/
|
|
491
504
|
function pathEscapesRoot(relativePath) {
|
|
492
505
|
return relativePath === ".." || relativePath.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(relativePath);
|
|
493
506
|
}
|
|
507
|
+
/** Whether a single path segment is a hidden (dot-prefixed) name. */
|
|
508
|
+
function isHiddenPathSegment(segment) {
|
|
509
|
+
return segment.startsWith(".") && segment !== "." && segment !== "..";
|
|
510
|
+
}
|
|
511
|
+
/** Split a path on both separators, so one predicate serves either platform. */
|
|
512
|
+
function splitPathSegments(filePath) {
|
|
513
|
+
return filePath.split(/[/\\]/);
|
|
514
|
+
}
|
|
494
515
|
async function assertWritablePathInsideRoot(params) {
|
|
495
516
|
const { rootPath, targetPath } = params;
|
|
496
517
|
let existingPath = targetPath;
|
|
@@ -721,8 +742,45 @@ async function listDirectoryFiles(dir) {
|
|
|
721
742
|
return [];
|
|
722
743
|
}
|
|
723
744
|
}
|
|
745
|
+
/** How many dot-prefixed segments a path has, used to prefer a named alias over a hidden one. */
|
|
746
|
+
function countHiddenSegments(filePath) {
|
|
747
|
+
return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* The real file a path denotes, posix-separated so it compares against the globby results
|
|
751
|
+
* that produce it. Two paths share an identity when they resolve to the very same file --
|
|
752
|
+
* a link beside its target, a link into a shared tree, or a cycle that walks back into an
|
|
753
|
+
* ancestor and yields the same file forty levels down.
|
|
754
|
+
*/
|
|
755
|
+
async function realFileIdentity(filePath) {
|
|
756
|
+
try {
|
|
757
|
+
return toPosixPath(await (0, node_fs_promises.realpath)(filePath));
|
|
758
|
+
} catch {
|
|
759
|
+
return toPosixPath(filePath);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Pick the one path that represents a file among the paths that resolve to it.
|
|
764
|
+
*
|
|
765
|
+
* The path that walked through no link at all wins outright: it is already the real one,
|
|
766
|
+
* so it equals the file's identity. That keeps the real location of a file as the path
|
|
767
|
+
* callers see, rather than an alias that happens to sort first -- a directory link named
|
|
768
|
+
* `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a cycle must not replace
|
|
769
|
+
* `sub/note.md` with the same file reached back through the cycle.
|
|
770
|
+
* Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
|
|
771
|
+
* named one represents the entry rather than a hidden alias that a hidden-entry rule may
|
|
772
|
+
* then drop, taking the named path's content with it. `candidates` arrives in sorted
|
|
773
|
+
* order, so ties keep the first one deterministically.
|
|
774
|
+
*/
|
|
775
|
+
function chooseRepresentative(candidates, identity) {
|
|
776
|
+
return candidates.reduce((best, candidate) => {
|
|
777
|
+
if (toPosixPath(best) === identity) return best;
|
|
778
|
+
if (toPosixPath(candidate) === identity) return candidate;
|
|
779
|
+
return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
|
|
780
|
+
});
|
|
781
|
+
}
|
|
724
782
|
async function findFilesByGlobs(globs, options = {}) {
|
|
725
|
-
const { type = "all", followSymbolicLinks = true, ignore } = options;
|
|
783
|
+
const { type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
|
|
726
784
|
const globbyOptions = type === "file" ? {
|
|
727
785
|
onlyFiles: true,
|
|
728
786
|
onlyDirectories: false
|
|
@@ -737,23 +795,18 @@ async function findFilesByGlobs(globs, options = {}) {
|
|
|
737
795
|
const results = (0, globby.globbySync)(normalizedGlobs, {
|
|
738
796
|
absolute: true,
|
|
739
797
|
followSymbolicLinks,
|
|
798
|
+
dot,
|
|
740
799
|
...ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {},
|
|
741
800
|
...globbyOptions
|
|
742
801
|
});
|
|
743
|
-
const
|
|
744
|
-
const deduped = [];
|
|
802
|
+
const candidatesByFile = /* @__PURE__ */ new Map();
|
|
745
803
|
for (const result of results.toSorted()) {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
realResult = result;
|
|
751
|
-
}
|
|
752
|
-
if (seenRealPaths.has(realResult)) continue;
|
|
753
|
-
seenRealPaths.add(realResult);
|
|
754
|
-
deduped.push(result);
|
|
804
|
+
const identity = await realFileIdentity(result);
|
|
805
|
+
const candidates = candidatesByFile.get(identity);
|
|
806
|
+
if (candidates === void 0) candidatesByFile.set(identity, [result]);
|
|
807
|
+
else candidates.push(result);
|
|
755
808
|
}
|
|
756
|
-
return
|
|
809
|
+
return [...candidatesByFile.entries()].map(([identity, candidates]) => chooseRepresentative(candidates, identity)).toSorted();
|
|
757
810
|
}
|
|
758
811
|
async function removeDirectory(dirPath) {
|
|
759
812
|
if ([
|
|
@@ -912,6 +965,25 @@ var CLIError = class extends Error {
|
|
|
912
965
|
}
|
|
913
966
|
};
|
|
914
967
|
//#endregion
|
|
968
|
+
//#region src/utils/warned-once.ts
|
|
969
|
+
/**
|
|
970
|
+
* The messages a once-per-run warning has already emitted in this process.
|
|
971
|
+
* This lives in its own module, free of imports, so the vitest setup file can
|
|
972
|
+
* clear it between tests without pulling `logger.js` into every test's module
|
|
973
|
+
* graph (which would defeat the module mocks some of those tests install).
|
|
974
|
+
*/
|
|
975
|
+
const warnedOnceMessages = /* @__PURE__ */ new Set();
|
|
976
|
+
/** Whether `message` has not been emitted yet; records it when it has not. */
|
|
977
|
+
function claimWarnOnce(message) {
|
|
978
|
+
if (warnedOnceMessages.has(message)) return false;
|
|
979
|
+
warnedOnceMessages.add(message);
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
/** Forget which warnings were already emitted, so each test starts silent. */
|
|
983
|
+
function resetWarnedOnceMessages() {
|
|
984
|
+
warnedOnceMessages.clear();
|
|
985
|
+
}
|
|
986
|
+
//#endregion
|
|
915
987
|
//#region src/utils/logger.ts
|
|
916
988
|
/**
|
|
917
989
|
* Base class for shared verbose/silent state and configuration logic
|
|
@@ -1064,6 +1136,17 @@ const fallbackLogger = new ConsoleLogger();
|
|
|
1064
1136
|
function warnWithFallback(logger, message) {
|
|
1065
1137
|
(logger ?? fallbackLogger).warn(message);
|
|
1066
1138
|
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Emit a warning at most once per run. A single `generate` reads the same source
|
|
1141
|
+
* file once per enabled tool target, so a warning that describes the source
|
|
1142
|
+
* rather than the target would otherwise be printed a dozen identical times.
|
|
1143
|
+
* Diagnostics that name the file they are about qualify; anything whose text
|
|
1144
|
+
* varies with what the user should do next does not.
|
|
1145
|
+
*/
|
|
1146
|
+
function warnOnceWithFallback(logger, message) {
|
|
1147
|
+
if (!claimWarnOnce(message)) return;
|
|
1148
|
+
warnWithFallback(logger, message);
|
|
1149
|
+
}
|
|
1067
1150
|
//#endregion
|
|
1068
1151
|
//#region src/utils/validation.ts
|
|
1069
1152
|
/**
|
|
@@ -1142,6 +1225,7 @@ const ConfigParamsSchema = zod_mini.z.object({
|
|
|
1142
1225
|
dryRun: (0, zod_mini.optional)(zod_mini.z.boolean()),
|
|
1143
1226
|
check: (0, zod_mini.optional)(zod_mini.z.boolean()),
|
|
1144
1227
|
inputRoot: (0, zod_mini.optional)(zod_mini.z.string()),
|
|
1228
|
+
inputRoots: (0, zod_mini.optional)(zod_mini.z.array(zod_mini.z.string()).check((0, zod_mini.minLength)(1, "inputRoots must be non-empty"))),
|
|
1145
1229
|
sources: (0, zod_mini.optional)(zod_mini.z.array(SourceEntrySchema))
|
|
1146
1230
|
});
|
|
1147
1231
|
zod_mini.z.partial(ConfigParamsSchema);
|
|
@@ -1154,14 +1238,39 @@ zod_mini.z.required(ConfigParamsSchema);
|
|
|
1154
1238
|
* Normalizes the configuration file location to an absolute path.
|
|
1155
1239
|
*
|
|
1156
1240
|
* `ConfigResolver` always supplies the path it actually loaded; the fallback
|
|
1157
|
-
* only covers direct programmatic construction
|
|
1158
|
-
*
|
|
1241
|
+
* only covers direct programmatic construction. `anchorDir` is the directory
|
|
1242
|
+
* the config file lives next to — for the default `.rulesync/` layout this
|
|
1243
|
+
* is the parent of the primary source tree.
|
|
1159
1244
|
*/
|
|
1160
|
-
function normalizeConfigFilePath({ configFilePath,
|
|
1161
|
-
if (configFilePath === void 0) return (0, node_path.join)(
|
|
1245
|
+
function normalizeConfigFilePath({ configFilePath, anchorDir }) {
|
|
1246
|
+
if (configFilePath === void 0) return (0, node_path.join)(anchorDir, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
|
|
1162
1247
|
return (0, node_path.isAbsolute)(configFilePath) ? configFilePath : (0, node_path.resolve)(configFilePath);
|
|
1163
1248
|
}
|
|
1164
1249
|
/**
|
|
1250
|
+
* Resolves any accepted input-root shape (`inputRoot`, `inputRoots`, or
|
|
1251
|
+
* neither) to the canonical non-empty tuple of absolute paths that
|
|
1252
|
+
* `Config` stores. Relative entries are resolved against the current
|
|
1253
|
+
* working directory at call time.
|
|
1254
|
+
*
|
|
1255
|
+
* Semantics (post-refactor):
|
|
1256
|
+
* - Each entry in `inputRoots` is a rulesync **source tree** (the directory
|
|
1257
|
+
* that directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). No implicit
|
|
1258
|
+
* `.rulesync/` join is applied.
|
|
1259
|
+
* - The legacy singular `inputRoot` is a shorthand for "parent of the
|
|
1260
|
+
* default `.rulesync/` source tree", and is expanded to
|
|
1261
|
+
* `[join(inputRoot, ".rulesync")]` before hitting any consumer. This is
|
|
1262
|
+
* the ONLY place `.rulesync` is appended by convention.
|
|
1263
|
+
* - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
|
|
1264
|
+
* so existing projects with a single `.rulesync/` tree keep working
|
|
1265
|
+
* unchanged.
|
|
1266
|
+
*
|
|
1267
|
+
* Callers must have already run `assertInputRootFieldsExclusive`.
|
|
1268
|
+
*/
|
|
1269
|
+
function normalizeInputRoots({ inputRoot, inputRoots }) {
|
|
1270
|
+
const resolved = (inputRoots !== void 0 && inputRoots.length > 0 ? inputRoots : inputRoot !== void 0 ? [(0, node_path.join)(inputRoot, RULESYNC_RELATIVE_DIR_PATH)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)]).map((entry) => (0, node_path.isAbsolute)(entry) ? entry : (0, node_path.resolve)(entry));
|
|
1271
|
+
return [resolved[0], ...resolved.slice(1)];
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1165
1274
|
* Conflicting target pairs that cannot be used together.
|
|
1166
1275
|
* Exported so `rulesync doctor` can report the same conflicts as diagnostics
|
|
1167
1276
|
* without duplicating the list.
|
|
@@ -1198,6 +1307,28 @@ const assertTargetsFeaturesExclusive = ({ targets, features }) => {
|
|
|
1198
1307
|
if (targets !== void 0 && !Array.isArray(targets) && features !== void 0) throw new Error("Invalid config: when 'targets' is in object form, 'features' must be omitted. Declare per-target features inside the 'targets' object instead.");
|
|
1199
1308
|
};
|
|
1200
1309
|
/**
|
|
1310
|
+
* Rejects a single user-authored config file (or a single programmatic
|
|
1311
|
+
* construction) that defines both `inputRoot` and `inputRoots` — the two
|
|
1312
|
+
* fields express the same setting at singular vs. list level and cannot
|
|
1313
|
+
* be combined within one file without ambiguity.
|
|
1314
|
+
*
|
|
1315
|
+
* The check is intentionally per-file: base and local config files can each
|
|
1316
|
+
* be valid in isolation and merge into a state where both survive, and the
|
|
1317
|
+
* resolver picks `inputRoots` in that case (see `resolveEffectiveInputRoots`).
|
|
1318
|
+
* Only a single file declaring both is a genuine authoring error.
|
|
1319
|
+
*/
|
|
1320
|
+
const assertInputRootFieldsExclusive = ({ inputRoot, inputRoots }) => {
|
|
1321
|
+
if (inputRoot !== void 0 && inputRoots !== void 0) throw new Error("Invalid config: 'inputRoot' and 'inputRoots' cannot be combined. Remove 'inputRoot' and keep 'inputRoots', or reduce 'inputRoots' to a single-element 'inputRoot' string.");
|
|
1322
|
+
};
|
|
1323
|
+
/**
|
|
1324
|
+
* Rejects an explicitly supplied empty `inputRoots` list. Omitting the field
|
|
1325
|
+
* selects the conventional default, while an empty list has no meaningful
|
|
1326
|
+
* source-tree semantics and must not be treated as an absent override.
|
|
1327
|
+
*/
|
|
1328
|
+
const assertInputRootsNonEmpty = ({ inputRoots }) => {
|
|
1329
|
+
if (inputRoots !== void 0 && inputRoots.length === 0) throw new Error("Invalid config: 'inputRoots' must be non-empty.");
|
|
1330
|
+
};
|
|
1331
|
+
/**
|
|
1201
1332
|
* Normalizes a post-resolution `ConfigParams` input by rejecting the case
|
|
1202
1333
|
* where both `targets` and `features` are undefined — a degenerate state
|
|
1203
1334
|
* that would silently produce a no-op config (no targets, no features).
|
|
@@ -1233,14 +1364,35 @@ var Config = class Config {
|
|
|
1233
1364
|
gitignoreDestination;
|
|
1234
1365
|
dryRun;
|
|
1235
1366
|
check;
|
|
1236
|
-
|
|
1367
|
+
/**
|
|
1368
|
+
* Ordered, absolute-path list of rulesync source trees. Each entry is a
|
|
1369
|
+
* source tree itself — the directory that directly contains `rules/`,
|
|
1370
|
+
* `skills/`, `mcp.jsonc`, etc. No implicit `.rulesync/` join is applied.
|
|
1371
|
+
*
|
|
1372
|
+
* Always non-empty by construction — the constructor either normalizes
|
|
1373
|
+
* an `inputRoot`/`inputRoots` input or falls back to a single-element
|
|
1374
|
+
* list containing `join(<cwd>, ".rulesync")`.
|
|
1375
|
+
*
|
|
1376
|
+
* `inputRoot` (singular) is a deprecated backward-compatibility alias
|
|
1377
|
+
* that expands to `[join(inputRoot, ".rulesync")]`.
|
|
1378
|
+
*
|
|
1379
|
+
* Typed as a non-empty tuple so the one internal caller that legitimately
|
|
1380
|
+
* needs "the primary root" (`normalizeConfigFilePath` fallback) can index
|
|
1381
|
+
* `[0]` without a runtime null-check.
|
|
1382
|
+
*/
|
|
1383
|
+
inputRoots;
|
|
1237
1384
|
configFilePath;
|
|
1238
1385
|
sources;
|
|
1239
|
-
constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, configFilePath, sources, configFileTargets }) {
|
|
1386
|
+
constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
|
|
1240
1387
|
assertTargetsFeaturesExclusive({
|
|
1241
1388
|
targets,
|
|
1242
1389
|
features
|
|
1243
1390
|
});
|
|
1391
|
+
assertInputRootFieldsExclusive({
|
|
1392
|
+
inputRoot,
|
|
1393
|
+
inputRoots
|
|
1394
|
+
});
|
|
1395
|
+
assertInputRootsNonEmpty({ inputRoots });
|
|
1244
1396
|
assertTargetsOrFeaturesProvided({
|
|
1245
1397
|
targets,
|
|
1246
1398
|
features
|
|
@@ -1268,10 +1420,13 @@ var Config = class Config {
|
|
|
1268
1420
|
this.gitignoreDestination = gitignoreDestination ?? "gitignore";
|
|
1269
1421
|
this.dryRun = dryRun ?? false;
|
|
1270
1422
|
this.check = check ?? false;
|
|
1271
|
-
this.
|
|
1423
|
+
this.inputRoots = normalizeInputRoots({
|
|
1424
|
+
inputRoot,
|
|
1425
|
+
inputRoots
|
|
1426
|
+
});
|
|
1272
1427
|
this.configFilePath = normalizeConfigFilePath({
|
|
1273
1428
|
configFilePath,
|
|
1274
|
-
|
|
1429
|
+
anchorDir: (0, node_path.dirname)(this.inputRoots[0])
|
|
1275
1430
|
});
|
|
1276
1431
|
this.sources = sources ?? [];
|
|
1277
1432
|
}
|
|
@@ -1455,16 +1610,22 @@ var Config = class Config {
|
|
|
1455
1610
|
return this.check;
|
|
1456
1611
|
}
|
|
1457
1612
|
/**
|
|
1458
|
-
* Returns the
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1613
|
+
* Returns the ordered list of rulesync source trees. Each entry is the
|
|
1614
|
+
* source tree itself — the directory that directly contains `rules/`,
|
|
1615
|
+
* `skills/`, `mcp.jsonc`, etc. Values are absolute paths captured at
|
|
1616
|
+
* config-construction time, so this accessor is pure and never depends on
|
|
1617
|
+
* a live `process.cwd()` read.
|
|
1461
1618
|
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1619
|
+
* The returned tuple is always non-empty: when no `inputRoot`/`inputRoots`
|
|
1620
|
+
* was supplied, `[join(process.cwd(), ".rulesync")]` is snapshotted once
|
|
1621
|
+
* during construction. The first entry is the required base source tree.
|
|
1622
|
+
* Later entries are optional overlays and may be absent; when present, they
|
|
1623
|
+
* take precedence when the same relative source path exists in more than
|
|
1624
|
+
* one root (see per-feature merge policies in the processor
|
|
1625
|
+
* `loadRulesync*` methods).
|
|
1465
1626
|
*/
|
|
1466
|
-
|
|
1467
|
-
return this.
|
|
1627
|
+
getInputRoots() {
|
|
1628
|
+
return this.inputRoots;
|
|
1468
1629
|
}
|
|
1469
1630
|
/**
|
|
1470
1631
|
* Returns the absolute path of the configuration file this config was
|
|
@@ -1505,6 +1666,7 @@ const getDefaults = () => ({
|
|
|
1505
1666
|
dryRun: false,
|
|
1506
1667
|
check: false,
|
|
1507
1668
|
inputRoot: void 0,
|
|
1669
|
+
inputRoots: void 0,
|
|
1508
1670
|
sources: []
|
|
1509
1671
|
});
|
|
1510
1672
|
const loadConfigFromFile = async (filePath) => {
|
|
@@ -1516,8 +1678,23 @@ const loadConfigFromFile = async (filePath) => {
|
|
|
1516
1678
|
targets: configParams.targets,
|
|
1517
1679
|
features: configParams.features
|
|
1518
1680
|
});
|
|
1681
|
+
try {
|
|
1682
|
+
assertInputRootFieldsExclusive({
|
|
1683
|
+
inputRoot: configParams.inputRoot,
|
|
1684
|
+
inputRoots: configParams.inputRoots
|
|
1685
|
+
});
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1688
|
+
throw new Error(`${detail} (in ${JSON.stringify(filePath)})`, { cause: error });
|
|
1689
|
+
}
|
|
1519
1690
|
return configParams;
|
|
1520
1691
|
};
|
|
1692
|
+
function mergeInputRootConfigs({ baseConfig, localConfig }) {
|
|
1693
|
+
return {
|
|
1694
|
+
inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
|
|
1695
|
+
inputRoots: localConfig.inputRoots ?? baseConfig.inputRoots
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1521
1698
|
const mergeConfigs = (baseConfig, localConfig) => {
|
|
1522
1699
|
return {
|
|
1523
1700
|
targets: localConfig.targets ?? baseConfig.targets,
|
|
@@ -1535,7 +1712,10 @@ const mergeConfigs = (baseConfig, localConfig) => {
|
|
|
1535
1712
|
gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination,
|
|
1536
1713
|
dryRun: localConfig.dryRun ?? baseConfig.dryRun,
|
|
1537
1714
|
check: localConfig.check ?? baseConfig.check,
|
|
1538
|
-
|
|
1715
|
+
...mergeInputRootConfigs({
|
|
1716
|
+
baseConfig,
|
|
1717
|
+
localConfig
|
|
1718
|
+
}),
|
|
1539
1719
|
sources: localConfig.sources ?? baseConfig.sources
|
|
1540
1720
|
};
|
|
1541
1721
|
};
|
|
@@ -1564,13 +1744,14 @@ function assertMergedTargetsFeaturesExclusive({ configByFile, validatedConfigPat
|
|
|
1564
1744
|
}
|
|
1565
1745
|
}
|
|
1566
1746
|
/**
|
|
1567
|
-
* Resolve the effective `global` flag. When an
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1747
|
+
* Resolve the effective `global` flag. When an input root (singular
|
|
1748
|
+
* `inputRoot` or plural `inputRoots`) is in play the user is decoupling
|
|
1749
|
+
* source from output, so a config-file `global: true` is dropped (unless
|
|
1750
|
+
* the caller also explicitly passes `global`); a warning is emitted in
|
|
1570
1751
|
* that case. Returns the resolved boolean `global`.
|
|
1571
1752
|
*/
|
|
1572
1753
|
function resolveGlobal({ logger, resolvedInputRoot, global, configByFile, validatedConfigPath }) {
|
|
1573
|
-
if (resolvedInputRoot !== void 0 && global === void 0 && configByFile.global === true) warnWithFallback(logger, `Ignoring "global: true" from ${JSON.stringify(validatedConfigPath)} because an
|
|
1754
|
+
if (resolvedInputRoot !== void 0 && global === void 0 && configByFile.global === true) warnWithFallback(logger, `Ignoring "global: true" from ${JSON.stringify(validatedConfigPath)} because an input root was configured; pass global=true (CLI: --global) to keep user-scope output. Output will be project-scope (global=false).`);
|
|
1574
1755
|
return pick({
|
|
1575
1756
|
cli: global,
|
|
1576
1757
|
file: resolvedInputRoot !== void 0 ? false : configByFile.global,
|
|
@@ -1594,17 +1775,81 @@ function resolveFeaturesAndTargets({ features, targets, configByFile }) {
|
|
|
1594
1775
|
resolvedTargets: userProvidedTargets ?? getDefaults().targets
|
|
1595
1776
|
};
|
|
1596
1777
|
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Resolve the effective, non-empty, absolute-path list of source-tree roots
|
|
1780
|
+
* by applying CLI > file > default precedence and preferring `inputRoots`
|
|
1781
|
+
* over `inputRoot` when both survive the base+local merge. Duplicates (after
|
|
1782
|
+
* normalization to absolute paths) are removed silently so overlapping
|
|
1783
|
+
* base/local declarations do not double-count the same tree.
|
|
1784
|
+
*
|
|
1785
|
+
* Semantics (post-refactor):
|
|
1786
|
+
* - `inputRoots` entries are the source trees themselves (each holds
|
|
1787
|
+
* `rules/`, `skills/`, `mcp.jsonc`, etc.); they are passed through
|
|
1788
|
+
* unchanged.
|
|
1789
|
+
* - `inputRoot` (legacy singular) is a shorthand for "parent of the
|
|
1790
|
+
* `.rulesync/` source tree" and is expanded to `join(inputRoot,
|
|
1791
|
+
* ".rulesync")` before it hits any consumer.
|
|
1792
|
+
* - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
|
|
1793
|
+
* so existing projects keep working unchanged.
|
|
1794
|
+
*
|
|
1795
|
+
* When both the merged file config has `inputRoots` and the CLI supplied
|
|
1796
|
+
* `inputRoot` (or vice versa), CLI wins outright — matching how every
|
|
1797
|
+
* other field is resolved. When only the file config supplies both, the
|
|
1798
|
+
* plural wins over the singular and the drop is logged at debug level.
|
|
1799
|
+
*/
|
|
1800
|
+
function resolveEffectiveInputRoots({ cliInputRoot, cliInputRoots, configByFile, cwd, logger }) {
|
|
1801
|
+
let source;
|
|
1802
|
+
let field;
|
|
1803
|
+
if (cliInputRoots !== void 0 && cliInputRoots.length > 0) {
|
|
1804
|
+
source = cliInputRoots;
|
|
1805
|
+
field = "inputRoots";
|
|
1806
|
+
} else if (cliInputRoot !== void 0) {
|
|
1807
|
+
source = [(0, node_path.join)(cliInputRoot, RULESYNC_RELATIVE_DIR_PATH)];
|
|
1808
|
+
field = "inputRoot";
|
|
1809
|
+
} else if (configByFile.inputRoots !== void 0 && configByFile.inputRoots.length > 0) {
|
|
1810
|
+
source = configByFile.inputRoots;
|
|
1811
|
+
field = "inputRoots";
|
|
1812
|
+
if (configByFile.inputRoot !== void 0) logger?.debug(`Both 'inputRoot' and 'inputRoots' were set after merging base and local configs; 'inputRoots' wins and 'inputRoot' was dropped.`);
|
|
1813
|
+
} else if (configByFile.inputRoot !== void 0) {
|
|
1814
|
+
source = [(0, node_path.join)(configByFile.inputRoot, RULESYNC_RELATIVE_DIR_PATH)];
|
|
1815
|
+
field = "inputRoot";
|
|
1816
|
+
} else source = [(0, node_path.join)(cwd, RULESYNC_RELATIVE_DIR_PATH)];
|
|
1817
|
+
const candidates = source.map((entry) => (0, node_path.resolve)(cwd, entry));
|
|
1818
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1819
|
+
const resolved = [];
|
|
1820
|
+
for (const absolute of candidates) {
|
|
1821
|
+
if (seen.has(absolute)) continue;
|
|
1822
|
+
seen.add(absolute);
|
|
1823
|
+
resolved.push(absolute);
|
|
1824
|
+
}
|
|
1825
|
+
return {
|
|
1826
|
+
inputRoots: [resolved[0], ...resolved.slice(1)],
|
|
1827
|
+
candidates,
|
|
1828
|
+
field
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1597
1831
|
var ConfigResolver = class {
|
|
1598
|
-
static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot }, { logger } = {}) {
|
|
1832
|
+
static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
|
|
1599
1833
|
const cwd = (0, node_path.resolve)(process.cwd());
|
|
1834
|
+
assertInputRootFieldsExclusive({
|
|
1835
|
+
inputRoot,
|
|
1836
|
+
inputRoots
|
|
1837
|
+
});
|
|
1838
|
+
assertInputRootsNonEmpty({ inputRoots });
|
|
1600
1839
|
if (inputRoot !== void 0) validateOutputRoot(inputRoot);
|
|
1601
|
-
|
|
1840
|
+
if (inputRoots !== void 0) for (const entry of inputRoots) validateOutputRoot(entry);
|
|
1841
|
+
const cliConfigAnchor = inputRoot;
|
|
1842
|
+
const hasCliInputRootOverride = inputRoot !== void 0 || inputRoots !== void 0;
|
|
1843
|
+
const validatedConfigPath = resolvePath(configPath, (0, node_path.resolve)(cliConfigAnchor ?? cwd));
|
|
1602
1844
|
const baseConfig = await loadConfigFromFile(validatedConfigPath);
|
|
1603
1845
|
const configDir = (0, node_path.dirname)(validatedConfigPath);
|
|
1604
1846
|
const localConfigPath = (0, node_path.join)(configDir, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH);
|
|
1605
1847
|
const localConfig = await loadConfigFromFile(localConfigPath);
|
|
1606
1848
|
const configByFile = mergeConfigs(baseConfig, localConfig);
|
|
1607
|
-
if (
|
|
1849
|
+
if (!hasCliInputRootOverride) {
|
|
1850
|
+
if (configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
|
|
1851
|
+
if (configByFile.inputRoots !== void 0) for (const entry of configByFile.inputRoots) validateOutputRoot(entry);
|
|
1852
|
+
}
|
|
1608
1853
|
assertMergedTargetsFeaturesExclusive({
|
|
1609
1854
|
configByFile,
|
|
1610
1855
|
validatedConfigPath,
|
|
@@ -1630,10 +1875,16 @@ var ConfigResolver = class {
|
|
|
1630
1875
|
silent: resolvedSilent
|
|
1631
1876
|
});
|
|
1632
1877
|
}
|
|
1633
|
-
const
|
|
1878
|
+
const resolvedInputRoots = resolveEffectiveInputRoots({
|
|
1879
|
+
cliInputRoot: inputRoot,
|
|
1880
|
+
cliInputRoots: inputRoots,
|
|
1881
|
+
configByFile,
|
|
1882
|
+
cwd,
|
|
1883
|
+
logger
|
|
1884
|
+
}).inputRoots;
|
|
1634
1885
|
const resolvedGlobal = resolveGlobal({
|
|
1635
1886
|
logger,
|
|
1636
|
-
resolvedInputRoot,
|
|
1887
|
+
resolvedInputRoot: inputRoot !== void 0 || inputRoots !== void 0 || configByFile.inputRoot !== void 0 || configByFile.inputRoots !== void 0 ? resolvedInputRoots[0] : void 0,
|
|
1637
1888
|
global,
|
|
1638
1889
|
configByFile,
|
|
1639
1890
|
validatedConfigPath
|
|
@@ -1697,7 +1948,7 @@ var ConfigResolver = class {
|
|
|
1697
1948
|
file: configByFile.check,
|
|
1698
1949
|
fallback: getDefaults().check
|
|
1699
1950
|
}),
|
|
1700
|
-
|
|
1951
|
+
inputRoots: resolvedInputRoots,
|
|
1701
1952
|
configFilePath: validatedConfigPath,
|
|
1702
1953
|
sources: configByFile.sources ?? getDefaults().sources,
|
|
1703
1954
|
flattenedCommandNaming: configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming,
|
|
@@ -1840,8 +2091,7 @@ var AiFile = class {
|
|
|
1840
2091
|
const fullPath = node_path.default.join(this.outputRoot, this.relativeDirPath, this.relativeFilePath);
|
|
1841
2092
|
const resolvedFull = (0, node_path.resolve)(fullPath);
|
|
1842
2093
|
const resolvedBase = (0, node_path.resolve)(this.outputRoot);
|
|
1843
|
-
|
|
1844
|
-
if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
|
|
2094
|
+
if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
|
|
1845
2095
|
return fullPath;
|
|
1846
2096
|
}
|
|
1847
2097
|
getFileContent() {
|
|
@@ -1903,6 +2153,27 @@ var RulesyncFile = class extends AiFile {
|
|
|
1903
2153
|
}
|
|
1904
2154
|
};
|
|
1905
2155
|
//#endregion
|
|
2156
|
+
//#region src/utils/control-characters.ts
|
|
2157
|
+
/**
|
|
2158
|
+
* Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
|
|
2159
|
+
* introducer U+009B), the bidirectional overrides and isolates, and the Unicode
|
|
2160
|
+
* line and paragraph separators, and the plain LRM/RLM marks. A name or value
|
|
2161
|
+
* copied out of an untrusted config file, a fetched repository, or a tool's own
|
|
2162
|
+
* settings file must never reach the terminal with these intact: they let the
|
|
2163
|
+
* text forge log lines, reorder what is printed around them, or inject escape
|
|
2164
|
+
* sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
|
|
2165
|
+
* neutral characters beside them, so they go too — a diagnostic line is not the
|
|
2166
|
+
* place to preserve the typography of a right-to-left name.
|
|
2167
|
+
*/
|
|
2168
|
+
const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
|
|
2169
|
+
/**
|
|
2170
|
+
* Removes every control character from `text` so it is safe to splice into a
|
|
2171
|
+
* log line or other terminal output.
|
|
2172
|
+
*/
|
|
2173
|
+
function stripControlCharacters(text) {
|
|
2174
|
+
return text.replace(CONTROL_CHARACTERS_PATTERN, "");
|
|
2175
|
+
}
|
|
2176
|
+
//#endregion
|
|
1906
2177
|
//#region src/utils/type-guards.ts
|
|
1907
2178
|
/**
|
|
1908
2179
|
* Type guard to check if a value is a plain object (Record<string, unknown>).
|
|
@@ -2026,7 +2297,7 @@ function parseFrontmatter(content, filePath) {
|
|
|
2026
2297
|
let body;
|
|
2027
2298
|
let hasFrontmatter;
|
|
2028
2299
|
try {
|
|
2029
|
-
const result = (0, gray_matter.default)(content);
|
|
2300
|
+
const result = (0, gray_matter.default)(content, {});
|
|
2030
2301
|
frontmatter = result.data;
|
|
2031
2302
|
body = result.content;
|
|
2032
2303
|
hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
|
|
@@ -2040,6 +2311,104 @@ function parseFrontmatter(content, filePath) {
|
|
|
2040
2311
|
hasFrontmatter
|
|
2041
2312
|
};
|
|
2042
2313
|
}
|
|
2314
|
+
/**
|
|
2315
|
+
* A top-level `key: value` entry. Nested entries are left alone deliberately:
|
|
2316
|
+
* the repair below rewrites a value's meaning, and the failure it exists for —
|
|
2317
|
+
* an unquoted sentence with a colon in it — is a `description`, which is always
|
|
2318
|
+
* top-level.
|
|
2319
|
+
*/
|
|
2320
|
+
const TOP_LEVEL_ENTRY_PATTERN = /^([A-Za-z_][\w.-]*):[^\S\r\n]+(\S.*)$/;
|
|
2321
|
+
/** A plain scalar that already starts as some other YAML construct. */
|
|
2322
|
+
const YAML_CONSTRUCT_PREFIX_PATTERN = /^["'|>&*![{#]/;
|
|
2323
|
+
/**
|
|
2324
|
+
* Cut a plain scalar at its inline comment — whitespace followed by `#`.
|
|
2325
|
+
*
|
|
2326
|
+
* This has to happen before anything else looks at the value, or a line such as
|
|
2327
|
+
* `allowed-tools: Read # TODO: add Bash later` reads as needing repair and comes
|
|
2328
|
+
* back quoted with the comment inside it, which for a list of tool permissions
|
|
2329
|
+
* would grant what the comment had disabled. Scanning by hand rather than with
|
|
2330
|
+
* `/\s+#.*$/`: that pattern is unanchored, so a long run of spaces with no `#`
|
|
2331
|
+
* after it backtracks from every starting position, and a value padded with a
|
|
2332
|
+
* megabyte of spaces takes minutes to reject. This is linear in the value.
|
|
2333
|
+
*/
|
|
2334
|
+
function stripInlineComment(rawValue) {
|
|
2335
|
+
for (let index = 1; index < rawValue.length; index++) if (rawValue[index] === "#" && /\s/.test(rawValue[index - 1] ?? "")) return rawValue.slice(0, index).trimEnd();
|
|
2336
|
+
return rawValue.trimEnd();
|
|
2337
|
+
}
|
|
2338
|
+
function repairFrontmatterLine(line) {
|
|
2339
|
+
const unchanged = {
|
|
2340
|
+
line,
|
|
2341
|
+
droppedComment: false
|
|
2342
|
+
};
|
|
2343
|
+
const carriageReturn = line.endsWith("\r") ? "\r" : "";
|
|
2344
|
+
const bareLine = carriageReturn === "" ? line : line.slice(0, -1);
|
|
2345
|
+
const match = TOP_LEVEL_ENTRY_PATTERN.exec(bareLine);
|
|
2346
|
+
if (!match) return unchanged;
|
|
2347
|
+
const [, key = "", rawValue = ""] = match;
|
|
2348
|
+
const value = stripInlineComment(rawValue);
|
|
2349
|
+
if (value === "") return unchanged;
|
|
2350
|
+
if (!/:(?:\s|$)/.test(value)) return unchanged;
|
|
2351
|
+
if (YAML_CONSTRUCT_PREFIX_PATTERN.test(value)) return unchanged;
|
|
2352
|
+
return {
|
|
2353
|
+
line: `${key}: ${JSON.stringify(value)}${carriageReturn}`,
|
|
2354
|
+
droppedComment: value !== rawValue.trimEnd()
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Quote the unquoted scalars that make a frontmatter block unparseable, or
|
|
2359
|
+
* return `undefined` when there is nothing to repair. Only the frontmatter
|
|
2360
|
+
* block is rewritten; the body is passed through untouched.
|
|
2361
|
+
*/
|
|
2362
|
+
function repairMalformedFrontmatterYaml(content) {
|
|
2363
|
+
const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
|
|
2364
|
+
if (!opening) return;
|
|
2365
|
+
const blockStart = opening[0].length;
|
|
2366
|
+
const closing = /\r?\n---/.exec(content.slice(blockStart));
|
|
2367
|
+
if (!closing) return;
|
|
2368
|
+
const blockEnd = blockStart + closing.index;
|
|
2369
|
+
const block = content.slice(blockStart, blockEnd);
|
|
2370
|
+
const repairedLines = block.split("\n").map(repairFrontmatterLine);
|
|
2371
|
+
const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
|
|
2372
|
+
if (repairedBlock === block) return;
|
|
2373
|
+
return {
|
|
2374
|
+
content: content.slice(0, blockStart) + repairedBlock + content.slice(blockEnd),
|
|
2375
|
+
droppedComment: repairedLines.some(({ droppedComment }) => droppedComment)
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Parse frontmatter, retrying once with unquoted colon-bearing values quoted.
|
|
2380
|
+
*
|
|
2381
|
+
* Files authored for another client routinely carry YAML that only that
|
|
2382
|
+
* client's parser accepts — `description: Use this skill when: the user asks
|
|
2383
|
+
* about PDFs` is the case the Agent Skills client guide names. Without a retry
|
|
2384
|
+
* such a file is not merely reported, it is dropped: the lenient skill import
|
|
2385
|
+
* catches the parse error and skips the whole skill. The retry is deliberately
|
|
2386
|
+
* narrow — one pass, top-level entries only, and the original error is what
|
|
2387
|
+
* surfaces if it does not help, so a genuinely broken file still fails with the
|
|
2388
|
+
* message that describes what is actually wrong with it. A file with no closing
|
|
2389
|
+
* `---`, or one whose opening fence carries a language tag, is not repaired at
|
|
2390
|
+
* all: neither is a frontmatter block gray-matter would have read.
|
|
2391
|
+
*
|
|
2392
|
+
* @see https://agentskills.io/client-implementation/adding-skills-support
|
|
2393
|
+
*/
|
|
2394
|
+
function parseFrontmatterWithYamlRepair(content, filePath, options = {}) {
|
|
2395
|
+
try {
|
|
2396
|
+
return parseFrontmatter(content, filePath);
|
|
2397
|
+
} catch (error) {
|
|
2398
|
+
const repaired = repairMalformedFrontmatterYaml(content);
|
|
2399
|
+
if (repaired === void 0) throw error;
|
|
2400
|
+
let result;
|
|
2401
|
+
try {
|
|
2402
|
+
result = parseFrontmatter(repaired.content, filePath);
|
|
2403
|
+
} catch {
|
|
2404
|
+
throw error;
|
|
2405
|
+
}
|
|
2406
|
+
if (options.quiet === true) return result;
|
|
2407
|
+
const commentNote = repaired.droppedComment ? " Text following a space and `#` was read as a YAML comment and left out of the value." : "";
|
|
2408
|
+
warnOnceWithFallback(void 0, `Recovered malformed YAML frontmatter in ${filePath === void 0 ? "the input" : stripControlCharacters(toPosixPath(filePath))} by quoting values that contain a colon.${commentNote} Quote them in the file itself so other tools can read it too.`);
|
|
2409
|
+
return result;
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2043
2412
|
//#endregion
|
|
2044
2413
|
//#region src/features/checks/rulesync-check.ts
|
|
2045
2414
|
const RulesyncCheckFrontmatterSchema = zod_mini.z.looseObject({
|
|
@@ -2097,8 +2466,9 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
|
|
|
2097
2466
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
2098
2467
|
};
|
|
2099
2468
|
}
|
|
2100
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
|
|
2101
|
-
const
|
|
2469
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
2470
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
|
|
2471
|
+
const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
|
|
2102
2472
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
|
|
2103
2473
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
2104
2474
|
const result = RulesyncCheckFrontmatterSchema.safeParse(frontmatter);
|
|
@@ -2106,7 +2476,7 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
|
|
|
2106
2476
|
const filename = (0, node_path.basename)(relativeFilePath);
|
|
2107
2477
|
return new RulesyncCheck({
|
|
2108
2478
|
outputRoot,
|
|
2109
|
-
relativeDirPath:
|
|
2479
|
+
relativeDirPath: dirPath,
|
|
2110
2480
|
relativeFilePath: filename,
|
|
2111
2481
|
frontmatter: result.data,
|
|
2112
2482
|
body: content.trim()
|
|
@@ -2177,8 +2547,9 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
|
|
|
2177
2547
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
2178
2548
|
};
|
|
2179
2549
|
}
|
|
2180
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
|
|
2181
|
-
const
|
|
2550
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
2551
|
+
const dirPath = relativeDirPath ?? RulesyncCommand.getSettablePaths().relativeDirPath;
|
|
2552
|
+
const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
|
|
2182
2553
|
const fileContent = await readFileContent(filePath);
|
|
2183
2554
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(fileContent, filePath);
|
|
2184
2555
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
@@ -2186,7 +2557,7 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
|
|
|
2186
2557
|
if (!result.success) throw new Error(`Invalid frontmatter in ${relativeFilePath}: ${formatError(result.error)}`);
|
|
2187
2558
|
return new RulesyncCommand({
|
|
2188
2559
|
outputRoot,
|
|
2189
|
-
relativeDirPath:
|
|
2560
|
+
relativeDirPath: dirPath,
|
|
2190
2561
|
relativeFilePath,
|
|
2191
2562
|
frontmatter: result.data,
|
|
2192
2563
|
body: content.trim(),
|
|
@@ -3575,8 +3946,19 @@ const CANONICAL_TO_VIBE_EVENT_NAMES = {
|
|
|
3575
3946
|
};
|
|
3576
3947
|
/**
|
|
3577
3948
|
* Map Mistral Vibe snake_case event names to canonical camelCase.
|
|
3949
|
+
*
|
|
3950
|
+
* The pre-2.21.0 spellings are accepted alongside the current ones. Vibe's
|
|
3951
|
+
* strict `HookType` enum rejects a file that still uses them, so such a file is
|
|
3952
|
+
* already dead on disk; reading it here and emitting the renamed spelling is
|
|
3953
|
+
* what repairs it, whereas leaving the old name unmapped would route the hook
|
|
3954
|
+
* into a tool override block and lose the event.
|
|
3578
3955
|
*/
|
|
3579
|
-
const VIBE_TO_CANONICAL_EVENT_NAMES =
|
|
3956
|
+
const VIBE_TO_CANONICAL_EVENT_NAMES = {
|
|
3957
|
+
...Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k])),
|
|
3958
|
+
before_tool: "preToolUse",
|
|
3959
|
+
after_tool: "postToolUse",
|
|
3960
|
+
post_agent_turn: "stop"
|
|
3961
|
+
};
|
|
3580
3962
|
/**
|
|
3581
3963
|
* Map canonical camelCase event names to Qwen Code PascalCase.
|
|
3582
3964
|
*
|
|
@@ -3797,15 +4179,17 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
|
|
|
3797
4179
|
error: null
|
|
3798
4180
|
};
|
|
3799
4181
|
}
|
|
3800
|
-
static async fromFile({ outputRoot = process.cwd(), validate = true }) {
|
|
4182
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
|
|
3801
4183
|
const paths = RulesyncHooks.getSettablePaths();
|
|
4184
|
+
const overrideDirPath = relativeDirPath;
|
|
3802
4185
|
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
3803
|
-
const
|
|
4186
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
4187
|
+
const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
3804
4188
|
if (!await fileExists(filePath)) continue;
|
|
3805
4189
|
const fileContent = await readFileContent(filePath);
|
|
3806
4190
|
return new RulesyncHooks({
|
|
3807
4191
|
outputRoot,
|
|
3808
|
-
relativeDirPath:
|
|
4192
|
+
relativeDirPath: candidateDirPath,
|
|
3809
4193
|
relativeFilePath: candidate.relativeFilePath,
|
|
3810
4194
|
fileContent,
|
|
3811
4195
|
validate
|
|
@@ -3832,21 +4216,23 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
|
|
|
3832
4216
|
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
|
|
3833
4217
|
relativeFilePath: RULESYNC_AIIGNORE_FILE_NAME
|
|
3834
4218
|
},
|
|
3835
|
-
legacy: {
|
|
4219
|
+
legacy: [{
|
|
3836
4220
|
relativeDirPath: ".",
|
|
3837
4221
|
relativeFilePath: RULESYNC_IGNORE_RELATIVE_FILE_PATH
|
|
3838
|
-
}
|
|
4222
|
+
}]
|
|
3839
4223
|
};
|
|
3840
4224
|
}
|
|
3841
|
-
static async fromFile({ outputRoot = process.cwd() } = {}) {
|
|
4225
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath } = {}) {
|
|
3842
4226
|
const paths = this.getSettablePaths();
|
|
3843
|
-
const
|
|
3844
|
-
const
|
|
4227
|
+
const recommendedDirPath = relativeDirPath ?? paths.recommended.relativeDirPath;
|
|
4228
|
+
const recommendedPath = (0, node_path.join)(outputRoot, recommendedDirPath, paths.recommended.relativeFilePath);
|
|
4229
|
+
const [legacy] = paths.legacy;
|
|
4230
|
+
const legacyPath = (0, node_path.join)(outputRoot, legacy.relativeDirPath, legacy.relativeFilePath);
|
|
3845
4231
|
if (await fileExists(recommendedPath)) {
|
|
3846
4232
|
const fileContent = await readFileContent(recommendedPath);
|
|
3847
4233
|
return new RulesyncIgnore({
|
|
3848
4234
|
outputRoot,
|
|
3849
|
-
relativeDirPath:
|
|
4235
|
+
relativeDirPath: recommendedDirPath,
|
|
3850
4236
|
relativeFilePath: paths.recommended.relativeFilePath,
|
|
3851
4237
|
fileContent
|
|
3852
4238
|
});
|
|
@@ -3855,15 +4241,15 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
|
|
|
3855
4241
|
const fileContent = await readFileContent(legacyPath);
|
|
3856
4242
|
return new RulesyncIgnore({
|
|
3857
4243
|
outputRoot,
|
|
3858
|
-
relativeDirPath:
|
|
3859
|
-
relativeFilePath:
|
|
4244
|
+
relativeDirPath: legacy.relativeDirPath,
|
|
4245
|
+
relativeFilePath: legacy.relativeFilePath,
|
|
3860
4246
|
fileContent
|
|
3861
4247
|
});
|
|
3862
4248
|
}
|
|
3863
4249
|
const fileContent = await readFileContent(recommendedPath);
|
|
3864
4250
|
return new RulesyncIgnore({
|
|
3865
4251
|
outputRoot,
|
|
3866
|
-
relativeDirPath:
|
|
4252
|
+
relativeDirPath: recommendedDirPath,
|
|
3867
4253
|
relativeFilePath: paths.recommended.relativeFilePath,
|
|
3868
4254
|
fileContent
|
|
3869
4255
|
});
|
|
@@ -4019,6 +4405,118 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
|
|
|
4019
4405
|
warp: zod_mini.z.optional(toolScopedMcpSchema),
|
|
4020
4406
|
zed: zod_mini.z.optional(toolScopedMcpSchema)
|
|
4021
4407
|
});
|
|
4408
|
+
/**
|
|
4409
|
+
* The tool-scoped block keys that carry a `{toolname}.mcpServers` sub-map.
|
|
4410
|
+
* Derived from `RulesyncMcpFileSchema`'s own shape so this set can never drift
|
|
4411
|
+
* from the schema — every tool-scoped block declared above is treated as a
|
|
4412
|
+
* "merge servers by name" site by `mergeMcpJsonOverlays`, and everything else
|
|
4413
|
+
* (including `$schema` and top-level Kimi Code timeout fields) is replaced
|
|
4414
|
+
* atomically.
|
|
4415
|
+
*/
|
|
4416
|
+
const TOOL_SCOPED_MCP_KEYS = new Set(Object.keys(RulesyncMcpFileSchema.def.shape).filter((key) => key !== "$schema" && key !== "mcpServers"));
|
|
4417
|
+
/**
|
|
4418
|
+
* Return the first candidate path (recommended, then legacy variants) that
|
|
4419
|
+
* exists under `outputRoot`, or `undefined` when none is present. Shared
|
|
4420
|
+
* between `fromFile` (single-root) and `fromRoots` (multi-root) so both
|
|
4421
|
+
* paths honour the same intra-root resolution order.
|
|
4422
|
+
*
|
|
4423
|
+
* When `overrideDirPath` is provided it replaces the candidates'
|
|
4424
|
+
* class-level `relativeDirPath` (which defaults to `.rulesync/`) so the
|
|
4425
|
+
* caller can point at a non-default source tree (e.g. `.rulesync.local/`).
|
|
4426
|
+
* Also returned is the effective `relativeDirPath` for the winning
|
|
4427
|
+
* candidate so the caller can reconstruct a `RulesyncMcp` with matching
|
|
4428
|
+
* anchor fields.
|
|
4429
|
+
*/
|
|
4430
|
+
async function findFirstExistingCandidate({ paths, outputRoot, overrideDirPath }) {
|
|
4431
|
+
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
4432
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
4433
|
+
const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
4434
|
+
if (await fileExists(filePath)) return {
|
|
4435
|
+
filePath,
|
|
4436
|
+
candidate: {
|
|
4437
|
+
relativeDirPath: candidateDirPath,
|
|
4438
|
+
relativeFilePath: candidate.relativeFilePath
|
|
4439
|
+
}
|
|
4440
|
+
};
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
/**
|
|
4444
|
+
* Merge two parsed MCP JSON objects with the one-level policy from the
|
|
4445
|
+
* inputRoots plan: the top-level `mcpServers` map and each
|
|
4446
|
+
* `<toolname>.mcpServers` sub-map are merged by server name (later wins per
|
|
4447
|
+
* key). Every other value — individual server configs, other top-level keys
|
|
4448
|
+
* — is replaced atomically. This keeps the merge predictable: an overlay can
|
|
4449
|
+
* add or replace whole shared servers, but a partial patch of one server's
|
|
4450
|
+
* `args`/`env` is deliberately not supported.
|
|
4451
|
+
*/
|
|
4452
|
+
function getRecordField({ value, path }) {
|
|
4453
|
+
if (value === void 0) return {};
|
|
4454
|
+
if (!isRecord$1(value)) throw new Error(`Invalid MCP overlay: '${path}' must be an object.`);
|
|
4455
|
+
return value;
|
|
4456
|
+
}
|
|
4457
|
+
/**
|
|
4458
|
+
* Overlay one record onto another, dropping any overlay key that could reach
|
|
4459
|
+
* `Object.prototype`. Every overlay merge goes through this so a `__proto__`
|
|
4460
|
+
* entry cannot enter the merged config from any depth — top-level
|
|
4461
|
+
* `mcpServers`, a tool-scoped block, or that block's own `mcpServers`.
|
|
4462
|
+
*/
|
|
4463
|
+
function mergeRecordsSkippingPollutionKeys({ base, overlay }) {
|
|
4464
|
+
const merged = { ...base };
|
|
4465
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
4466
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
4467
|
+
merged[key] = value;
|
|
4468
|
+
}
|
|
4469
|
+
return merged;
|
|
4470
|
+
}
|
|
4471
|
+
function mergeMcpJsonOverlays({ base, overlay }) {
|
|
4472
|
+
const merged = { ...base };
|
|
4473
|
+
for (const [key, overlayValue] of Object.entries(overlay)) {
|
|
4474
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
4475
|
+
if (key === "mcpServers") {
|
|
4476
|
+
merged.mcpServers = mergeRecordsSkippingPollutionKeys({
|
|
4477
|
+
base: getRecordField({
|
|
4478
|
+
value: base.mcpServers,
|
|
4479
|
+
path: "mcpServers"
|
|
4480
|
+
}),
|
|
4481
|
+
overlay: getRecordField({
|
|
4482
|
+
value: overlayValue,
|
|
4483
|
+
path: "mcpServers"
|
|
4484
|
+
})
|
|
4485
|
+
});
|
|
4486
|
+
continue;
|
|
4487
|
+
}
|
|
4488
|
+
if (TOOL_SCOPED_MCP_KEYS.has(key)) {
|
|
4489
|
+
const baseBlock = getRecordField({
|
|
4490
|
+
value: base[key],
|
|
4491
|
+
path: key
|
|
4492
|
+
});
|
|
4493
|
+
const overlayBlock = getRecordField({
|
|
4494
|
+
value: overlayValue,
|
|
4495
|
+
path: key
|
|
4496
|
+
});
|
|
4497
|
+
const mergedBlock = mergeRecordsSkippingPollutionKeys({
|
|
4498
|
+
base: baseBlock,
|
|
4499
|
+
overlay: overlayBlock
|
|
4500
|
+
});
|
|
4501
|
+
const baseServers = getRecordField({
|
|
4502
|
+
value: baseBlock.mcpServers,
|
|
4503
|
+
path: `${key}.mcpServers`
|
|
4504
|
+
});
|
|
4505
|
+
const overlayServers = getRecordField({
|
|
4506
|
+
value: overlayBlock.mcpServers,
|
|
4507
|
+
path: `${key}.mcpServers`
|
|
4508
|
+
});
|
|
4509
|
+
if (Object.keys(baseServers).length > 0 || Object.keys(overlayServers).length > 0) mergedBlock.mcpServers = mergeRecordsSkippingPollutionKeys({
|
|
4510
|
+
base: baseServers,
|
|
4511
|
+
overlay: overlayServers
|
|
4512
|
+
});
|
|
4513
|
+
merged[key] = mergedBlock;
|
|
4514
|
+
continue;
|
|
4515
|
+
}
|
|
4516
|
+
merged[key] = overlayValue;
|
|
4517
|
+
}
|
|
4518
|
+
return merged;
|
|
4519
|
+
}
|
|
4022
4520
|
var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
|
|
4023
4521
|
json;
|
|
4024
4522
|
constructor(params) {
|
|
@@ -4055,28 +4553,136 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
|
|
|
4055
4553
|
error: null
|
|
4056
4554
|
};
|
|
4057
4555
|
}
|
|
4058
|
-
|
|
4556
|
+
/**
|
|
4557
|
+
* Load and merge MCP source files across the configured input roots.
|
|
4558
|
+
*
|
|
4559
|
+
* `inputRoots` entries are the source trees themselves (e.g.
|
|
4560
|
+
* `/repo/.rulesync`, `/repo/.rulesync.local`). Per-root behavior mirrors
|
|
4561
|
+
* `fromFile`: each root's own candidate paths (recommended `mcp.jsonc`,
|
|
4562
|
+
* legacy `mcp.json`, deprecated `.mcp.json`) are checked INSIDE that
|
|
4563
|
+
* source tree and the first hit is loaded. Roots that have no candidate
|
|
4564
|
+
* contribute nothing.
|
|
4565
|
+
*
|
|
4566
|
+
* Cross-root behavior: the parsed JSON objects are folded left-to-right
|
|
4567
|
+
* with `mergeMcpJsonOverlays`, so later roots overlay earlier ones by
|
|
4568
|
+
* server name (one level deep) and replace every other value atomically.
|
|
4569
|
+
* With one root, this delegates to `fromFile` so JSONC formatting and the
|
|
4570
|
+
* actual candidate path are preserved. With multiple roots, each source is
|
|
4571
|
+
* parsed and schema-validated before merging so failures name the originating
|
|
4572
|
+
* file. The merged object is necessarily synthetic, serialized JSON anchored
|
|
4573
|
+
* to the first root's recommended path.
|
|
4574
|
+
*
|
|
4575
|
+
* A multi-root configuration where only one root actually supplies a file is
|
|
4576
|
+
* treated as the single-root case: nothing is merged, so the original file
|
|
4577
|
+
* content is kept verbatim (preserving JSONC comments) and the instance is
|
|
4578
|
+
* anchored at the root that supplied it rather than at the primary root's
|
|
4579
|
+
* recommended path.
|
|
4580
|
+
*
|
|
4581
|
+
* When no root supplies any candidate, this falls back to reading the
|
|
4582
|
+
* primary root's recommended path so the underlying file-not-found error
|
|
4583
|
+
* matches the single-root behavior of `fromFile`.
|
|
4584
|
+
*/
|
|
4585
|
+
static async fromRoots({ inputRoots, validate = true, logger }) {
|
|
4586
|
+
if (inputRoots.length === 1) {
|
|
4587
|
+
const [primary] = inputRoots;
|
|
4588
|
+
return this.fromFile({
|
|
4589
|
+
outputRoot: (0, node_path.dirname)(primary),
|
|
4590
|
+
relativeDirPath: (0, node_path.basename)(primary),
|
|
4591
|
+
validate,
|
|
4592
|
+
logger
|
|
4593
|
+
});
|
|
4594
|
+
}
|
|
4059
4595
|
const paths = this.getSettablePaths();
|
|
4596
|
+
const rootSources = [];
|
|
4597
|
+
for (const root of inputRoots) {
|
|
4598
|
+
const parent = (0, node_path.dirname)(root);
|
|
4599
|
+
const treeName = (0, node_path.basename)(root);
|
|
4600
|
+
const found = await findFirstExistingCandidate({
|
|
4601
|
+
paths,
|
|
4602
|
+
outputRoot: parent,
|
|
4603
|
+
overrideDirPath: treeName
|
|
4604
|
+
});
|
|
4605
|
+
if (found === void 0) continue;
|
|
4606
|
+
const { filePath, candidate } = found;
|
|
4607
|
+
if (filePath.endsWith(".mcp.json")) {
|
|
4608
|
+
const recommendedPath = (0, node_path.join)(parent, treeName, paths.recommended.relativeFilePath);
|
|
4609
|
+
logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
|
|
4610
|
+
}
|
|
4611
|
+
const fileContent = await readFileContent(filePath);
|
|
4612
|
+
let parsed;
|
|
4613
|
+
try {
|
|
4614
|
+
parsed = parseJsonc$8(fileContent);
|
|
4615
|
+
if (!isRecord$1(parsed)) throw new Error("Expected a JSON object.");
|
|
4616
|
+
if (validate) {
|
|
4617
|
+
const result = RulesyncMcpFileSchema.safeParse(parsed);
|
|
4618
|
+
if (!result.success) throw result.error;
|
|
4619
|
+
}
|
|
4620
|
+
} catch (error) {
|
|
4621
|
+
throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
|
|
4622
|
+
}
|
|
4623
|
+
rootSources.push({
|
|
4624
|
+
record: parsed,
|
|
4625
|
+
outputRoot: parent,
|
|
4626
|
+
relativeDirPath: candidate.relativeDirPath,
|
|
4627
|
+
relativeFilePath: candidate.relativeFilePath,
|
|
4628
|
+
fileContent
|
|
4629
|
+
});
|
|
4630
|
+
}
|
|
4631
|
+
if (rootSources.length === 0) {
|
|
4632
|
+
const primary = inputRoots[0];
|
|
4633
|
+
return this.fromFile({
|
|
4634
|
+
outputRoot: (0, node_path.dirname)(primary),
|
|
4635
|
+
relativeDirPath: (0, node_path.basename)(primary),
|
|
4636
|
+
validate,
|
|
4637
|
+
logger
|
|
4638
|
+
});
|
|
4639
|
+
}
|
|
4640
|
+
const onlySource = rootSources.length === 1 ? rootSources[0] : void 0;
|
|
4641
|
+
if (onlySource !== void 0) return new RulesyncMcp({
|
|
4642
|
+
outputRoot: onlySource.outputRoot,
|
|
4643
|
+
relativeDirPath: onlySource.relativeDirPath,
|
|
4644
|
+
relativeFilePath: onlySource.relativeFilePath,
|
|
4645
|
+
fileContent: onlySource.fileContent,
|
|
4646
|
+
validate
|
|
4647
|
+
});
|
|
4648
|
+
const merged = rootSources.reduce((acc, next) => mergeMcpJsonOverlays({
|
|
4649
|
+
base: acc,
|
|
4650
|
+
overlay: next.record
|
|
4651
|
+
}), {});
|
|
4652
|
+
const primary = inputRoots[0];
|
|
4653
|
+
return new RulesyncMcp({
|
|
4654
|
+
outputRoot: (0, node_path.dirname)(primary),
|
|
4655
|
+
relativeDirPath: (0, node_path.basename)(primary),
|
|
4656
|
+
relativeFilePath: paths.recommended.relativeFilePath,
|
|
4657
|
+
fileContent: JSON.stringify(merged, null, 2),
|
|
4658
|
+
validate
|
|
4659
|
+
});
|
|
4660
|
+
}
|
|
4661
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true, logger }) {
|
|
4662
|
+
const paths = this.getSettablePaths();
|
|
4663
|
+
const overrideDirPath = relativeDirPath;
|
|
4060
4664
|
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
4061
|
-
const
|
|
4665
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
4666
|
+
const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
4062
4667
|
if (!await fileExists(filePath)) continue;
|
|
4063
4668
|
if (candidate.relativeFilePath === ".mcp.json") {
|
|
4064
|
-
const recommendedPath = (0, node_path.join)(outputRoot,
|
|
4669
|
+
const recommendedPath = (0, node_path.join)(outputRoot, candidateDirPath, paths.recommended.relativeFilePath);
|
|
4065
4670
|
logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
|
|
4066
4671
|
}
|
|
4067
4672
|
const fileContent = await readFileContent(filePath);
|
|
4068
4673
|
return new RulesyncMcp({
|
|
4069
4674
|
outputRoot,
|
|
4070
|
-
relativeDirPath:
|
|
4675
|
+
relativeDirPath: candidateDirPath,
|
|
4071
4676
|
relativeFilePath: candidate.relativeFilePath,
|
|
4072
4677
|
fileContent,
|
|
4073
4678
|
validate
|
|
4074
4679
|
});
|
|
4075
4680
|
}
|
|
4076
|
-
const
|
|
4681
|
+
const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
|
|
4682
|
+
const fileContent = await readFileContent((0, node_path.join)(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath));
|
|
4077
4683
|
return new RulesyncMcp({
|
|
4078
4684
|
outputRoot,
|
|
4079
|
-
relativeDirPath:
|
|
4685
|
+
relativeDirPath: fallbackDirPath,
|
|
4080
4686
|
relativeFilePath: paths.recommended.relativeFilePath,
|
|
4081
4687
|
fileContent,
|
|
4082
4688
|
validate
|
|
@@ -5271,15 +5877,17 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
|
|
|
5271
5877
|
error: null
|
|
5272
5878
|
};
|
|
5273
5879
|
}
|
|
5274
|
-
static async fromFile({ outputRoot = process.cwd(), validate = true }) {
|
|
5880
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
|
|
5275
5881
|
const paths = RulesyncPermissions.getSettablePaths();
|
|
5882
|
+
const overrideDirPath = relativeDirPath;
|
|
5276
5883
|
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
5277
|
-
const
|
|
5884
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
5885
|
+
const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
5278
5886
|
if (!await fileExists(filePath)) continue;
|
|
5279
5887
|
const fileContent = await readFileContent(filePath);
|
|
5280
5888
|
return new RulesyncPermissions({
|
|
5281
5889
|
outputRoot,
|
|
5282
|
-
relativeDirPath:
|
|
5890
|
+
relativeDirPath: candidateDirPath,
|
|
5283
5891
|
relativeFilePath: candidate.relativeFilePath,
|
|
5284
5892
|
fileContent,
|
|
5285
5893
|
validate
|
|
@@ -5443,8 +6051,9 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
5443
6051
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
5444
6052
|
};
|
|
5445
6053
|
}
|
|
5446
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
|
|
5447
|
-
const
|
|
6054
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
|
|
6055
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths().recommended.relativeDirPath;
|
|
6056
|
+
const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
|
|
5448
6057
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
|
|
5449
6058
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
5450
6059
|
const result = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
|
|
@@ -5457,7 +6066,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
5457
6066
|
};
|
|
5458
6067
|
return new RulesyncRule({
|
|
5459
6068
|
outputRoot,
|
|
5460
|
-
relativeDirPath:
|
|
6069
|
+
relativeDirPath: dirPath,
|
|
5461
6070
|
relativeFilePath,
|
|
5462
6071
|
frontmatter: validatedFrontmatter,
|
|
5463
6072
|
body: content.trim(),
|
|
@@ -5469,8 +6078,474 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
5469
6078
|
}
|
|
5470
6079
|
};
|
|
5471
6080
|
//#endregion
|
|
6081
|
+
//#region src/utils/concurrency.ts
|
|
6082
|
+
/**
|
|
6083
|
+
* Map over items with a bounded number of operations in flight.
|
|
6084
|
+
*
|
|
6085
|
+
* `Promise.all(items.map(...))` starts every operation at once, which is fine
|
|
6086
|
+
* for a handful of paths and not fine for a directory tree of unknown size: a
|
|
6087
|
+
* few thousand concurrent `realpath` calls queue on the libuv thread pool and
|
|
6088
|
+
* hold their closures alive while they wait. Results keep the input order.
|
|
6089
|
+
*
|
|
6090
|
+
* `withSemaphore` in `src/lib/github-utils.ts` bounds concurrency too, but it
|
|
6091
|
+
* wraps one call at a time: the caller still writes `Promise.all(items.map(…))`
|
|
6092
|
+
* around it, so every item's promise chain is allocated up front. This walks a
|
|
6093
|
+
* shared cursor with `limit` workers instead, so a list of unknown size costs
|
|
6094
|
+
* `limit` pending operations rather than one per item.
|
|
6095
|
+
*/
|
|
6096
|
+
async function mapWithConcurrency({ items, limit, mapper }) {
|
|
6097
|
+
const results = Array.from({ length: items.length });
|
|
6098
|
+
let nextIndex = 0;
|
|
6099
|
+
const runWorker = async () => {
|
|
6100
|
+
while (nextIndex < items.length) {
|
|
6101
|
+
const index = nextIndex;
|
|
6102
|
+
nextIndex += 1;
|
|
6103
|
+
const item = items[index];
|
|
6104
|
+
if (item === void 0) continue;
|
|
6105
|
+
results[index] = await mapper(item);
|
|
6106
|
+
}
|
|
6107
|
+
};
|
|
6108
|
+
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
6109
|
+
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
|
|
6110
|
+
return results;
|
|
6111
|
+
}
|
|
6112
|
+
//#endregion
|
|
5472
6113
|
//#region src/types/ai-dir.ts
|
|
5473
|
-
|
|
6114
|
+
/**
|
|
6115
|
+
* Directories that hold credentials. Excluding these protects something, so
|
|
6116
|
+
* their exclusion is reported rather than silent.
|
|
6117
|
+
*/
|
|
6118
|
+
const NEVER_CARRIED_CREDENTIAL_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
6119
|
+
".ssh",
|
|
6120
|
+
".aws",
|
|
6121
|
+
".gnupg"
|
|
6122
|
+
]);
|
|
6123
|
+
/**
|
|
6124
|
+
* Directories that are refused only when the path leaves the skill directory to
|
|
6125
|
+
* reach them. These are the per-application trees of a home directory, where
|
|
6126
|
+
* naming every credential file is a list always one release behind -- `gcloud`
|
|
6127
|
+
* alone writes `credentials.db` and `application_default_credentials.json`, and
|
|
6128
|
+
* `.config/anthropic/` holds an API key. A skill that ships a `.config/` of its
|
|
6129
|
+
* own still carries it: what is refused is a link that reaches the *user's*.
|
|
6130
|
+
*
|
|
6131
|
+
* A tool home is deliberately absent. `~/.claude/skills/` and `~/.codex/` hold
|
|
6132
|
+
* the global skills this feature exists to share, so refusing a link that
|
|
6133
|
+
* reaches one would refuse the ordinary case along with the bad one.
|
|
6134
|
+
*/
|
|
6135
|
+
const NEVER_CARRIED_ESCAPING_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
6136
|
+
".config",
|
|
6137
|
+
".local",
|
|
6138
|
+
".azure",
|
|
6139
|
+
".m2",
|
|
6140
|
+
".terraform.d",
|
|
6141
|
+
".docker",
|
|
6142
|
+
".kube",
|
|
6143
|
+
"keychains"
|
|
6144
|
+
]);
|
|
6145
|
+
/**
|
|
6146
|
+
* Whether an escaping real path passes through a directory only reachable by
|
|
6147
|
+
* escaping.
|
|
6148
|
+
*
|
|
6149
|
+
* Only the segments *past* the skill directory count. A global skill lives at
|
|
6150
|
+
* `~/.config/agents/skills/<name>` for Amp, Devin and Muse alike, so judging
|
|
6151
|
+
* the whole real path would refuse a link that never left the skills tree it
|
|
6152
|
+
* was already in -- the shared-directory case, reported as a credential.
|
|
6153
|
+
*/
|
|
6154
|
+
function escapesIntoCredentialDir({ realDirPath, realFilePath }) {
|
|
6155
|
+
const normalize = (segment) => normalizePathSegment(segment.toLowerCase());
|
|
6156
|
+
const dirSegments = splitPathSegments(realDirPath).map(normalize);
|
|
6157
|
+
const fileSegments = splitPathSegments(realFilePath).map(normalize);
|
|
6158
|
+
let shared = 0;
|
|
6159
|
+
while (shared < dirSegments.length && shared < fileSegments.length && dirSegments[shared] === fileSegments[shared]) shared += 1;
|
|
6160
|
+
return (dirSegments.length - shared > 1 ? fileSegments : fileSegments.slice(shared)).some((segment) => NEVER_CARRIED_ESCAPING_DIR_NAMES.has(segment));
|
|
6161
|
+
}
|
|
6162
|
+
/**
|
|
6163
|
+
* Directories that are never part of a skill for the ordinary reasons: a
|
|
6164
|
+
* nested repository, or a build/cache tree. Leaving these out is what a user
|
|
6165
|
+
* expects, so it happens quietly.
|
|
6166
|
+
*/
|
|
6167
|
+
const NEVER_CARRIED_NOISE_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
6168
|
+
".git",
|
|
6169
|
+
".hg",
|
|
6170
|
+
".svn",
|
|
6171
|
+
".cache",
|
|
6172
|
+
".venv",
|
|
6173
|
+
".tox",
|
|
6174
|
+
".mypy_cache",
|
|
6175
|
+
".pytest_cache",
|
|
6176
|
+
".ruff_cache",
|
|
6177
|
+
".gradle",
|
|
6178
|
+
".next",
|
|
6179
|
+
".nuxt",
|
|
6180
|
+
".turbo",
|
|
6181
|
+
".parcel-cache",
|
|
6182
|
+
".nyc_output",
|
|
6183
|
+
".terraform"
|
|
6184
|
+
]);
|
|
6185
|
+
/** Files that hold credentials. Compared lower-cased. */
|
|
6186
|
+
const NEVER_CARRIED_CREDENTIAL_FILE_NAMES = /* @__PURE__ */ new Set([
|
|
6187
|
+
".npmrc",
|
|
6188
|
+
".netrc",
|
|
6189
|
+
".git-credentials",
|
|
6190
|
+
".pgpass",
|
|
6191
|
+
".pypirc",
|
|
6192
|
+
".htpasswd",
|
|
6193
|
+
".dockercfg",
|
|
6194
|
+
".envrc"
|
|
6195
|
+
]);
|
|
6196
|
+
/** Files that are local noise. Compared lower-cased. */
|
|
6197
|
+
const NEVER_CARRIED_NOISE_FILE_NAMES = /* @__PURE__ */ new Set([".ds_store"]);
|
|
6198
|
+
/** Credential files whose parent directory is otherwise ordinary content. */
|
|
6199
|
+
const NEVER_CARRIED_PATH_SUFFIXES = [
|
|
6200
|
+
".docker/config.json",
|
|
6201
|
+
".kube/config",
|
|
6202
|
+
".config/gh/hosts.yml",
|
|
6203
|
+
".config/gcloud/credentials.db",
|
|
6204
|
+
".gem/credentials",
|
|
6205
|
+
"gcloud/application_default_credentials.json",
|
|
6206
|
+
".codex/auth.json",
|
|
6207
|
+
".gemini/oauth_creds.json"
|
|
6208
|
+
];
|
|
6209
|
+
/** Whether a path ends in one of the credential files named above. */
|
|
6210
|
+
function endsWithNeverCarriedSuffix(filePath) {
|
|
6211
|
+
const posixPath = toPosixPath(filePath).toLowerCase();
|
|
6212
|
+
return NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`));
|
|
6213
|
+
}
|
|
6214
|
+
/**
|
|
6215
|
+
* `.env.<suffix>` spellings that are templates rather than real values.
|
|
6216
|
+
* Everything else matching `.env*` is treated as holding secrets, because
|
|
6217
|
+
* `.env.production` is no less sensitive than `.env` itself.
|
|
6218
|
+
*/
|
|
6219
|
+
const ENV_TEMPLATE_SUFFIXES = /* @__PURE__ */ new Set([
|
|
6220
|
+
"example",
|
|
6221
|
+
"sample",
|
|
6222
|
+
"template",
|
|
6223
|
+
"dist",
|
|
6224
|
+
"defaults"
|
|
6225
|
+
]);
|
|
6226
|
+
/**
|
|
6227
|
+
* Kernel pseudo-filesystems, matched against the resolved real path. A link
|
|
6228
|
+
* into one of these does not reach a file at all: `/proc/self/environ` reads
|
|
6229
|
+
* back the entire environment of the running process, API keys included, and
|
|
6230
|
+
* `stat` reports it as an ordinary file. Nothing a skill carries lives here.
|
|
6231
|
+
*/
|
|
6232
|
+
const NEVER_CARRIED_REAL_PATH_ROOTS = [
|
|
6233
|
+
"/proc",
|
|
6234
|
+
"/sys",
|
|
6235
|
+
"/dev"
|
|
6236
|
+
];
|
|
6237
|
+
/**
|
|
6238
|
+
* Whether a directory of this name is pruned during the walk, so it is never
|
|
6239
|
+
* descended into at all. Derived from the directory names above so the pruning
|
|
6240
|
+
* and the path check below cannot drift apart.
|
|
6241
|
+
*/
|
|
6242
|
+
function isNeverCarriedDirName(dirName) {
|
|
6243
|
+
const normalized = normalizePathSegment(dirName.toLowerCase());
|
|
6244
|
+
return NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(normalized) || NEVER_CARRIED_NOISE_DIR_NAMES.has(normalized) || isCredentialFileName(normalized);
|
|
6245
|
+
}
|
|
6246
|
+
/**
|
|
6247
|
+
* Windows drops trailing dots and spaces from a name, so a file called `.env `
|
|
6248
|
+
* is written as `.env` once it lands in a tool directory there. Normalizing
|
|
6249
|
+
* before every comparison means the name is judged as what it becomes.
|
|
6250
|
+
*/
|
|
6251
|
+
function normalizePathSegment(segment) {
|
|
6252
|
+
const normalized = segment.replace(/[\s.]+$/, "");
|
|
6253
|
+
return normalized === "" ? segment : normalized;
|
|
6254
|
+
}
|
|
6255
|
+
/**
|
|
6256
|
+
* Why a path reaches something that is never skill content, or `undefined`
|
|
6257
|
+
* when it does not.
|
|
6258
|
+
*
|
|
6259
|
+
* Carrying hidden entries means a secret sitting in a skill directory would be
|
|
6260
|
+
* copied into every enabled tool root, multiplying the places it can be
|
|
6261
|
+
* committed from, and a `.venv` would be copied file by file into each of them.
|
|
6262
|
+
* None of these names is ever skill content, so excluding them costs nothing.
|
|
6263
|
+
*
|
|
6264
|
+
* The check is applied to the resolved real path as well as the literal one:
|
|
6265
|
+
* the names are what makes an entry dangerous, and a symbolic link named
|
|
6266
|
+
* `vendor` pointing at `~/.aws` is exactly as dangerous as a directory called
|
|
6267
|
+
* `.aws`. Comparison is lower-cased because macOS and Windows resolve `.SSH`
|
|
6268
|
+
* and `.ssh` to the same file.
|
|
6269
|
+
*/
|
|
6270
|
+
function classifyNeverCarried(relativePath) {
|
|
6271
|
+
const segments = toPosixPath(relativePath).toLowerCase().split("/").filter((segment) => segment !== "" && segment !== ".").map(normalizePathSegment);
|
|
6272
|
+
const fileName = segments.at(-1) ?? "";
|
|
6273
|
+
const posixPath = segments.join("/");
|
|
6274
|
+
if (segments.slice(0, -1).some((segment) => isCredentialFileName(segment))) return "credential";
|
|
6275
|
+
if (segments.some((segment) => NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(segment))) return "credential";
|
|
6276
|
+
if (segments.some((segment) => NEVER_CARRIED_NOISE_DIR_NAMES.has(segment))) return "noise";
|
|
6277
|
+
if (isCredentialFileName(fileName)) return "credential";
|
|
6278
|
+
if (NEVER_CARRIED_NOISE_FILE_NAMES.has(fileName)) return "noise";
|
|
6279
|
+
if (NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`))) return "credential";
|
|
6280
|
+
}
|
|
6281
|
+
/**
|
|
6282
|
+
* Whether a single path segment names a credential file, whether it is the file
|
|
6283
|
+
* itself or a directory somebody gave that name to.
|
|
6284
|
+
*/
|
|
6285
|
+
function isCredentialFileName(segment) {
|
|
6286
|
+
const name = normalizePathSegment(segment.toLowerCase());
|
|
6287
|
+
if (NEVER_CARRIED_CREDENTIAL_FILE_NAMES.has(name)) return true;
|
|
6288
|
+
for (const base of [".env", ".envrc"]) {
|
|
6289
|
+
if (name === base) return true;
|
|
6290
|
+
if (name.startsWith(`${base}.`)) {
|
|
6291
|
+
const lastPiece = name.split(".").at(-1) ?? "";
|
|
6292
|
+
return !ENV_TEMPLATE_SUFFIXES.has(lastPiece);
|
|
6293
|
+
}
|
|
6294
|
+
}
|
|
6295
|
+
return false;
|
|
6296
|
+
}
|
|
6297
|
+
/** Whether a resolved real path points into a kernel pseudo-filesystem. */
|
|
6298
|
+
function isSystemPseudoPath(absolutePath) {
|
|
6299
|
+
const posixPath = toPosixPath(absolutePath);
|
|
6300
|
+
return NEVER_CARRIED_REAL_PATH_ROOTS.some((root) => posixPath === root || posixPath.startsWith(`${root}/`));
|
|
6301
|
+
}
|
|
6302
|
+
/** How many links a chain may be followed before it is treated as a loop. */
|
|
6303
|
+
const MAX_LINK_CHAIN_HOPS = 40;
|
|
6304
|
+
/**
|
|
6305
|
+
* Whether reaching a path goes through a kernel pseudo-filesystem, even when it
|
|
6306
|
+
* does not end in one.
|
|
6307
|
+
*
|
|
6308
|
+
* Asking `realpath` alone is not enough, and the entries it is not enough for
|
|
6309
|
+
* are the dangerous ones: `/proc/<pid>/fd/N`, `exe`, `cwd` and `root` are magic
|
|
6310
|
+
* links, so resolving them lands *outside* `/proc`, on whatever file the
|
|
6311
|
+
* process happens to hold open — a private key another program is reading right
|
|
6312
|
+
* now would come back as an ordinary path and be carried. Following the chain a
|
|
6313
|
+
* hop at a time, and checking each hop, is what sees the `/proc` in the middle.
|
|
6314
|
+
*/
|
|
6315
|
+
async function resolvesThroughSystemPseudoPath(filePath) {
|
|
6316
|
+
let currentPath = (0, node_path.resolve)(filePath);
|
|
6317
|
+
let hops = 0;
|
|
6318
|
+
for (; hops < MAX_LINK_CHAIN_HOPS; hops++) {
|
|
6319
|
+
if (isSystemPseudoPath(currentPath)) return {
|
|
6320
|
+
throughPseudoPath: true,
|
|
6321
|
+
hops
|
|
6322
|
+
};
|
|
6323
|
+
try {
|
|
6324
|
+
if (isSystemPseudoPath(await (0, node_fs_promises.realpath)((0, node_path.dirname)(currentPath)))) return {
|
|
6325
|
+
throughPseudoPath: true,
|
|
6326
|
+
hops
|
|
6327
|
+
};
|
|
6328
|
+
} catch {}
|
|
6329
|
+
let linkStats;
|
|
6330
|
+
try {
|
|
6331
|
+
linkStats = await (0, node_fs_promises.lstat)(currentPath);
|
|
6332
|
+
} catch {
|
|
6333
|
+
return {
|
|
6334
|
+
throughPseudoPath: false,
|
|
6335
|
+
hops
|
|
6336
|
+
};
|
|
6337
|
+
}
|
|
6338
|
+
if (!linkStats.isSymbolicLink()) break;
|
|
6339
|
+
let target;
|
|
6340
|
+
try {
|
|
6341
|
+
target = await (0, node_fs_promises.readlink)(currentPath);
|
|
6342
|
+
} catch {
|
|
6343
|
+
return {
|
|
6344
|
+
throughPseudoPath: false,
|
|
6345
|
+
hops
|
|
6346
|
+
};
|
|
6347
|
+
}
|
|
6348
|
+
currentPath = (0, node_path.isAbsolute)(target) ? target : (0, node_path.resolve)((0, node_path.dirname)(currentPath), target);
|
|
6349
|
+
}
|
|
6350
|
+
return {
|
|
6351
|
+
throughPseudoPath: false,
|
|
6352
|
+
hops
|
|
6353
|
+
};
|
|
6354
|
+
}
|
|
6355
|
+
const MAX_CARRIED_FILES = 1e4;
|
|
6356
|
+
const MAX_CARRIED_DIRECTORIES = 1e4;
|
|
6357
|
+
/**
|
|
6358
|
+
* The bounds above limit what is *carried*; this one limits what is *looked at*.
|
|
6359
|
+
* A directory holding nothing but a few hundred thousand links to itself carries
|
|
6360
|
+
* no files and occupies no depth, and would still cost a `stat` apiece.
|
|
6361
|
+
*/
|
|
6362
|
+
const MAX_CARRIED_ENTRIES_EXAMINED = 2e5;
|
|
6363
|
+
const MAX_CARRIED_BYTES = 104857600;
|
|
6364
|
+
/** How many `realpath` calls the carried-file filter keeps in flight. */
|
|
6365
|
+
const CARRIED_REALPATH_CONCURRENCY = 32;
|
|
6366
|
+
/** Sort directory entries by name so a walk of the same tree is reproducible. */
|
|
6367
|
+
function compareByName(left, right) {
|
|
6368
|
+
if (left.name === right.name) return 0;
|
|
6369
|
+
return left.name < right.name ? -1 : 1;
|
|
6370
|
+
}
|
|
6371
|
+
/**
|
|
6372
|
+
* Order the routes that cross the same number of symbolic links: the one with
|
|
6373
|
+
* the fewest hidden segments first, so a named alias represents a shared tree
|
|
6374
|
+
* rather than a hidden one that a hidden-entry rule then refuses, taking the
|
|
6375
|
+
* named route's content with it.
|
|
6376
|
+
*/
|
|
6377
|
+
function comparePendingCarriedDirs(left, right) {
|
|
6378
|
+
if (left.hiddenSegments !== right.hiddenSegments) return left.hiddenSegments - right.hiddenSegments;
|
|
6379
|
+
if (left.depth !== right.depth) return left.depth - right.depth;
|
|
6380
|
+
if (left.dirPath === right.dirPath) return 0;
|
|
6381
|
+
return left.dirPath < right.dirPath ? -1 : 1;
|
|
6382
|
+
}
|
|
6383
|
+
/**
|
|
6384
|
+
* Collect the files under a carried directory, following symbolic links but
|
|
6385
|
+
* visiting each real directory exactly once, by its cheapest route.
|
|
6386
|
+
*
|
|
6387
|
+
* A glob walk cannot do this safely. Two links in one directory that both point
|
|
6388
|
+
* back at an ancestor double the paths walked per level, and the walker follows
|
|
6389
|
+
* them until the kernel's ELOOP limit (~40), so the path array alone exhausts
|
|
6390
|
+
* the heap long before anything reads a file — a depth bound only lowers the
|
|
6391
|
+
* exponent, while the base is whatever number of links the tree's author chose.
|
|
6392
|
+
* Remembering the real directories already visited removes the multiplication
|
|
6393
|
+
* itself: a cycle, and an alias for a directory already walked, both stop at the
|
|
6394
|
+
* entry that closes them.
|
|
6395
|
+
*
|
|
6396
|
+
* Which route represents a directory then matters, because the others are
|
|
6397
|
+
* dropped. The walk proceeds in rounds by the number of symbolic links crossed:
|
|
6398
|
+
* everything reachable without crossing one, then everything one link away, and
|
|
6399
|
+
* so on. A real location therefore always wins over an alias for it — at any
|
|
6400
|
+
* nesting depth, not just among siblings, which a depth-first walk could not
|
|
6401
|
+
* promise — and among aliases the named one wins over a hidden one.
|
|
6402
|
+
*
|
|
6403
|
+
* A broken link is skipped: it resolves to nothing to read.
|
|
6404
|
+
*/
|
|
6405
|
+
async function walkCarriedFiles(dirPath, { skipHiddenRoutes = false } = {}) {
|
|
6406
|
+
const filePaths = [];
|
|
6407
|
+
const visitedRealDirPaths = /* @__PURE__ */ new Set();
|
|
6408
|
+
const truncations = /* @__PURE__ */ new Set();
|
|
6409
|
+
const unreadablePaths = /* @__PURE__ */ new Set();
|
|
6410
|
+
const pseudoPaths = /* @__PURE__ */ new Set();
|
|
6411
|
+
const depthStoppedRealDirPaths = /* @__PURE__ */ new Set();
|
|
6412
|
+
let deferredLinkedDirs = [];
|
|
6413
|
+
/** Whether the walk has hit a bound that ends it rather than one branch of it. */
|
|
6414
|
+
let examinedEntries = 0;
|
|
6415
|
+
const isFull = () => truncations.has("count") || truncations.has("directories") || truncations.has("entries");
|
|
6416
|
+
const addFile = (filePath) => {
|
|
6417
|
+
if (filePaths.length >= 1e4) {
|
|
6418
|
+
truncations.add("count");
|
|
6419
|
+
return;
|
|
6420
|
+
}
|
|
6421
|
+
filePaths.push(filePath);
|
|
6422
|
+
};
|
|
6423
|
+
/** Carry what a symbolic link names, or hold its directory for the next round. */
|
|
6424
|
+
const routeLinkedEntry = async (child, entryName) => {
|
|
6425
|
+
let targetStats;
|
|
6426
|
+
try {
|
|
6427
|
+
targetStats = await (0, node_fs_promises.stat)(child.dirPath);
|
|
6428
|
+
} catch {
|
|
6429
|
+
return;
|
|
6430
|
+
}
|
|
6431
|
+
if (targetStats.isFile()) {
|
|
6432
|
+
addFile(child.dirPath);
|
|
6433
|
+
return;
|
|
6434
|
+
}
|
|
6435
|
+
if (!targetStats.isDirectory() || isNeverCarriedDirName(entryName)) return;
|
|
6436
|
+
const { throughPseudoPath, hops } = await resolvesThroughSystemPseudoPath(child.dirPath);
|
|
6437
|
+
examinedEntries += hops;
|
|
6438
|
+
if (throughPseudoPath) {
|
|
6439
|
+
pseudoPaths.add(child.dirPath);
|
|
6440
|
+
return;
|
|
6441
|
+
}
|
|
6442
|
+
deferredLinkedDirs.push(child);
|
|
6443
|
+
};
|
|
6444
|
+
/** Walk one directory and everything below it that no symbolic link leads to. */
|
|
6445
|
+
const walkWithoutCrossingLinks = async (pending) => {
|
|
6446
|
+
if (isFull()) return;
|
|
6447
|
+
let realCurrentPath;
|
|
6448
|
+
try {
|
|
6449
|
+
realCurrentPath = await (0, node_fs_promises.realpath)(pending.dirPath);
|
|
6450
|
+
} catch {
|
|
6451
|
+
unreadablePaths.add(pending.dirPath);
|
|
6452
|
+
return;
|
|
6453
|
+
}
|
|
6454
|
+
if (visitedRealDirPaths.has(realCurrentPath)) return;
|
|
6455
|
+
if (isSystemPseudoPath(realCurrentPath)) {
|
|
6456
|
+
pseudoPaths.add(pending.dirPath);
|
|
6457
|
+
return;
|
|
6458
|
+
}
|
|
6459
|
+
if (pending.depth > 12) {
|
|
6460
|
+
depthStoppedRealDirPaths.add(realCurrentPath);
|
|
6461
|
+
return;
|
|
6462
|
+
}
|
|
6463
|
+
if (visitedRealDirPaths.size >= 1e4) {
|
|
6464
|
+
truncations.add("directories");
|
|
6465
|
+
return;
|
|
6466
|
+
}
|
|
6467
|
+
visitedRealDirPaths.add(realCurrentPath);
|
|
6468
|
+
let entries;
|
|
6469
|
+
try {
|
|
6470
|
+
entries = await (0, node_fs_promises.readdir)(pending.dirPath, { withFileTypes: true });
|
|
6471
|
+
} catch {
|
|
6472
|
+
unreadablePaths.add(pending.dirPath);
|
|
6473
|
+
return;
|
|
6474
|
+
}
|
|
6475
|
+
const realSubDirs = [];
|
|
6476
|
+
for (const entry of entries.toSorted(compareByName)) {
|
|
6477
|
+
if (isFull()) return;
|
|
6478
|
+
examinedEntries += 1;
|
|
6479
|
+
if (examinedEntries > 2e5) {
|
|
6480
|
+
truncations.add("entries");
|
|
6481
|
+
return;
|
|
6482
|
+
}
|
|
6483
|
+
if (skipHiddenRoutes && isHiddenPathSegment(entry.name)) continue;
|
|
6484
|
+
const entryPath = (0, node_path.join)(pending.dirPath, entry.name);
|
|
6485
|
+
const child = {
|
|
6486
|
+
dirPath: entryPath,
|
|
6487
|
+
depth: pending.depth + 1,
|
|
6488
|
+
hiddenSegments: pending.hiddenSegments + (isHiddenPathSegment(entry.name) ? 1 : 0)
|
|
6489
|
+
};
|
|
6490
|
+
if (entry.isFile()) {
|
|
6491
|
+
addFile(entryPath);
|
|
6492
|
+
continue;
|
|
6493
|
+
}
|
|
6494
|
+
if (entry.isDirectory()) {
|
|
6495
|
+
if (!isNeverCarriedDirName(entry.name)) realSubDirs.push(child);
|
|
6496
|
+
continue;
|
|
6497
|
+
}
|
|
6498
|
+
if (!entry.isSymbolicLink()) continue;
|
|
6499
|
+
await routeLinkedEntry(child, entry.name);
|
|
6500
|
+
}
|
|
6501
|
+
for (const realSubDir of realSubDirs) {
|
|
6502
|
+
if (isFull()) return;
|
|
6503
|
+
await walkWithoutCrossingLinks(realSubDir);
|
|
6504
|
+
}
|
|
6505
|
+
};
|
|
6506
|
+
let round = [{
|
|
6507
|
+
dirPath,
|
|
6508
|
+
depth: 0,
|
|
6509
|
+
hiddenSegments: 0
|
|
6510
|
+
}];
|
|
6511
|
+
while (round.length > 0 && !isFull()) {
|
|
6512
|
+
deferredLinkedDirs = [];
|
|
6513
|
+
for (const pending of round.toSorted(comparePendingCarriedDirs)) {
|
|
6514
|
+
if (isFull()) break;
|
|
6515
|
+
await walkWithoutCrossingLinks(pending);
|
|
6516
|
+
}
|
|
6517
|
+
round = deferredLinkedDirs;
|
|
6518
|
+
}
|
|
6519
|
+
for (const realDirPath of depthStoppedRealDirPaths) if (!visitedRealDirPaths.has(realDirPath)) {
|
|
6520
|
+
truncations.add("depth");
|
|
6521
|
+
break;
|
|
6522
|
+
}
|
|
6523
|
+
return {
|
|
6524
|
+
filePaths: filePaths.toSorted(),
|
|
6525
|
+
truncations,
|
|
6526
|
+
unreadablePaths: [...unreadablePaths],
|
|
6527
|
+
pseudoPaths: [...pseudoPaths]
|
|
6528
|
+
};
|
|
6529
|
+
}
|
|
6530
|
+
/** Render a set of refused or noteworthy paths for one warning line. */
|
|
6531
|
+
function formatReportedPaths(paths) {
|
|
6532
|
+
const sorted = [...paths].toSorted();
|
|
6533
|
+
const named = sorted.slice(0, 10).map((filePath) => stripControlCharacters(toPosixPath(filePath))).join(", ");
|
|
6534
|
+
const remaining = sorted.length - 10;
|
|
6535
|
+
return {
|
|
6536
|
+
count: sorted.length,
|
|
6537
|
+
list: `${named}${remaining > 0 ? `, and ${remaining} more` : ""}`
|
|
6538
|
+
};
|
|
6539
|
+
}
|
|
6540
|
+
/** "entry" or "entries", so the warnings below read as sentences. */
|
|
6541
|
+
function entryWord(count) {
|
|
6542
|
+
return count === 1 ? "entry" : "entries";
|
|
6543
|
+
}
|
|
6544
|
+
/** "resolves" or "resolve", to agree with the entry count it follows. */
|
|
6545
|
+
function resolveWord(count) {
|
|
6546
|
+
return count === 1 ? "resolves" : "resolve";
|
|
6547
|
+
}
|
|
6548
|
+
var AiDir = class AiDir {
|
|
5474
6549
|
/**
|
|
5475
6550
|
* @example "."
|
|
5476
6551
|
*/
|
|
@@ -5520,8 +6595,7 @@ var AiDir = class {
|
|
|
5520
6595
|
const fullPath = node_path.default.join(this.outputRoot, this.relativeDirPath, this.dirName);
|
|
5521
6596
|
const resolvedFull = (0, node_path.resolve)(fullPath);
|
|
5522
6597
|
const resolvedBase = (0, node_path.resolve)(this.outputRoot);
|
|
5523
|
-
|
|
5524
|
-
if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
|
|
6598
|
+
if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
|
|
5525
6599
|
return fullPath;
|
|
5526
6600
|
}
|
|
5527
6601
|
getMainFile() {
|
|
@@ -5547,9 +6621,227 @@ var AiDir = class {
|
|
|
5547
6621
|
};
|
|
5548
6622
|
}
|
|
5549
6623
|
/**
|
|
6624
|
+
* A nested repository inside a carried directory is the one exclusion worth
|
|
6625
|
+
* reporting: unlike `.DS_Store`, it is there on purpose, and the tree it
|
|
6626
|
+
* points at is simply not reproduced on generate. Only the top level is
|
|
6627
|
+
* checked, which is where a submodule or a stray `git init` puts it, so the
|
|
6628
|
+
* check costs one stat per directory rather than a second walk. `fileExists`
|
|
6629
|
+
* is a bare `stat`, so it answers for a submodule pointer file and for a real
|
|
6630
|
+
* `.git` directory alike.
|
|
6631
|
+
*/
|
|
6632
|
+
static async warnOnNestedGitDirectory(dirPath) {
|
|
6633
|
+
const gitEntryPath = (0, node_path.join)(dirPath, ".git");
|
|
6634
|
+
if (await fileExists(gitEntryPath)) warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(gitEntryPath))} with its directory: a nested repository is excluded, so the files it tracks are copied but its history is not.`);
|
|
6635
|
+
}
|
|
6636
|
+
/** Whether any segment of a relative path is dot-prefixed. */
|
|
6637
|
+
static hasHiddenSegment(relativePath) {
|
|
6638
|
+
return splitPathSegments(relativePath).some(isHiddenPathSegment);
|
|
6639
|
+
}
|
|
6640
|
+
/**
|
|
6641
|
+
* Whether the entry a path ends at is itself hidden, its ancestors aside.
|
|
6642
|
+
* A shared skill tree usually lives under a dot-directory, so an ancestor
|
|
6643
|
+
* says nothing about the file; its own name is what was chosen for it.
|
|
6644
|
+
*/
|
|
6645
|
+
static hasHiddenName(relativePath) {
|
|
6646
|
+
return isHiddenPathSegment(splitPathSegments(relativePath).at(-1) ?? "");
|
|
6647
|
+
}
|
|
6648
|
+
/**
|
|
6649
|
+
* Drop the entries a skill directory must not carry.
|
|
6650
|
+
*
|
|
6651
|
+
* Three rules. `classifyNeverCarried` comes first, evaluated against the
|
|
6652
|
+
* resolved real path as well as the literal one: names that are never skill
|
|
6653
|
+
* content stay out however they are reached, so renaming a symbolic link
|
|
6654
|
+
* does not turn `~/.aws` into content a skill carries. Next, a real path
|
|
6655
|
+
* inside a kernel pseudo-filesystem is refused outright — that is process
|
|
6656
|
+
* state, not a file.
|
|
6657
|
+
*
|
|
6658
|
+
* The third concerns hidden entries a symbolic link reaches outside the
|
|
6659
|
+
* directory. Following symlinks out of a source tree is deliberate and
|
|
6660
|
+
* documented — it is how a shared skill is referenced from several projects
|
|
6661
|
+
* without being duplicated (issue #1707), and the trust boundary is the tree
|
|
6662
|
+
* you point Rulesync at. Carrying hidden entries changes what that costs,
|
|
6663
|
+
* though: one ordinary-looking link to a home directory would pull in every
|
|
6664
|
+
* dotfile under it, and those are the entries with credential value. What
|
|
6665
|
+
* decides is the name in the skill directory, not what the link resolves
|
|
6666
|
+
* through: a named file keeps its documented behavior even when the target
|
|
6667
|
+
* sits under a dot-directory such as `~/.dotfiles`, because somebody chose
|
|
6668
|
+
* that name. What the link resolves *to* still counts at the end of the path,
|
|
6669
|
+
* though — `notes.md` pointing at `~/.claude/.credentials.json` reaches a
|
|
6670
|
+
* file nobody named for a skill — so a hidden final segment is refused on
|
|
6671
|
+
* either side. Reaching outside is reported either way, since content from
|
|
6672
|
+
* outside the tree is about to be copied into every enabled tool root.
|
|
6673
|
+
*
|
|
6674
|
+
* A path that cannot be resolved is kept: `realpath` fails on a broken link
|
|
6675
|
+
* or a race, and neither is a reason to silently drop a file.
|
|
6676
|
+
*/
|
|
6677
|
+
/** Report what a carried directory left out, once both walks have had their say. */
|
|
6678
|
+
static warnOnRefusedCarriedFiles(dirPath, carried) {
|
|
6679
|
+
const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
|
|
6680
|
+
if (carried.refusedCredentials.size > 0) {
|
|
6681
|
+
const { count, list } = formatReportedPaths(carried.refusedCredentials);
|
|
6682
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} named as a credential store: ${list}. A skill must not ship secrets; read them from the environment instead.`);
|
|
6683
|
+
}
|
|
6684
|
+
if (carried.refusedPseudoPaths.size > 0) {
|
|
6685
|
+
const { count, list } = formatReportedPaths(carried.refusedPseudoPaths);
|
|
6686
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a system pseudo-filesystem: ${list}. Those read back process state, not skill content.`);
|
|
6687
|
+
}
|
|
6688
|
+
if (carried.refusedNoiseAliases.size > 0) {
|
|
6689
|
+
const { count, list } = formatReportedPaths(carried.refusedNoiseAliases);
|
|
6690
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a directory a skill never carries: ${list}. A nested repository, or a build or cache tree, is the usual target.`);
|
|
6691
|
+
}
|
|
6692
|
+
if (carried.refusedEscapedHidden.size > 0) {
|
|
6693
|
+
const { count, list } = formatReportedPaths(carried.refusedEscapedHidden);
|
|
6694
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} hidden ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Copy them into the directory if the skill really needs them.`);
|
|
6695
|
+
}
|
|
6696
|
+
if (carried.carriedFromOutside.size > 0) {
|
|
6697
|
+
const { count, list } = formatReportedPaths([...carried.carriedFromOutside].map((filePath) => {
|
|
6698
|
+
const realFilePath = carried.realFilePathByPath.get(filePath);
|
|
6699
|
+
return realFilePath === void 0 ? filePath : `${filePath} -> ${realFilePath}`;
|
|
6700
|
+
}));
|
|
6701
|
+
warnOnceWithFallback(void 0, `Carrying ${count} ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Their content is copied into every generated tool directory.`);
|
|
6702
|
+
}
|
|
6703
|
+
}
|
|
6704
|
+
static async filterCarriedFiles(dirPath, filePaths) {
|
|
6705
|
+
let realDirPath;
|
|
6706
|
+
try {
|
|
6707
|
+
realDirPath = await (0, node_fs_promises.realpath)(dirPath);
|
|
6708
|
+
} catch {
|
|
6709
|
+
realDirPath = (0, node_path.resolve)(dirPath);
|
|
6710
|
+
}
|
|
6711
|
+
const refusedCredentials = /* @__PURE__ */ new Set();
|
|
6712
|
+
const refusedPseudoPaths = /* @__PURE__ */ new Set();
|
|
6713
|
+
const refusedEscapedHidden = /* @__PURE__ */ new Set();
|
|
6714
|
+
const refusedNoiseAliases = /* @__PURE__ */ new Set();
|
|
6715
|
+
const carriedFromOutside = /* @__PURE__ */ new Set();
|
|
6716
|
+
const realFilePathByPath = /* @__PURE__ */ new Map();
|
|
6717
|
+
const verdicts = await mapWithConcurrency({
|
|
6718
|
+
items: filePaths,
|
|
6719
|
+
limit: CARRIED_REALPATH_CONCURRENCY,
|
|
6720
|
+
mapper: async (filePath) => {
|
|
6721
|
+
const literalPath = (0, node_path.relative)(dirPath, filePath);
|
|
6722
|
+
const literalReason = classifyNeverCarried(literalPath);
|
|
6723
|
+
if (literalReason !== void 0) {
|
|
6724
|
+
if (literalReason === "credential") refusedCredentials.add(filePath);
|
|
6725
|
+
return false;
|
|
6726
|
+
}
|
|
6727
|
+
let realFilePath;
|
|
6728
|
+
try {
|
|
6729
|
+
realFilePath = await (0, node_fs_promises.realpath)(filePath);
|
|
6730
|
+
} catch {
|
|
6731
|
+
return true;
|
|
6732
|
+
}
|
|
6733
|
+
realFilePathByPath.set(filePath, realFilePath);
|
|
6734
|
+
if (isSystemPseudoPath(realFilePath)) {
|
|
6735
|
+
refusedPseudoPaths.add(filePath);
|
|
6736
|
+
return false;
|
|
6737
|
+
}
|
|
6738
|
+
const realPath = (0, node_path.relative)(realDirPath, realFilePath);
|
|
6739
|
+
const realReason = classifyNeverCarried(realPath);
|
|
6740
|
+
if (realReason !== void 0) {
|
|
6741
|
+
if (realReason === "credential") refusedCredentials.add(filePath);
|
|
6742
|
+
else refusedNoiseAliases.add(filePath);
|
|
6743
|
+
return false;
|
|
6744
|
+
}
|
|
6745
|
+
if (!pathEscapesRoot(realPath)) return true;
|
|
6746
|
+
if ((await resolvesThroughSystemPseudoPath(filePath)).throughPseudoPath) {
|
|
6747
|
+
refusedPseudoPaths.add(filePath);
|
|
6748
|
+
return false;
|
|
6749
|
+
}
|
|
6750
|
+
if (endsWithNeverCarriedSuffix(realFilePath)) {
|
|
6751
|
+
refusedCredentials.add(filePath);
|
|
6752
|
+
return false;
|
|
6753
|
+
}
|
|
6754
|
+
if (escapesIntoCredentialDir({
|
|
6755
|
+
realDirPath,
|
|
6756
|
+
realFilePath
|
|
6757
|
+
})) {
|
|
6758
|
+
refusedCredentials.add(filePath);
|
|
6759
|
+
return false;
|
|
6760
|
+
}
|
|
6761
|
+
if (AiDir.hasHiddenSegment(literalPath) || AiDir.hasHiddenName(realPath)) {
|
|
6762
|
+
refusedEscapedHidden.add(filePath);
|
|
6763
|
+
return false;
|
|
6764
|
+
}
|
|
6765
|
+
carriedFromOutside.add(filePath);
|
|
6766
|
+
return true;
|
|
6767
|
+
}
|
|
6768
|
+
});
|
|
6769
|
+
return {
|
|
6770
|
+
filePaths: filePaths.filter((_filePath, index) => verdicts[index]),
|
|
6771
|
+
realFilePathByPath,
|
|
6772
|
+
refusedCredentials,
|
|
6773
|
+
refusedPseudoPaths,
|
|
6774
|
+
refusedEscapedHidden,
|
|
6775
|
+
refusedNoiseAliases,
|
|
6776
|
+
carriedFromOutside
|
|
6777
|
+
};
|
|
6778
|
+
}
|
|
6779
|
+
/**
|
|
6780
|
+
* Report what the walk had to leave behind, so a skill that silently lost
|
|
6781
|
+
* files says so rather than generating a directory that is quietly short.
|
|
6782
|
+
*/
|
|
6783
|
+
static warnOnCarriedWalkLimits({ reportedDirPath, truncations, unreadablePaths }) {
|
|
6784
|
+
if (truncations.has("depth")) warnOnceWithFallback(void 0, `Not carrying the entries more than 12 directories below ${reportedDirPath}: a skill directory is walked to that depth only. A symbolic link that reaches a large tree is the usual cause.`);
|
|
6785
|
+
if (truncations.has("count")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} beyond the first ${MAX_CARRIED_FILES}: a directory may carry at most that many files. A symbolic link that reaches a large tree is the usual cause.`);
|
|
6786
|
+
if (truncations.has("directories")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} below the first ${MAX_CARRIED_DIRECTORIES} directories: a directory may carry files from at most that many directories. A symbolic link that reaches a large tree is the usual cause.`);
|
|
6787
|
+
if (truncations.has("entries")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} that come after the first ${MAX_CARRIED_ENTRIES_EXAMINED} looked at: a directory is walked over at most that many entries. A tree of symbolic links that lead back into it is the usual cause.`);
|
|
6788
|
+
if (unreadablePaths.length > 0) {
|
|
6789
|
+
const { count, list } = formatReportedPaths(unreadablePaths);
|
|
6790
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that could not be read: ${list}. A permission the current user does not hold is the usual cause.`);
|
|
6791
|
+
}
|
|
6792
|
+
}
|
|
6793
|
+
/**
|
|
6794
|
+
* Walk the directory once more with the hidden routes pruned, and take the
|
|
6795
|
+
* files the first walk had to leave behind because the route that reached
|
|
6796
|
+
* them ran through a hidden directory. This can only add files, and only ones
|
|
6797
|
+
* a fully named route reaches.
|
|
6798
|
+
*/
|
|
6799
|
+
static async recoverCarriedFilesFromNamedRoutes({ dirPath, carried, carriedPaths, carriedRealPaths, walk }) {
|
|
6800
|
+
const named = await walkCarriedFiles(dirPath, { skipHiddenRoutes: true });
|
|
6801
|
+
const recovered = await AiDir.filterCarriedFiles(dirPath, named.filePaths);
|
|
6802
|
+
for (const truncation of named.truncations) walk.truncations.add(truncation);
|
|
6803
|
+
for (const unreadablePath of named.unreadablePaths) if (!walk.unreadablePaths.includes(unreadablePath)) walk.unreadablePaths.push(unreadablePath);
|
|
6804
|
+
for (const pseudoPath of named.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
|
|
6805
|
+
for (const refused of recovered.refusedCredentials) carried.refusedCredentials.add(refused);
|
|
6806
|
+
for (const refused of recovered.refusedPseudoPaths) carried.refusedPseudoPaths.add(refused);
|
|
6807
|
+
for (const refused of recovered.refusedNoiseAliases) carried.refusedNoiseAliases.add(refused);
|
|
6808
|
+
for (const refused of recovered.refusedEscapedHidden) carried.refusedEscapedHidden.add(refused);
|
|
6809
|
+
for (const filePath of recovered.filePaths) {
|
|
6810
|
+
const realFilePath = recovered.realFilePathByPath.get(filePath) ?? filePath;
|
|
6811
|
+
if (carriedRealPaths.has(realFilePath)) continue;
|
|
6812
|
+
if (carriedPaths.length >= 1e4) {
|
|
6813
|
+
walk.truncations.add("count");
|
|
6814
|
+
break;
|
|
6815
|
+
}
|
|
6816
|
+
carriedRealPaths.add(realFilePath);
|
|
6817
|
+
carriedPaths.push(filePath);
|
|
6818
|
+
carried.realFilePathByPath.set(filePath, realFilePath);
|
|
6819
|
+
if (recovered.carriedFromOutside.has(filePath)) carried.carriedFromOutside.add(filePath);
|
|
6820
|
+
}
|
|
6821
|
+
for (const filePath of carried.refusedEscapedHidden) {
|
|
6822
|
+
const realFilePath = carried.realFilePathByPath.get(filePath) ?? filePath;
|
|
6823
|
+
if (carriedRealPaths.has(realFilePath)) carried.refusedEscapedHidden.delete(filePath);
|
|
6824
|
+
}
|
|
6825
|
+
}
|
|
6826
|
+
/**
|
|
5550
6827
|
* Recursively collects all files from a directory, excluding the specified main file.
|
|
5551
6828
|
* This is a common utility for loading additional files alongside the main file.
|
|
5552
6829
|
*
|
|
6830
|
+
* Hidden entries are included. The directories this walks are skill trees,
|
|
6831
|
+
* whose specification says a skill directory "may contain any files and
|
|
6832
|
+
* directories beyond the required `SKILL.md`" — a `.env.example` beside the
|
|
6833
|
+
* scripts that read it is content, not noise, and dropping it silently on
|
|
6834
|
+
* both import and generate loses part of the skill. What is left out is the
|
|
6835
|
+
* set of entries that are never skill content — a nested repository's `.git`,
|
|
6836
|
+
* the macOS Finder's `.DS_Store`, credential stores, build and cache trees —
|
|
6837
|
+
* as decided by `classifyNeverCarried`. Whole directories from that set are
|
|
6838
|
+
* pruned during the walk too, so it never descends into them at all.
|
|
6839
|
+
*
|
|
6840
|
+
* The walk is bounded and cycle-aware — see `walkCarriedFiles` — because the
|
|
6841
|
+
* tree may contain symbolic links that somebody else chose.
|
|
6842
|
+
*
|
|
6843
|
+
* @see https://agentskills.io/specification
|
|
6844
|
+
*
|
|
5553
6845
|
* @param outputRoot - The base directory path
|
|
5554
6846
|
* @param relativeDirPath - The relative path to the directory containing the skill
|
|
5555
6847
|
* @param dirName - The name of the directory
|
|
@@ -5558,14 +6850,57 @@ var AiDir = class {
|
|
|
5558
6850
|
*/
|
|
5559
6851
|
static async collectOtherFiles(outputRoot, relativeDirPath, dirName, excludeFileName) {
|
|
5560
6852
|
const dirPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName);
|
|
5561
|
-
const
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
6853
|
+
const walk = await walkCarriedFiles(dirPath);
|
|
6854
|
+
const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
|
|
6855
|
+
await AiDir.warnOnNestedGitDirectory(dirPath);
|
|
6856
|
+
const carried = await AiDir.filterCarriedFiles(dirPath, walk.filePaths);
|
|
6857
|
+
const carriedPaths = [...carried.filePaths];
|
|
6858
|
+
const carriedRealPaths = new Set(carriedPaths.map((filePath) => carried.realFilePathByPath.get(filePath) ?? filePath));
|
|
6859
|
+
if (carried.refusedEscapedHidden.size > 0) await AiDir.recoverCarriedFilesFromNamedRoutes({
|
|
6860
|
+
dirPath,
|
|
6861
|
+
carried,
|
|
6862
|
+
carriedPaths,
|
|
6863
|
+
carriedRealPaths,
|
|
6864
|
+
walk
|
|
6865
|
+
});
|
|
6866
|
+
for (const pseudoPath of walk.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
|
|
6867
|
+
AiDir.warnOnCarriedWalkLimits({
|
|
6868
|
+
reportedDirPath,
|
|
6869
|
+
truncations: walk.truncations,
|
|
6870
|
+
unreadablePaths: walk.unreadablePaths
|
|
6871
|
+
});
|
|
6872
|
+
AiDir.warnOnRefusedCarriedFiles(dirPath, carried);
|
|
6873
|
+
const filteredPaths = carriedPaths.toSorted().filter((filePath) => (0, node_path.basename)(filePath) !== excludeFileName);
|
|
6874
|
+
const files = [];
|
|
6875
|
+
let carriedBytes = 0;
|
|
6876
|
+
for (const [index, filePath] of filteredPaths.entries()) {
|
|
6877
|
+
const classifiedPath = carried.realFilePathByPath.get(filePath) ?? filePath;
|
|
6878
|
+
let fileHandle;
|
|
6879
|
+
try {
|
|
6880
|
+
fileHandle = await (0, node_fs_promises.open)(classifiedPath, node_fs.constants.O_RDONLY | (node_fs.constants.O_NOFOLLOW ?? 0));
|
|
6881
|
+
} catch (error) {
|
|
6882
|
+
warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
|
|
6883
|
+
continue;
|
|
6884
|
+
}
|
|
6885
|
+
try {
|
|
6886
|
+
const fileSize = (await fileHandle.stat()).size;
|
|
6887
|
+
if (carriedBytes + fileSize > 104857600) {
|
|
6888
|
+
warnOnceWithFallback(void 0, `Not carrying ${filteredPaths.length - index} of the ${filteredPaths.length} entries under ${reportedDirPath}: a directory may carry at most ${MAX_CARRIED_BYTES / 1024 / 1024}MB. A symbolic link that reaches a large tree is the usual cause.`);
|
|
6889
|
+
break;
|
|
6890
|
+
}
|
|
6891
|
+
const fileBuffer = await fileHandle.readFile();
|
|
6892
|
+
carriedBytes += fileBuffer.byteLength;
|
|
6893
|
+
files.push({
|
|
6894
|
+
relativeFilePathToDirPath: (0, node_path.relative)(dirPath, filePath),
|
|
6895
|
+
fileBuffer
|
|
6896
|
+
});
|
|
6897
|
+
} catch (error) {
|
|
6898
|
+
warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
|
|
6899
|
+
} finally {
|
|
6900
|
+
await fileHandle.close();
|
|
6901
|
+
}
|
|
6902
|
+
}
|
|
6903
|
+
return files;
|
|
5569
6904
|
}
|
|
5570
6905
|
};
|
|
5571
6906
|
const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
|
|
@@ -5705,7 +7040,11 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
|
|
|
5705
7040
|
"disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
5706
7041
|
"user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
5707
7042
|
enabled: zod_mini.z.optional(zod_mini.z.boolean()),
|
|
5708
|
-
"allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
|
|
7043
|
+
"allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
7044
|
+
license: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
7045
|
+
compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
7046
|
+
metadata: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
7047
|
+
version: zod_mini.z.optional(zod_mini.z.unknown())
|
|
5709
7048
|
})),
|
|
5710
7049
|
grokcli: zod_mini.z.optional(zod_mini.z.looseObject({
|
|
5711
7050
|
"disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
@@ -5788,7 +7127,7 @@ var RulesyncSkill = class RulesyncSkill extends AiDir {
|
|
|
5788
7127
|
const skillDirPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName);
|
|
5789
7128
|
const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
|
|
5790
7129
|
if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
|
|
5791
|
-
const { frontmatter, body: content, hasFrontmatter } =
|
|
7130
|
+
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
|
|
5792
7131
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${skillFilePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
5793
7132
|
const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter);
|
|
5794
7133
|
if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
|
|
@@ -5881,8 +7220,9 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
5881
7220
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
5882
7221
|
};
|
|
5883
7222
|
}
|
|
5884
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
|
|
5885
|
-
const
|
|
7223
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
7224
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
|
|
7225
|
+
const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
|
|
5886
7226
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
|
|
5887
7227
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
5888
7228
|
const result = RulesyncSubagentFrontmatterSchema.safeParse(frontmatter);
|
|
@@ -5890,7 +7230,7 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
5890
7230
|
const filename = (0, node_path.basename)(relativeFilePath);
|
|
5891
7231
|
return new RulesyncSubagent({
|
|
5892
7232
|
outputRoot,
|
|
5893
|
-
relativeDirPath:
|
|
7233
|
+
relativeDirPath: dirPath,
|
|
5894
7234
|
relativeFilePath: filename,
|
|
5895
7235
|
frontmatter: result.data,
|
|
5896
7236
|
body: content.trim()
|
|
@@ -5900,16 +7240,18 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
5900
7240
|
//#endregion
|
|
5901
7241
|
//#region src/features/skills/skills-utils.ts
|
|
5902
7242
|
/**
|
|
5903
|
-
* Returns the set of local skill directory names (excluding `.curated`)
|
|
7243
|
+
* Returns the set of local skill directory names (excluding `.curated`)
|
|
7244
|
+
* from a rulesync source tree (e.g. `/repo/.rulesync` or
|
|
7245
|
+
* `/repo/.rulesync.local`).
|
|
5904
7246
|
*/
|
|
5905
|
-
async function getLocalSkillDirNames(
|
|
5906
|
-
const skillsDir = (0, node_path.join)(
|
|
7247
|
+
async function getLocalSkillDirNames(sourceTree) {
|
|
7248
|
+
const skillsDir = (0, node_path.join)(sourceTree, SKILLS_FEATURE_SUBDIR);
|
|
5907
7249
|
const names = /* @__PURE__ */ new Set();
|
|
5908
7250
|
if (!await directoryExists(skillsDir)) return names;
|
|
5909
7251
|
const dirPaths = await findFilesByGlobs((0, node_path.join)(skillsDir, "*"), { type: "dir" });
|
|
5910
7252
|
for (const dirPath of dirPaths) {
|
|
5911
7253
|
const name = (0, node_path.basename)(dirPath);
|
|
5912
|
-
if (name === (0, node_path.basename)(
|
|
7254
|
+
if (name === (0, node_path.basename)(CURATED_SKILLS_FEATURE_SUBDIR)) continue;
|
|
5913
7255
|
names.add(name);
|
|
5914
7256
|
}
|
|
5915
7257
|
return names;
|
|
@@ -6225,12 +7567,25 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
|
|
|
6225
7567
|
//#region src/types/feature-processor.ts
|
|
6226
7568
|
var FeatureProcessor = class {
|
|
6227
7569
|
outputRoot;
|
|
6228
|
-
|
|
7570
|
+
/**
|
|
7571
|
+
* Ordered, non-empty list of rulesync source-tree directories. Each entry
|
|
7572
|
+
* is a source tree itself — the directory that directly contains feature
|
|
7573
|
+
* subdirectories (`rules/`, `commands/`, …) and single-file features
|
|
7574
|
+
* (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence in
|
|
7575
|
+
* per-feature merges. Defaults to `[join(process.cwd(), ".rulesync")]`.
|
|
7576
|
+
*
|
|
7577
|
+
* The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
|
|
7578
|
+
* `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
|
|
7579
|
+
* and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
|
|
7580
|
+
* reaches a processor — every internal consumer only ever sees the
|
|
7581
|
+
* plural form, with the source tree already resolved.
|
|
7582
|
+
*/
|
|
7583
|
+
inputRoots;
|
|
6229
7584
|
dryRun;
|
|
6230
7585
|
logger;
|
|
6231
|
-
constructor({ outputRoot = process.cwd(),
|
|
7586
|
+
constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, logger }) {
|
|
6232
7587
|
this.outputRoot = outputRoot;
|
|
6233
|
-
this.
|
|
7588
|
+
this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
|
|
6234
7589
|
this.dryRun = dryRun;
|
|
6235
7590
|
this.logger = logger;
|
|
6236
7591
|
}
|
|
@@ -6297,6 +7652,198 @@ var FeatureProcessor = class {
|
|
|
6297
7652
|
return orphanFiles.length;
|
|
6298
7653
|
}
|
|
6299
7654
|
};
|
|
7655
|
+
/**
|
|
7656
|
+
* Messages already reported for a given logger.
|
|
7657
|
+
*
|
|
7658
|
+
* One `generate` run constructs a single-file processor per tool target and
|
|
7659
|
+
* per output root — more than twenty times for `--targets "*"` — and each one
|
|
7660
|
+
* re-resolves the same roots. Keying on the logger — created once per run, and
|
|
7661
|
+
* once per test — keeps the shadowing warning to a single line instead of
|
|
7662
|
+
* repeating it for every target. `--watch` reuses one logger across runs, so
|
|
7663
|
+
* {@link resetRootShadowingWarnings} clears the set at the start of each one.
|
|
7664
|
+
*/
|
|
7665
|
+
const warnedRootShadowingByLogger = /* @__PURE__ */ new WeakMap();
|
|
7666
|
+
/**
|
|
7667
|
+
* Forget which shadowing warnings have already been reported.
|
|
7668
|
+
*
|
|
7669
|
+
* `generate` calls this once per run. Without it, `--watch` reuses one logger
|
|
7670
|
+
* for the whole session, so the warning would be printed on the first
|
|
7671
|
+
* generation and never again — the opposite of why it is a warning, since
|
|
7672
|
+
* `--watch` is exactly when an overlay is most likely to be added or edited.
|
|
7673
|
+
*/
|
|
7674
|
+
function resetRootShadowingWarnings({ logger }) {
|
|
7675
|
+
warnedRootShadowingByLogger.delete(logger);
|
|
7676
|
+
}
|
|
7677
|
+
/**
|
|
7678
|
+
* Return the last input root that contains any of the given `relativePaths`,
|
|
7679
|
+
* or `undefined` when none of the roots has any of them. Used by single-file
|
|
7680
|
+
* features (hooks, permissions, ignore) to implement the "later root wins
|
|
7681
|
+
* the whole file" merge policy without materializing the file contents.
|
|
7682
|
+
*
|
|
7683
|
+
* `relativePaths` accepts a small list so features that historically read
|
|
7684
|
+
* either a recommended path or a legacy alias (e.g. `.rulesync/mcp.jsonc`
|
|
7685
|
+
* plus `.rulesync/mcp.json`) can preserve that resolution order per root.
|
|
7686
|
+
* A root counts as "having" the file as long as at least one candidate path
|
|
7687
|
+
* is present.
|
|
7688
|
+
*/
|
|
7689
|
+
async function pickLastRootWithFile({ inputRoots, relativePaths, logger, artifactName }) {
|
|
7690
|
+
let winner;
|
|
7691
|
+
const rootsWithFile = [];
|
|
7692
|
+
for (const root of inputRoots) for (const relativePath of relativePaths) if (await fileExists((0, node_path.join)(root, relativePath))) {
|
|
7693
|
+
winner = root;
|
|
7694
|
+
rootsWithFile.push(root);
|
|
7695
|
+
break;
|
|
7696
|
+
}
|
|
7697
|
+
if (rootsWithFile.length > 1 && winner !== void 0) {
|
|
7698
|
+
const shadowed = rootsWithFile.slice(0, -1);
|
|
7699
|
+
const message = `${artifactName} is provided by more than one input root; '${stripControlCharacters(winner)}' replaces the whole file from ${shadowed.map((root) => `'${stripControlCharacters(root)}'`).join(", ")}.`;
|
|
7700
|
+
let warnedMessages = warnedRootShadowingByLogger.get(logger);
|
|
7701
|
+
if (warnedMessages === void 0) {
|
|
7702
|
+
warnedMessages = /* @__PURE__ */ new Set();
|
|
7703
|
+
warnedRootShadowingByLogger.set(logger, warnedMessages);
|
|
7704
|
+
}
|
|
7705
|
+
if (!warnedMessages.has(message)) {
|
|
7706
|
+
warnedMessages.add(message);
|
|
7707
|
+
logger.warn(message);
|
|
7708
|
+
}
|
|
7709
|
+
}
|
|
7710
|
+
return winner;
|
|
7711
|
+
}
|
|
7712
|
+
/**
|
|
7713
|
+
* Merge per-root result lists into a single ordered list, keeping the
|
|
7714
|
+
* later root's entry when two roots produced an item with the same
|
|
7715
|
+
* identity. Identity is intentionally provided by the caller so
|
|
7716
|
+
* per-feature nuances (case-insensitive filesystems, directory names for
|
|
7717
|
+
* skills, server names for MCP) live next to the feature that owns them.
|
|
7718
|
+
*
|
|
7719
|
+
* The returned list preserves the FIRST appearance order of each identity —
|
|
7720
|
+
* items in the earliest root keep their position, but their content is
|
|
7721
|
+
* replaced by the last root that provided the same identity. This matches
|
|
7722
|
+
* the "overlay" mental model: an overlay changes content, not order.
|
|
7723
|
+
*/
|
|
7724
|
+
function mergeByIdentity({ perRoot, identity }) {
|
|
7725
|
+
const order = [];
|
|
7726
|
+
const winnerByKey = /* @__PURE__ */ new Map();
|
|
7727
|
+
for (const rootItems of perRoot) for (const item of rootItems) {
|
|
7728
|
+
const key = identity(item);
|
|
7729
|
+
if (!winnerByKey.has(key)) order.push(key);
|
|
7730
|
+
winnerByKey.set(key, item);
|
|
7731
|
+
}
|
|
7732
|
+
return order.map((key) => winnerByKey.get(key));
|
|
7733
|
+
}
|
|
7734
|
+
/**
|
|
7735
|
+
* The key two spellings share when a case-insensitive filesystem would give
|
|
7736
|
+
* them one file. `toLowerCase()` is locale-independent (unlike
|
|
7737
|
+
* `toLocaleLowerCase`, it does not turn `I` into the Turkish `ı` under a Turkish
|
|
7738
|
+
* locale), and the NFC pass folds the composed and decomposed spellings of an
|
|
7739
|
+
* accented name — which macOS also resolves to a single directory —
|
|
7740
|
+
* onto each other.
|
|
7741
|
+
*
|
|
7742
|
+
* This is simple lowercasing rather than full Unicode case folding, so it is
|
|
7743
|
+
* deliberately narrower than what a filesystem considers one file: a Greek
|
|
7744
|
+
* final sigma, a Turkish `ı` under NTFS's upcasing, and a Win32 name whose
|
|
7745
|
+
* trailing dot is stripped all still produce distinct keys. Those pairs keep
|
|
7746
|
+
* the pre-existing behavior (both are imported, and the later one wins on the
|
|
7747
|
+
* filesystem); folding them here would instead drop names that a
|
|
7748
|
+
* case-sensitive filesystem keeps genuinely apart.
|
|
7749
|
+
*/
|
|
7750
|
+
function caseFoldIdentity(identity) {
|
|
7751
|
+
return identity.normalize("NFC").toLowerCase();
|
|
7752
|
+
}
|
|
7753
|
+
/**
|
|
7754
|
+
* Group spellings by their case-folded identity, keeping every original
|
|
7755
|
+
* spelling. On a case-sensitive filesystem one identity can cover several
|
|
7756
|
+
* spellings at once, and the caller needs them all to describe a collision
|
|
7757
|
+
* accurately.
|
|
7758
|
+
*/
|
|
7759
|
+
function groupSpellingsByCaseFoldedIdentity(spellings) {
|
|
7760
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
7761
|
+
for (const spelling of spellings) {
|
|
7762
|
+
const identity = caseFoldIdentity(spelling);
|
|
7763
|
+
const existing = grouped.get(identity);
|
|
7764
|
+
if (existing === void 0) grouped.set(identity, [spelling]);
|
|
7765
|
+
else existing.push(spelling);
|
|
7766
|
+
}
|
|
7767
|
+
return grouped;
|
|
7768
|
+
}
|
|
7769
|
+
/**
|
|
7770
|
+
* Build the warning emitted when a `.curated/` entry and a local entry in the
|
|
7771
|
+
* same tree differ only in case.
|
|
7772
|
+
*
|
|
7773
|
+
* `.curated/` is expanded from a declarative source (an external Git repository
|
|
7774
|
+
* or npm package), so its names are untrusted input; both sides are stripped of
|
|
7775
|
+
* control characters before they reach the terminal.
|
|
7776
|
+
*
|
|
7777
|
+
* The winning local spelling is the LAST one, matching the precedence
|
|
7778
|
+
* {@link mergeByCaseInsensitiveIdentity} applies afterwards; any other spelling
|
|
7779
|
+
* that folds onto the same identity is listed too, so the message never names a
|
|
7780
|
+
* spelling that loses.
|
|
7781
|
+
*/
|
|
7782
|
+
function formatCuratedCaseCollisionWarning({ artifactKind, entryNoun, treeDirPath, curatedSpelling, localSpellings }) {
|
|
7783
|
+
const winner = localSpellings[localSpellings.length - 1] ?? "";
|
|
7784
|
+
const shadowed = localSpellings.slice(0, -1);
|
|
7785
|
+
const shadowedSuffix = shadowed.length === 0 ? "" : ` Other local spellings that fold onto the same identity: ${shadowed.map((spelling) => `'${stripControlCharacters(spelling)}'`).join(", ")}.`;
|
|
7786
|
+
return `Case-insensitive ${artifactKind} collision under ${stripControlCharacters(treeDirPath)}: curated '${stripControlCharacters(curatedSpelling)}' and local '${stripControlCharacters(winner)}' resolve to the same identity. The local ${entryNoun} wins and the curated ${entryNoun} is skipped.` + shadowedSuffix;
|
|
7787
|
+
}
|
|
7788
|
+
/**
|
|
7789
|
+
* Merge artifacts whose filenames are case-insensitive identities, warning
|
|
7790
|
+
* when distinct spellings collapse to the same key. Exact-name overlays are
|
|
7791
|
+
* intentional and remain quiet; only case-only ambiguity is diagnosed.
|
|
7792
|
+
*
|
|
7793
|
+
* Precedence here is the opposite of {@link ClaimedIdentities}: this merges
|
|
7794
|
+
* overlay roots, where the LAST entry wins, while the tool-side loaders keep
|
|
7795
|
+
* the FIRST root to claim a name.
|
|
7796
|
+
*/
|
|
7797
|
+
function mergeByCaseInsensitiveIdentity({ perRoot, identity, artifactName, logger }) {
|
|
7798
|
+
const spellingByKey = /* @__PURE__ */ new Map();
|
|
7799
|
+
const warnedKeys = /* @__PURE__ */ new Set();
|
|
7800
|
+
return mergeByIdentity({
|
|
7801
|
+
perRoot,
|
|
7802
|
+
identity: (item) => {
|
|
7803
|
+
const spelling = identity(item);
|
|
7804
|
+
const key = caseFoldIdentity(spelling);
|
|
7805
|
+
const previousSpelling = spellingByKey.get(key);
|
|
7806
|
+
if (previousSpelling !== void 0 && previousSpelling !== spelling && !warnedKeys.has(key)) {
|
|
7807
|
+
logger.warn(`Case-insensitive ${artifactName} collision: '${stripControlCharacters(previousSpelling)}' and '${stripControlCharacters(spelling)}' resolve to the same identity. The later entry wins.`);
|
|
7808
|
+
warnedKeys.add(key);
|
|
7809
|
+
}
|
|
7810
|
+
if (previousSpelling === void 0) spellingByKey.set(key, spelling);
|
|
7811
|
+
return key;
|
|
7812
|
+
}
|
|
7813
|
+
});
|
|
7814
|
+
}
|
|
7815
|
+
/**
|
|
7816
|
+
* Tracks the import identities already claimed while scanning, folding case
|
|
7817
|
+
* through {@link caseFoldIdentity}.
|
|
7818
|
+
*
|
|
7819
|
+
* The tool-side loaders scan several roots in precedence order and keep the
|
|
7820
|
+
* first spelling of each identity. Comparing those identities exactly lets
|
|
7821
|
+
* `.junie/skills/dup-skill` and `.agents/skills/Dup-Skill` both through, and
|
|
7822
|
+
* since macOS and Windows resolve the two written-back directories to a
|
|
7823
|
+
* single one, the shared Agent Skills copy lands last and overwrites the
|
|
7824
|
+
* tool-specific one — inverting the precedence the roots were ordered by.
|
|
7825
|
+
*
|
|
7826
|
+
* The FIRST claimer wins, which is the opposite of the overlay precedence in
|
|
7827
|
+
* {@link mergeByCaseInsensitiveIdentity}: roots are passed in precedence
|
|
7828
|
+
* order, so the earliest one to name a skill is the one that should keep it.
|
|
7829
|
+
*/
|
|
7830
|
+
var ClaimedIdentities = class {
|
|
7831
|
+
claimByKey = /* @__PURE__ */ new Map();
|
|
7832
|
+
/**
|
|
7833
|
+
* Claim `identity` on behalf of `source`. Returns `null` when nothing held
|
|
7834
|
+
* it yet, or the standing claim when something did.
|
|
7835
|
+
*/
|
|
7836
|
+
claim({ identity, source }) {
|
|
7837
|
+
const key = caseFoldIdentity(identity);
|
|
7838
|
+
const claimed = this.claimByKey.get(key);
|
|
7839
|
+
if (claimed !== void 0) return claimed;
|
|
7840
|
+
this.claimByKey.set(key, {
|
|
7841
|
+
spelling: identity,
|
|
7842
|
+
source
|
|
7843
|
+
});
|
|
7844
|
+
return null;
|
|
7845
|
+
}
|
|
7846
|
+
};
|
|
6300
7847
|
//#endregion
|
|
6301
7848
|
//#region src/constants/amp-paths.ts
|
|
6302
7849
|
const AMP_DIR = ".amp";
|
|
@@ -8619,10 +10166,10 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8619
10166
|
toolTarget;
|
|
8620
10167
|
global;
|
|
8621
10168
|
getFactory;
|
|
8622
|
-
constructor({ outputRoot = process.cwd(),
|
|
10169
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
|
|
8623
10170
|
super({
|
|
8624
10171
|
outputRoot,
|
|
8625
|
-
|
|
10172
|
+
inputRoots,
|
|
8626
10173
|
dryRun,
|
|
8627
10174
|
logger
|
|
8628
10175
|
});
|
|
@@ -8659,11 +10206,15 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8659
10206
|
return toolFiles.filter((file) => file instanceof ToolCheck).flatMap((toolCheck) => toolCheck.toRulesyncChecks());
|
|
8660
10207
|
}
|
|
8661
10208
|
/**
|
|
8662
|
-
*
|
|
8663
|
-
*
|
|
10209
|
+
* Load check files from a single source-tree's `checks/` subtree.
|
|
10210
|
+
* `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
|
|
10211
|
+
* `/repo/.rulesync.local`).
|
|
8664
10212
|
*/
|
|
8665
|
-
async
|
|
8666
|
-
const
|
|
10213
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
10214
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
10215
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
10216
|
+
const treeChecksDirPath = (0, node_path.join)(treeName, CHECKS_FEATURE_SUBDIR);
|
|
10217
|
+
const checksDir = (0, node_path.join)(sourceTree, CHECKS_FEATURE_SUBDIR);
|
|
8667
10218
|
if (!await directoryExists(checksDir)) {
|
|
8668
10219
|
this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
|
|
8669
10220
|
return [];
|
|
@@ -8679,7 +10230,8 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8679
10230
|
const filepath = (0, node_path.join)(checksDir, mdFile);
|
|
8680
10231
|
try {
|
|
8681
10232
|
const rulesyncCheck = await RulesyncCheck.fromFile({
|
|
8682
|
-
outputRoot:
|
|
10233
|
+
outputRoot: treeParent,
|
|
10234
|
+
relativeDirPath: treeChecksDirPath,
|
|
8683
10235
|
relativeFilePath: mdFile,
|
|
8684
10236
|
validate: true
|
|
8685
10237
|
});
|
|
@@ -8690,6 +10242,21 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8690
10242
|
continue;
|
|
8691
10243
|
}
|
|
8692
10244
|
}
|
|
10245
|
+
return rulesyncChecks;
|
|
10246
|
+
}
|
|
10247
|
+
/**
|
|
10248
|
+
* Implementation of abstract method from Processor
|
|
10249
|
+
* Load and parse rulesync check files from every configured input root's
|
|
10250
|
+
* `.rulesync/checks/` directory, merging by relative file path so a check
|
|
10251
|
+
* from a later root replaces the earlier root's copy.
|
|
10252
|
+
*/
|
|
10253
|
+
async loadRulesyncFiles() {
|
|
10254
|
+
const rulesyncChecks = mergeByCaseInsensitiveIdentity({
|
|
10255
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
10256
|
+
identity: (check) => check.getRelativeFilePath(),
|
|
10257
|
+
artifactName: "check",
|
|
10258
|
+
logger: this.logger
|
|
10259
|
+
});
|
|
8693
10260
|
this.logger.debug(`Successfully loaded ${rulesyncChecks.length} rulesync checks`);
|
|
8694
10261
|
return rulesyncChecks;
|
|
8695
10262
|
}
|
|
@@ -10168,18 +11735,23 @@ function commandSlug(relativeFilePath) {
|
|
|
10168
11735
|
return (0, node_path.basename)(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
10169
11736
|
}
|
|
10170
11737
|
/**
|
|
10171
|
-
* Whether a rulesync command exists whose slug matches `dirName
|
|
11738
|
+
* Whether a rulesync command exists whose slug matches `dirName` in any of
|
|
11739
|
+
* the configured input roots.
|
|
11740
|
+
*
|
|
11741
|
+
* `inputRoots[i]` is a source tree itself (e.g. `/repo/.rulesync` or
|
|
11742
|
+
* `/repo/.rulesync.local`), so commands live directly under
|
|
11743
|
+
* `<sourceTree>/commands/`.
|
|
10172
11744
|
*
|
|
10173
11745
|
* Used by the skills-surface `isDirOwned` hooks of tools whose commands are
|
|
10174
11746
|
* emitted as `<slug>/SKILL.md` into the skills tree: a directory matching a
|
|
10175
11747
|
* current command slug is owned by the commands feature, so the skills
|
|
10176
11748
|
* feature must neither import it as a skill nor delete it as an orphan
|
|
10177
|
-
* skill. Once the command is removed from
|
|
10178
|
-
* directory stops matching and the skills feature cleans
|
|
10179
|
-
* orphan.
|
|
11749
|
+
* skill. Once the command is removed from every source tree's `commands/`
|
|
11750
|
+
* directory, the directory stops matching and the skills feature cleans
|
|
11751
|
+
* it up as a regular orphan.
|
|
10180
11752
|
*/
|
|
10181
|
-
async function rulesyncCommandSlugExists({
|
|
10182
|
-
return (await findFilesByGlobs((0, node_path.join)(
|
|
11753
|
+
async function rulesyncCommandSlugExists({ inputRoots, dirName }) {
|
|
11754
|
+
return (await Promise.all(inputRoots.map((root) => findFilesByGlobs((0, node_path.join)(root, COMMANDS_FEATURE_SUBDIR, "**", "*.md"))))).flat().some((filePath) => commandSlug((0, node_path.basename)(filePath)) === dirName);
|
|
10183
11755
|
}
|
|
10184
11756
|
//#endregion
|
|
10185
11757
|
//#region src/features/commands/devin-command.ts
|
|
@@ -10869,11 +12441,10 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
|
|
|
10869
12441
|
* output is lost — so this warns rather than failing the run the way the
|
|
10870
12442
|
* Hermes check does, where the two surfaces really do write the same path.
|
|
10871
12443
|
*/
|
|
10872
|
-
static async validateRulesyncCommands({
|
|
12444
|
+
static async validateRulesyncCommands({ inputRoots, rulesyncCommands, logger }) {
|
|
10873
12445
|
const commandNames = new Set(rulesyncCommands.filter((command) => this.isTargetedByRulesyncCommand(command)).map((command) => (0, node_path.basename)(command.getRelativeFilePath(), ".md")));
|
|
10874
12446
|
if (commandNames.size === 0) return;
|
|
10875
|
-
const
|
|
10876
|
-
const shadowed = (await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"))).map((filePath) => (0, node_path.basename)((0, node_path.dirname)(filePath))).filter((skillName) => commandNames.has(skillName));
|
|
12447
|
+
const shadowed = (await Promise.all(inputRoots.map((root) => findFilesByGlobs((0, node_path.join)(root, SKILLS_FEATURE_SUBDIR, "**", "SKILL.md"))))).flat().map((filePath) => (0, node_path.basename)((0, node_path.dirname)(filePath))).filter((skillName) => commandNames.has(skillName));
|
|
10877
12448
|
if (shadowed.length > 0) logger.warn(`Grok CLI resolves skills before commands, so these skills shadow the same-named commands, which will never be reachable: ${[...new Set(shadowed)].toSorted().join(", ")}. Rename either side to make both invocable.`);
|
|
10878
12449
|
}
|
|
10879
12450
|
static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
|
|
@@ -10919,6 +12490,26 @@ function toolSkillSearchRoots(paths) {
|
|
|
10919
12490
|
* https://agentskills.io/client-implementation/adding-skills-support
|
|
10920
12491
|
*/
|
|
10921
12492
|
const AGENT_SKILLS_INTEROP_ROOTS = /* @__PURE__ */ new Set([toPosixPath(AGENTSMD_SKILLS_DIR_PATH), toPosixPath(AMP_SKILLS_GLOBAL_DIR)]);
|
|
12493
|
+
/**
|
|
12494
|
+
* The one spec violation both sides of the conversion report. Generation warns
|
|
12495
|
+
* about it before writing the file; import warns about it because a conformant
|
|
12496
|
+
* client would skip the skill entirely, and a user who never sees that has no
|
|
12497
|
+
* reason to fix it.
|
|
12498
|
+
*/
|
|
12499
|
+
const EMPTY_SKILL_DESCRIPTION_VIOLATION = "`description` is required and must not be empty; conformant clients skip a skill without one";
|
|
12500
|
+
/**
|
|
12501
|
+
* Report a skill read from disk whose `description` is empty. This lives on the
|
|
12502
|
+
* read rather than on any one tool class because several targets share one
|
|
12503
|
+
* `.agents/skills` tree: which of them reports the skill must not depend on
|
|
12504
|
+
* which target the user happened to enable. Every loader calls it — the two
|
|
12505
|
+
* directory loaders (`loadSkillDirContent`, `SimulatedSkill.fromDirDefault`)
|
|
12506
|
+
* and the flat-file one — so the form a tool stores its skills in does not
|
|
12507
|
+
* decide whether the problem is reported either.
|
|
12508
|
+
*/
|
|
12509
|
+
function warnOnEmptyLoadedDescription({ skillFilePath, description }) {
|
|
12510
|
+
if (typeof description !== "string" || description.length > 0) return;
|
|
12511
|
+
warnOnceWithFallback(void 0, `${stripControlCharacters(toPosixPath(skillFilePath))}: ${EMPTY_SKILL_DESCRIPTION_VIOLATION}. Rulesync imports it anyway so the content is not lost; fill it in before relying on this skill.`);
|
|
12512
|
+
}
|
|
10922
12513
|
function isAgentSkillsInteropRoot(relativeDirPath) {
|
|
10923
12514
|
return AGENT_SKILLS_INTEROP_ROOTS.has(toPosixPath(relativeDirPath));
|
|
10924
12515
|
}
|
|
@@ -11031,7 +12622,11 @@ var ToolSkill = class extends AiDir {
|
|
|
11031
12622
|
const skillDirPath = (0, node_path.join)(outputRoot, actualRelativeDirPath, dirName);
|
|
11032
12623
|
const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
|
|
11033
12624
|
if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
|
|
11034
|
-
const { frontmatter, body: content } =
|
|
12625
|
+
const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
|
|
12626
|
+
warnOnEmptyLoadedDescription({
|
|
12627
|
+
skillFilePath,
|
|
12628
|
+
description: frontmatter.description
|
|
12629
|
+
});
|
|
11035
12630
|
const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
|
|
11036
12631
|
return {
|
|
11037
12632
|
outputRoot,
|
|
@@ -11156,6 +12751,10 @@ function toSpecConformantAgentSkillFields(section, { coerceMetadata = true } = {
|
|
|
11156
12751
|
...allowedTools !== void 0 && allowedTools.length > 0 && { "allowed-tools": allowedTools }
|
|
11157
12752
|
};
|
|
11158
12753
|
}
|
|
12754
|
+
/** The `SKILL.md` a diagnostic should point at, in the scope it is written to. */
|
|
12755
|
+
function agentSkillFilePath({ outputRoot, relativeDirPath, dirName }) {
|
|
12756
|
+
return (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
|
|
12757
|
+
}
|
|
11159
12758
|
/**
|
|
11160
12759
|
* Collect the normative violations the Agent Skills spec defines for a skill
|
|
11161
12760
|
* about to be written. These are reported as warnings rather than errors:
|
|
@@ -11182,7 +12781,7 @@ function collectAgentSkillViolations({ frontmatter, dirName, sourceAllowedTools
|
|
|
11182
12781
|
if (!NAME_PATTERN.test(name)) violations.push(`\`name\` "${name}" must contain only lowercase letters, digits and single hyphens, with no leading, trailing or consecutive hyphens`);
|
|
11183
12782
|
if (name !== dirName) violations.push(`\`name\` "${name}" must match its parent directory name "${dirName}"; conformant clients require them to be equal`);
|
|
11184
12783
|
}
|
|
11185
|
-
if (description.length === 0) violations.push(
|
|
12784
|
+
if (description.length === 0) violations.push(EMPTY_SKILL_DESCRIPTION_VIOLATION);
|
|
11186
12785
|
else if (description.length > DESCRIPTION_MAX_LENGTH) violations.push(`\`description\` is ${description.length} characters; the Agent Skills spec allows at most ${DESCRIPTION_MAX_LENGTH}`);
|
|
11187
12786
|
const { compatibility } = frontmatter;
|
|
11188
12787
|
if (typeof compatibility !== "string" && compatibility !== void 0) violations.push("`compatibility` must be a string; the Agent Skills spec does not allow a mapping here");
|
|
@@ -11301,12 +12900,16 @@ var AgentsSkillsSkill = class AgentsSkillsSkill extends ToolSkill {
|
|
|
11301
12900
|
* directory rather than a same-named project path.
|
|
11302
12901
|
*/
|
|
11303
12902
|
static reportSpecViolations({ outputRoot, relativeDirPath, dirName, frontmatter, sourceAllowedTools, logger }) {
|
|
11304
|
-
const skillPath = (
|
|
12903
|
+
const skillPath = agentSkillFilePath({
|
|
12904
|
+
outputRoot,
|
|
12905
|
+
relativeDirPath,
|
|
12906
|
+
dirName
|
|
12907
|
+
});
|
|
11305
12908
|
for (const violation of collectAgentSkillViolations({
|
|
11306
12909
|
frontmatter,
|
|
11307
12910
|
dirName,
|
|
11308
12911
|
sourceAllowedTools
|
|
11309
|
-
})) warnWithFallback(logger, `${skillPath}: ${violation}`);
|
|
12912
|
+
})) warnWithFallback(logger, `${stripControlCharacters(toPosixPath(skillPath))}: ${violation}`);
|
|
11310
12913
|
}
|
|
11311
12914
|
static isTargetedByRulesyncSkill(rulesyncSkill) {
|
|
11312
12915
|
const targets = rulesyncSkill.getFrontmatter().targets;
|
|
@@ -11645,7 +13248,7 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
|
|
|
11645
13248
|
static getExtraSharedWritePaths() {
|
|
11646
13249
|
return getHermesagentSharedConfigWritePaths();
|
|
11647
13250
|
}
|
|
11648
|
-
static async validateRulesyncCommands({
|
|
13251
|
+
static async validateRulesyncCommands({ inputRoots, rulesyncCommands }) {
|
|
11649
13252
|
const commandSlugs = /* @__PURE__ */ new Set();
|
|
11650
13253
|
const commandOrigins = /* @__PURE__ */ new Map();
|
|
11651
13254
|
for (const command of rulesyncCommands.filter((candidate) => this.isTargetedByRulesyncCommand(candidate))) {
|
|
@@ -11657,13 +13260,25 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
|
|
|
11657
13260
|
commandOrigins.set(slug, origin);
|
|
11658
13261
|
commandSlugs.add(slug);
|
|
11659
13262
|
}
|
|
11660
|
-
const
|
|
11661
|
-
const
|
|
11662
|
-
|
|
11663
|
-
|
|
11664
|
-
|
|
11665
|
-
|
|
11666
|
-
|
|
13263
|
+
const skillsByName = /* @__PURE__ */ new Map();
|
|
13264
|
+
for (const rootPath of inputRoots) {
|
|
13265
|
+
const skillsRoot = (0, node_path.join)(rootPath, SKILLS_FEATURE_SUBDIR);
|
|
13266
|
+
const skillFiles = await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"));
|
|
13267
|
+
const loaded = await Promise.all(skillFiles.map(async (filePath) => {
|
|
13268
|
+
const dirName = toPosixPath((0, node_path.relative)(skillsRoot, (0, node_path.dirname)(filePath)));
|
|
13269
|
+
return {
|
|
13270
|
+
rulesyncSkill: await RulesyncSkill.fromDir({
|
|
13271
|
+
outputRoot: rootPath,
|
|
13272
|
+
relativeDirPath: SKILLS_FEATURE_SUBDIR,
|
|
13273
|
+
dirName
|
|
13274
|
+
}),
|
|
13275
|
+
dirName,
|
|
13276
|
+
rootPath
|
|
13277
|
+
};
|
|
13278
|
+
}));
|
|
13279
|
+
for (const entry of loaded) skillsByName.set(caseFoldIdentity(entry.dirName), entry);
|
|
13280
|
+
}
|
|
13281
|
+
const collisions = [...skillsByName.values()].map(({ rulesyncSkill }) => rulesyncSkill).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
|
|
11667
13282
|
if (collisions.length > 0) throw new Error(`Hermes command and skill slash-name collision: ${[...new Set(collisions)].toSorted().join(", ")}`);
|
|
11668
13283
|
}
|
|
11669
13284
|
static async getAuxiliaryFiles({ toolCommands, outputRoot, global = false, forDeletion = false }) {
|
|
@@ -11796,6 +13411,8 @@ const JUNIE_PERMISSIONS_FILE_NAME = "allowlist.json";
|
|
|
11796
13411
|
const JUNIE_IGNORE_FILE_NAME = ".aiignore";
|
|
11797
13412
|
const JUNIE_RULE_FILE_NAME = "AGENTS.md";
|
|
11798
13413
|
const JUNIE_LEGACY_RULE_FILE_NAME = "guidelines.md";
|
|
13414
|
+
const JUNIE_RULES_DIR_NAME = "rules";
|
|
13415
|
+
const JUNIE_PLAYBOOK_FILE_NAME = "playbook.md";
|
|
11799
13416
|
//#endregion
|
|
11800
13417
|
//#region src/features/commands/junie-command.ts
|
|
11801
13418
|
const JunieCommandFrontmatterSchema = zod_mini.z.looseObject({ description: zod_mini.z.optional(zod_mini.z.string()) });
|
|
@@ -13756,10 +15373,10 @@ var CommandsProcessor = class extends FeatureProcessor {
|
|
|
13756
15373
|
global;
|
|
13757
15374
|
getFactory;
|
|
13758
15375
|
flattenedCommandNaming;
|
|
13759
|
-
constructor({ outputRoot = process.cwd(),
|
|
15376
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
|
|
13760
15377
|
super({
|
|
13761
15378
|
outputRoot,
|
|
13762
|
-
|
|
15379
|
+
inputRoots,
|
|
13763
15380
|
dryRun,
|
|
13764
15381
|
logger
|
|
13765
15382
|
});
|
|
@@ -13774,7 +15391,7 @@ var CommandsProcessor = class extends FeatureProcessor {
|
|
|
13774
15391
|
const rulesyncCommands = rulesyncFiles.filter((file) => file instanceof RulesyncCommand);
|
|
13775
15392
|
const factory = this.getFactory(this.toolTarget);
|
|
13776
15393
|
await factory.class.validateRulesyncCommands?.({
|
|
13777
|
-
|
|
15394
|
+
inputRoots: this.inputRoots,
|
|
13778
15395
|
rulesyncCommands,
|
|
13779
15396
|
logger: this.logger
|
|
13780
15397
|
});
|
|
@@ -13826,16 +15443,36 @@ var CommandsProcessor = class extends FeatureProcessor {
|
|
|
13826
15443
|
return rel;
|
|
13827
15444
|
}
|
|
13828
15445
|
/**
|
|
13829
|
-
*
|
|
13830
|
-
*
|
|
15446
|
+
* Load rulesync command files from a single source-tree's `commands/`
|
|
15447
|
+
* subtree. `sourceTree` is the source tree itself (e.g.
|
|
15448
|
+
* `/repo/.rulesync` or `/repo/.rulesync.local`).
|
|
13831
15449
|
*/
|
|
13832
|
-
async
|
|
13833
|
-
const
|
|
15450
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
15451
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
15452
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
15453
|
+
const treeCommandsDirPath = (0, node_path.join)(treeName, COMMANDS_FEATURE_SUBDIR);
|
|
15454
|
+
const basePath = (0, node_path.join)(sourceTree, COMMANDS_FEATURE_SUBDIR);
|
|
13834
15455
|
const rulesyncCommandPaths = await findFilesByGlobs((0, node_path.join)(basePath, "**", "*.md"));
|
|
13835
|
-
|
|
13836
|
-
outputRoot:
|
|
15456
|
+
return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
|
|
15457
|
+
outputRoot: treeParent,
|
|
15458
|
+
relativeDirPath: treeCommandsDirPath,
|
|
13837
15459
|
relativeFilePath: this.safeRelativePath(basePath, path)
|
|
13838
15460
|
})));
|
|
15461
|
+
}
|
|
15462
|
+
/**
|
|
15463
|
+
* Implementation of abstract method from FeatureProcessor
|
|
15464
|
+
* Load and parse rulesync command files from every configured input root's
|
|
15465
|
+
* `.rulesync/commands/` directory, merging by relative path so a command
|
|
15466
|
+
* with the same target path from a later root replaces the earlier root's
|
|
15467
|
+
* copy.
|
|
15468
|
+
*/
|
|
15469
|
+
async loadRulesyncFiles() {
|
|
15470
|
+
const rulesyncCommands = mergeByCaseInsensitiveIdentity({
|
|
15471
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
15472
|
+
identity: (command) => command.getRelativeFilePath(),
|
|
15473
|
+
artifactName: "command",
|
|
15474
|
+
logger: this.logger
|
|
15475
|
+
});
|
|
13839
15476
|
this.logger.debug(`Successfully loaded ${rulesyncCommands.length} rulesync commands`);
|
|
13840
15477
|
return rulesyncCommands;
|
|
13841
15478
|
}
|
|
@@ -19475,6 +21112,7 @@ function vibeEntryToCanonicalDef(raw) {
|
|
|
19475
21112
|
const entry = raw;
|
|
19476
21113
|
const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
|
|
19477
21114
|
if (vibeEvent === void 0) return null;
|
|
21115
|
+
if (isPrototypePollutionKey(vibeEvent)) return null;
|
|
19478
21116
|
const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
|
|
19479
21117
|
const def = { type: "command" };
|
|
19480
21118
|
if (typeof entry.command === "string") def.command = entry.command;
|
|
@@ -20023,10 +21661,10 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
|
|
|
20023
21661
|
var HooksProcessor = class extends FeatureProcessor {
|
|
20024
21662
|
toolTarget;
|
|
20025
21663
|
global;
|
|
20026
|
-
constructor({ outputRoot = process.cwd(),
|
|
21664
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
|
|
20027
21665
|
super({
|
|
20028
21666
|
outputRoot,
|
|
20029
|
-
|
|
21667
|
+
inputRoots,
|
|
20030
21668
|
dryRun,
|
|
20031
21669
|
logger
|
|
20032
21670
|
});
|
|
@@ -20036,9 +21674,17 @@ var HooksProcessor = class extends FeatureProcessor {
|
|
|
20036
21674
|
this.global = global;
|
|
20037
21675
|
}
|
|
20038
21676
|
async loadRulesyncFiles() {
|
|
21677
|
+
const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncHooks.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
|
|
21678
|
+
const sourceTree = await pickLastRootWithFile({
|
|
21679
|
+
inputRoots: this.inputRoots,
|
|
21680
|
+
relativePaths,
|
|
21681
|
+
logger: this.logger,
|
|
21682
|
+
artifactName: "The hooks file"
|
|
21683
|
+
}) ?? this.inputRoots[0];
|
|
20039
21684
|
try {
|
|
20040
21685
|
return [await RulesyncHooks.fromFile({
|
|
20041
|
-
outputRoot:
|
|
21686
|
+
outputRoot: (0, node_path.dirname)(sourceTree),
|
|
21687
|
+
relativeDirPath: (0, node_path.basename)(sourceTree),
|
|
20042
21688
|
validate: true
|
|
20043
21689
|
})];
|
|
20044
21690
|
} catch (error) {
|
|
@@ -21580,10 +23226,10 @@ var IgnoreProcessor = class extends FeatureProcessor {
|
|
|
21580
23226
|
getFactory;
|
|
21581
23227
|
featureOptions;
|
|
21582
23228
|
global;
|
|
21583
|
-
constructor({ outputRoot = process.cwd(),
|
|
23229
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
|
|
21584
23230
|
super({
|
|
21585
23231
|
outputRoot,
|
|
21586
|
-
|
|
23232
|
+
inputRoots,
|
|
21587
23233
|
dryRun,
|
|
21588
23234
|
logger
|
|
21589
23235
|
});
|
|
@@ -21600,11 +23246,35 @@ var IgnoreProcessor = class extends FeatureProcessor {
|
|
|
21600
23246
|
}
|
|
21601
23247
|
/**
|
|
21602
23248
|
* Implementation of abstract method from FeatureProcessor
|
|
21603
|
-
*
|
|
23249
|
+
*
|
|
23250
|
+
* Load and parse the rulesync ignore file. `inputRoots[i]` is a source
|
|
23251
|
+
* tree itself (e.g. `/repo/.rulesync.local`); the recommended `.aiignore`
|
|
23252
|
+
* lives directly inside it. The legacy `.rulesyncignore` is shared at the
|
|
23253
|
+
* project root, so it is intentionally not considered when choosing which
|
|
23254
|
+
* source tree wins; `RulesyncIgnore.fromFile` still uses it as a fallback
|
|
23255
|
+
* for the chosen tree.
|
|
23256
|
+
*
|
|
23257
|
+
* When multiple input roots are configured, the last root that provides
|
|
23258
|
+
* an ignore file wins entirely (whole-file replacement — no line-level
|
|
23259
|
+
* merge in this slice; see the "Deliberately out of scope" section of
|
|
23260
|
+
* the inputRoots plan for context). If no root has the file, fall back
|
|
23261
|
+
* to the primary root's path so the underlying `RulesyncIgnore.fromFile`
|
|
23262
|
+
* surfaces the same missing-file error it would in the single-root case.
|
|
21604
23263
|
*/
|
|
21605
23264
|
async loadRulesyncFiles() {
|
|
23265
|
+
const paths = RulesyncIgnore.getSettablePaths();
|
|
23266
|
+
const relativePaths = getRulesyncSourceCandidates({ paths }).filter((candidate) => candidate.relativeDirPath === paths.recommended.relativeDirPath).map((candidate) => candidate.relativeFilePath);
|
|
23267
|
+
const sourceTree = await pickLastRootWithFile({
|
|
23268
|
+
inputRoots: this.inputRoots,
|
|
23269
|
+
relativePaths,
|
|
23270
|
+
logger: this.logger,
|
|
23271
|
+
artifactName: "The ignore file (.aiignore)"
|
|
23272
|
+
}) ?? this.inputRoots[0];
|
|
21606
23273
|
try {
|
|
21607
|
-
return [await RulesyncIgnore.fromFile({
|
|
23274
|
+
return [await RulesyncIgnore.fromFile({
|
|
23275
|
+
outputRoot: (0, node_path.dirname)(sourceTree),
|
|
23276
|
+
relativeDirPath: (0, node_path.basename)(sourceTree)
|
|
23277
|
+
})];
|
|
21608
23278
|
} catch (error) {
|
|
21609
23279
|
this.logger.error(`Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`);
|
|
21610
23280
|
return [];
|
|
@@ -26639,11 +28309,86 @@ function disabledNamesOf(config) {
|
|
|
26639
28309
|
return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
|
|
26640
28310
|
}
|
|
26641
28311
|
/**
|
|
28312
|
+
* The value `mcp.mcpConfigPath` needs so Rovo Dev reads the project-scope
|
|
28313
|
+
* `.rovodev/mcp.json`. A config-file value, not a filesystem path, so it is
|
|
28314
|
+
* always POSIX-separated.
|
|
28315
|
+
*/
|
|
28316
|
+
const ROVODEV_PROJECT_MCP_CONFIG_POINTER = node_path.posix.join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME);
|
|
28317
|
+
/**
|
|
28318
|
+
* The keys that make an entry in `mcp.json` something Rovo Dev can start:
|
|
28319
|
+
* a local process, or a remote endpoint under either spelling the canonical
|
|
28320
|
+
* config accepts.
|
|
28321
|
+
*/
|
|
28322
|
+
const MCP_SERVER_ENDPOINT_KEYS = [
|
|
28323
|
+
"command",
|
|
28324
|
+
"url",
|
|
28325
|
+
"httpUrl"
|
|
28326
|
+
];
|
|
28327
|
+
function normalizeMcpConfigPathValue(value) {
|
|
28328
|
+
return toPosixPath(value).replace(/^\.\//, "");
|
|
28329
|
+
}
|
|
28330
|
+
/**
|
|
28331
|
+
* Point `mcp.mcpConfigPath` at the project-scope `mcp.json` rulesync writes,
|
|
28332
|
+
* and report whether the block gained a value it did not already carry.
|
|
28333
|
+
*
|
|
28334
|
+
* Rovo Dev's `mcpConfigPath` defaults to a file under the user's home
|
|
28335
|
+
* directory, so a repo-committed `.rovodev/mcp.json` is inert until the active
|
|
28336
|
+
* config points at it — the Bitbucket Agentic Pipelines guide documents
|
|
28337
|
+
* registering the server and setting the pointer as two required steps. Global
|
|
28338
|
+
* scope is left alone: there the default already resolves to the file rulesync
|
|
28339
|
+
* writes.
|
|
28340
|
+
*
|
|
28341
|
+
* The pointer names one config rather than merging with the default, so it is
|
|
28342
|
+
* written only when this project actually has a Rovo Dev server to run — a
|
|
28343
|
+
* server that targets `rovodev` and is not disabled. Otherwise `mcp.json` is
|
|
28344
|
+
* generated empty, and pointing at it would take away the user's global
|
|
28345
|
+
* servers for this repository in exchange for nothing.
|
|
28346
|
+
*
|
|
28347
|
+
* That condition can also stop holding after the fact, once the last server is
|
|
28348
|
+
* removed from the canonical config or switched off. Rulesync does not take
|
|
28349
|
+
* the pointer back out — it cannot tell its own past value from a user who
|
|
28350
|
+
* typed the same string — but it says so, since the file is now a live setting
|
|
28351
|
+
* that resolves to nothing.
|
|
28352
|
+
*
|
|
28353
|
+
* A pointer the user aimed somewhere else is theirs, not ours: overwriting it
|
|
28354
|
+
* would silently redirect Rovo Dev away from a file they chose. It is named in
|
|
28355
|
+
* a warning instead, because the generated `mcp.json` is unread while it
|
|
28356
|
+
* stands.
|
|
28357
|
+
*
|
|
28358
|
+
* Whatever the outcome, it is logged: writing the pointer turns servers that
|
|
28359
|
+
* were generated-but-never-read into servers Rovo Dev actually spawns, and
|
|
28360
|
+
* points it away from the global MCP config, so it is not something to do
|
|
28361
|
+
* quietly.
|
|
28362
|
+
*
|
|
28363
|
+
* @see https://support.atlassian.com/bitbucket-cloud/docs/rovo-dev-advanced-agentic-configuration/
|
|
28364
|
+
* @see https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/
|
|
28365
|
+
*/
|
|
28366
|
+
function applyProjectMcpConfigPointer({ existingMcp, global, hasLiveServers, logger }) {
|
|
28367
|
+
if (global) return false;
|
|
28368
|
+
const existing = existingMcp.mcpConfigPath;
|
|
28369
|
+
const pointsAtGeneratedFile = typeof existing === "string" && normalizeMcpConfigPathValue(existing) === ROVODEV_PROJECT_MCP_CONFIG_POINTER;
|
|
28370
|
+
if (!hasLiveServers) {
|
|
28371
|
+
if (pointsAtGeneratedFile) logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)} points at ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)}, which now has no enabled server. Rovo Dev reads that file instead of the global MCP config, so this project has no MCP servers at all until one targeting rovodev is added back — remove the mcp.mcpConfigPath line to fall back to the global config.`);
|
|
28372
|
+
return false;
|
|
28373
|
+
}
|
|
28374
|
+
if (existing === void 0) {
|
|
28375
|
+
existingMcp.mcpConfigPath = ROVODEV_PROJECT_MCP_CONFIG_POINTER;
|
|
28376
|
+
logger?.info(`Rovo Dev MCP: setting mcp.mcpConfigPath to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}" in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev will now launch the servers in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} for this project instead of the ones in the default global MCP config.`);
|
|
28377
|
+
return true;
|
|
28378
|
+
}
|
|
28379
|
+
if (pointsAtGeneratedFile) return false;
|
|
28380
|
+
logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${JSON.stringify(existing)} in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev reads MCP servers from that path, so the generated ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} is unused until it is set to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}".`);
|
|
28381
|
+
return false;
|
|
28382
|
+
}
|
|
28383
|
+
/**
|
|
26642
28384
|
* Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
|
|
26643
28385
|
* `~/.rovodev/config.yml` (global). Carries `disabledMcpServers` — the key
|
|
26644
|
-
* Rovo Dev actually consults to switch a server off —
|
|
26645
|
-
*
|
|
26646
|
-
*
|
|
28386
|
+
* Rovo Dev actually consults to switch a server off — plus `mcpConfigPath`,
|
|
28387
|
+
* which rulesync authors in project scope when the key is absent and this
|
|
28388
|
+
* project has a server to run (see `applyProjectMcpConfigPointer`). The block
|
|
28389
|
+
* is recomputed from the existing one, so user keys (`allowedMcpServers`,
|
|
28390
|
+
* ...), a `mcpConfigPath` the user aimed elsewhere, and disabled names for
|
|
28391
|
+
* servers rulesync does not manage all survive.
|
|
26647
28392
|
*/
|
|
26648
28393
|
var RovodevMcpConfigYaml = class extends ToolFile {
|
|
26649
28394
|
isDeletable() {
|
|
@@ -26776,7 +28521,17 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
|
|
|
26776
28521
|
const mergedDisabled = [...existingDisabled.filter((name) => !managedNameSet.has(name)), ...disabledNames].toSorted();
|
|
26777
28522
|
if (mergedDisabled.length > 0) existingMcp.disabledMcpServers = mergedDisabled;
|
|
26778
28523
|
else delete existingMcp.disabledMcpServers;
|
|
26779
|
-
|
|
28524
|
+
const wrotePointer = applyProjectMcpConfigPointer({
|
|
28525
|
+
existingMcp,
|
|
28526
|
+
global,
|
|
28527
|
+
hasLiveServers: managedNames.filter((name) => {
|
|
28528
|
+
if (disabledNames.includes(name)) return false;
|
|
28529
|
+
const server = servers[name];
|
|
28530
|
+
return isRecord$1(server) && MCP_SERVER_ENDPOINT_KEYS.some((endpointKey) => server[endpointKey] !== void 0);
|
|
28531
|
+
}).length > 0,
|
|
28532
|
+
logger
|
|
28533
|
+
});
|
|
28534
|
+
if (mergedDisabled.length === 0 && !wrotePointer && existingContent.trim() === "") return [];
|
|
26780
28535
|
const fileContent = applySharedConfigPatch({
|
|
26781
28536
|
fileKey: ROVODEV_CONFIG_SHARED_FILE_KEY,
|
|
26782
28537
|
feature: "mcp",
|
|
@@ -27869,10 +29624,10 @@ var McpProcessor = class extends FeatureProcessor {
|
|
|
27869
29624
|
toolTarget;
|
|
27870
29625
|
global;
|
|
27871
29626
|
getFactory;
|
|
27872
|
-
constructor({ outputRoot = process.cwd(),
|
|
29627
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
|
|
27873
29628
|
super({
|
|
27874
29629
|
outputRoot,
|
|
27875
|
-
|
|
29630
|
+
inputRoots,
|
|
27876
29631
|
dryRun,
|
|
27877
29632
|
logger
|
|
27878
29633
|
});
|
|
@@ -27888,7 +29643,10 @@ var McpProcessor = class extends FeatureProcessor {
|
|
|
27888
29643
|
*/
|
|
27889
29644
|
async loadRulesyncFiles() {
|
|
27890
29645
|
try {
|
|
27891
|
-
return [await RulesyncMcp.
|
|
29646
|
+
return [await RulesyncMcp.fromRoots({
|
|
29647
|
+
inputRoots: this.inputRoots,
|
|
29648
|
+
logger: this.logger
|
|
29649
|
+
})];
|
|
27892
29650
|
} catch (error) {
|
|
27893
29651
|
this.logger.error(`Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`);
|
|
27894
29652
|
return [];
|
|
@@ -29430,27 +31188,6 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
|
|
|
29430
31188
|
return { permission };
|
|
29431
31189
|
}
|
|
29432
31190
|
//#endregion
|
|
29433
|
-
//#region src/utils/control-characters.ts
|
|
29434
|
-
/**
|
|
29435
|
-
* Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
|
|
29436
|
-
* introducer U+009B), the bidirectional overrides and isolates, and the Unicode
|
|
29437
|
-
* line and paragraph separators, and the plain LRM/RLM marks. A name or value
|
|
29438
|
-
* copied out of an untrusted config file, a fetched repository, or a tool's own
|
|
29439
|
-
* settings file must never reach the terminal with these intact: they let the
|
|
29440
|
-
* text forge log lines, reorder what is printed around them, or inject escape
|
|
29441
|
-
* sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
|
|
29442
|
-
* neutral characters beside them, so they go too — a diagnostic line is not the
|
|
29443
|
-
* place to preserve the typography of a right-to-left name.
|
|
29444
|
-
*/
|
|
29445
|
-
const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
|
|
29446
|
-
/**
|
|
29447
|
-
* Removes every control character from `text` so it is safe to splice into a
|
|
29448
|
-
* log line or other terminal output.
|
|
29449
|
-
*/
|
|
29450
|
-
function stripControlCharacters(text) {
|
|
29451
|
-
return text.replace(CONTROL_CHARACTERS_PATTERN, "");
|
|
29452
|
-
}
|
|
29453
|
-
//#endregion
|
|
29454
31191
|
//#region src/features/permissions/claudecode-permissions.ts
|
|
29455
31192
|
/**
|
|
29456
31193
|
* Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
|
|
@@ -29536,8 +31273,8 @@ function deepMergeRecords(base, patch) {
|
|
|
29536
31273
|
*
|
|
29537
31274
|
* Deliberately NOT listed:
|
|
29538
31275
|
* - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
|
|
29539
|
-
* `
|
|
29540
|
-
* emitting them under `--global`.
|
|
31276
|
+
* `CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL` refuses them in both scopes
|
|
31277
|
+
* rather than emitting them under `--global`.
|
|
29541
31278
|
* - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
|
|
29542
31279
|
* unit is the individual entry's mode, not the settings key, and the same
|
|
29543
31280
|
* lists carry `deny` entries that project settings *do* honor — dropping a
|
|
@@ -29556,6 +31293,30 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
|
|
|
29556
31293
|
["allowAppleEvents"]
|
|
29557
31294
|
];
|
|
29558
31295
|
/**
|
|
31296
|
+
* `sandbox.*` paths documented with a `Managed` scope: Claude Code reads them
|
|
31297
|
+
* only from the settings file an organization deploys. Neither file rulesync
|
|
31298
|
+
* writes is that file, so they are dropped in **both** scopes — the `sandbox`
|
|
31299
|
+
* counterpart of {@link CLAUDECODE_UNHONORED_KEY_SOURCES}, which does the same
|
|
31300
|
+
* for top-level `Managed` keys.
|
|
31301
|
+
*
|
|
31302
|
+
* Both only ever *narrow* the policy — they stop a lower-scoped file from
|
|
31303
|
+
* re-opening what managed settings blocked — so neither is trust-widening. They
|
|
31304
|
+
* are dropped rather than written for the opposite reason: written into a
|
|
31305
|
+
* project or user file they do nothing at all, and a `sandbox` block that reads
|
|
31306
|
+
* as though it locked the policy to managed values while Claude Code ignores it
|
|
31307
|
+
* is the more dangerous of the two failure modes.
|
|
31308
|
+
*
|
|
31309
|
+
* Import keeps them, unlike the command-executing paths: the value in an
|
|
31310
|
+
* existing `settings.json` was hand-written to be honored somewhere, and
|
|
31311
|
+
* round-tripping it preserves the author's intent for the day it moves into a
|
|
31312
|
+
* managed file. The cost is a warning on every generate until it is removed,
|
|
31313
|
+
* which the refusal message points at.
|
|
31314
|
+
*
|
|
31315
|
+
* @see https://code.claude.com/docs/en/settings-reference#sandbox-filesystem-allowmanagedreadpathsonly
|
|
31316
|
+
* — "Scope: `Managed`"; the `network` entry says the same.
|
|
31317
|
+
*/
|
|
31318
|
+
const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadPathsOnly"], ["network", "allowManagedDomainsOnly"]];
|
|
31319
|
+
/**
|
|
29559
31320
|
* Walks `segments` from `root`, returning the record they name or `undefined` if
|
|
29560
31321
|
* any step is missing or not a record. Shared by everything below that addresses
|
|
29561
31322
|
* a `sandbox` path, so a nested path added to one of the tables is actually
|
|
@@ -29602,6 +31363,18 @@ function deleteSandboxPath({ target, path }) {
|
|
|
29602
31363
|
return true;
|
|
29603
31364
|
}
|
|
29604
31365
|
/**
|
|
31366
|
+
* The one warning that names every trust-affecting setting this generate wrote
|
|
31367
|
+
* to `relativeFilePath`. Emitted once per file: the individual reasons are what
|
|
31368
|
+
* matter, but the "review this as you would a hook" framing only needs saying
|
|
31369
|
+
* once, and repeating it per key buries the reasons in boilerplate.
|
|
31370
|
+
*/
|
|
31371
|
+
function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
|
|
31372
|
+
if (entries.length === 0) return;
|
|
31373
|
+
const one = entries.length === 1;
|
|
31374
|
+
const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
|
|
31375
|
+
logger?.warn(`Claude Code permissions: writing ${entries.length} trust-affecting ${one ? "setting" : "settings"} to ${relativeFilePath}; review ${one ? "it" : "them"} as you would a hook, especially if this permissions file came from 'rulesync fetch'. ${details}.`);
|
|
31376
|
+
}
|
|
31377
|
+
/**
|
|
29605
31378
|
* The `permissions.defaultMode` values that start a session with fewer prompts
|
|
29606
31379
|
* than the default. `plan` and `default` are absent because they do not widen
|
|
29607
31380
|
* anything.
|
|
@@ -29614,14 +31387,22 @@ const CLAUDECODE_WIDENING_DEFAULT_MODES = {
|
|
|
29614
31387
|
/**
|
|
29615
31388
|
* The `permissions` fields that widen rather than restrict: a `defaultMode` that
|
|
29616
31389
|
* removes prompts, and `additionalDirectories`, which moves the
|
|
29617
|
-
* working-directory boundary.
|
|
29618
|
-
* shareable permissions file should not loosen the permission system quietly.
|
|
31390
|
+
* working-directory boundary. Reported for the same reason `disableAllHooks` is:
|
|
31391
|
+
* a shareable permissions file should not loosen the permission system quietly.
|
|
29619
31392
|
*/
|
|
29620
|
-
function
|
|
31393
|
+
function collectWideningPermissionFields({ fields }) {
|
|
31394
|
+
const entries = [];
|
|
29621
31395
|
const defaultMode = fields.defaultMode;
|
|
29622
|
-
if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode))
|
|
31396
|
+
if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) entries.push({
|
|
31397
|
+
label: `permissions.defaultMode: "${defaultMode}"`,
|
|
31398
|
+
reason: CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]
|
|
31399
|
+
});
|
|
29623
31400
|
const additionalDirectories = fields.additionalDirectories;
|
|
29624
|
-
if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0))
|
|
31401
|
+
if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) entries.push({
|
|
31402
|
+
label: "permissions.additionalDirectories",
|
|
31403
|
+
reason: "moves the boundary of what Claude Code may read and edit outside the project"
|
|
31404
|
+
});
|
|
31405
|
+
return entries;
|
|
29625
31406
|
}
|
|
29626
31407
|
/**
|
|
29627
31408
|
* `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
|
|
@@ -29638,6 +31419,18 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
|
|
|
29638
31419
|
["socatPath"]
|
|
29639
31420
|
];
|
|
29640
31421
|
/**
|
|
31422
|
+
* The predicates the "which value actually widens?" tables are built from.
|
|
31423
|
+
* Each names the value that does *not* widen and reports everything else, never
|
|
31424
|
+
* the reverse: the override is authored JSONC, so a key can carry any value at
|
|
31425
|
+
* all, and one Claude Code coerces is still honored. Reporting an off-type value
|
|
31426
|
+
* keeps the warning fail-safe — silence has to mean "this cannot loosen
|
|
31427
|
+
* anything", not "this is not the type the table expected".
|
|
31428
|
+
*/
|
|
31429
|
+
const isNotFalse = (value) => value !== false;
|
|
31430
|
+
const isNotTrue = (value) => value !== true;
|
|
31431
|
+
const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
|
|
31432
|
+
const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
|
|
31433
|
+
/**
|
|
29641
31434
|
* `sandbox` paths that loosen the sandbox rather than naming something to run:
|
|
29642
31435
|
* they let commands out of it, weaken the isolation it provides, or redirect
|
|
29643
31436
|
* where its traffic goes. They are written like `env` is — the ordinary uses are
|
|
@@ -29657,77 +31450,77 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
|
|
|
29657
31450
|
{
|
|
29658
31451
|
path: ["allowAppleEvents"],
|
|
29659
31452
|
reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
|
|
29660
|
-
widens:
|
|
31453
|
+
widens: isNotFalse
|
|
29661
31454
|
},
|
|
29662
31455
|
{
|
|
29663
31456
|
path: ["allowUnsandboxedCommands"],
|
|
29664
31457
|
reason: "controls whether Claude may retry a blocked command outside the sandbox",
|
|
29665
|
-
widens:
|
|
31458
|
+
widens: isNotFalse
|
|
29666
31459
|
},
|
|
29667
31460
|
{
|
|
29668
31461
|
path: ["autoAllowBashIfSandboxed"],
|
|
29669
31462
|
reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
|
|
29670
|
-
widens:
|
|
31463
|
+
widens: isNotFalse
|
|
29671
31464
|
},
|
|
29672
31465
|
{
|
|
29673
31466
|
path: ["enableWeakerNestedSandbox"],
|
|
29674
31467
|
reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
|
|
29675
|
-
widens:
|
|
31468
|
+
widens: isNotFalse
|
|
29676
31469
|
},
|
|
29677
31470
|
{
|
|
29678
31471
|
path: ["enableWeakerNetworkIsolation"],
|
|
29679
31472
|
reason: "weakens the sandbox's network isolation on macOS",
|
|
29680
|
-
widens:
|
|
31473
|
+
widens: isNotFalse
|
|
29681
31474
|
},
|
|
29682
31475
|
{
|
|
29683
31476
|
path: ["enabled"],
|
|
29684
31477
|
reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
|
|
29685
|
-
widens:
|
|
31478
|
+
widens: isNotFalse
|
|
29686
31479
|
},
|
|
29687
31480
|
{
|
|
29688
31481
|
path: ["excludedCommands"],
|
|
29689
31482
|
reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
|
|
29690
|
-
widens:
|
|
31483
|
+
widens: isNonEmptyList
|
|
29691
31484
|
},
|
|
29692
31485
|
{
|
|
29693
31486
|
path: ["filesystem", "allowRead"],
|
|
29694
31487
|
reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
|
|
29695
|
-
widens:
|
|
31488
|
+
widens: isNonEmptyList
|
|
29696
31489
|
},
|
|
29697
31490
|
{
|
|
29698
31491
|
path: ["filesystem", "allowWrite"],
|
|
29699
31492
|
reason: "adds paths sandboxed commands may write to, outside the working directory",
|
|
29700
|
-
widens:
|
|
31493
|
+
widens: isNonEmptyList
|
|
29701
31494
|
},
|
|
29702
31495
|
{
|
|
29703
31496
|
path: ["ignoreViolations"],
|
|
29704
31497
|
reason: "hides the sandbox violations it names, so a blocked access stops being reported",
|
|
29705
|
-
widens: (value) =>
|
|
31498
|
+
widens: (value) => isNonEmptyMap(value) && isNotFalse(value)
|
|
29706
31499
|
},
|
|
29707
31500
|
{
|
|
29708
31501
|
path: ["network", "allowAllUnixSockets"],
|
|
29709
31502
|
reason: "lets sandboxed commands connect to every Unix socket",
|
|
29710
|
-
widens:
|
|
31503
|
+
widens: isNotFalse
|
|
29711
31504
|
},
|
|
29712
31505
|
{
|
|
29713
31506
|
path: ["network", "allowedDomains"],
|
|
29714
31507
|
reason: "pre-allows domains sandboxed commands may reach without a prompt",
|
|
29715
|
-
widens:
|
|
31508
|
+
widens: isNonEmptyList
|
|
29716
31509
|
},
|
|
29717
31510
|
{
|
|
29718
31511
|
path: ["network", "allowLocalBinding"],
|
|
29719
31512
|
reason: "lets sandboxed commands bind local ports",
|
|
29720
|
-
widens:
|
|
31513
|
+
widens: isNotFalse
|
|
29721
31514
|
},
|
|
29722
31515
|
{
|
|
29723
31516
|
path: ["network", "allowMachLookup"],
|
|
29724
31517
|
reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
|
|
29725
|
-
widens:
|
|
31518
|
+
widens: isNonEmptyList
|
|
29726
31519
|
},
|
|
29727
31520
|
{
|
|
29728
31521
|
path: ["network", "allowUnixSockets"],
|
|
29729
31522
|
reason: "names Unix sockets sandboxed commands may reach, and one such as `/var/run/docker.sock` is host access",
|
|
29730
|
-
widens:
|
|
31523
|
+
widens: isNonEmptyList
|
|
29731
31524
|
},
|
|
29732
31525
|
{
|
|
29733
31526
|
path: ["network", "httpProxyPort"],
|
|
@@ -29741,11 +31534,12 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
|
|
|
29741
31534
|
}
|
|
29742
31535
|
];
|
|
29743
31536
|
/**
|
|
29744
|
-
*
|
|
29745
|
-
*
|
|
29746
|
-
*
|
|
31537
|
+
* Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
|
|
31538
|
+
* the values are written, just not silently. Called on the filtered `sandbox`
|
|
31539
|
+
* so it never claims to be writing a path the scope filters dropped.
|
|
29747
31540
|
*/
|
|
29748
|
-
function
|
|
31541
|
+
function collectTrustAffectingSandboxPaths({ sandbox }) {
|
|
31542
|
+
const entries = [];
|
|
29749
31543
|
for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
|
|
29750
31544
|
const leaf = path.at(-1);
|
|
29751
31545
|
if (leaf === void 0) continue;
|
|
@@ -29756,38 +31550,49 @@ function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger })
|
|
|
29756
31550
|
if (parent === void 0) continue;
|
|
29757
31551
|
const value = parent[leaf];
|
|
29758
31552
|
if (value === void 0 || !widens(value)) continue;
|
|
29759
|
-
|
|
31553
|
+
entries.push({
|
|
31554
|
+
label: `sandbox.${path.join(".")}`,
|
|
31555
|
+
reason
|
|
31556
|
+
});
|
|
29760
31557
|
}
|
|
31558
|
+
return entries;
|
|
29761
31559
|
}
|
|
31560
|
+
/** Paths that name an executable Claude Code runs. Refused in both scopes. */
|
|
31561
|
+
const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
|
|
31562
|
+
paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
|
|
31563
|
+
warn: ({ label, relativeFilePath }) => `Claude Code permissions: '${label}' names an executable Claude Code runs, so rulesync does not write it to ${relativeFilePath}. A permissions file is shareable — 'rulesync fetch' copies one into a project — and is not where a reviewer looks for a command to run; set this path in ${relativeFilePath} by hand.`
|
|
31564
|
+
};
|
|
29762
31565
|
/**
|
|
29763
|
-
*
|
|
29764
|
-
*
|
|
31566
|
+
* Paths Claude Code honors only from managed settings. Refused in both scopes,
|
|
31567
|
+
* like the command-executing paths, because managed settings are not a file
|
|
31568
|
+
* rulesync writes in either of them.
|
|
29765
31569
|
*/
|
|
29766
|
-
|
|
29767
|
-
|
|
29768
|
-
|
|
29769
|
-
|
|
29770
|
-
|
|
29771
|
-
|
|
29772
|
-
|
|
29773
|
-
|
|
29774
|
-
|
|
29775
|
-
return filtered;
|
|
29776
|
-
}
|
|
31570
|
+
const CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL = {
|
|
31571
|
+
paths: CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS,
|
|
31572
|
+
warn: ({ label, relativeFilePath }) => `Claude Code permissions: '${label}' is only honored in managed settings, which rulesync does not generate, so it is not written to ${relativeFilePath}. Set it in the managed settings file by hand, and check ${relativeFilePath} for a stale value an earlier generate may have left there.`
|
|
31573
|
+
};
|
|
31574
|
+
/** Paths Claude Code honors only above project scope. Refused at project scope. */
|
|
31575
|
+
const CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL = {
|
|
31576
|
+
paths: CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS,
|
|
31577
|
+
warn: ({ label, relativeFilePath }) => `Claude Code permissions: '${label}' is only honored in user/managed/--settings settings, so it is not written to the project-scoped ${relativeFilePath}. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`
|
|
31578
|
+
};
|
|
29777
31579
|
/**
|
|
29778
|
-
* Copy of the authored `sandbox` override with
|
|
29779
|
-
* removed, warning once per dropped path. Only the override copy is
|
|
29780
|
-
* a value already hand-written in the target file is left untouched,
|
|
29781
|
-
* the `qwencode` `security.allowPrivateNetworkHooks` precedent.
|
|
31580
|
+
* Copy of the authored `sandbox` override with every path of every passed
|
|
31581
|
+
* refusal removed, warning once per dropped path. Only the override copy is
|
|
31582
|
+
* filtered — a value already hand-written in the target file is left untouched,
|
|
31583
|
+
* matching the `qwencode` `security.allowPrivateNetworkHooks` precedent.
|
|
29782
31584
|
*/
|
|
29783
|
-
function
|
|
31585
|
+
function stripSandboxPaths({ sandbox, refusals, relativeFilePath, logger }) {
|
|
29784
31586
|
const filtered = structuredClone(sandbox);
|
|
29785
|
-
for (const path of
|
|
31587
|
+
for (const { paths, warn } of refusals) for (const path of paths) {
|
|
29786
31588
|
if (!deleteSandboxPath({
|
|
29787
31589
|
target: filtered,
|
|
29788
31590
|
path
|
|
29789
31591
|
})) continue;
|
|
29790
|
-
logger?.warn(
|
|
31592
|
+
logger?.warn(warn({
|
|
31593
|
+
label: `sandbox.${path.join(".")}`,
|
|
31594
|
+
relativeFilePath
|
|
31595
|
+
}));
|
|
29791
31596
|
}
|
|
29792
31597
|
return filtered;
|
|
29793
31598
|
}
|
|
@@ -29811,7 +31616,7 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
|
|
|
29811
31616
|
* such entries to `deny`), so the "reads as masked but isn't" state this guards
|
|
29812
31617
|
* against cannot slip through a differently-spelled value.
|
|
29813
31618
|
*
|
|
29814
|
-
* Like `
|
|
31619
|
+
* Like `stripSandboxPaths`, only the override copy is filtered — a
|
|
29815
31620
|
* value already in the target file is left untouched, which is why the warning
|
|
29816
31621
|
* points at it.
|
|
29817
31622
|
*
|
|
@@ -29978,6 +31783,9 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
|
|
|
29978
31783
|
allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
|
|
29979
31784
|
allowedMcpServers: "allowlists the MCP servers that may be used, and entries from every settings file merge into one list, so an entry here widens an allowlist deployed elsewhere",
|
|
29980
31785
|
autoMode: "auto-approves shell commands with a classifier rather than with a prompt",
|
|
31786
|
+
claudeMdExcludes: "skips the CLAUDE.md files its patterns match, so the instructions a repository relies on can be dropped from every session",
|
|
31787
|
+
companyAnnouncements: "prints the strings it names at startup as your organization's announcement, so a fetched value speaks to the reader with your organization's voice",
|
|
31788
|
+
crossSessionInbound: "decides what a session does with messages arriving from your other Claude Code sessions, and `accept` delivers them straight to Claude",
|
|
29981
31789
|
disableAllHooks: "controls whether hooks run at all",
|
|
29982
31790
|
disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
|
|
29983
31791
|
enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
|
|
@@ -29986,17 +31794,58 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
|
|
|
29986
31794
|
env: "sets environment variables for every process Claude Code spawns, so a value such as `NODE_OPTIONS` or `PATH` runs code and `ANTHROPIC_BASE_URL` redirects every prompt",
|
|
29987
31795
|
extraKnownMarketplaces: "registers plugin marketplace sources",
|
|
29988
31796
|
httpHookAllowedEnvVars: "controls which environment variables an HTTP hook may put in a request header, credentials included",
|
|
31797
|
+
modelOverrides: "maps the model IDs it names to provider-specific model IDs, so an entry decides which inference profile or deployment every call for that model is routed to",
|
|
29989
31798
|
outputStyle: "replaces the system prompt every session runs with",
|
|
31799
|
+
prUrlTemplate: "rewrites the pull-request links Claude Code renders, so they can point at a host of the template's choosing rather than at the reviewed PR",
|
|
31800
|
+
remoteControlAtStartup: "connects Remote Control automatically at session start, and the transcript of a connected session is stored on Anthropic servers to sync it across devices",
|
|
29990
31801
|
skipAutoPermissionPrompt: "removes the confirmation shown before auto-approval mode starts",
|
|
29991
|
-
skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts"
|
|
31802
|
+
skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts",
|
|
31803
|
+
skipWebFetchPreflight: "turns off the WebFetch domain safety check, so WebFetch retrieves any URL without consulting Anthropic's blocklist"
|
|
29992
31804
|
};
|
|
29993
31805
|
/**
|
|
29994
31806
|
* The keys from the table above that only widen at one particular value.
|
|
29995
31807
|
* `disableSkillShellExecution: true` turns inline shell execution off, which
|
|
29996
|
-
* restricts;
|
|
29997
|
-
* use to undo a user setting, so only
|
|
29998
|
-
|
|
29999
|
-
|
|
31808
|
+
* restricts; anything else re-opens it, and that is what a fetched override
|
|
31809
|
+
* could use to undo a user setting. The rest default to off, so only a value
|
|
31810
|
+
* other than the one that leaves them off is worth a line — and for the
|
|
31811
|
+
* list-valued and map-valued keys, only a non-empty one, since an empty list or
|
|
31812
|
+
* map excludes, announces and overrides nothing.
|
|
31813
|
+
*/
|
|
31814
|
+
const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = {
|
|
31815
|
+
claudeMdExcludes: isNonEmptyList,
|
|
31816
|
+
companyAnnouncements: isNonEmptyList,
|
|
31817
|
+
crossSessionInbound: (value) => value !== "hold" && value !== "refuse",
|
|
31818
|
+
disableSkillShellExecution: isNotTrue,
|
|
31819
|
+
modelOverrides: isNonEmptyMap,
|
|
31820
|
+
remoteControlAtStartup: isNotFalse,
|
|
31821
|
+
skipWebFetchPreflight: isNotFalse
|
|
31822
|
+
};
|
|
31823
|
+
/**
|
|
31824
|
+
* Top-level keys a project-scoped `.claude/settings.json` honors at one value
|
|
31825
|
+
* but ignores at another — the value-level counterpart of
|
|
31826
|
+
* {@link CLAUDECODE_USER_SCOPE_ONLY_KEYS}, which is scoped per key. The ignored
|
|
31827
|
+
* value is dropped at project scope for the same reason a wholly unhonored key
|
|
31828
|
+
* is: committing it would read as a policy that never applies.
|
|
31829
|
+
*
|
|
31830
|
+
* `remoteControlAtStartup` is the only entry whose honored value can be decided
|
|
31831
|
+
* from the value alone. Claude Code honors a `false` from project or local
|
|
31832
|
+
* settings — a repository may turn auto-connect off for its own checkout — but
|
|
31833
|
+
* ignores a `true`, so that a checked-in file cannot turn Remote Control on for
|
|
31834
|
+
* everyone who opens the repository.
|
|
31835
|
+
*
|
|
31836
|
+
* `crossSessionInbound` is on the same documented list but deliberately absent
|
|
31837
|
+
* here: it is a ladder (`accept` < `hold` < `refuse`) whose project value is
|
|
31838
|
+
* honored only when it is stricter than the one above it, which no per-value
|
|
31839
|
+
* predicate can decide without reading the user's own settings. It is warned
|
|
31840
|
+
* about through {@link CLAUDECODE_TRUST_AFFECTING_KEYS} instead, since under
|
|
31841
|
+
* `--global` its loosening value is honored outright.
|
|
31842
|
+
*
|
|
31843
|
+
* @see https://code.claude.com/docs/en/settings#security-keys-where-the-stricter-value-applies
|
|
31844
|
+
*/
|
|
31845
|
+
const CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES = { remoteControlAtStartup: {
|
|
31846
|
+
ignored: isNotFalse,
|
|
31847
|
+
note: "Claude Code honors only a `false` there, so that a checked-in file cannot turn Remote Control on for everyone who opens the repository"
|
|
31848
|
+
} };
|
|
30000
31849
|
/**
|
|
30001
31850
|
* A key name is authored data that ends up in a log line, so strip the control
|
|
30002
31851
|
* characters that would let it forge a line or hide the warnings beside it, and
|
|
@@ -30024,12 +31873,13 @@ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
|
|
|
30024
31873
|
/**
|
|
30025
31874
|
* Copy of the authored top-level passthrough with the keys the target file
|
|
30026
31875
|
* cannot honor removed, warning once per dropped key. Like
|
|
30027
|
-
* `
|
|
31876
|
+
* `stripSandboxPaths`, only the override copy is filtered — a value
|
|
30028
31877
|
* already hand-written in the target file is left untouched, which is why the
|
|
30029
31878
|
* warning points at it.
|
|
30030
31879
|
*/
|
|
30031
31880
|
function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
|
|
30032
31881
|
const filtered = {};
|
|
31882
|
+
const trustAffecting = [];
|
|
30033
31883
|
for (const [key, value] of Object.entries(overrides)) {
|
|
30034
31884
|
const shown = displayKey(key);
|
|
30035
31885
|
const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
|
|
@@ -30045,11 +31895,22 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
|
|
|
30045
31895
|
logger?.warn(`Claude Code permissions: '${shown}' is not honored in the project-scoped ${relativeFilePath}, so it is not written there — Claude Code reads it from user, local or managed settings. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`);
|
|
30046
31896
|
continue;
|
|
30047
31897
|
}
|
|
30048
|
-
const
|
|
30049
|
-
if (
|
|
31898
|
+
const projectIgnored = Object.hasOwn(CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES, canonicalKey) ? CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES[canonicalKey] : void 0;
|
|
31899
|
+
if (!global && projectIgnored !== void 0 && projectIgnored.ignored(value)) {
|
|
31900
|
+
logger?.warn(`Claude Code permissions: this value of '${shown}' is not honored in the project-scoped ${relativeFilePath}, so it is not written there — ${projectIgnored.note}. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`);
|
|
31901
|
+
continue;
|
|
31902
|
+
}
|
|
31903
|
+
const widensAtValue = Object.hasOwn(CLAUDECODE_TRUST_KEY_WIDENING_VALUES, canonicalKey) ? CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey] : void 0;
|
|
31904
|
+
if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) trustAffecting.push({
|
|
31905
|
+
label: shown,
|
|
31906
|
+
reason: CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]
|
|
31907
|
+
});
|
|
30050
31908
|
filtered[key] = value;
|
|
30051
31909
|
}
|
|
30052
|
-
return
|
|
31910
|
+
return {
|
|
31911
|
+
filtered,
|
|
31912
|
+
trustAffecting
|
|
31913
|
+
};
|
|
30053
31914
|
}
|
|
30054
31915
|
const CLAUDE_PATH_RULE_ALIASES = {
|
|
30055
31916
|
Write: "Edit",
|
|
@@ -30119,15 +31980,12 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
30119
31980
|
config,
|
|
30120
31981
|
logger
|
|
30121
31982
|
});
|
|
31983
|
+
const trustAffecting = [];
|
|
30122
31984
|
const overridePermissions = config.claudecode?.permissions;
|
|
30123
31985
|
if (overridePermissions && typeof overridePermissions === "object") {
|
|
30124
31986
|
const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
|
|
30125
31987
|
const nonListFields = Object.fromEntries(Object.entries(rest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
|
|
30126
|
-
|
|
30127
|
-
fields: nonListFields,
|
|
30128
|
-
relativeFilePath: paths.relativeFilePath,
|
|
30129
|
-
logger
|
|
30130
|
-
});
|
|
31988
|
+
trustAffecting.push(...collectWideningPermissionFields({ fields: nonListFields }));
|
|
30131
31989
|
settings.permissions = {
|
|
30132
31990
|
...settings.permissions,
|
|
30133
31991
|
...nonListFields
|
|
@@ -30135,25 +31993,22 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
30135
31993
|
}
|
|
30136
31994
|
const overrideSandbox = config.claudecode?.sandbox;
|
|
30137
31995
|
if (isPlainRecord(overrideSandbox)) {
|
|
30138
|
-
const
|
|
31996
|
+
const honorableSandbox = stripSandboxPaths({
|
|
30139
31997
|
sandbox: overrideSandbox,
|
|
31998
|
+
refusals: [
|
|
31999
|
+
CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL,
|
|
32000
|
+
CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL,
|
|
32001
|
+
...global ? [] : [CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL]
|
|
32002
|
+
],
|
|
30140
32003
|
relativeFilePath: paths.relativeFilePath,
|
|
30141
32004
|
logger
|
|
30142
32005
|
});
|
|
30143
|
-
const scopedSandbox = global ?
|
|
30144
|
-
sandbox:
|
|
30145
|
-
sandbox: executableFreeSandbox,
|
|
30146
|
-
relativeFilePath: paths.relativeFilePath,
|
|
30147
|
-
logger
|
|
30148
|
-
}),
|
|
30149
|
-
relativeFilePath: paths.relativeFilePath,
|
|
30150
|
-
logger
|
|
30151
|
-
});
|
|
30152
|
-
warnOnTrustAffectingSandboxPaths({
|
|
30153
|
-
sandbox: scopedSandbox,
|
|
32006
|
+
const scopedSandbox = global ? honorableSandbox : stripProjectIgnoredMaskEntries({
|
|
32007
|
+
sandbox: honorableSandbox,
|
|
30154
32008
|
relativeFilePath: paths.relativeFilePath,
|
|
30155
32009
|
logger
|
|
30156
32010
|
});
|
|
32011
|
+
trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
|
|
30157
32012
|
if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
|
|
30158
32013
|
}
|
|
30159
32014
|
const overrideTopLevel = {};
|
|
@@ -30163,13 +32018,19 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
30163
32018
|
if (value === void 0) continue;
|
|
30164
32019
|
overrideTopLevel[key] = value;
|
|
30165
32020
|
}
|
|
30166
|
-
const scopedTopLevel = stripUnhonoredTopLevelKeys({
|
|
32021
|
+
const { filtered: scopedTopLevel, trustAffecting: trustAffectingTopLevel } = stripUnhonoredTopLevelKeys({
|
|
30167
32022
|
overrides: overrideTopLevel,
|
|
30168
32023
|
global,
|
|
30169
32024
|
relativeFilePath: paths.relativeFilePath,
|
|
30170
32025
|
logger
|
|
30171
32026
|
});
|
|
32027
|
+
trustAffecting.push(...trustAffectingTopLevel);
|
|
30172
32028
|
if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
|
|
32029
|
+
warnOnTrustAffectingEntries({
|
|
32030
|
+
entries: trustAffecting,
|
|
32031
|
+
relativeFilePath: paths.relativeFilePath,
|
|
32032
|
+
logger
|
|
32033
|
+
});
|
|
30173
32034
|
const managedToolNames = managedClaudeToolNames(config);
|
|
30174
32035
|
const merged = applyPermissions({
|
|
30175
32036
|
settings,
|
|
@@ -35147,11 +37008,8 @@ const TOOL_KEY_TO_CATEGORY = {
|
|
|
35147
37008
|
updateConfluencePage: "edit"
|
|
35148
37009
|
};
|
|
35149
37010
|
const MANAGED_TOOL_KEYS = [.../* @__PURE__ */ new Set([...Object.values(CATEGORY_TO_TOOL_KEYS).flat(), ...Object.keys(TOOL_KEY_TO_CATEGORY)])];
|
|
35150
|
-
const OWNED_TOOL_PERMISSION_KEYS = [
|
|
35151
|
-
|
|
35152
|
-
"allowedExternalPaths",
|
|
35153
|
-
"default"
|
|
35154
|
-
];
|
|
37011
|
+
const OWNED_TOOL_PERMISSION_KEYS = ["allowedExternalPaths", "default"];
|
|
37012
|
+
const MANAGED_BASH_KEYS = ["default", "commands"];
|
|
35155
37013
|
/**
|
|
35156
37014
|
* Permissions adapter for Rovo Dev CLI.
|
|
35157
37015
|
*
|
|
@@ -35287,6 +37145,22 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
|
|
|
35287
37145
|
}
|
|
35288
37146
|
};
|
|
35289
37147
|
/**
|
|
37148
|
+
* Report a `toolPermissions.bash.runInSandbox: false` that this generate is
|
|
37149
|
+
* about to carry through.
|
|
37150
|
+
*
|
|
37151
|
+
* The unmanaged `bash` siblings survive untouched, which for this one means a
|
|
37152
|
+
* generate is not the reset a user might read it as: every command the agent
|
|
37153
|
+
* runs stays outside the sandbox. Rulesync never authors the key and will not
|
|
37154
|
+
* start owning it, but a setting that persists on a committed file and loosens
|
|
37155
|
+
* containment should not do so without saying anything — least of all on the
|
|
37156
|
+
* path where the user just tightened `.rulesync/permissions.*`.
|
|
37157
|
+
*/
|
|
37158
|
+
function warnAboutPreservedSandboxOptOut({ existingToolPermissions, filePath, logger }) {
|
|
37159
|
+
const existingBash = existingToolPermissions.bash;
|
|
37160
|
+
if (!isRecord$1(existingBash) || existingBash.runInSandbox !== false) return;
|
|
37161
|
+
logger?.warn(`${filePath}: keeping toolPermissions.bash.runInSandbox: false — rulesync does not manage that key, so regenerating does not restore Rovo Dev's sandbox for bash commands.`);
|
|
37162
|
+
}
|
|
37163
|
+
/**
|
|
35290
37164
|
* Resolve the `toolPermissions` block to write, merging the generated levels
|
|
35291
37165
|
* over the existing file. Every other top-level key of `config.yml` is the
|
|
35292
37166
|
* caller's to preserve; inside this block, keys rulesync manages are owned and
|
|
@@ -35294,6 +37168,11 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
|
|
|
35294
37168
|
*/
|
|
35295
37169
|
function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
|
|
35296
37170
|
const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
|
|
37171
|
+
warnAboutPreservedSandboxOptOut({
|
|
37172
|
+
existingToolPermissions,
|
|
37173
|
+
filePath,
|
|
37174
|
+
logger
|
|
37175
|
+
});
|
|
35297
37176
|
if (Object.keys(generated).length === 0 && sourceStatesRules) {
|
|
35298
37177
|
if (!isRecord$1(existing)) return;
|
|
35299
37178
|
const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
|
|
@@ -35302,9 +37181,12 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
|
|
|
35302
37181
|
}
|
|
35303
37182
|
const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
|
|
35304
37183
|
const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
|
|
37184
|
+
const hasExistingBashRecord = isRecord$1(existingToolPermissions.bash);
|
|
37185
|
+
const existingBash = hasExistingBashRecord ? { ...existingToolPermissions.bash } : {};
|
|
35305
37186
|
warnAboutDroppedOwnedKeys({
|
|
35306
37187
|
existingToolPermissions,
|
|
35307
37188
|
existingTools,
|
|
37189
|
+
existingBash,
|
|
35308
37190
|
generated,
|
|
35309
37191
|
filePath,
|
|
35310
37192
|
logger
|
|
@@ -35314,15 +37196,22 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
|
|
|
35314
37196
|
delete existingToolPermissions[toolKey];
|
|
35315
37197
|
delete existingTools[toolKey];
|
|
35316
37198
|
}
|
|
37199
|
+
for (const bashKey of MANAGED_BASH_KEYS) delete existingBash[bashKey];
|
|
35317
37200
|
const tools = {
|
|
35318
37201
|
...existingTools,
|
|
35319
37202
|
...generated.tools
|
|
35320
37203
|
};
|
|
37204
|
+
const bash = {
|
|
37205
|
+
...existingBash,
|
|
37206
|
+
...generated.bash
|
|
37207
|
+
};
|
|
35321
37208
|
if (hasExistingToolsRecord) delete existingToolPermissions.tools;
|
|
37209
|
+
if (hasExistingBashRecord) delete existingToolPermissions.bash;
|
|
35322
37210
|
return {
|
|
35323
37211
|
...existingToolPermissions,
|
|
35324
37212
|
...generated,
|
|
35325
|
-
...Object.keys(tools).length > 0 ? { tools } : {}
|
|
37213
|
+
...Object.keys(tools).length > 0 ? { tools } : {},
|
|
37214
|
+
...Object.keys(bash).length > 0 ? { bash } : {}
|
|
35326
37215
|
};
|
|
35327
37216
|
}
|
|
35328
37217
|
/**
|
|
@@ -35331,9 +37220,14 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
|
|
|
35331
37220
|
* `/directories` and by an "always allow" answer to a prompt — so their removal
|
|
35332
37221
|
* must not be silent.
|
|
35333
37222
|
*/
|
|
35334
|
-
function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, generated, filePath, logger }) {
|
|
37223
|
+
function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, existingBash, generated, filePath, logger }) {
|
|
35335
37224
|
const newTools = generated.tools ?? {};
|
|
35336
|
-
const
|
|
37225
|
+
const newBash = generated.bash ?? {};
|
|
37226
|
+
const droppedKeys = [
|
|
37227
|
+
...OWNED_TOOL_PERMISSION_KEYS.filter((ownedKey) => existingToolPermissions[ownedKey] !== void 0 && generated[ownedKey] === void 0),
|
|
37228
|
+
...MANAGED_TOOL_KEYS.filter((toolKey) => existingTools[toolKey] !== void 0 && newTools[toolKey] === void 0).map((toolKey) => `tools.${toolKey}`),
|
|
37229
|
+
...MANAGED_BASH_KEYS.filter((bashKey) => existingBash[bashKey] !== void 0 && newBash[bashKey] === void 0).map((bashKey) => `bash.${bashKey}`)
|
|
37230
|
+
];
|
|
35337
37231
|
if (droppedKeys.length > 0) logger?.warn(`Rovo Dev permissions: removing ${droppedKeys.map((key) => `"${key}"`).join(", ")} from ${filePath} because the rulesync source no longer produces them.`);
|
|
35338
37232
|
}
|
|
35339
37233
|
/**
|
|
@@ -37441,10 +39335,10 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
|
|
|
37441
39335
|
var PermissionsProcessor = class extends FeatureProcessor {
|
|
37442
39336
|
toolTarget;
|
|
37443
39337
|
global;
|
|
37444
|
-
constructor({ outputRoot = process.cwd(),
|
|
39338
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
|
|
37445
39339
|
super({
|
|
37446
39340
|
outputRoot,
|
|
37447
|
-
|
|
39341
|
+
inputRoots,
|
|
37448
39342
|
dryRun,
|
|
37449
39343
|
logger
|
|
37450
39344
|
});
|
|
@@ -37454,9 +39348,17 @@ var PermissionsProcessor = class extends FeatureProcessor {
|
|
|
37454
39348
|
this.global = global;
|
|
37455
39349
|
}
|
|
37456
39350
|
async loadRulesyncFiles() {
|
|
39351
|
+
const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncPermissions.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
|
|
39352
|
+
const sourceTree = await pickLastRootWithFile({
|
|
39353
|
+
inputRoots: this.inputRoots,
|
|
39354
|
+
relativePaths,
|
|
39355
|
+
logger: this.logger,
|
|
39356
|
+
artifactName: "The permissions file"
|
|
39357
|
+
}) ?? this.inputRoots[0];
|
|
37457
39358
|
try {
|
|
37458
39359
|
return [await RulesyncPermissions.fromFile({
|
|
37459
|
-
outputRoot:
|
|
39360
|
+
outputRoot: (0, node_path.dirname)(sourceTree),
|
|
39361
|
+
relativeDirPath: (0, node_path.basename)(sourceTree),
|
|
37460
39362
|
validate: true
|
|
37461
39363
|
})];
|
|
37462
39364
|
} catch (error) {
|
|
@@ -37604,9 +39506,13 @@ var SimulatedSkill = class extends ToolSkill {
|
|
|
37604
39506
|
const skillDirPath = (0, node_path.join)(outputRoot, actualRelativeDirPath, dirName);
|
|
37605
39507
|
const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
|
|
37606
39508
|
if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
|
|
37607
|
-
const { frontmatter, body: content } =
|
|
39509
|
+
const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
|
|
37608
39510
|
const result = SimulatedSkillFrontmatterSchema.safeParse(frontmatter);
|
|
37609
39511
|
if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
|
|
39512
|
+
warnOnEmptyLoadedDescription({
|
|
39513
|
+
skillFilePath,
|
|
39514
|
+
description: result.data.description
|
|
39515
|
+
});
|
|
37610
39516
|
const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
|
|
37611
39517
|
return {
|
|
37612
39518
|
outputRoot,
|
|
@@ -37880,13 +39786,26 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
|
|
|
37880
39786
|
//#region src/types/dir-feature-processor.ts
|
|
37881
39787
|
var DirFeatureProcessor = class {
|
|
37882
39788
|
outputRoot;
|
|
37883
|
-
|
|
39789
|
+
/**
|
|
39790
|
+
* Ordered, non-empty list of rulesync source-tree directories. Each entry
|
|
39791
|
+
* is a source tree itself — the directory that directly contains
|
|
39792
|
+
* feature subdirectories (`rules/`, `skills/`, …) and single-file
|
|
39793
|
+
* features (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence
|
|
39794
|
+
* when two trees supply the same relative path. Defaults to
|
|
39795
|
+
* `[join(process.cwd(), ".rulesync")]`.
|
|
39796
|
+
*
|
|
39797
|
+
* The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
|
|
39798
|
+
* `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
|
|
39799
|
+
* and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
|
|
39800
|
+
* reaches a processor.
|
|
39801
|
+
*/
|
|
39802
|
+
inputRoots;
|
|
37884
39803
|
dryRun;
|
|
37885
39804
|
avoidBlockScalars;
|
|
37886
39805
|
logger;
|
|
37887
|
-
constructor({ outputRoot = process.cwd(),
|
|
39806
|
+
constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, avoidBlockScalars = false, logger }) {
|
|
37888
39807
|
this.outputRoot = outputRoot;
|
|
37889
|
-
this.
|
|
39808
|
+
this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
|
|
37890
39809
|
this.dryRun = dryRun;
|
|
37891
39810
|
this.avoidBlockScalars = avoidBlockScalars;
|
|
37892
39811
|
this.logger = logger;
|
|
@@ -40145,9 +42064,9 @@ var DevinSkill = class DevinSkill extends ToolSkill {
|
|
|
40145
42064
|
* slug is owned by the commands feature: it must not be imported as a
|
|
40146
42065
|
* skill nor deleted as an orphan skill.
|
|
40147
42066
|
*/
|
|
40148
|
-
static async isDirOwned({ dirName,
|
|
42067
|
+
static async isDirOwned({ dirName, inputRoots }) {
|
|
40149
42068
|
return !await rulesyncCommandSlugExists({
|
|
40150
|
-
|
|
42069
|
+
inputRoots,
|
|
40151
42070
|
dirName
|
|
40152
42071
|
});
|
|
40153
42072
|
}
|
|
@@ -40197,7 +42116,11 @@ const FactorydroidSkillFrontmatterSchema = zod_mini.z.looseObject({
|
|
|
40197
42116
|
"user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
40198
42117
|
"disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
40199
42118
|
enabled: zod_mini.z.optional(zod_mini.z.boolean()),
|
|
40200
|
-
"allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
|
|
42119
|
+
"allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
42120
|
+
license: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
42121
|
+
compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
42122
|
+
metadata: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
42123
|
+
version: zod_mini.z.optional(zod_mini.z.unknown())
|
|
40201
42124
|
});
|
|
40202
42125
|
/**
|
|
40203
42126
|
* Represents a Factory Droid skill directory.
|
|
@@ -40250,16 +42173,10 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
|
|
|
40250
42173
|
};
|
|
40251
42174
|
}
|
|
40252
42175
|
toRulesyncSkill() {
|
|
40253
|
-
const
|
|
40254
|
-
const factorydroidBlock = {
|
|
40255
|
-
...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
|
|
40256
|
-
...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
|
|
40257
|
-
...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
|
|
40258
|
-
...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
|
|
40259
|
-
};
|
|
42176
|
+
const { name, description, ...factorydroidBlock } = this.getFrontmatter();
|
|
40260
42177
|
const rulesyncFrontmatter = {
|
|
40261
|
-
name
|
|
40262
|
-
description
|
|
42178
|
+
name,
|
|
42179
|
+
description,
|
|
40263
42180
|
targets: ["*"],
|
|
40264
42181
|
...Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }
|
|
40265
42182
|
};
|
|
@@ -40286,13 +42203,13 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
|
|
|
40286
42203
|
rootFrontmatter: rulesyncFrontmatter,
|
|
40287
42204
|
section: factorydroidSection
|
|
40288
42205
|
});
|
|
42206
|
+
const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
|
|
40289
42207
|
const factorydroidFrontmatter = {
|
|
40290
42208
|
name: rulesyncFrontmatter.name,
|
|
40291
42209
|
description: rulesyncFrontmatter.description,
|
|
42210
|
+
...section,
|
|
40292
42211
|
...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
|
|
40293
|
-
...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
|
|
40294
|
-
...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
|
|
40295
|
-
...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
|
|
42212
|
+
...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
|
|
40296
42213
|
};
|
|
40297
42214
|
return new FactorydroidSkill({
|
|
40298
42215
|
outputRoot,
|
|
@@ -40704,7 +42621,10 @@ var JunieSkill = class JunieSkill extends ToolSkill {
|
|
|
40704
42621
|
}
|
|
40705
42622
|
}
|
|
40706
42623
|
static getSettablePaths(_options) {
|
|
40707
|
-
return {
|
|
42624
|
+
return {
|
|
42625
|
+
relativeDirPath: JUNIE_SKILLS_DIR_PATH,
|
|
42626
|
+
importOnlySkillRoots: [AGENTSMD_SKILLS_DIR_PATH]
|
|
42627
|
+
};
|
|
40708
42628
|
}
|
|
40709
42629
|
getFrontmatter() {
|
|
40710
42630
|
return JunieSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
|
|
@@ -41120,7 +43040,7 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
|
|
|
41120
43040
|
}
|
|
41121
43041
|
static async fromFlatFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
|
|
41122
43042
|
const filePath = (0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath);
|
|
41123
|
-
const { frontmatter, body } =
|
|
43043
|
+
const { frontmatter, body } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath);
|
|
41124
43044
|
const result = KimiCodeFlatSkillFrontmatterSchema.safeParse(frontmatter);
|
|
41125
43045
|
if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
|
|
41126
43046
|
const fileName = (0, node_path.basename)(relativeFilePath, (0, node_path.extname)(relativeFilePath));
|
|
@@ -41130,6 +43050,10 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
|
|
|
41130
43050
|
name: result.data.name ?? fileName,
|
|
41131
43051
|
description: result.data.description ?? firstBodyLine?.slice(0, 240) ?? "No description provided."
|
|
41132
43052
|
};
|
|
43053
|
+
warnOnEmptyLoadedDescription({
|
|
43054
|
+
skillFilePath: filePath,
|
|
43055
|
+
description: normalizedFrontmatter.description
|
|
43056
|
+
});
|
|
41133
43057
|
return new KimiCodeSkill({
|
|
41134
43058
|
outputRoot,
|
|
41135
43059
|
relativeDirPath,
|
|
@@ -42133,7 +44057,7 @@ var ReasonixSkill = class ReasonixSkill extends ToolSkill {
|
|
|
42133
44057
|
static async isDirOwned({ outputRoot, relativeDirPath, dirName }) {
|
|
42134
44058
|
const skillFilePath = (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
|
|
42135
44059
|
try {
|
|
42136
|
-
const { frontmatter } =
|
|
44060
|
+
const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath, { quiet: true });
|
|
42137
44061
|
return frontmatter["runAs"] !== REASONIX_SUBAGENT_RUN_AS;
|
|
42138
44062
|
} catch {
|
|
42139
44063
|
return true;
|
|
@@ -42502,8 +44426,7 @@ var TaktSkill = class TaktSkill extends ToolSkill {
|
|
|
42502
44426
|
const fullPath = (0, node_path.join)(this.outputRoot, this.relativeDirPath);
|
|
42503
44427
|
const resolvedFull = (0, node_path.resolve)(fullPath);
|
|
42504
44428
|
const resolvedBase = (0, node_path.resolve)(this.outputRoot);
|
|
42505
|
-
|
|
42506
|
-
if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
|
|
44429
|
+
if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
|
|
42507
44430
|
return fullPath;
|
|
42508
44431
|
}
|
|
42509
44432
|
getRelativePathFromCwd() {
|
|
@@ -42866,9 +44789,9 @@ var WarpSkill = class WarpSkill extends ToolSkill {
|
|
|
42866
44789
|
* slug is owned by the commands feature: it must not be imported as a
|
|
42867
44790
|
* skill nor deleted as an orphan skill.
|
|
42868
44791
|
*/
|
|
42869
|
-
static async isDirOwned({ dirName,
|
|
44792
|
+
static async isDirOwned({ dirName, inputRoots }) {
|
|
42870
44793
|
return !await rulesyncCommandSlugExists({
|
|
42871
|
-
|
|
44794
|
+
inputRoots,
|
|
42872
44795
|
dirName
|
|
42873
44796
|
});
|
|
42874
44797
|
}
|
|
@@ -43426,10 +45349,10 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43426
45349
|
toolTarget;
|
|
43427
45350
|
global;
|
|
43428
45351
|
getFactory;
|
|
43429
|
-
constructor({ outputRoot = process.cwd(),
|
|
45352
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
|
|
43430
45353
|
super({
|
|
43431
45354
|
outputRoot,
|
|
43432
|
-
|
|
45355
|
+
inputRoots,
|
|
43433
45356
|
dryRun,
|
|
43434
45357
|
avoidBlockScalars: toolTarget === "cursor",
|
|
43435
45358
|
logger
|
|
@@ -43467,39 +45390,67 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43467
45390
|
return rulesyncSkills;
|
|
43468
45391
|
}
|
|
43469
45392
|
/**
|
|
43470
|
-
*
|
|
43471
|
-
*
|
|
43472
|
-
*
|
|
43473
|
-
*
|
|
45393
|
+
* Load rulesync skill directories from a single source-tree's `skills/`
|
|
45394
|
+
* (and `skills/.curated/`) subtree. `sourceTree` is the source tree
|
|
45395
|
+
* itself (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`). Intra-tree:
|
|
45396
|
+
* local skills take precedence over curated skills with the same name.
|
|
43474
45397
|
*/
|
|
43475
|
-
async
|
|
43476
|
-
const
|
|
45398
|
+
async loadRulesyncDirsForRoot(sourceTree) {
|
|
45399
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
45400
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
45401
|
+
const treeSkillsDirPath = (0, node_path.join)(treeName, SKILLS_FEATURE_SUBDIR);
|
|
45402
|
+
const treeCuratedSkillsDirPath = (0, node_path.join)(treeName, CURATED_SKILLS_FEATURE_SUBDIR);
|
|
45403
|
+
const localDirNames = [...await getLocalSkillDirNames(sourceTree)];
|
|
43477
45404
|
const localSkills = await Promise.all(localDirNames.map((dirName) => RulesyncSkill.fromDir({
|
|
43478
|
-
outputRoot:
|
|
45405
|
+
outputRoot: treeParent,
|
|
45406
|
+
relativeDirPath: treeSkillsDirPath,
|
|
43479
45407
|
dirName,
|
|
43480
45408
|
global: this.global
|
|
43481
45409
|
})));
|
|
43482
|
-
const
|
|
43483
|
-
const curatedDirPath = (0, node_path.join)(
|
|
45410
|
+
const localSkillNamesByIdentity = groupSpellingsByCaseFoldedIdentity(localDirNames);
|
|
45411
|
+
const curatedDirPath = (0, node_path.join)(sourceTree, CURATED_SKILLS_FEATURE_SUBDIR);
|
|
43484
45412
|
let curatedSkills = [];
|
|
43485
45413
|
if (await directoryExists(curatedDirPath)) {
|
|
43486
45414
|
const nonConflicting = (await findFilesByGlobs((0, node_path.join)(curatedDirPath, "*"), { type: "dir" })).map((path) => (0, node_path.basename)(path)).filter((name) => {
|
|
43487
|
-
|
|
43488
|
-
|
|
43489
|
-
|
|
43490
|
-
|
|
43491
|
-
|
|
45415
|
+
const spellings = localSkillNamesByIdentity.get(caseFoldIdentity(name));
|
|
45416
|
+
if (spellings === void 0) return true;
|
|
45417
|
+
if (spellings.includes(name)) this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
|
|
45418
|
+
else this.logger.warn(formatCuratedCaseCollisionWarning({
|
|
45419
|
+
artifactKind: "skill",
|
|
45420
|
+
entryNoun: "skill",
|
|
45421
|
+
treeDirPath: treeSkillsDirPath,
|
|
45422
|
+
curatedSpelling: name,
|
|
45423
|
+
localSpellings: spellings
|
|
45424
|
+
}));
|
|
45425
|
+
return false;
|
|
43492
45426
|
});
|
|
43493
|
-
const curatedRelativeDirPath = RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH;
|
|
43494
45427
|
curatedSkills = await Promise.all(nonConflicting.map((dirName) => RulesyncSkill.fromDir({
|
|
43495
|
-
outputRoot:
|
|
43496
|
-
relativeDirPath:
|
|
45428
|
+
outputRoot: treeParent,
|
|
45429
|
+
relativeDirPath: treeCuratedSkillsDirPath,
|
|
43497
45430
|
dirName,
|
|
43498
45431
|
global: this.global
|
|
43499
45432
|
})));
|
|
43500
45433
|
}
|
|
43501
|
-
|
|
43502
|
-
|
|
45434
|
+
return [...localSkills, ...curatedSkills];
|
|
45435
|
+
}
|
|
45436
|
+
/**
|
|
45437
|
+
* Implementation of abstract method from DirFeatureProcessor.
|
|
45438
|
+
*
|
|
45439
|
+
* Load and parse rulesync skill directories from every configured input
|
|
45440
|
+
* root's `.rulesync/skills/` tree (each root also honours its own
|
|
45441
|
+
* `.curated/` subdirectory). When two roots supply a skill with the same
|
|
45442
|
+
* directory name, the later root's skill replaces the earlier root's copy
|
|
45443
|
+
* atomically (companion files included) — an overlay always ships a whole
|
|
45444
|
+
* skill directory, never a partial patch.
|
|
45445
|
+
*/
|
|
45446
|
+
async loadRulesyncDirs() {
|
|
45447
|
+
const allSkills = mergeByCaseInsensitiveIdentity({
|
|
45448
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncDirsForRoot(root))),
|
|
45449
|
+
identity: (skill) => skill.getDirName(),
|
|
45450
|
+
artifactName: "skill",
|
|
45451
|
+
logger: this.logger
|
|
45452
|
+
});
|
|
45453
|
+
this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills`);
|
|
43503
45454
|
return allSkills;
|
|
43504
45455
|
}
|
|
43505
45456
|
/**
|
|
@@ -43515,7 +45466,17 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43515
45466
|
}) : [];
|
|
43516
45467
|
const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
|
|
43517
45468
|
const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
|
|
43518
|
-
const
|
|
45469
|
+
const claimedSkillNames = new ClaimedIdentities();
|
|
45470
|
+
const claimSkillName = ({ skill, relativeDirPath, sourcePath }) => {
|
|
45471
|
+
const skillName = skill.getImportIdentity();
|
|
45472
|
+
const claimed = claimedSkillNames.claim({
|
|
45473
|
+
identity: skillName,
|
|
45474
|
+
source: relativeDirPath
|
|
45475
|
+
});
|
|
45476
|
+
if (claimed === null) return true;
|
|
45477
|
+
if (claimed.spelling !== skillName) this.logger.warn(`Case-insensitive ${this.toolTarget} skill collision: "${claimed.spelling}" and "${skillName}" resolve to the same skill directory. Keeping "${claimed.spelling}" from ${claimed.source === relativeDirPath ? "earlier in the same root" : `the higher-precedence ${claimed.source}`} and ignoring ${sourcePath}, which is not imported.`);
|
|
45478
|
+
return false;
|
|
45479
|
+
};
|
|
43519
45480
|
const toolSkills = [];
|
|
43520
45481
|
for (const root of roots) {
|
|
43521
45482
|
const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
|
|
@@ -43531,54 +45492,60 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43531
45492
|
outputRoot: rootOutputRoot,
|
|
43532
45493
|
relativeDirPath,
|
|
43533
45494
|
dirName,
|
|
43534
|
-
|
|
45495
|
+
inputRoots: this.inputRoots
|
|
43535
45496
|
})) continue;
|
|
43536
45497
|
ownedDirNames.push(dirName);
|
|
43537
45498
|
}
|
|
43538
45499
|
const directorySkills = (await Promise.all(ownedDirNames.map(async (dirName) => {
|
|
45500
|
+
const sourcePath = (0, node_path.join)(relativeDirPath, dirName);
|
|
43539
45501
|
try {
|
|
43540
|
-
return
|
|
43541
|
-
|
|
43542
|
-
|
|
43543
|
-
|
|
43544
|
-
|
|
43545
|
-
|
|
45502
|
+
return {
|
|
45503
|
+
skill: await factory.class.fromDir({
|
|
45504
|
+
outputRoot: rootOutputRoot,
|
|
45505
|
+
relativeDirPath,
|
|
45506
|
+
dirName,
|
|
45507
|
+
global: this.global
|
|
45508
|
+
}),
|
|
45509
|
+
sourcePath
|
|
45510
|
+
};
|
|
43546
45511
|
} catch (error) {
|
|
43547
45512
|
if (!isLenientRoot) throw error;
|
|
43548
|
-
this.logger.warn(`Skipping ${
|
|
45513
|
+
this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
|
|
43549
45514
|
return null;
|
|
43550
45515
|
}
|
|
43551
|
-
}))).filter((
|
|
43552
|
-
for (const skill of directorySkills) {
|
|
43553
|
-
|
|
43554
|
-
|
|
43555
|
-
|
|
43556
|
-
|
|
43557
|
-
}
|
|
45516
|
+
}))).filter((loaded) => loaded !== null);
|
|
45517
|
+
for (const { skill, sourcePath } of directorySkills) if (claimSkillName({
|
|
45518
|
+
skill,
|
|
45519
|
+
relativeDirPath,
|
|
45520
|
+
sourcePath
|
|
45521
|
+
})) toolSkills.push(skill);
|
|
43558
45522
|
if (!factory.class.fromFlatFile) continue;
|
|
43559
45523
|
const fromFlatFile = factory.class.fromFlatFile;
|
|
43560
45524
|
const directoryStems = new Set(ownedDirNames);
|
|
43561
45525
|
const flatFilePaths = (await findFilesByGlobs((0, node_path.join)(skillsDirPath, "*.md"), { type: "file" })).filter((filePath) => !directoryStems.has((0, node_path.basename)(filePath, ".md")));
|
|
43562
45526
|
const flatSkills = (await Promise.all(flatFilePaths.map(async (filePath) => {
|
|
45527
|
+
const sourcePath = (0, node_path.join)(relativeDirPath, (0, node_path.basename)(filePath));
|
|
43563
45528
|
try {
|
|
43564
|
-
return
|
|
43565
|
-
|
|
43566
|
-
|
|
43567
|
-
|
|
43568
|
-
|
|
43569
|
-
|
|
45529
|
+
return {
|
|
45530
|
+
skill: await fromFlatFile({
|
|
45531
|
+
outputRoot: rootOutputRoot,
|
|
45532
|
+
relativeDirPath,
|
|
45533
|
+
relativeFilePath: (0, node_path.basename)(filePath),
|
|
45534
|
+
global: this.global
|
|
45535
|
+
}),
|
|
45536
|
+
sourcePath
|
|
45537
|
+
};
|
|
43570
45538
|
} catch (error) {
|
|
43571
45539
|
if (!isLenientRoot) throw error;
|
|
43572
|
-
this.logger.warn(`Skipping ${
|
|
45540
|
+
this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
|
|
43573
45541
|
return null;
|
|
43574
45542
|
}
|
|
43575
|
-
}))).filter((
|
|
43576
|
-
for (const skill of flatSkills) {
|
|
43577
|
-
|
|
43578
|
-
|
|
43579
|
-
|
|
43580
|
-
|
|
43581
|
-
}
|
|
45543
|
+
}))).filter((loaded) => loaded !== null);
|
|
45544
|
+
for (const { skill, sourcePath } of flatSkills) if (claimSkillName({
|
|
45545
|
+
skill,
|
|
45546
|
+
relativeDirPath,
|
|
45547
|
+
sourcePath
|
|
45548
|
+
})) toolSkills.push(skill);
|
|
43582
45549
|
}
|
|
43583
45550
|
this.logger.debug(`Successfully loaded ${toolSkills.length} skills from ${roots.length} root(s)`);
|
|
43584
45551
|
return toolSkills;
|
|
@@ -43608,7 +45575,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43608
45575
|
outputRoot: this.outputRoot,
|
|
43609
45576
|
relativeDirPath: root,
|
|
43610
45577
|
dirName,
|
|
43611
|
-
|
|
45578
|
+
inputRoots: this.inputRoots
|
|
43612
45579
|
})) continue;
|
|
43613
45580
|
const toolSkill = factory.class.forDeletion({
|
|
43614
45581
|
outputRoot: this.outputRoot,
|
|
@@ -46366,6 +48333,7 @@ const JunieSubagentFrontmatterSchema = zod_mini.z.looseObject({
|
|
|
46366
48333
|
disallowedTools: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
46367
48334
|
mcpServers: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
46368
48335
|
model: zod_mini.z.optional(zod_mini.z.string()),
|
|
48336
|
+
permissionMode: zod_mini.z.optional(zod_mini.z.string()),
|
|
46369
48337
|
reasoningLevel: zod_mini.z.optional(zod_mini.z.string()),
|
|
46370
48338
|
maxTurns: zod_mini.z.optional(zod_mini.z.number()),
|
|
46371
48339
|
skills: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
@@ -47425,7 +49393,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
|
|
|
47425
49393
|
const paths = this.getSettablePaths({ global });
|
|
47426
49394
|
const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
|
|
47427
49395
|
const fileContent = await readFileContent(filePath);
|
|
47428
|
-
const { frontmatter, body: content } =
|
|
49396
|
+
const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(fileContent, filePath);
|
|
47429
49397
|
const result = ReasonixSubagentFrontmatterSchema.safeParse(frontmatter);
|
|
47430
49398
|
if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
|
|
47431
49399
|
return new ReasonixSubagent({
|
|
@@ -47454,7 +49422,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
|
|
|
47454
49422
|
static async isFileOwned({ outputRoot, relativeDirPath, relativeFilePath }) {
|
|
47455
49423
|
const filePath = (0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath);
|
|
47456
49424
|
try {
|
|
47457
|
-
const { frontmatter } =
|
|
49425
|
+
const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath, { quiet: true });
|
|
47458
49426
|
return frontmatter["runAs"] === REASONIX_SUBAGENT_RUN_AS;
|
|
47459
49427
|
} catch {
|
|
47460
49428
|
return false;
|
|
@@ -48346,14 +50314,25 @@ const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((targe
|
|
|
48346
50314
|
const subagentsProcessorToolTargetsGlobal = allToolTargetKeys$1.filter((target) => {
|
|
48347
50315
|
return toolSubagentFactories.get(target)?.meta.supportsGlobal ?? false;
|
|
48348
50316
|
});
|
|
50317
|
+
/**
|
|
50318
|
+
* Stands in for a discovery root when a subagent came from a tool's own config
|
|
50319
|
+
* file rather than a directory (see `loadAdditionalImportFiles`). The angle
|
|
50320
|
+
* brackets keep it from ever matching a real relative directory path.
|
|
50321
|
+
*/
|
|
50322
|
+
const INLINE_SOURCE = "<inline>";
|
|
50323
|
+
/**
|
|
50324
|
+
* The single "root" of the post-conversion output guard, which de-duplicates
|
|
50325
|
+
* `.rulesync/subagents/` paths rather than discovery roots.
|
|
50326
|
+
*/
|
|
50327
|
+
const OUTPUT_SOURCE = "<output>";
|
|
48349
50328
|
var SubagentsProcessor = class extends FeatureProcessor {
|
|
48350
50329
|
toolTarget;
|
|
48351
50330
|
global;
|
|
48352
50331
|
getFactory;
|
|
48353
|
-
constructor({ outputRoot = process.cwd(),
|
|
50332
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
|
|
48354
50333
|
super({
|
|
48355
50334
|
outputRoot,
|
|
48356
|
-
|
|
50335
|
+
inputRoots,
|
|
48357
50336
|
dryRun,
|
|
48358
50337
|
logger
|
|
48359
50338
|
});
|
|
@@ -48399,24 +50378,31 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48399
50378
|
rulesyncSubagents.push(toolSubagent.toRulesyncSubagent());
|
|
48400
50379
|
}
|
|
48401
50380
|
const uniqueRulesyncSubagents = [];
|
|
48402
|
-
const
|
|
50381
|
+
const claimedOutputPaths = new ClaimedIdentities();
|
|
48403
50382
|
for (const rulesyncSubagent of rulesyncSubagents) {
|
|
48404
50383
|
const outputPath = (0, node_path.join)(rulesyncSubagent.getRelativeDirPath(), rulesyncSubagent.getRelativeFilePath());
|
|
48405
|
-
|
|
48406
|
-
|
|
50384
|
+
const claimed = claimedOutputPaths.claim({
|
|
50385
|
+
identity: outputPath,
|
|
50386
|
+
source: OUTPUT_SOURCE
|
|
50387
|
+
});
|
|
50388
|
+
if (claimed !== null) {
|
|
50389
|
+
this.logger.warn(claimed.spelling === outputPath ? `Multiple ${this.toolTarget} subagents resolve to "${outputPath}"; keeping the first and ignoring this copy.` : `${this.toolTarget} subagent "${outputPath}" differs only in case from "${claimed.spelling}", which is the same file on a case-insensitive filesystem; keeping the first and ignoring this copy.`);
|
|
48407
50390
|
continue;
|
|
48408
50391
|
}
|
|
48409
|
-
seenOutputPaths.add(outputPath);
|
|
48410
50392
|
uniqueRulesyncSubagents.push(rulesyncSubagent);
|
|
48411
50393
|
}
|
|
48412
50394
|
return uniqueRulesyncSubagents;
|
|
48413
50395
|
}
|
|
48414
50396
|
/**
|
|
48415
|
-
*
|
|
48416
|
-
*
|
|
50397
|
+
* Load subagent files from a single source-tree's `subagents/` subtree.
|
|
50398
|
+
* `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
|
|
50399
|
+
* `/repo/.rulesync.local`).
|
|
48417
50400
|
*/
|
|
48418
|
-
async
|
|
48419
|
-
const
|
|
50401
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
50402
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
50403
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
50404
|
+
const treeSubagentsDirPath = (0, node_path.join)(treeName, SUBAGENTS_FEATURE_SUBDIR);
|
|
50405
|
+
const subagentsDir = (0, node_path.join)(sourceTree, SUBAGENTS_FEATURE_SUBDIR);
|
|
48420
50406
|
if (!await directoryExists(subagentsDir)) {
|
|
48421
50407
|
this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
|
|
48422
50408
|
return [];
|
|
@@ -48432,7 +50418,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48432
50418
|
const filepath = (0, node_path.join)(subagentsDir, mdFile);
|
|
48433
50419
|
try {
|
|
48434
50420
|
const rulesyncSubagent = await RulesyncSubagent.fromFile({
|
|
48435
|
-
outputRoot:
|
|
50421
|
+
outputRoot: treeParent,
|
|
50422
|
+
relativeDirPath: treeSubagentsDirPath,
|
|
48436
50423
|
relativeFilePath: mdFile,
|
|
48437
50424
|
validate: true
|
|
48438
50425
|
});
|
|
@@ -48443,8 +50430,24 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48443
50430
|
continue;
|
|
48444
50431
|
}
|
|
48445
50432
|
}
|
|
50433
|
+
return rulesyncSubagents;
|
|
50434
|
+
}
|
|
50435
|
+
/**
|
|
50436
|
+
* Implementation of abstract method from Processor
|
|
50437
|
+
* Load and parse rulesync subagent files from every configured input root's
|
|
50438
|
+
* `.rulesync/subagents/` directory, merging by relative file path so a
|
|
50439
|
+
* subagent with the same target path from a later root replaces the
|
|
50440
|
+
* earlier root's copy.
|
|
50441
|
+
*/
|
|
50442
|
+
async loadRulesyncFiles() {
|
|
50443
|
+
const rulesyncSubagents = mergeByCaseInsensitiveIdentity({
|
|
50444
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
50445
|
+
identity: (subagent) => subagent.getRelativeFilePath(),
|
|
50446
|
+
artifactName: "subagent",
|
|
50447
|
+
logger: this.logger
|
|
50448
|
+
});
|
|
48446
50449
|
if (rulesyncSubagents.length === 0) {
|
|
48447
|
-
this.logger.debug(`No valid subagents found
|
|
50450
|
+
this.logger.debug(`No valid subagents found`);
|
|
48448
50451
|
return [];
|
|
48449
50452
|
}
|
|
48450
50453
|
this.logger.debug(`Successfully loaded ${rulesyncSubagents.length} rulesync subagents`);
|
|
@@ -48459,7 +50462,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48459
50462
|
const paths = factory.class.getSettablePaths({ global: this.global });
|
|
48460
50463
|
const roots = forDeletion ? [paths.relativeDirPath] : [paths.relativeDirPath, ...paths.importDirPaths ?? []];
|
|
48461
50464
|
const toolSubagents = [];
|
|
48462
|
-
const
|
|
50465
|
+
const claimedRelativeFilePaths = new ClaimedIdentities();
|
|
48463
50466
|
for (const root of roots) {
|
|
48464
50467
|
const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
|
|
48465
50468
|
const dirPath = typeof root === "string" ? root : root.relativeDirPath;
|
|
@@ -48498,37 +50501,77 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48498
50501
|
relativeFilePath: toRelativeFilePath(path),
|
|
48499
50502
|
global: this.global
|
|
48500
50503
|
})));
|
|
48501
|
-
|
|
48502
|
-
|
|
48503
|
-
|
|
48504
|
-
|
|
48505
|
-
|
|
48506
|
-
continue;
|
|
48507
|
-
}
|
|
48508
|
-
seenRelativeFilePaths.add(key);
|
|
48509
|
-
deduped.push(subagent);
|
|
48510
|
-
}
|
|
48511
|
-
toolSubagents.push(...deduped);
|
|
50504
|
+
toolSubagents.push(...this.claimStandaloneSubagents({
|
|
50505
|
+
loaded,
|
|
50506
|
+
dirPath,
|
|
50507
|
+
claimedRelativeFilePaths
|
|
50508
|
+
}));
|
|
48512
50509
|
}
|
|
48513
50510
|
if (!forDeletion && factory.class.loadAdditionalImportFiles) {
|
|
48514
50511
|
const additionalSubagents = await factory.class.loadAdditionalImportFiles({
|
|
48515
50512
|
outputRoot: this.outputRoot,
|
|
48516
50513
|
global: this.global
|
|
48517
50514
|
});
|
|
48518
|
-
|
|
48519
|
-
|
|
48520
|
-
|
|
48521
|
-
|
|
48522
|
-
continue;
|
|
48523
|
-
}
|
|
48524
|
-
seenRelativeFilePaths.add(key);
|
|
48525
|
-
toolSubagents.push(subagent);
|
|
48526
|
-
}
|
|
50515
|
+
toolSubagents.push(...this.claimInlineSubagents({
|
|
50516
|
+
additionalSubagents,
|
|
50517
|
+
claimedRelativeFilePaths
|
|
50518
|
+
}));
|
|
48527
50519
|
}
|
|
48528
50520
|
this.logger.debug(`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${roots.length} root(s)`);
|
|
48529
50521
|
return toolSubagents;
|
|
48530
50522
|
}
|
|
48531
50523
|
/**
|
|
50524
|
+
* Keeps the subagents from one discovery root whose import identity is still
|
|
50525
|
+
* unclaimed, warning about each copy that loses. Split out of
|
|
50526
|
+
* `loadToolFiles` so the two de-duplication passes stay readable side by
|
|
50527
|
+
* side (and so that method stays within the linter's complexity budget).
|
|
50528
|
+
*
|
|
50529
|
+
* When more than one discovery root is scanned (e.g. Junie's `.junie/agents/`
|
|
50530
|
+
* plus `.agents/`), two roots can hold a subagent with the same relative
|
|
50531
|
+
* path. Downstream conversion keys by that path, so a later one would
|
|
50532
|
+
* silently overwrite an earlier one. Warn instead of failing, keeping the
|
|
50533
|
+
* earlier (higher-precedence) root's file.
|
|
50534
|
+
*/
|
|
50535
|
+
claimStandaloneSubagents({ loaded, dirPath, claimedRelativeFilePaths }) {
|
|
50536
|
+
const deduped = [];
|
|
50537
|
+
for (const subagent of loaded) {
|
|
50538
|
+
const key = subagent.getImportIdentity();
|
|
50539
|
+
const claimed = claimedRelativeFilePaths.claim({
|
|
50540
|
+
identity: key,
|
|
50541
|
+
source: dirPath
|
|
50542
|
+
});
|
|
50543
|
+
if (claimed === null) {
|
|
50544
|
+
deduped.push(subagent);
|
|
50545
|
+
continue;
|
|
50546
|
+
}
|
|
50547
|
+
const keptFrom = claimed.source === dirPath ? `the earlier one in ${dirPath}` : `the one from the higher-precedence ${claimed.source}`;
|
|
50548
|
+
this.logger.warn(claimed.spelling === key ? `Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath}; keeping ${keptFrom} and ignoring this copy.` : `Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath} differs only in case from "${claimed.spelling}"; keeping ${keptFrom} and ignoring this copy.`);
|
|
50549
|
+
}
|
|
50550
|
+
return deduped;
|
|
50551
|
+
}
|
|
50552
|
+
/**
|
|
50553
|
+
* The same claim-or-warn pass for subagents defined inline in a tool's own
|
|
50554
|
+
* config file (see `loadAdditionalImportFiles`), which are scanned after
|
|
50555
|
+
* every standalone file so a Markdown file of the same name wins.
|
|
50556
|
+
*/
|
|
50557
|
+
claimInlineSubagents({ additionalSubagents, claimedRelativeFilePaths }) {
|
|
50558
|
+
const deduped = [];
|
|
50559
|
+
for (const subagent of additionalSubagents) {
|
|
50560
|
+
const key = subagent.getImportIdentity();
|
|
50561
|
+
const claimed = claimedRelativeFilePaths.claim({
|
|
50562
|
+
identity: key,
|
|
50563
|
+
source: INLINE_SOURCE
|
|
50564
|
+
});
|
|
50565
|
+
if (claimed === null) {
|
|
50566
|
+
deduped.push(subagent);
|
|
50567
|
+
continue;
|
|
50568
|
+
}
|
|
50569
|
+
const kept = claimed.source === INLINE_SOURCE ? "the earlier inline definition" : `the standalone file in ${claimed.source}`;
|
|
50570
|
+
this.logger.warn(claimed.spelling === key ? `Duplicate ${this.toolTarget} subagent "${key}" defined inline; keeping ${kept} and ignoring the inline copy.` : `Inline ${this.toolTarget} subagent "${key}" differs only in case from "${claimed.spelling}"; keeping ${kept} and ignoring the inline copy.`);
|
|
50571
|
+
}
|
|
50572
|
+
return deduped;
|
|
50573
|
+
}
|
|
50574
|
+
/**
|
|
48532
50575
|
* Implementation of abstract method from FeatureProcessor
|
|
48533
50576
|
* Return the tool targets that this processor supports
|
|
48534
50577
|
*/
|
|
@@ -51458,17 +53501,32 @@ var HermesagentRule = class HermesagentRule extends ToolRule {
|
|
|
51458
53501
|
* Rule generator for JetBrains Junie AI coding agent
|
|
51459
53502
|
*
|
|
51460
53503
|
* Generates `.junie/AGENTS.md` files based on rulesync rule content. Junie CLI
|
|
51461
|
-
* resolves project guidelines
|
|
51462
|
-
* `AGENTS.md`
|
|
51463
|
-
*
|
|
51464
|
-
*
|
|
51465
|
-
*
|
|
51466
|
-
*
|
|
51467
|
-
*
|
|
53504
|
+
* resolves project guidelines in this order: `.junie/AGENTS.md` → root
|
|
53505
|
+
* `AGENTS.md` **combined with `.junie/playbook.md` and every
|
|
53506
|
+
* `.junie/rules/*.md`** → legacy `.junie/guidelines.md` / `.junie/guidelines/`.
|
|
53507
|
+
* The multi-file branch exists, but it is unreachable while `.junie/AGENTS.md`
|
|
53508
|
+
* is present: that file "is used exclusively and no other guidelines files are
|
|
53509
|
+
* combined with it". So emitting `.junie/rules/*.md` next to the root file
|
|
53510
|
+
* rulesync writes would produce files Junie never reads, and moving the root
|
|
53511
|
+
* output to project-root `AGENTS.md` would both change every existing output
|
|
53512
|
+
* path and collide with the `agentsmd` target. Non-root rules therefore stay
|
|
53513
|
+
* folded into the single root `.junie/AGENTS.md` by the RulesProcessor
|
|
53514
|
+
* (`nonRoot` is `undefined`, mirroring the warp / deepagents targets) — the
|
|
53515
|
+
* fold is lossless, since Junie loads that one file in full. The original
|
|
53516
|
+
* rationale for this shape was recorded in issue #2211 and re-confirmed
|
|
53517
|
+
* against the 2026-08-21 docs revision in issue #2728. The legacy
|
|
51468
53518
|
* `.junie/guidelines.md` is still accepted as an import fallback, but
|
|
51469
53519
|
* generation always targets `.junie/AGENTS.md`. Junie uses plain markdown
|
|
51470
53520
|
* without frontmatter requirements.
|
|
51471
53521
|
*
|
|
53522
|
+
* The multi-file branch is still read on **import**, though: a repo that
|
|
53523
|
+
* authors `.junie/rules/*.md` or `.junie/playbook.md` by hand — the live
|
|
53524
|
+
* layout whenever `.junie/AGENTS.md` is absent, which is exactly the state a
|
|
53525
|
+
* first `rulesync import` finds — declares those paths as `importOnlyRoots`
|
|
53526
|
+
* and imports them as non-root rules. They are never written back there:
|
|
53527
|
+
* generating `.junie/AGENTS.md` moves Junie onto the first branch, where the
|
|
53528
|
+
* folded root file carries the same content.
|
|
53529
|
+
*
|
|
51472
53530
|
* Global (user) scope writes a single `~/.junie/AGENTS.md` file. Junie merges
|
|
51473
53531
|
* these user-scope guidelines with the project guidelines (both are included
|
|
51474
53532
|
* and marked clearly).
|
|
@@ -51490,6 +53548,14 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51490
53548
|
alternativeRoots: [{
|
|
51491
53549
|
relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
|
|
51492
53550
|
relativeFilePath: JUNIE_LEGACY_RULE_FILE_NAME
|
|
53551
|
+
}],
|
|
53552
|
+
importOnlyRoots: [{
|
|
53553
|
+
relativeDirPath: buildToolPath(JUNIE_DIR, JUNIE_RULES_DIR_NAME, excludeToolDir),
|
|
53554
|
+
onlyWhenRootAbsent: true
|
|
53555
|
+
}, {
|
|
53556
|
+
relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
|
|
53557
|
+
relativeFilePath: JUNIE_PLAYBOOK_FILE_NAME,
|
|
53558
|
+
onlyWhenRootAbsent: true
|
|
51493
53559
|
}]
|
|
51494
53560
|
};
|
|
51495
53561
|
}
|
|
@@ -51500,7 +53566,7 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51500
53566
|
static isRootRelativeFilePath(relativeFilePath) {
|
|
51501
53567
|
return relativeFilePath === "AGENTS.md" || relativeFilePath === "guidelines.md";
|
|
51502
53568
|
}
|
|
51503
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
|
|
53569
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath: relativeDirPathParam, relativeFilePath, validate = true, global = false }) {
|
|
51504
53570
|
if (global) {
|
|
51505
53571
|
const paths = this.getSettablePaths({ global: true });
|
|
51506
53572
|
if (!("root" in paths) || !paths.root) throw new Error("JunieRule global settable paths must include a root path");
|
|
@@ -51514,7 +53580,8 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51514
53580
|
root: true
|
|
51515
53581
|
});
|
|
51516
53582
|
}
|
|
51517
|
-
const
|
|
53583
|
+
const settablePaths = this.getSettablePaths();
|
|
53584
|
+
const relativeDirPath = relativeDirPathParam ?? settablePaths.root.relativeDirPath;
|
|
51518
53585
|
const relativePath = (0, node_path.join)(relativeDirPath, relativeFilePath);
|
|
51519
53586
|
const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
|
|
51520
53587
|
return new JunieRule({
|
|
@@ -51523,7 +53590,7 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51523
53590
|
relativeFilePath,
|
|
51524
53591
|
fileContent,
|
|
51525
53592
|
validate,
|
|
51526
|
-
root: JunieRule.isRootRelativeFilePath(relativeFilePath)
|
|
53593
|
+
root: relativeDirPath === settablePaths.root.relativeDirPath && JunieRule.isRootRelativeFilePath(relativeFilePath)
|
|
51527
53594
|
});
|
|
51528
53595
|
}
|
|
51529
53596
|
static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
|
|
@@ -53875,10 +55942,23 @@ const defaultGetFactory = (target) => {
|
|
|
53875
55942
|
if (!factory) throw new Error(`Unsupported tool target: ${target}`);
|
|
53876
55943
|
return factory;
|
|
53877
55944
|
};
|
|
53878
|
-
|
|
53879
|
-
|
|
55945
|
+
/**
|
|
55946
|
+
* How many skipped import-only paths a single warning names before it
|
|
55947
|
+
* summarizes the rest. Keeps one line readable when a rules directory holds
|
|
55948
|
+
* dozens of files.
|
|
55949
|
+
*/
|
|
55950
|
+
const MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS = 10;
|
|
55951
|
+
/**
|
|
55952
|
+
* Fall back to a tool's legacy roots when its primary root file is absent.
|
|
55953
|
+
*
|
|
55954
|
+
* The primary hits are passed in rather than globbed here, so that callers
|
|
55955
|
+
* which need "the root file Rulesync generates" — rather than "whatever root
|
|
55956
|
+
* the tool will read" — can keep the two apart. A legacy root is a file
|
|
55957
|
+
* Rulesync reads but never writes, and the difference matters to them.
|
|
55958
|
+
*/
|
|
55959
|
+
const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob) => {
|
|
53880
55960
|
if (primaryFilePaths.length > 0) return primaryFilePaths;
|
|
53881
|
-
if (alternativeRoots) return findFilesByGlobs(alternativeRoots.map(buildAltGlob));
|
|
55961
|
+
if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
|
|
53882
55962
|
return [];
|
|
53883
55963
|
};
|
|
53884
55964
|
var RulesProcessor = class extends FeatureProcessor {
|
|
@@ -53890,10 +55970,10 @@ var RulesProcessor = class extends FeatureProcessor {
|
|
|
53890
55970
|
getFactory;
|
|
53891
55971
|
skills;
|
|
53892
55972
|
featureOptions;
|
|
53893
|
-
constructor({ outputRoot = process.cwd(),
|
|
55973
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
|
|
53894
55974
|
super({
|
|
53895
55975
|
outputRoot,
|
|
53896
|
-
|
|
55976
|
+
inputRoots,
|
|
53897
55977
|
dryRun,
|
|
53898
55978
|
logger
|
|
53899
55979
|
});
|
|
@@ -54268,25 +56348,45 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54268
56348
|
claimedBy.set(target.toLowerCase(), source);
|
|
54269
56349
|
continue;
|
|
54270
56350
|
}
|
|
54271
|
-
this.logger.warn(`Both ${previous} and ${source} import to ${(0, node_path.join)(RULESYNC_RULES_RELATIVE_DIR_PATH, target)} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
|
|
56351
|
+
this.logger.warn(`Both ${stripControlCharacters(previous)} and ${stripControlCharacters(source)} import to ${stripControlCharacters((0, node_path.join)(RULESYNC_RULES_RELATIVE_DIR_PATH, target))} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
|
|
54272
56352
|
}
|
|
54273
56353
|
return rulesyncRules;
|
|
54274
56354
|
}
|
|
54275
56355
|
/**
|
|
54276
|
-
*
|
|
54277
|
-
*
|
|
56356
|
+
* Load rulesync rule files from a single source-tree's `rules/` (and
|
|
56357
|
+
* `rules/.curated/`) subtree. `sourceTree` is the source tree itself
|
|
56358
|
+
* (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`), NOT its parent.
|
|
56359
|
+
*
|
|
56360
|
+
* Intra-tree behavior — the local-wins-over-curated rule and the
|
|
56361
|
+
* case-insensitive collision handling — is preserved from the previous
|
|
56362
|
+
* single-root implementation. See `loadRulesyncFiles` for how the
|
|
56363
|
+
* per-root results are combined into the effective set.
|
|
54278
56364
|
*/
|
|
54279
|
-
async
|
|
54280
|
-
const
|
|
54281
|
-
const
|
|
56365
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
56366
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
56367
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
56368
|
+
const treeRulesDirPath = (0, node_path.join)(treeName, RULES_FEATURE_SUBDIR);
|
|
56369
|
+
const rulesyncOutputRoot = (0, node_path.join)(sourceTree, RULES_FEATURE_SUBDIR);
|
|
56370
|
+
const curatedOutputRoot = (0, node_path.join)(sourceTree, CURATED_RULES_FEATURE_SUBDIR);
|
|
54282
56371
|
const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([findFilesByGlobs((0, node_path.join)(rulesyncOutputRoot, "**", "*.md")), findFilesByGlobs((0, node_path.join)(curatedOutputRoot, "**", "*.md"))]);
|
|
54283
56372
|
const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
|
|
54284
56373
|
const localFiles = files.filter((file) => !(0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`));
|
|
54285
|
-
const
|
|
56374
|
+
const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => (0, node_path.relative)(rulesyncOutputRoot, file)));
|
|
54286
56375
|
const curatedFiles = files.filter((file) => (0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`)).map((file) => ({
|
|
54287
56376
|
file,
|
|
54288
56377
|
relativeFilePath: (0, node_path.relative)(curatedOutputRoot, file)
|
|
54289
|
-
})).filter(({ relativeFilePath }) =>
|
|
56378
|
+
})).filter(({ relativeFilePath }) => {
|
|
56379
|
+
const spellings = localRelativePathsByIdentity.get(caseFoldIdentity(relativeFilePath));
|
|
56380
|
+
if (spellings === void 0) return true;
|
|
56381
|
+
if (!spellings.includes(relativeFilePath)) this.logger.warn(formatCuratedCaseCollisionWarning({
|
|
56382
|
+
artifactKind: "rule",
|
|
56383
|
+
entryNoun: "file",
|
|
56384
|
+
treeDirPath: treeRulesDirPath,
|
|
56385
|
+
curatedSpelling: (0, node_path.join)(".curated", relativeFilePath),
|
|
56386
|
+
localSpellings: spellings
|
|
56387
|
+
}));
|
|
56388
|
+
return false;
|
|
56389
|
+
});
|
|
54290
56390
|
const selectedFiles = [...localFiles.map((file) => ({
|
|
54291
56391
|
file,
|
|
54292
56392
|
sourceRelativeFilePath: (0, node_path.relative)(rulesyncOutputRoot, file),
|
|
@@ -54296,25 +56396,41 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54296
56396
|
sourceRelativeFilePath: (0, node_path.join)(".curated", relativeFilePath),
|
|
54297
56397
|
relativeFilePath
|
|
54298
56398
|
}))];
|
|
54299
|
-
this.logger.debug(`Found ${selectedFiles.length} rulesync files`);
|
|
54300
|
-
|
|
56399
|
+
this.logger.debug(`Found ${selectedFiles.length} rulesync files under ${rulesyncOutputRoot}`);
|
|
56400
|
+
return await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
|
|
54301
56401
|
checkPathTraversal({
|
|
54302
56402
|
relativePath: sourceRelativeFilePath,
|
|
54303
56403
|
intendedRootDir: rulesyncOutputRoot
|
|
54304
56404
|
});
|
|
54305
56405
|
const rule = await RulesyncRule.fromFile({
|
|
54306
|
-
outputRoot:
|
|
56406
|
+
outputRoot: treeParent,
|
|
56407
|
+
relativeDirPath: treeRulesDirPath,
|
|
54307
56408
|
relativeFilePath: sourceRelativeFilePath
|
|
54308
56409
|
});
|
|
54309
56410
|
if (sourceRelativeFilePath === relativeFilePath) return rule;
|
|
54310
56411
|
return new RulesyncRule({
|
|
54311
|
-
outputRoot:
|
|
54312
|
-
relativeDirPath:
|
|
56412
|
+
outputRoot: treeParent,
|
|
56413
|
+
relativeDirPath: treeRulesDirPath,
|
|
54313
56414
|
relativeFilePath,
|
|
54314
56415
|
frontmatter: rule.getFrontmatter(),
|
|
54315
56416
|
body: rule.getBody()
|
|
54316
56417
|
});
|
|
54317
56418
|
}));
|
|
56419
|
+
}
|
|
56420
|
+
/**
|
|
56421
|
+
* Implementation of abstract method from FeatureProcessor
|
|
56422
|
+
* Load and parse rulesync rule files from every configured input root's
|
|
56423
|
+
* `.rulesync/rules/` directory, merging by relative path so that a rule
|
|
56424
|
+
* with the same target path from a later root replaces the earlier root's
|
|
56425
|
+
* copy (case-insensitive, matching the intra-root collision handling).
|
|
56426
|
+
*/
|
|
56427
|
+
async loadRulesyncFiles() {
|
|
56428
|
+
const rulesyncRules = mergeByCaseInsensitiveIdentity({
|
|
56429
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
56430
|
+
identity: (rule) => rule.getRelativeFilePath(),
|
|
56431
|
+
artifactName: "rule",
|
|
56432
|
+
logger: this.logger
|
|
56433
|
+
});
|
|
54318
56434
|
const factory = this.getFactory(this.toolTarget);
|
|
54319
56435
|
const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
|
|
54320
56436
|
if (targetedRootRules.length === 0 && rulesyncRules.length > 0) this.logger.warn(`No root rulesync rule file found for target '${this.toolTarget}'. Consider adding 'root: true' to one of your rule files in ${RULESYNC_RULES_RELATIVE_DIR_PATH}.`);
|
|
@@ -54408,23 +56524,42 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54408
56524
|
});
|
|
54409
56525
|
}).filter((rule) => rule.isDeletable());
|
|
54410
56526
|
};
|
|
56527
|
+
/**
|
|
56528
|
+
* Import counterpart of {@link buildDeletionRulesFromPaths} for the
|
|
56529
|
+
* root-shaped scans (root, legacy roots, and read-only roots), whose
|
|
56530
|
+
* paths all sit at a directory Rulesync resolves from the file itself.
|
|
56531
|
+
*/
|
|
56532
|
+
const buildImportRulesFromPaths = (filePaths) => Promise.all(filePaths.map((filePath) => {
|
|
56533
|
+
const relativeDirPath = resolveRelativeDirPath(filePath);
|
|
56534
|
+
checkPathTraversal({
|
|
56535
|
+
relativePath: relativeDirPath,
|
|
56536
|
+
intendedRootDir: this.outputRoot
|
|
56537
|
+
});
|
|
56538
|
+
return factory.class.fromFile({
|
|
56539
|
+
outputRoot: this.outputRoot,
|
|
56540
|
+
relativeDirPath,
|
|
56541
|
+
relativeFilePath: (0, node_path.basename)(filePath),
|
|
56542
|
+
global: this.global
|
|
56543
|
+
});
|
|
56544
|
+
}));
|
|
56545
|
+
/**
|
|
56546
|
+
* The tool's own root file, as opposed to whichever root
|
|
56547
|
+
* `rootToolRules` ends up resolving. A legacy root reached through
|
|
56548
|
+
* `alternativeRoots` is deliberately not counted here: it is a
|
|
56549
|
+
* hand-authored file Rulesync never writes, so it has folded nothing in,
|
|
56550
|
+
* and in Junie's resolution order it ranks *below* the multi-file layout
|
|
56551
|
+
* that `importOnlyRoots` describes. Gating those roots on it would drop
|
|
56552
|
+
* exactly the files the tool is really reading.
|
|
56553
|
+
*
|
|
56554
|
+
* Resolved once, up front, so the two blocks that need it do not depend
|
|
56555
|
+
* on each other's evaluation order.
|
|
56556
|
+
*/
|
|
56557
|
+
const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath)) : [];
|
|
54411
56558
|
const rootToolRules = await (async () => {
|
|
54412
56559
|
if (!settablePaths.root) return [];
|
|
54413
|
-
const uniqueRootFilePaths = await findFilesWithFallback(
|
|
56560
|
+
const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
|
|
54414
56561
|
if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
|
|
54415
|
-
return await
|
|
54416
|
-
const relativeDirPath = resolveRelativeDirPath(filePath);
|
|
54417
|
-
checkPathTraversal({
|
|
54418
|
-
relativePath: relativeDirPath,
|
|
54419
|
-
intendedRootDir: this.outputRoot
|
|
54420
|
-
});
|
|
54421
|
-
return factory.class.fromFile({
|
|
54422
|
-
outputRoot: this.outputRoot,
|
|
54423
|
-
relativeFilePath: (0, node_path.basename)(filePath),
|
|
54424
|
-
relativeDirPath,
|
|
54425
|
-
global: this.global
|
|
54426
|
-
});
|
|
54427
|
-
}));
|
|
56562
|
+
return await buildImportRulesFromPaths(uniqueRootFilePaths);
|
|
54428
56563
|
})();
|
|
54429
56564
|
this.logger.debug(`Found ${rootToolRules.length} root tool rule files`);
|
|
54430
56565
|
const localRootToolRules = await (async () => {
|
|
@@ -54436,7 +56571,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54436
56571
|
fileName
|
|
54437
56572
|
}));
|
|
54438
56573
|
if (!settablePaths.root) return [];
|
|
54439
|
-
return await findFilesWithFallback((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, fileName));
|
|
56574
|
+
return await findFilesWithFallback(await findFilesByGlobs((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName)), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, fileName));
|
|
54440
56575
|
})();
|
|
54441
56576
|
if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
|
|
54442
56577
|
return (await Promise.all(filePaths.map(async (filePath) => {
|
|
@@ -54512,6 +56647,29 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54512
56647
|
}));
|
|
54513
56648
|
})();
|
|
54514
56649
|
this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`);
|
|
56650
|
+
const importOnlyToolRules = await (async () => {
|
|
56651
|
+
const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
|
|
56652
|
+
if (forDeletion || !importOnlyRoots || importOnlyRoots.length === 0) return [];
|
|
56653
|
+
const rootFilePath = primaryRootFilePaths[0];
|
|
56654
|
+
const scannedPaths = [];
|
|
56655
|
+
const skippedPaths = [];
|
|
56656
|
+
for (const importOnlyRoot of importOnlyRoots) {
|
|
56657
|
+
const matchedPaths = await findFilesByGlobs((0, node_path.join)(this.outputRoot, importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), { type: "file" });
|
|
56658
|
+
if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
|
|
56659
|
+
skippedPaths.push(...matchedPaths);
|
|
56660
|
+
continue;
|
|
56661
|
+
}
|
|
56662
|
+
scannedPaths.push(...matchedPaths);
|
|
56663
|
+
}
|
|
56664
|
+
if (skippedPaths.length > 0 && rootFilePath !== void 0) {
|
|
56665
|
+
const skippedNames = skippedPaths.map((filePath) => stripControlCharacters((0, node_path.relative)(this.outputRoot, filePath)));
|
|
56666
|
+
const listedNames = skippedNames.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
|
|
56667
|
+
const remainingCount = skippedNames.length - listedNames.length;
|
|
56668
|
+
this.logger.warn(`Not importing ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} for ${this.toolTarget}: ${stripControlCharacters((0, node_path.relative)(this.outputRoot, rootFilePath))} exists, and the tool reads that file exclusively. Delete them once you have checked that content is in the root file, or move it into ${RULESYNC_RULES_RELATIVE_DIR_PATH} if it is not.`);
|
|
56669
|
+
}
|
|
56670
|
+
return await buildImportRulesFromPaths(scannedPaths);
|
|
56671
|
+
})();
|
|
56672
|
+
this.logger.debug(`Found ${importOnlyToolRules.length} import-only tool rule files`);
|
|
54515
56673
|
const nonRootToolRules = await (async () => {
|
|
54516
56674
|
if (!settablePaths.nonRoot) return [];
|
|
54517
56675
|
const nonRootOutputRoot = (0, node_path.join)(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
|
|
@@ -54544,6 +56702,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54544
56702
|
})();
|
|
54545
56703
|
this.logger.debug(`Found ${nonRootToolRules.length} non-root tool rule files`);
|
|
54546
56704
|
return [
|
|
56705
|
+
...importOnlyToolRules,
|
|
54547
56706
|
...rootToolRules,
|
|
54548
56707
|
...localRootToolRules,
|
|
54549
56708
|
...rootMirrorDeletionRules,
|
|
@@ -54657,6 +56816,7 @@ function resolveToolOutputRoot({ outputRoot, toolTarget, global }) {
|
|
|
54657
56816
|
* `.rulesync/` files to disk. Rulesync file instances live in memory only.
|
|
54658
56817
|
*/
|
|
54659
56818
|
async function convertFromTool(params) {
|
|
56819
|
+
resetWarnedOnceMessages();
|
|
54660
56820
|
const packagingTarget = [params.fromTool, ...params.toTools].find(isPackagingToolTarget);
|
|
54661
56821
|
if (packagingTarget) throw new Error(`Plugin packaging target '${packagingTarget}' is not supported by convert. Use import and generate with explicit plugin directories.`);
|
|
54662
56822
|
const ctx = params;
|
|
@@ -55351,14 +57511,53 @@ function warnUnsupportedTargets(params) {
|
|
|
55351
57511
|
}
|
|
55352
57512
|
}
|
|
55353
57513
|
/**
|
|
55354
|
-
*
|
|
55355
|
-
*
|
|
55356
|
-
*
|
|
55357
|
-
*
|
|
55358
|
-
*
|
|
57514
|
+
* Inspect every configured input-root path. The first entry is the required
|
|
57515
|
+
* base source tree; later entries are optional overlays and may be absent.
|
|
57516
|
+
* Each existing entry is a rulesync source tree itself (the directory that
|
|
57517
|
+
* directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). Existing empty
|
|
57518
|
+
* directories are valid because delete and check workflows still need to
|
|
57519
|
+
* inspect generated outputs.
|
|
55359
57520
|
*/
|
|
55360
|
-
async function
|
|
55361
|
-
|
|
57521
|
+
async function inspectInputRoots(inputRoots) {
|
|
57522
|
+
const existing = [];
|
|
57523
|
+
const missing = [];
|
|
57524
|
+
const invalidOverlays = [];
|
|
57525
|
+
const nonDirectories = /* @__PURE__ */ new Set();
|
|
57526
|
+
for (const [index, root] of inputRoots.entries()) if (await directoryExists(root)) existing.push(root);
|
|
57527
|
+
else {
|
|
57528
|
+
missing.push(root);
|
|
57529
|
+
if (await fileExists(root)) {
|
|
57530
|
+
nonDirectories.add(root);
|
|
57531
|
+
if (index > 0) invalidOverlays.push(root);
|
|
57532
|
+
}
|
|
57533
|
+
}
|
|
57534
|
+
const primaryRoot = inputRoots[0];
|
|
57535
|
+
const displayPrimaryRoot = stripControlCharacters(primaryRoot ?? "");
|
|
57536
|
+
if (primaryRoot === void 0 || existing.includes(primaryRoot)) {
|
|
57537
|
+
const invalidOverlay = invalidOverlays[0];
|
|
57538
|
+
return {
|
|
57539
|
+
existing,
|
|
57540
|
+
missing,
|
|
57541
|
+
message: invalidOverlay === void 0 ? void 0 : `Configured optional input root '${stripControlCharacters(invalidOverlay)}' exists but is not a directory.`
|
|
57542
|
+
};
|
|
57543
|
+
}
|
|
57544
|
+
const defaultRoot = (0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH);
|
|
57545
|
+
if (primaryRoot === defaultRoot && !nonDirectories.has(primaryRoot)) return {
|
|
57546
|
+
existing,
|
|
57547
|
+
missing,
|
|
57548
|
+
message: `Rulesync source directory '${defaultRoot}' does not exist. Run 'rulesync init' first.`
|
|
57549
|
+
};
|
|
57550
|
+
const settingHint = `your input root setting ('inputRoots', or the deprecated 'inputRoot')`;
|
|
57551
|
+
if (nonDirectories.has(primaryRoot)) return {
|
|
57552
|
+
existing,
|
|
57553
|
+
missing,
|
|
57554
|
+
message: `Configured primary input root '${displayPrimaryRoot}' exists but is not a directory. Point ${settingHint} at a directory.`
|
|
57555
|
+
};
|
|
57556
|
+
return {
|
|
57557
|
+
existing,
|
|
57558
|
+
missing,
|
|
57559
|
+
message: `Configured primary input root '${displayPrimaryRoot}' does not exist. Create the directory or update ${settingHint}.`
|
|
57560
|
+
};
|
|
55362
57561
|
}
|
|
55363
57562
|
function dependsOnReachable(byId, from, target) {
|
|
55364
57563
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -55489,7 +57688,7 @@ async function warnSkillSubagentNameCollisions(params) {
|
|
|
55489
57688
|
const subagentsDirPath = subagentFactory.class.getSettablePaths({ global }).relativeDirPath;
|
|
55490
57689
|
if (subagentsDirPath !== skillFactory.class.getSettablePaths({ global }).relativeDirPath) continue;
|
|
55491
57690
|
const subagentsProcessor = new SubagentsProcessor({
|
|
55492
|
-
|
|
57691
|
+
inputRoots: config.getInputRoots(),
|
|
55493
57692
|
toolTarget,
|
|
55494
57693
|
global,
|
|
55495
57694
|
logger
|
|
@@ -55497,7 +57696,7 @@ async function warnSkillSubagentNameCollisions(params) {
|
|
|
55497
57696
|
const subagentNames = new Set((await subagentsProcessor.loadRulesyncFiles()).filter((file) => file instanceof RulesyncSubagent).filter((file) => subagentFactory.class.isTargetedByRulesyncSubagent(file)).map((file) => (0, node_path.basename)(file.getRelativeFilePath(), (0, node_path.extname)(file.getRelativeFilePath()))));
|
|
55498
57697
|
if (subagentNames.size === 0) continue;
|
|
55499
57698
|
const skillNames = (await new SkillsProcessor({
|
|
55500
|
-
|
|
57699
|
+
inputRoots: config.getInputRoots(),
|
|
55501
57700
|
toolTarget,
|
|
55502
57701
|
global,
|
|
55503
57702
|
logger
|
|
@@ -55546,6 +57745,8 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
|
|
|
55546
57745
|
*/
|
|
55547
57746
|
async function generate(params) {
|
|
55548
57747
|
const { config, logger } = params;
|
|
57748
|
+
resetRootShadowingWarnings({ logger });
|
|
57749
|
+
resetWarnedOnceMessages();
|
|
55549
57750
|
for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
|
|
55550
57751
|
toolTarget,
|
|
55551
57752
|
outputRoot
|
|
@@ -55684,7 +57885,7 @@ async function generateRulesCore(params) {
|
|
|
55684
57885
|
toolTarget,
|
|
55685
57886
|
global: config.getGlobal()
|
|
55686
57887
|
}),
|
|
55687
|
-
|
|
57888
|
+
inputRoots: config.getInputRoots(),
|
|
55688
57889
|
toolTarget,
|
|
55689
57890
|
global: config.getGlobal(),
|
|
55690
57891
|
simulateCommands: config.getSimulateCommands(),
|
|
@@ -55734,7 +57935,7 @@ async function generateIgnoreCore(params) {
|
|
|
55734
57935
|
for (const outputRoot of config.getOutputRoots(toolTarget)) try {
|
|
55735
57936
|
const processor = new IgnoreProcessor({
|
|
55736
57937
|
outputRoot,
|
|
55737
|
-
|
|
57938
|
+
inputRoots: config.getInputRoots(),
|
|
55738
57939
|
toolTarget,
|
|
55739
57940
|
global,
|
|
55740
57941
|
dryRun: config.isPreviewMode(),
|
|
@@ -55781,7 +57982,7 @@ async function generateMcpCore(params) {
|
|
|
55781
57982
|
toolTarget,
|
|
55782
57983
|
global: config.getGlobal()
|
|
55783
57984
|
}),
|
|
55784
|
-
|
|
57985
|
+
inputRoots: config.getInputRoots(),
|
|
55785
57986
|
toolTarget,
|
|
55786
57987
|
global: config.getGlobal(),
|
|
55787
57988
|
dryRun: config.isPreviewMode(),
|
|
@@ -55827,7 +58028,7 @@ async function generateCommandsCore(params) {
|
|
|
55827
58028
|
toolTarget,
|
|
55828
58029
|
global: config.getGlobal()
|
|
55829
58030
|
}),
|
|
55830
|
-
|
|
58031
|
+
inputRoots: config.getInputRoots(),
|
|
55831
58032
|
toolTarget,
|
|
55832
58033
|
global: config.getGlobal(),
|
|
55833
58034
|
dryRun: config.isPreviewMode(),
|
|
@@ -55874,7 +58075,7 @@ async function generateSubagentsCore(params) {
|
|
|
55874
58075
|
toolTarget,
|
|
55875
58076
|
global: config.getGlobal()
|
|
55876
58077
|
}),
|
|
55877
|
-
|
|
58078
|
+
inputRoots: config.getInputRoots(),
|
|
55878
58079
|
toolTarget,
|
|
55879
58080
|
global: config.getGlobal(),
|
|
55880
58081
|
dryRun: config.isPreviewMode(),
|
|
@@ -55921,7 +58122,7 @@ async function generateSkillsCore(params) {
|
|
|
55921
58122
|
toolTarget,
|
|
55922
58123
|
global: config.getGlobal()
|
|
55923
58124
|
}),
|
|
55924
|
-
|
|
58125
|
+
inputRoots: config.getInputRoots(),
|
|
55925
58126
|
toolTarget,
|
|
55926
58127
|
global: config.getGlobal(),
|
|
55927
58128
|
dryRun: config.isPreviewMode(),
|
|
@@ -55966,7 +58167,7 @@ async function generateHooksCore(params) {
|
|
|
55966
58167
|
toolTarget,
|
|
55967
58168
|
global: config.getGlobal()
|
|
55968
58169
|
}),
|
|
55969
|
-
|
|
58170
|
+
inputRoots: config.getInputRoots(),
|
|
55970
58171
|
toolTarget,
|
|
55971
58172
|
global: config.getGlobal(),
|
|
55972
58173
|
dryRun: config.isPreviewMode(),
|
|
@@ -56008,7 +58209,7 @@ async function generatePermissionsCore(params) {
|
|
|
56008
58209
|
toolTarget,
|
|
56009
58210
|
global: config.getGlobal()
|
|
56010
58211
|
}),
|
|
56011
|
-
|
|
58212
|
+
inputRoots: config.getInputRoots(),
|
|
56012
58213
|
toolTarget,
|
|
56013
58214
|
global: config.getGlobal(),
|
|
56014
58215
|
dryRun: config.isPreviewMode(),
|
|
@@ -56054,7 +58255,7 @@ async function generateChecksCore(params) {
|
|
|
56054
58255
|
toolTarget,
|
|
56055
58256
|
global: config.getGlobal()
|
|
56056
58257
|
}),
|
|
56057
|
-
|
|
58258
|
+
inputRoots: config.getInputRoots(),
|
|
56058
58259
|
toolTarget,
|
|
56059
58260
|
global: config.getGlobal(),
|
|
56060
58261
|
dryRun: config.isPreviewMode(),
|
|
@@ -56105,6 +58306,7 @@ function getToolOutputRoot({ config, tool }) {
|
|
|
56105
58306
|
*/
|
|
56106
58307
|
async function importFromTool(params) {
|
|
56107
58308
|
const { config, tool, logger } = params;
|
|
58309
|
+
resetWarnedOnceMessages();
|
|
56108
58310
|
await assertPluginRootSafe({
|
|
56109
58311
|
toolTarget: tool,
|
|
56110
58312
|
outputRoot: getToolOutputRoot({
|
|
@@ -56918,12 +59120,6 @@ Object.defineProperty(exports, "checkPathTraversal", {
|
|
|
56918
59120
|
return checkPathTraversal;
|
|
56919
59121
|
}
|
|
56920
59122
|
});
|
|
56921
|
-
Object.defineProperty(exports, "checkRulesyncDirExists", {
|
|
56922
|
-
enumerable: true,
|
|
56923
|
-
get: function() {
|
|
56924
|
-
return checkRulesyncDirExists;
|
|
56925
|
-
}
|
|
56926
|
-
});
|
|
56927
59123
|
Object.defineProperty(exports, "convertFromTool", {
|
|
56928
59124
|
enumerable: true,
|
|
56929
59125
|
get: function() {
|
|
@@ -57020,6 +59216,12 @@ Object.defineProperty(exports, "importFromTool", {
|
|
|
57020
59216
|
return importFromTool;
|
|
57021
59217
|
}
|
|
57022
59218
|
});
|
|
59219
|
+
Object.defineProperty(exports, "inspectInputRoots", {
|
|
59220
|
+
enumerable: true,
|
|
59221
|
+
get: function() {
|
|
59222
|
+
return inspectInputRoots;
|
|
59223
|
+
}
|
|
59224
|
+
});
|
|
57023
59225
|
Object.defineProperty(exports, "isPackagingToolTarget", {
|
|
57024
59226
|
enumerable: true,
|
|
57025
59227
|
get: function() {
|
|
@@ -57044,12 +59246,24 @@ Object.defineProperty(exports, "loadYaml", {
|
|
|
57044
59246
|
return loadYaml;
|
|
57045
59247
|
}
|
|
57046
59248
|
});
|
|
59249
|
+
Object.defineProperty(exports, "mergeInputRootConfigs", {
|
|
59250
|
+
enumerable: true,
|
|
59251
|
+
get: function() {
|
|
59252
|
+
return mergeInputRootConfigs;
|
|
59253
|
+
}
|
|
59254
|
+
});
|
|
57047
59255
|
Object.defineProperty(exports, "parseJsonc", {
|
|
57048
59256
|
enumerable: true,
|
|
57049
59257
|
get: function() {
|
|
57050
59258
|
return parseJsonc$8;
|
|
57051
59259
|
}
|
|
57052
59260
|
});
|
|
59261
|
+
Object.defineProperty(exports, "pathEscapesRoot", {
|
|
59262
|
+
enumerable: true,
|
|
59263
|
+
get: function() {
|
|
59264
|
+
return pathEscapesRoot;
|
|
59265
|
+
}
|
|
59266
|
+
});
|
|
57053
59267
|
Object.defineProperty(exports, "readFileContent", {
|
|
57054
59268
|
enumerable: true,
|
|
57055
59269
|
get: function() {
|
|
@@ -57092,6 +59306,18 @@ Object.defineProperty(exports, "removeTempDirectory", {
|
|
|
57092
59306
|
return removeTempDirectory;
|
|
57093
59307
|
}
|
|
57094
59308
|
});
|
|
59309
|
+
Object.defineProperty(exports, "resetWarnedOnceMessages", {
|
|
59310
|
+
enumerable: true,
|
|
59311
|
+
get: function() {
|
|
59312
|
+
return resetWarnedOnceMessages;
|
|
59313
|
+
}
|
|
59314
|
+
});
|
|
59315
|
+
Object.defineProperty(exports, "resolveEffectiveInputRoots", {
|
|
59316
|
+
enumerable: true,
|
|
59317
|
+
get: function() {
|
|
59318
|
+
return resolveEffectiveInputRoots;
|
|
59319
|
+
}
|
|
59320
|
+
});
|
|
57095
59321
|
Object.defineProperty(exports, "resolvePath", {
|
|
57096
59322
|
enumerable: true,
|
|
57097
59323
|
get: function() {
|