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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ZodError } from "zod";
|
|
2
2
|
import { meta, minLength, nonnegative, optional, refine, z } from "zod/mini";
|
|
3
|
-
import { chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { chmod, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import path, { basename, dirname, extname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
|
|
5
5
|
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
6
6
|
import os from "node:os";
|
|
@@ -9,6 +9,7 @@ import { globbySync, isGitIgnoredSync } from "globby";
|
|
|
9
9
|
import matter from "gray-matter";
|
|
10
10
|
import { YAMLException, dump, load } from "js-yaml";
|
|
11
11
|
import { omit } from "es-toolkit/object";
|
|
12
|
+
import { constants } from "node:fs";
|
|
12
13
|
import { createHash } from "node:crypto";
|
|
13
14
|
import { isDeepStrictEqual } from "node:util";
|
|
14
15
|
import * as smolToml from "smol-toml";
|
|
@@ -113,11 +114,18 @@ const { join: join$1 } = posix;
|
|
|
113
114
|
const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
|
|
114
115
|
const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
|
|
115
116
|
const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
const
|
|
117
|
+
const RULES_FEATURE_SUBDIR = "rules";
|
|
118
|
+
const CURATED_RULES_FEATURE_SUBDIR = join$1(RULES_FEATURE_SUBDIR, ".curated");
|
|
119
|
+
const COMMANDS_FEATURE_SUBDIR = "commands";
|
|
120
|
+
const SUBAGENTS_FEATURE_SUBDIR = "subagents";
|
|
121
|
+
const CHECKS_FEATURE_SUBDIR = "checks";
|
|
122
|
+
const SKILLS_FEATURE_SUBDIR = "skills";
|
|
123
|
+
const CURATED_SKILLS_FEATURE_SUBDIR = join$1(SKILLS_FEATURE_SUBDIR, ".curated");
|
|
124
|
+
const RULESYNC_RULES_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
|
|
125
|
+
const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
|
|
126
|
+
const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
|
|
127
|
+
const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
|
|
128
|
+
const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
|
|
121
129
|
const RULESYNC_MCP_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
|
|
122
130
|
const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
|
|
123
131
|
const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
|
|
@@ -128,8 +136,8 @@ const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
|
|
|
128
136
|
const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
|
|
129
137
|
const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
|
|
130
138
|
const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
|
|
131
|
-
const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH,
|
|
132
|
-
const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$1(
|
|
139
|
+
const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
|
|
140
|
+
const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
|
|
133
141
|
const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
|
|
134
142
|
const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
|
|
135
143
|
const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
|
|
@@ -463,9 +471,22 @@ function isEnvTest() {
|
|
|
463
471
|
}
|
|
464
472
|
//#endregion
|
|
465
473
|
//#region src/utils/file.ts
|
|
474
|
+
/**
|
|
475
|
+
* Whether a relative path leads out of the root it is relative to. Matching
|
|
476
|
+
* whole segments matters: a directory really named `..cache` relatively
|
|
477
|
+
* resolves to `..cache/file`, which a prefix test would report as an escape.
|
|
478
|
+
*/
|
|
466
479
|
function pathEscapesRoot(relativePath) {
|
|
467
480
|
return relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath);
|
|
468
481
|
}
|
|
482
|
+
/** Whether a single path segment is a hidden (dot-prefixed) name. */
|
|
483
|
+
function isHiddenPathSegment(segment) {
|
|
484
|
+
return segment.startsWith(".") && segment !== "." && segment !== "..";
|
|
485
|
+
}
|
|
486
|
+
/** Split a path on both separators, so one predicate serves either platform. */
|
|
487
|
+
function splitPathSegments(filePath) {
|
|
488
|
+
return filePath.split(/[/\\]/);
|
|
489
|
+
}
|
|
469
490
|
async function assertWritablePathInsideRoot(params) {
|
|
470
491
|
const { rootPath, targetPath } = params;
|
|
471
492
|
let existingPath = targetPath;
|
|
@@ -696,8 +717,45 @@ async function listDirectoryFiles(dir) {
|
|
|
696
717
|
return [];
|
|
697
718
|
}
|
|
698
719
|
}
|
|
720
|
+
/** How many dot-prefixed segments a path has, used to prefer a named alias over a hidden one. */
|
|
721
|
+
function countHiddenSegments(filePath) {
|
|
722
|
+
return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* The real file a path denotes, posix-separated so it compares against the globby results
|
|
726
|
+
* that produce it. Two paths share an identity when they resolve to the very same file --
|
|
727
|
+
* a link beside its target, a link into a shared tree, or a cycle that walks back into an
|
|
728
|
+
* ancestor and yields the same file forty levels down.
|
|
729
|
+
*/
|
|
730
|
+
async function realFileIdentity(filePath) {
|
|
731
|
+
try {
|
|
732
|
+
return toPosixPath(await realpath(filePath));
|
|
733
|
+
} catch {
|
|
734
|
+
return toPosixPath(filePath);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Pick the one path that represents a file among the paths that resolve to it.
|
|
739
|
+
*
|
|
740
|
+
* The path that walked through no link at all wins outright: it is already the real one,
|
|
741
|
+
* so it equals the file's identity. That keeps the real location of a file as the path
|
|
742
|
+
* callers see, rather than an alias that happens to sort first -- a directory link named
|
|
743
|
+
* `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a cycle must not replace
|
|
744
|
+
* `sub/note.md` with the same file reached back through the cycle.
|
|
745
|
+
* Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
|
|
746
|
+
* named one represents the entry rather than a hidden alias that a hidden-entry rule may
|
|
747
|
+
* then drop, taking the named path's content with it. `candidates` arrives in sorted
|
|
748
|
+
* order, so ties keep the first one deterministically.
|
|
749
|
+
*/
|
|
750
|
+
function chooseRepresentative(candidates, identity) {
|
|
751
|
+
return candidates.reduce((best, candidate) => {
|
|
752
|
+
if (toPosixPath(best) === identity) return best;
|
|
753
|
+
if (toPosixPath(candidate) === identity) return candidate;
|
|
754
|
+
return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
|
|
755
|
+
});
|
|
756
|
+
}
|
|
699
757
|
async function findFilesByGlobs(globs, options = {}) {
|
|
700
|
-
const { type = "all", followSymbolicLinks = true, ignore } = options;
|
|
758
|
+
const { type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
|
|
701
759
|
const globbyOptions = type === "file" ? {
|
|
702
760
|
onlyFiles: true,
|
|
703
761
|
onlyDirectories: false
|
|
@@ -712,23 +770,18 @@ async function findFilesByGlobs(globs, options = {}) {
|
|
|
712
770
|
const results = globbySync(normalizedGlobs, {
|
|
713
771
|
absolute: true,
|
|
714
772
|
followSymbolicLinks,
|
|
773
|
+
dot,
|
|
715
774
|
...ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {},
|
|
716
775
|
...globbyOptions
|
|
717
776
|
});
|
|
718
|
-
const
|
|
719
|
-
const deduped = [];
|
|
777
|
+
const candidatesByFile = /* @__PURE__ */ new Map();
|
|
720
778
|
for (const result of results.toSorted()) {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
realResult = result;
|
|
726
|
-
}
|
|
727
|
-
if (seenRealPaths.has(realResult)) continue;
|
|
728
|
-
seenRealPaths.add(realResult);
|
|
729
|
-
deduped.push(result);
|
|
779
|
+
const identity = await realFileIdentity(result);
|
|
780
|
+
const candidates = candidatesByFile.get(identity);
|
|
781
|
+
if (candidates === void 0) candidatesByFile.set(identity, [result]);
|
|
782
|
+
else candidates.push(result);
|
|
730
783
|
}
|
|
731
|
-
return
|
|
784
|
+
return [...candidatesByFile.entries()].map(([identity, candidates]) => chooseRepresentative(candidates, identity)).toSorted();
|
|
732
785
|
}
|
|
733
786
|
async function removeDirectory(dirPath) {
|
|
734
787
|
if ([
|
|
@@ -887,6 +940,25 @@ var CLIError = class extends Error {
|
|
|
887
940
|
}
|
|
888
941
|
};
|
|
889
942
|
//#endregion
|
|
943
|
+
//#region src/utils/warned-once.ts
|
|
944
|
+
/**
|
|
945
|
+
* The messages a once-per-run warning has already emitted in this process.
|
|
946
|
+
* This lives in its own module, free of imports, so the vitest setup file can
|
|
947
|
+
* clear it between tests without pulling `logger.js` into every test's module
|
|
948
|
+
* graph (which would defeat the module mocks some of those tests install).
|
|
949
|
+
*/
|
|
950
|
+
const warnedOnceMessages = /* @__PURE__ */ new Set();
|
|
951
|
+
/** Whether `message` has not been emitted yet; records it when it has not. */
|
|
952
|
+
function claimWarnOnce(message) {
|
|
953
|
+
if (warnedOnceMessages.has(message)) return false;
|
|
954
|
+
warnedOnceMessages.add(message);
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
/** Forget which warnings were already emitted, so each test starts silent. */
|
|
958
|
+
function resetWarnedOnceMessages() {
|
|
959
|
+
warnedOnceMessages.clear();
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
890
962
|
//#region src/utils/logger.ts
|
|
891
963
|
/**
|
|
892
964
|
* Base class for shared verbose/silent state and configuration logic
|
|
@@ -1039,6 +1111,17 @@ const fallbackLogger = new ConsoleLogger();
|
|
|
1039
1111
|
function warnWithFallback(logger, message) {
|
|
1040
1112
|
(logger ?? fallbackLogger).warn(message);
|
|
1041
1113
|
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Emit a warning at most once per run. A single `generate` reads the same source
|
|
1116
|
+
* file once per enabled tool target, so a warning that describes the source
|
|
1117
|
+
* rather than the target would otherwise be printed a dozen identical times.
|
|
1118
|
+
* Diagnostics that name the file they are about qualify; anything whose text
|
|
1119
|
+
* varies with what the user should do next does not.
|
|
1120
|
+
*/
|
|
1121
|
+
function warnOnceWithFallback(logger, message) {
|
|
1122
|
+
if (!claimWarnOnce(message)) return;
|
|
1123
|
+
warnWithFallback(logger, message);
|
|
1124
|
+
}
|
|
1042
1125
|
//#endregion
|
|
1043
1126
|
//#region src/utils/validation.ts
|
|
1044
1127
|
/**
|
|
@@ -1117,6 +1200,7 @@ const ConfigParamsSchema = z.object({
|
|
|
1117
1200
|
dryRun: optional(z.boolean()),
|
|
1118
1201
|
check: optional(z.boolean()),
|
|
1119
1202
|
inputRoot: optional(z.string()),
|
|
1203
|
+
inputRoots: optional(z.array(z.string()).check(minLength(1, "inputRoots must be non-empty"))),
|
|
1120
1204
|
sources: optional(z.array(SourceEntrySchema))
|
|
1121
1205
|
});
|
|
1122
1206
|
z.partial(ConfigParamsSchema);
|
|
@@ -1129,14 +1213,39 @@ z.required(ConfigParamsSchema);
|
|
|
1129
1213
|
* Normalizes the configuration file location to an absolute path.
|
|
1130
1214
|
*
|
|
1131
1215
|
* `ConfigResolver` always supplies the path it actually loaded; the fallback
|
|
1132
|
-
* only covers direct programmatic construction
|
|
1133
|
-
*
|
|
1216
|
+
* only covers direct programmatic construction. `anchorDir` is the directory
|
|
1217
|
+
* the config file lives next to — for the default `.rulesync/` layout this
|
|
1218
|
+
* is the parent of the primary source tree.
|
|
1134
1219
|
*/
|
|
1135
|
-
function normalizeConfigFilePath({ configFilePath,
|
|
1136
|
-
if (configFilePath === void 0) return join(
|
|
1220
|
+
function normalizeConfigFilePath({ configFilePath, anchorDir }) {
|
|
1221
|
+
if (configFilePath === void 0) return join(anchorDir, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
|
|
1137
1222
|
return isAbsolute(configFilePath) ? configFilePath : resolve(configFilePath);
|
|
1138
1223
|
}
|
|
1139
1224
|
/**
|
|
1225
|
+
* Resolves any accepted input-root shape (`inputRoot`, `inputRoots`, or
|
|
1226
|
+
* neither) to the canonical non-empty tuple of absolute paths that
|
|
1227
|
+
* `Config` stores. Relative entries are resolved against the current
|
|
1228
|
+
* working directory at call time.
|
|
1229
|
+
*
|
|
1230
|
+
* Semantics (post-refactor):
|
|
1231
|
+
* - Each entry in `inputRoots` is a rulesync **source tree** (the directory
|
|
1232
|
+
* that directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). No implicit
|
|
1233
|
+
* `.rulesync/` join is applied.
|
|
1234
|
+
* - The legacy singular `inputRoot` is a shorthand for "parent of the
|
|
1235
|
+
* default `.rulesync/` source tree", and is expanded to
|
|
1236
|
+
* `[join(inputRoot, ".rulesync")]` before hitting any consumer. This is
|
|
1237
|
+
* the ONLY place `.rulesync` is appended by convention.
|
|
1238
|
+
* - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
|
|
1239
|
+
* so existing projects with a single `.rulesync/` tree keep working
|
|
1240
|
+
* unchanged.
|
|
1241
|
+
*
|
|
1242
|
+
* Callers must have already run `assertInputRootFieldsExclusive`.
|
|
1243
|
+
*/
|
|
1244
|
+
function normalizeInputRoots({ inputRoot, inputRoots }) {
|
|
1245
|
+
const resolved = (inputRoots !== void 0 && inputRoots.length > 0 ? inputRoots : inputRoot !== void 0 ? [join(inputRoot, RULESYNC_RELATIVE_DIR_PATH)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)]).map((entry) => isAbsolute(entry) ? entry : resolve(entry));
|
|
1246
|
+
return [resolved[0], ...resolved.slice(1)];
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1140
1249
|
* Conflicting target pairs that cannot be used together.
|
|
1141
1250
|
* Exported so `rulesync doctor` can report the same conflicts as diagnostics
|
|
1142
1251
|
* without duplicating the list.
|
|
@@ -1173,6 +1282,28 @@ const assertTargetsFeaturesExclusive = ({ targets, features }) => {
|
|
|
1173
1282
|
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.");
|
|
1174
1283
|
};
|
|
1175
1284
|
/**
|
|
1285
|
+
* Rejects a single user-authored config file (or a single programmatic
|
|
1286
|
+
* construction) that defines both `inputRoot` and `inputRoots` — the two
|
|
1287
|
+
* fields express the same setting at singular vs. list level and cannot
|
|
1288
|
+
* be combined within one file without ambiguity.
|
|
1289
|
+
*
|
|
1290
|
+
* The check is intentionally per-file: base and local config files can each
|
|
1291
|
+
* be valid in isolation and merge into a state where both survive, and the
|
|
1292
|
+
* resolver picks `inputRoots` in that case (see `resolveEffectiveInputRoots`).
|
|
1293
|
+
* Only a single file declaring both is a genuine authoring error.
|
|
1294
|
+
*/
|
|
1295
|
+
const assertInputRootFieldsExclusive = ({ inputRoot, inputRoots }) => {
|
|
1296
|
+
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.");
|
|
1297
|
+
};
|
|
1298
|
+
/**
|
|
1299
|
+
* Rejects an explicitly supplied empty `inputRoots` list. Omitting the field
|
|
1300
|
+
* selects the conventional default, while an empty list has no meaningful
|
|
1301
|
+
* source-tree semantics and must not be treated as an absent override.
|
|
1302
|
+
*/
|
|
1303
|
+
const assertInputRootsNonEmpty = ({ inputRoots }) => {
|
|
1304
|
+
if (inputRoots !== void 0 && inputRoots.length === 0) throw new Error("Invalid config: 'inputRoots' must be non-empty.");
|
|
1305
|
+
};
|
|
1306
|
+
/**
|
|
1176
1307
|
* Normalizes a post-resolution `ConfigParams` input by rejecting the case
|
|
1177
1308
|
* where both `targets` and `features` are undefined — a degenerate state
|
|
1178
1309
|
* that would silently produce a no-op config (no targets, no features).
|
|
@@ -1208,14 +1339,35 @@ var Config = class Config {
|
|
|
1208
1339
|
gitignoreDestination;
|
|
1209
1340
|
dryRun;
|
|
1210
1341
|
check;
|
|
1211
|
-
|
|
1342
|
+
/**
|
|
1343
|
+
* Ordered, absolute-path list of rulesync source trees. Each entry is a
|
|
1344
|
+
* source tree itself — the directory that directly contains `rules/`,
|
|
1345
|
+
* `skills/`, `mcp.jsonc`, etc. No implicit `.rulesync/` join is applied.
|
|
1346
|
+
*
|
|
1347
|
+
* Always non-empty by construction — the constructor either normalizes
|
|
1348
|
+
* an `inputRoot`/`inputRoots` input or falls back to a single-element
|
|
1349
|
+
* list containing `join(<cwd>, ".rulesync")`.
|
|
1350
|
+
*
|
|
1351
|
+
* `inputRoot` (singular) is a deprecated backward-compatibility alias
|
|
1352
|
+
* that expands to `[join(inputRoot, ".rulesync")]`.
|
|
1353
|
+
*
|
|
1354
|
+
* Typed as a non-empty tuple so the one internal caller that legitimately
|
|
1355
|
+
* needs "the primary root" (`normalizeConfigFilePath` fallback) can index
|
|
1356
|
+
* `[0]` without a runtime null-check.
|
|
1357
|
+
*/
|
|
1358
|
+
inputRoots;
|
|
1212
1359
|
configFilePath;
|
|
1213
1360
|
sources;
|
|
1214
|
-
constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, configFilePath, sources, configFileTargets }) {
|
|
1361
|
+
constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
|
|
1215
1362
|
assertTargetsFeaturesExclusive({
|
|
1216
1363
|
targets,
|
|
1217
1364
|
features
|
|
1218
1365
|
});
|
|
1366
|
+
assertInputRootFieldsExclusive({
|
|
1367
|
+
inputRoot,
|
|
1368
|
+
inputRoots
|
|
1369
|
+
});
|
|
1370
|
+
assertInputRootsNonEmpty({ inputRoots });
|
|
1219
1371
|
assertTargetsOrFeaturesProvided({
|
|
1220
1372
|
targets,
|
|
1221
1373
|
features
|
|
@@ -1243,10 +1395,13 @@ var Config = class Config {
|
|
|
1243
1395
|
this.gitignoreDestination = gitignoreDestination ?? "gitignore";
|
|
1244
1396
|
this.dryRun = dryRun ?? false;
|
|
1245
1397
|
this.check = check ?? false;
|
|
1246
|
-
this.
|
|
1398
|
+
this.inputRoots = normalizeInputRoots({
|
|
1399
|
+
inputRoot,
|
|
1400
|
+
inputRoots
|
|
1401
|
+
});
|
|
1247
1402
|
this.configFilePath = normalizeConfigFilePath({
|
|
1248
1403
|
configFilePath,
|
|
1249
|
-
|
|
1404
|
+
anchorDir: dirname(this.inputRoots[0])
|
|
1250
1405
|
});
|
|
1251
1406
|
this.sources = sources ?? [];
|
|
1252
1407
|
}
|
|
@@ -1430,16 +1585,22 @@ var Config = class Config {
|
|
|
1430
1585
|
return this.check;
|
|
1431
1586
|
}
|
|
1432
1587
|
/**
|
|
1433
|
-
* Returns the
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1588
|
+
* Returns the ordered list of rulesync source trees. Each entry is the
|
|
1589
|
+
* source tree itself — the directory that directly contains `rules/`,
|
|
1590
|
+
* `skills/`, `mcp.jsonc`, etc. Values are absolute paths captured at
|
|
1591
|
+
* config-construction time, so this accessor is pure and never depends on
|
|
1592
|
+
* a live `process.cwd()` read.
|
|
1436
1593
|
*
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1594
|
+
* The returned tuple is always non-empty: when no `inputRoot`/`inputRoots`
|
|
1595
|
+
* was supplied, `[join(process.cwd(), ".rulesync")]` is snapshotted once
|
|
1596
|
+
* during construction. The first entry is the required base source tree.
|
|
1597
|
+
* Later entries are optional overlays and may be absent; when present, they
|
|
1598
|
+
* take precedence when the same relative source path exists in more than
|
|
1599
|
+
* one root (see per-feature merge policies in the processor
|
|
1600
|
+
* `loadRulesync*` methods).
|
|
1440
1601
|
*/
|
|
1441
|
-
|
|
1442
|
-
return this.
|
|
1602
|
+
getInputRoots() {
|
|
1603
|
+
return this.inputRoots;
|
|
1443
1604
|
}
|
|
1444
1605
|
/**
|
|
1445
1606
|
* Returns the absolute path of the configuration file this config was
|
|
@@ -1480,6 +1641,7 @@ const getDefaults = () => ({
|
|
|
1480
1641
|
dryRun: false,
|
|
1481
1642
|
check: false,
|
|
1482
1643
|
inputRoot: void 0,
|
|
1644
|
+
inputRoots: void 0,
|
|
1483
1645
|
sources: []
|
|
1484
1646
|
});
|
|
1485
1647
|
const loadConfigFromFile = async (filePath) => {
|
|
@@ -1491,8 +1653,23 @@ const loadConfigFromFile = async (filePath) => {
|
|
|
1491
1653
|
targets: configParams.targets,
|
|
1492
1654
|
features: configParams.features
|
|
1493
1655
|
});
|
|
1656
|
+
try {
|
|
1657
|
+
assertInputRootFieldsExclusive({
|
|
1658
|
+
inputRoot: configParams.inputRoot,
|
|
1659
|
+
inputRoots: configParams.inputRoots
|
|
1660
|
+
});
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1663
|
+
throw new Error(`${detail} (in ${JSON.stringify(filePath)})`, { cause: error });
|
|
1664
|
+
}
|
|
1494
1665
|
return configParams;
|
|
1495
1666
|
};
|
|
1667
|
+
function mergeInputRootConfigs({ baseConfig, localConfig }) {
|
|
1668
|
+
return {
|
|
1669
|
+
inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
|
|
1670
|
+
inputRoots: localConfig.inputRoots ?? baseConfig.inputRoots
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1496
1673
|
const mergeConfigs = (baseConfig, localConfig) => {
|
|
1497
1674
|
return {
|
|
1498
1675
|
targets: localConfig.targets ?? baseConfig.targets,
|
|
@@ -1510,7 +1687,10 @@ const mergeConfigs = (baseConfig, localConfig) => {
|
|
|
1510
1687
|
gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination,
|
|
1511
1688
|
dryRun: localConfig.dryRun ?? baseConfig.dryRun,
|
|
1512
1689
|
check: localConfig.check ?? baseConfig.check,
|
|
1513
|
-
|
|
1690
|
+
...mergeInputRootConfigs({
|
|
1691
|
+
baseConfig,
|
|
1692
|
+
localConfig
|
|
1693
|
+
}),
|
|
1514
1694
|
sources: localConfig.sources ?? baseConfig.sources
|
|
1515
1695
|
};
|
|
1516
1696
|
};
|
|
@@ -1539,13 +1719,14 @@ function assertMergedTargetsFeaturesExclusive({ configByFile, validatedConfigPat
|
|
|
1539
1719
|
}
|
|
1540
1720
|
}
|
|
1541
1721
|
/**
|
|
1542
|
-
* Resolve the effective `global` flag. When an
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1722
|
+
* Resolve the effective `global` flag. When an input root (singular
|
|
1723
|
+
* `inputRoot` or plural `inputRoots`) is in play the user is decoupling
|
|
1724
|
+
* source from output, so a config-file `global: true` is dropped (unless
|
|
1725
|
+
* the caller also explicitly passes `global`); a warning is emitted in
|
|
1545
1726
|
* that case. Returns the resolved boolean `global`.
|
|
1546
1727
|
*/
|
|
1547
1728
|
function resolveGlobal({ logger, resolvedInputRoot, global, configByFile, validatedConfigPath }) {
|
|
1548
|
-
if (resolvedInputRoot !== void 0 && global === void 0 && configByFile.global === true) warnWithFallback(logger, `Ignoring "global: true" from ${JSON.stringify(validatedConfigPath)} because an
|
|
1729
|
+
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).`);
|
|
1549
1730
|
return pick({
|
|
1550
1731
|
cli: global,
|
|
1551
1732
|
file: resolvedInputRoot !== void 0 ? false : configByFile.global,
|
|
@@ -1569,17 +1750,81 @@ function resolveFeaturesAndTargets({ features, targets, configByFile }) {
|
|
|
1569
1750
|
resolvedTargets: userProvidedTargets ?? getDefaults().targets
|
|
1570
1751
|
};
|
|
1571
1752
|
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Resolve the effective, non-empty, absolute-path list of source-tree roots
|
|
1755
|
+
* by applying CLI > file > default precedence and preferring `inputRoots`
|
|
1756
|
+
* over `inputRoot` when both survive the base+local merge. Duplicates (after
|
|
1757
|
+
* normalization to absolute paths) are removed silently so overlapping
|
|
1758
|
+
* base/local declarations do not double-count the same tree.
|
|
1759
|
+
*
|
|
1760
|
+
* Semantics (post-refactor):
|
|
1761
|
+
* - `inputRoots` entries are the source trees themselves (each holds
|
|
1762
|
+
* `rules/`, `skills/`, `mcp.jsonc`, etc.); they are passed through
|
|
1763
|
+
* unchanged.
|
|
1764
|
+
* - `inputRoot` (legacy singular) is a shorthand for "parent of the
|
|
1765
|
+
* `.rulesync/` source tree" and is expanded to `join(inputRoot,
|
|
1766
|
+
* ".rulesync")` before it hits any consumer.
|
|
1767
|
+
* - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
|
|
1768
|
+
* so existing projects keep working unchanged.
|
|
1769
|
+
*
|
|
1770
|
+
* When both the merged file config has `inputRoots` and the CLI supplied
|
|
1771
|
+
* `inputRoot` (or vice versa), CLI wins outright — matching how every
|
|
1772
|
+
* other field is resolved. When only the file config supplies both, the
|
|
1773
|
+
* plural wins over the singular and the drop is logged at debug level.
|
|
1774
|
+
*/
|
|
1775
|
+
function resolveEffectiveInputRoots({ cliInputRoot, cliInputRoots, configByFile, cwd, logger }) {
|
|
1776
|
+
let source;
|
|
1777
|
+
let field;
|
|
1778
|
+
if (cliInputRoots !== void 0 && cliInputRoots.length > 0) {
|
|
1779
|
+
source = cliInputRoots;
|
|
1780
|
+
field = "inputRoots";
|
|
1781
|
+
} else if (cliInputRoot !== void 0) {
|
|
1782
|
+
source = [join(cliInputRoot, RULESYNC_RELATIVE_DIR_PATH)];
|
|
1783
|
+
field = "inputRoot";
|
|
1784
|
+
} else if (configByFile.inputRoots !== void 0 && configByFile.inputRoots.length > 0) {
|
|
1785
|
+
source = configByFile.inputRoots;
|
|
1786
|
+
field = "inputRoots";
|
|
1787
|
+
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.`);
|
|
1788
|
+
} else if (configByFile.inputRoot !== void 0) {
|
|
1789
|
+
source = [join(configByFile.inputRoot, RULESYNC_RELATIVE_DIR_PATH)];
|
|
1790
|
+
field = "inputRoot";
|
|
1791
|
+
} else source = [join(cwd, RULESYNC_RELATIVE_DIR_PATH)];
|
|
1792
|
+
const candidates = source.map((entry) => resolve(cwd, entry));
|
|
1793
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1794
|
+
const resolved = [];
|
|
1795
|
+
for (const absolute of candidates) {
|
|
1796
|
+
if (seen.has(absolute)) continue;
|
|
1797
|
+
seen.add(absolute);
|
|
1798
|
+
resolved.push(absolute);
|
|
1799
|
+
}
|
|
1800
|
+
return {
|
|
1801
|
+
inputRoots: [resolved[0], ...resolved.slice(1)],
|
|
1802
|
+
candidates,
|
|
1803
|
+
field
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1572
1806
|
var ConfigResolver = class {
|
|
1573
|
-
static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot }, { logger } = {}) {
|
|
1807
|
+
static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
|
|
1574
1808
|
const cwd = resolve(process.cwd());
|
|
1809
|
+
assertInputRootFieldsExclusive({
|
|
1810
|
+
inputRoot,
|
|
1811
|
+
inputRoots
|
|
1812
|
+
});
|
|
1813
|
+
assertInputRootsNonEmpty({ inputRoots });
|
|
1575
1814
|
if (inputRoot !== void 0) validateOutputRoot(inputRoot);
|
|
1576
|
-
|
|
1815
|
+
if (inputRoots !== void 0) for (const entry of inputRoots) validateOutputRoot(entry);
|
|
1816
|
+
const cliConfigAnchor = inputRoot;
|
|
1817
|
+
const hasCliInputRootOverride = inputRoot !== void 0 || inputRoots !== void 0;
|
|
1818
|
+
const validatedConfigPath = resolvePath(configPath, resolve(cliConfigAnchor ?? cwd));
|
|
1577
1819
|
const baseConfig = await loadConfigFromFile(validatedConfigPath);
|
|
1578
1820
|
const configDir = dirname(validatedConfigPath);
|
|
1579
1821
|
const localConfigPath = join(configDir, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH);
|
|
1580
1822
|
const localConfig = await loadConfigFromFile(localConfigPath);
|
|
1581
1823
|
const configByFile = mergeConfigs(baseConfig, localConfig);
|
|
1582
|
-
if (
|
|
1824
|
+
if (!hasCliInputRootOverride) {
|
|
1825
|
+
if (configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
|
|
1826
|
+
if (configByFile.inputRoots !== void 0) for (const entry of configByFile.inputRoots) validateOutputRoot(entry);
|
|
1827
|
+
}
|
|
1583
1828
|
assertMergedTargetsFeaturesExclusive({
|
|
1584
1829
|
configByFile,
|
|
1585
1830
|
validatedConfigPath,
|
|
@@ -1605,10 +1850,16 @@ var ConfigResolver = class {
|
|
|
1605
1850
|
silent: resolvedSilent
|
|
1606
1851
|
});
|
|
1607
1852
|
}
|
|
1608
|
-
const
|
|
1853
|
+
const resolvedInputRoots = resolveEffectiveInputRoots({
|
|
1854
|
+
cliInputRoot: inputRoot,
|
|
1855
|
+
cliInputRoots: inputRoots,
|
|
1856
|
+
configByFile,
|
|
1857
|
+
cwd,
|
|
1858
|
+
logger
|
|
1859
|
+
}).inputRoots;
|
|
1609
1860
|
const resolvedGlobal = resolveGlobal({
|
|
1610
1861
|
logger,
|
|
1611
|
-
resolvedInputRoot,
|
|
1862
|
+
resolvedInputRoot: inputRoot !== void 0 || inputRoots !== void 0 || configByFile.inputRoot !== void 0 || configByFile.inputRoots !== void 0 ? resolvedInputRoots[0] : void 0,
|
|
1612
1863
|
global,
|
|
1613
1864
|
configByFile,
|
|
1614
1865
|
validatedConfigPath
|
|
@@ -1672,7 +1923,7 @@ var ConfigResolver = class {
|
|
|
1672
1923
|
file: configByFile.check,
|
|
1673
1924
|
fallback: getDefaults().check
|
|
1674
1925
|
}),
|
|
1675
|
-
|
|
1926
|
+
inputRoots: resolvedInputRoots,
|
|
1676
1927
|
configFilePath: validatedConfigPath,
|
|
1677
1928
|
sources: configByFile.sources ?? getDefaults().sources,
|
|
1678
1929
|
flattenedCommandNaming: configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming,
|
|
@@ -1815,8 +2066,7 @@ var AiFile = class {
|
|
|
1815
2066
|
const fullPath = path.join(this.outputRoot, this.relativeDirPath, this.relativeFilePath);
|
|
1816
2067
|
const resolvedFull = resolve(fullPath);
|
|
1817
2068
|
const resolvedBase = resolve(this.outputRoot);
|
|
1818
|
-
|
|
1819
|
-
if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
|
|
2069
|
+
if (pathEscapesRoot(relative(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
|
|
1820
2070
|
return fullPath;
|
|
1821
2071
|
}
|
|
1822
2072
|
getFileContent() {
|
|
@@ -1878,6 +2128,27 @@ var RulesyncFile = class extends AiFile {
|
|
|
1878
2128
|
}
|
|
1879
2129
|
};
|
|
1880
2130
|
//#endregion
|
|
2131
|
+
//#region src/utils/control-characters.ts
|
|
2132
|
+
/**
|
|
2133
|
+
* Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
|
|
2134
|
+
* introducer U+009B), the bidirectional overrides and isolates, and the Unicode
|
|
2135
|
+
* line and paragraph separators, and the plain LRM/RLM marks. A name or value
|
|
2136
|
+
* copied out of an untrusted config file, a fetched repository, or a tool's own
|
|
2137
|
+
* settings file must never reach the terminal with these intact: they let the
|
|
2138
|
+
* text forge log lines, reorder what is printed around them, or inject escape
|
|
2139
|
+
* sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
|
|
2140
|
+
* neutral characters beside them, so they go too — a diagnostic line is not the
|
|
2141
|
+
* place to preserve the typography of a right-to-left name.
|
|
2142
|
+
*/
|
|
2143
|
+
const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
|
|
2144
|
+
/**
|
|
2145
|
+
* Removes every control character from `text` so it is safe to splice into a
|
|
2146
|
+
* log line or other terminal output.
|
|
2147
|
+
*/
|
|
2148
|
+
function stripControlCharacters(text) {
|
|
2149
|
+
return text.replace(CONTROL_CHARACTERS_PATTERN, "");
|
|
2150
|
+
}
|
|
2151
|
+
//#endregion
|
|
1881
2152
|
//#region src/utils/type-guards.ts
|
|
1882
2153
|
/**
|
|
1883
2154
|
* Type guard to check if a value is a plain object (Record<string, unknown>).
|
|
@@ -2001,7 +2272,7 @@ function parseFrontmatter(content, filePath) {
|
|
|
2001
2272
|
let body;
|
|
2002
2273
|
let hasFrontmatter;
|
|
2003
2274
|
try {
|
|
2004
|
-
const result = matter(content);
|
|
2275
|
+
const result = matter(content, {});
|
|
2005
2276
|
frontmatter = result.data;
|
|
2006
2277
|
body = result.content;
|
|
2007
2278
|
hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
|
|
@@ -2015,6 +2286,104 @@ function parseFrontmatter(content, filePath) {
|
|
|
2015
2286
|
hasFrontmatter
|
|
2016
2287
|
};
|
|
2017
2288
|
}
|
|
2289
|
+
/**
|
|
2290
|
+
* A top-level `key: value` entry. Nested entries are left alone deliberately:
|
|
2291
|
+
* the repair below rewrites a value's meaning, and the failure it exists for —
|
|
2292
|
+
* an unquoted sentence with a colon in it — is a `description`, which is always
|
|
2293
|
+
* top-level.
|
|
2294
|
+
*/
|
|
2295
|
+
const TOP_LEVEL_ENTRY_PATTERN = /^([A-Za-z_][\w.-]*):[^\S\r\n]+(\S.*)$/;
|
|
2296
|
+
/** A plain scalar that already starts as some other YAML construct. */
|
|
2297
|
+
const YAML_CONSTRUCT_PREFIX_PATTERN = /^["'|>&*![{#]/;
|
|
2298
|
+
/**
|
|
2299
|
+
* Cut a plain scalar at its inline comment — whitespace followed by `#`.
|
|
2300
|
+
*
|
|
2301
|
+
* This has to happen before anything else looks at the value, or a line such as
|
|
2302
|
+
* `allowed-tools: Read # TODO: add Bash later` reads as needing repair and comes
|
|
2303
|
+
* back quoted with the comment inside it, which for a list of tool permissions
|
|
2304
|
+
* would grant what the comment had disabled. Scanning by hand rather than with
|
|
2305
|
+
* `/\s+#.*$/`: that pattern is unanchored, so a long run of spaces with no `#`
|
|
2306
|
+
* after it backtracks from every starting position, and a value padded with a
|
|
2307
|
+
* megabyte of spaces takes minutes to reject. This is linear in the value.
|
|
2308
|
+
*/
|
|
2309
|
+
function stripInlineComment(rawValue) {
|
|
2310
|
+
for (let index = 1; index < rawValue.length; index++) if (rawValue[index] === "#" && /\s/.test(rawValue[index - 1] ?? "")) return rawValue.slice(0, index).trimEnd();
|
|
2311
|
+
return rawValue.trimEnd();
|
|
2312
|
+
}
|
|
2313
|
+
function repairFrontmatterLine(line) {
|
|
2314
|
+
const unchanged = {
|
|
2315
|
+
line,
|
|
2316
|
+
droppedComment: false
|
|
2317
|
+
};
|
|
2318
|
+
const carriageReturn = line.endsWith("\r") ? "\r" : "";
|
|
2319
|
+
const bareLine = carriageReturn === "" ? line : line.slice(0, -1);
|
|
2320
|
+
const match = TOP_LEVEL_ENTRY_PATTERN.exec(bareLine);
|
|
2321
|
+
if (!match) return unchanged;
|
|
2322
|
+
const [, key = "", rawValue = ""] = match;
|
|
2323
|
+
const value = stripInlineComment(rawValue);
|
|
2324
|
+
if (value === "") return unchanged;
|
|
2325
|
+
if (!/:(?:\s|$)/.test(value)) return unchanged;
|
|
2326
|
+
if (YAML_CONSTRUCT_PREFIX_PATTERN.test(value)) return unchanged;
|
|
2327
|
+
return {
|
|
2328
|
+
line: `${key}: ${JSON.stringify(value)}${carriageReturn}`,
|
|
2329
|
+
droppedComment: value !== rawValue.trimEnd()
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Quote the unquoted scalars that make a frontmatter block unparseable, or
|
|
2334
|
+
* return `undefined` when there is nothing to repair. Only the frontmatter
|
|
2335
|
+
* block is rewritten; the body is passed through untouched.
|
|
2336
|
+
*/
|
|
2337
|
+
function repairMalformedFrontmatterYaml(content) {
|
|
2338
|
+
const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
|
|
2339
|
+
if (!opening) return;
|
|
2340
|
+
const blockStart = opening[0].length;
|
|
2341
|
+
const closing = /\r?\n---/.exec(content.slice(blockStart));
|
|
2342
|
+
if (!closing) return;
|
|
2343
|
+
const blockEnd = blockStart + closing.index;
|
|
2344
|
+
const block = content.slice(blockStart, blockEnd);
|
|
2345
|
+
const repairedLines = block.split("\n").map(repairFrontmatterLine);
|
|
2346
|
+
const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
|
|
2347
|
+
if (repairedBlock === block) return;
|
|
2348
|
+
return {
|
|
2349
|
+
content: content.slice(0, blockStart) + repairedBlock + content.slice(blockEnd),
|
|
2350
|
+
droppedComment: repairedLines.some(({ droppedComment }) => droppedComment)
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Parse frontmatter, retrying once with unquoted colon-bearing values quoted.
|
|
2355
|
+
*
|
|
2356
|
+
* Files authored for another client routinely carry YAML that only that
|
|
2357
|
+
* client's parser accepts — `description: Use this skill when: the user asks
|
|
2358
|
+
* about PDFs` is the case the Agent Skills client guide names. Without a retry
|
|
2359
|
+
* such a file is not merely reported, it is dropped: the lenient skill import
|
|
2360
|
+
* catches the parse error and skips the whole skill. The retry is deliberately
|
|
2361
|
+
* narrow — one pass, top-level entries only, and the original error is what
|
|
2362
|
+
* surfaces if it does not help, so a genuinely broken file still fails with the
|
|
2363
|
+
* message that describes what is actually wrong with it. A file with no closing
|
|
2364
|
+
* `---`, or one whose opening fence carries a language tag, is not repaired at
|
|
2365
|
+
* all: neither is a frontmatter block gray-matter would have read.
|
|
2366
|
+
*
|
|
2367
|
+
* @see https://agentskills.io/client-implementation/adding-skills-support
|
|
2368
|
+
*/
|
|
2369
|
+
function parseFrontmatterWithYamlRepair(content, filePath, options = {}) {
|
|
2370
|
+
try {
|
|
2371
|
+
return parseFrontmatter(content, filePath);
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
const repaired = repairMalformedFrontmatterYaml(content);
|
|
2374
|
+
if (repaired === void 0) throw error;
|
|
2375
|
+
let result;
|
|
2376
|
+
try {
|
|
2377
|
+
result = parseFrontmatter(repaired.content, filePath);
|
|
2378
|
+
} catch {
|
|
2379
|
+
throw error;
|
|
2380
|
+
}
|
|
2381
|
+
if (options.quiet === true) return result;
|
|
2382
|
+
const commentNote = repaired.droppedComment ? " Text following a space and `#` was read as a YAML comment and left out of the value." : "";
|
|
2383
|
+
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.`);
|
|
2384
|
+
return result;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2018
2387
|
//#endregion
|
|
2019
2388
|
//#region src/features/checks/rulesync-check.ts
|
|
2020
2389
|
const RulesyncCheckFrontmatterSchema = z.looseObject({
|
|
@@ -2072,8 +2441,9 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
|
|
|
2072
2441
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
2073
2442
|
};
|
|
2074
2443
|
}
|
|
2075
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
|
|
2076
|
-
const
|
|
2444
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
2445
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
|
|
2446
|
+
const filePath = join(outputRoot, dirPath, relativeFilePath);
|
|
2077
2447
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
|
|
2078
2448
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
2079
2449
|
const result = RulesyncCheckFrontmatterSchema.safeParse(frontmatter);
|
|
@@ -2081,7 +2451,7 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
|
|
|
2081
2451
|
const filename = basename(relativeFilePath);
|
|
2082
2452
|
return new RulesyncCheck({
|
|
2083
2453
|
outputRoot,
|
|
2084
|
-
relativeDirPath:
|
|
2454
|
+
relativeDirPath: dirPath,
|
|
2085
2455
|
relativeFilePath: filename,
|
|
2086
2456
|
frontmatter: result.data,
|
|
2087
2457
|
body: content.trim()
|
|
@@ -2152,8 +2522,9 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
|
|
|
2152
2522
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
2153
2523
|
};
|
|
2154
2524
|
}
|
|
2155
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
|
|
2156
|
-
const
|
|
2525
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
2526
|
+
const dirPath = relativeDirPath ?? RulesyncCommand.getSettablePaths().relativeDirPath;
|
|
2527
|
+
const filePath = join(outputRoot, dirPath, relativeFilePath);
|
|
2157
2528
|
const fileContent = await readFileContent(filePath);
|
|
2158
2529
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(fileContent, filePath);
|
|
2159
2530
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
@@ -2161,7 +2532,7 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
|
|
|
2161
2532
|
if (!result.success) throw new Error(`Invalid frontmatter in ${relativeFilePath}: ${formatError(result.error)}`);
|
|
2162
2533
|
return new RulesyncCommand({
|
|
2163
2534
|
outputRoot,
|
|
2164
|
-
relativeDirPath:
|
|
2535
|
+
relativeDirPath: dirPath,
|
|
2165
2536
|
relativeFilePath,
|
|
2166
2537
|
frontmatter: result.data,
|
|
2167
2538
|
body: content.trim(),
|
|
@@ -3550,8 +3921,19 @@ const CANONICAL_TO_VIBE_EVENT_NAMES = {
|
|
|
3550
3921
|
};
|
|
3551
3922
|
/**
|
|
3552
3923
|
* Map Mistral Vibe snake_case event names to canonical camelCase.
|
|
3924
|
+
*
|
|
3925
|
+
* The pre-2.21.0 spellings are accepted alongside the current ones. Vibe's
|
|
3926
|
+
* strict `HookType` enum rejects a file that still uses them, so such a file is
|
|
3927
|
+
* already dead on disk; reading it here and emitting the renamed spelling is
|
|
3928
|
+
* what repairs it, whereas leaving the old name unmapped would route the hook
|
|
3929
|
+
* into a tool override block and lose the event.
|
|
3553
3930
|
*/
|
|
3554
|
-
const VIBE_TO_CANONICAL_EVENT_NAMES =
|
|
3931
|
+
const VIBE_TO_CANONICAL_EVENT_NAMES = {
|
|
3932
|
+
...Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k])),
|
|
3933
|
+
before_tool: "preToolUse",
|
|
3934
|
+
after_tool: "postToolUse",
|
|
3935
|
+
post_agent_turn: "stop"
|
|
3936
|
+
};
|
|
3555
3937
|
/**
|
|
3556
3938
|
* Map canonical camelCase event names to Qwen Code PascalCase.
|
|
3557
3939
|
*
|
|
@@ -3772,15 +4154,17 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
|
|
|
3772
4154
|
error: null
|
|
3773
4155
|
};
|
|
3774
4156
|
}
|
|
3775
|
-
static async fromFile({ outputRoot = process.cwd(), validate = true }) {
|
|
4157
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
|
|
3776
4158
|
const paths = RulesyncHooks.getSettablePaths();
|
|
4159
|
+
const overrideDirPath = relativeDirPath;
|
|
3777
4160
|
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
3778
|
-
const
|
|
4161
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
4162
|
+
const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
3779
4163
|
if (!await fileExists(filePath)) continue;
|
|
3780
4164
|
const fileContent = await readFileContent(filePath);
|
|
3781
4165
|
return new RulesyncHooks({
|
|
3782
4166
|
outputRoot,
|
|
3783
|
-
relativeDirPath:
|
|
4167
|
+
relativeDirPath: candidateDirPath,
|
|
3784
4168
|
relativeFilePath: candidate.relativeFilePath,
|
|
3785
4169
|
fileContent,
|
|
3786
4170
|
validate
|
|
@@ -3807,21 +4191,23 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
|
|
|
3807
4191
|
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
|
|
3808
4192
|
relativeFilePath: RULESYNC_AIIGNORE_FILE_NAME
|
|
3809
4193
|
},
|
|
3810
|
-
legacy: {
|
|
4194
|
+
legacy: [{
|
|
3811
4195
|
relativeDirPath: ".",
|
|
3812
4196
|
relativeFilePath: RULESYNC_IGNORE_RELATIVE_FILE_PATH
|
|
3813
|
-
}
|
|
4197
|
+
}]
|
|
3814
4198
|
};
|
|
3815
4199
|
}
|
|
3816
|
-
static async fromFile({ outputRoot = process.cwd() } = {}) {
|
|
4200
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath } = {}) {
|
|
3817
4201
|
const paths = this.getSettablePaths();
|
|
3818
|
-
const
|
|
3819
|
-
const
|
|
4202
|
+
const recommendedDirPath = relativeDirPath ?? paths.recommended.relativeDirPath;
|
|
4203
|
+
const recommendedPath = join(outputRoot, recommendedDirPath, paths.recommended.relativeFilePath);
|
|
4204
|
+
const [legacy] = paths.legacy;
|
|
4205
|
+
const legacyPath = join(outputRoot, legacy.relativeDirPath, legacy.relativeFilePath);
|
|
3820
4206
|
if (await fileExists(recommendedPath)) {
|
|
3821
4207
|
const fileContent = await readFileContent(recommendedPath);
|
|
3822
4208
|
return new RulesyncIgnore({
|
|
3823
4209
|
outputRoot,
|
|
3824
|
-
relativeDirPath:
|
|
4210
|
+
relativeDirPath: recommendedDirPath,
|
|
3825
4211
|
relativeFilePath: paths.recommended.relativeFilePath,
|
|
3826
4212
|
fileContent
|
|
3827
4213
|
});
|
|
@@ -3830,15 +4216,15 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
|
|
|
3830
4216
|
const fileContent = await readFileContent(legacyPath);
|
|
3831
4217
|
return new RulesyncIgnore({
|
|
3832
4218
|
outputRoot,
|
|
3833
|
-
relativeDirPath:
|
|
3834
|
-
relativeFilePath:
|
|
4219
|
+
relativeDirPath: legacy.relativeDirPath,
|
|
4220
|
+
relativeFilePath: legacy.relativeFilePath,
|
|
3835
4221
|
fileContent
|
|
3836
4222
|
});
|
|
3837
4223
|
}
|
|
3838
4224
|
const fileContent = await readFileContent(recommendedPath);
|
|
3839
4225
|
return new RulesyncIgnore({
|
|
3840
4226
|
outputRoot,
|
|
3841
|
-
relativeDirPath:
|
|
4227
|
+
relativeDirPath: recommendedDirPath,
|
|
3842
4228
|
relativeFilePath: paths.recommended.relativeFilePath,
|
|
3843
4229
|
fileContent
|
|
3844
4230
|
});
|
|
@@ -3994,6 +4380,118 @@ const RulesyncMcpFileSchema = z.looseObject({
|
|
|
3994
4380
|
warp: z.optional(toolScopedMcpSchema),
|
|
3995
4381
|
zed: z.optional(toolScopedMcpSchema)
|
|
3996
4382
|
});
|
|
4383
|
+
/**
|
|
4384
|
+
* The tool-scoped block keys that carry a `{toolname}.mcpServers` sub-map.
|
|
4385
|
+
* Derived from `RulesyncMcpFileSchema`'s own shape so this set can never drift
|
|
4386
|
+
* from the schema — every tool-scoped block declared above is treated as a
|
|
4387
|
+
* "merge servers by name" site by `mergeMcpJsonOverlays`, and everything else
|
|
4388
|
+
* (including `$schema` and top-level Kimi Code timeout fields) is replaced
|
|
4389
|
+
* atomically.
|
|
4390
|
+
*/
|
|
4391
|
+
const TOOL_SCOPED_MCP_KEYS = new Set(Object.keys(RulesyncMcpFileSchema.def.shape).filter((key) => key !== "$schema" && key !== "mcpServers"));
|
|
4392
|
+
/**
|
|
4393
|
+
* Return the first candidate path (recommended, then legacy variants) that
|
|
4394
|
+
* exists under `outputRoot`, or `undefined` when none is present. Shared
|
|
4395
|
+
* between `fromFile` (single-root) and `fromRoots` (multi-root) so both
|
|
4396
|
+
* paths honour the same intra-root resolution order.
|
|
4397
|
+
*
|
|
4398
|
+
* When `overrideDirPath` is provided it replaces the candidates'
|
|
4399
|
+
* class-level `relativeDirPath` (which defaults to `.rulesync/`) so the
|
|
4400
|
+
* caller can point at a non-default source tree (e.g. `.rulesync.local/`).
|
|
4401
|
+
* Also returned is the effective `relativeDirPath` for the winning
|
|
4402
|
+
* candidate so the caller can reconstruct a `RulesyncMcp` with matching
|
|
4403
|
+
* anchor fields.
|
|
4404
|
+
*/
|
|
4405
|
+
async function findFirstExistingCandidate({ paths, outputRoot, overrideDirPath }) {
|
|
4406
|
+
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
4407
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
4408
|
+
const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
4409
|
+
if (await fileExists(filePath)) return {
|
|
4410
|
+
filePath,
|
|
4411
|
+
candidate: {
|
|
4412
|
+
relativeDirPath: candidateDirPath,
|
|
4413
|
+
relativeFilePath: candidate.relativeFilePath
|
|
4414
|
+
}
|
|
4415
|
+
};
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
/**
|
|
4419
|
+
* Merge two parsed MCP JSON objects with the one-level policy from the
|
|
4420
|
+
* inputRoots plan: the top-level `mcpServers` map and each
|
|
4421
|
+
* `<toolname>.mcpServers` sub-map are merged by server name (later wins per
|
|
4422
|
+
* key). Every other value — individual server configs, other top-level keys
|
|
4423
|
+
* — is replaced atomically. This keeps the merge predictable: an overlay can
|
|
4424
|
+
* add or replace whole shared servers, but a partial patch of one server's
|
|
4425
|
+
* `args`/`env` is deliberately not supported.
|
|
4426
|
+
*/
|
|
4427
|
+
function getRecordField({ value, path }) {
|
|
4428
|
+
if (value === void 0) return {};
|
|
4429
|
+
if (!isRecord$1(value)) throw new Error(`Invalid MCP overlay: '${path}' must be an object.`);
|
|
4430
|
+
return value;
|
|
4431
|
+
}
|
|
4432
|
+
/**
|
|
4433
|
+
* Overlay one record onto another, dropping any overlay key that could reach
|
|
4434
|
+
* `Object.prototype`. Every overlay merge goes through this so a `__proto__`
|
|
4435
|
+
* entry cannot enter the merged config from any depth — top-level
|
|
4436
|
+
* `mcpServers`, a tool-scoped block, or that block's own `mcpServers`.
|
|
4437
|
+
*/
|
|
4438
|
+
function mergeRecordsSkippingPollutionKeys({ base, overlay }) {
|
|
4439
|
+
const merged = { ...base };
|
|
4440
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
4441
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
4442
|
+
merged[key] = value;
|
|
4443
|
+
}
|
|
4444
|
+
return merged;
|
|
4445
|
+
}
|
|
4446
|
+
function mergeMcpJsonOverlays({ base, overlay }) {
|
|
4447
|
+
const merged = { ...base };
|
|
4448
|
+
for (const [key, overlayValue] of Object.entries(overlay)) {
|
|
4449
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
4450
|
+
if (key === "mcpServers") {
|
|
4451
|
+
merged.mcpServers = mergeRecordsSkippingPollutionKeys({
|
|
4452
|
+
base: getRecordField({
|
|
4453
|
+
value: base.mcpServers,
|
|
4454
|
+
path: "mcpServers"
|
|
4455
|
+
}),
|
|
4456
|
+
overlay: getRecordField({
|
|
4457
|
+
value: overlayValue,
|
|
4458
|
+
path: "mcpServers"
|
|
4459
|
+
})
|
|
4460
|
+
});
|
|
4461
|
+
continue;
|
|
4462
|
+
}
|
|
4463
|
+
if (TOOL_SCOPED_MCP_KEYS.has(key)) {
|
|
4464
|
+
const baseBlock = getRecordField({
|
|
4465
|
+
value: base[key],
|
|
4466
|
+
path: key
|
|
4467
|
+
});
|
|
4468
|
+
const overlayBlock = getRecordField({
|
|
4469
|
+
value: overlayValue,
|
|
4470
|
+
path: key
|
|
4471
|
+
});
|
|
4472
|
+
const mergedBlock = mergeRecordsSkippingPollutionKeys({
|
|
4473
|
+
base: baseBlock,
|
|
4474
|
+
overlay: overlayBlock
|
|
4475
|
+
});
|
|
4476
|
+
const baseServers = getRecordField({
|
|
4477
|
+
value: baseBlock.mcpServers,
|
|
4478
|
+
path: `${key}.mcpServers`
|
|
4479
|
+
});
|
|
4480
|
+
const overlayServers = getRecordField({
|
|
4481
|
+
value: overlayBlock.mcpServers,
|
|
4482
|
+
path: `${key}.mcpServers`
|
|
4483
|
+
});
|
|
4484
|
+
if (Object.keys(baseServers).length > 0 || Object.keys(overlayServers).length > 0) mergedBlock.mcpServers = mergeRecordsSkippingPollutionKeys({
|
|
4485
|
+
base: baseServers,
|
|
4486
|
+
overlay: overlayServers
|
|
4487
|
+
});
|
|
4488
|
+
merged[key] = mergedBlock;
|
|
4489
|
+
continue;
|
|
4490
|
+
}
|
|
4491
|
+
merged[key] = overlayValue;
|
|
4492
|
+
}
|
|
4493
|
+
return merged;
|
|
4494
|
+
}
|
|
3997
4495
|
var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
|
|
3998
4496
|
json;
|
|
3999
4497
|
constructor(params) {
|
|
@@ -4030,28 +4528,136 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
|
|
|
4030
4528
|
error: null
|
|
4031
4529
|
};
|
|
4032
4530
|
}
|
|
4033
|
-
|
|
4531
|
+
/**
|
|
4532
|
+
* Load and merge MCP source files across the configured input roots.
|
|
4533
|
+
*
|
|
4534
|
+
* `inputRoots` entries are the source trees themselves (e.g.
|
|
4535
|
+
* `/repo/.rulesync`, `/repo/.rulesync.local`). Per-root behavior mirrors
|
|
4536
|
+
* `fromFile`: each root's own candidate paths (recommended `mcp.jsonc`,
|
|
4537
|
+
* legacy `mcp.json`, deprecated `.mcp.json`) are checked INSIDE that
|
|
4538
|
+
* source tree and the first hit is loaded. Roots that have no candidate
|
|
4539
|
+
* contribute nothing.
|
|
4540
|
+
*
|
|
4541
|
+
* Cross-root behavior: the parsed JSON objects are folded left-to-right
|
|
4542
|
+
* with `mergeMcpJsonOverlays`, so later roots overlay earlier ones by
|
|
4543
|
+
* server name (one level deep) and replace every other value atomically.
|
|
4544
|
+
* With one root, this delegates to `fromFile` so JSONC formatting and the
|
|
4545
|
+
* actual candidate path are preserved. With multiple roots, each source is
|
|
4546
|
+
* parsed and schema-validated before merging so failures name the originating
|
|
4547
|
+
* file. The merged object is necessarily synthetic, serialized JSON anchored
|
|
4548
|
+
* to the first root's recommended path.
|
|
4549
|
+
*
|
|
4550
|
+
* A multi-root configuration where only one root actually supplies a file is
|
|
4551
|
+
* treated as the single-root case: nothing is merged, so the original file
|
|
4552
|
+
* content is kept verbatim (preserving JSONC comments) and the instance is
|
|
4553
|
+
* anchored at the root that supplied it rather than at the primary root's
|
|
4554
|
+
* recommended path.
|
|
4555
|
+
*
|
|
4556
|
+
* When no root supplies any candidate, this falls back to reading the
|
|
4557
|
+
* primary root's recommended path so the underlying file-not-found error
|
|
4558
|
+
* matches the single-root behavior of `fromFile`.
|
|
4559
|
+
*/
|
|
4560
|
+
static async fromRoots({ inputRoots, validate = true, logger }) {
|
|
4561
|
+
if (inputRoots.length === 1) {
|
|
4562
|
+
const [primary] = inputRoots;
|
|
4563
|
+
return this.fromFile({
|
|
4564
|
+
outputRoot: dirname(primary),
|
|
4565
|
+
relativeDirPath: basename(primary),
|
|
4566
|
+
validate,
|
|
4567
|
+
logger
|
|
4568
|
+
});
|
|
4569
|
+
}
|
|
4570
|
+
const paths = this.getSettablePaths();
|
|
4571
|
+
const rootSources = [];
|
|
4572
|
+
for (const root of inputRoots) {
|
|
4573
|
+
const parent = dirname(root);
|
|
4574
|
+
const treeName = basename(root);
|
|
4575
|
+
const found = await findFirstExistingCandidate({
|
|
4576
|
+
paths,
|
|
4577
|
+
outputRoot: parent,
|
|
4578
|
+
overrideDirPath: treeName
|
|
4579
|
+
});
|
|
4580
|
+
if (found === void 0) continue;
|
|
4581
|
+
const { filePath, candidate } = found;
|
|
4582
|
+
if (filePath.endsWith(".mcp.json")) {
|
|
4583
|
+
const recommendedPath = join(parent, treeName, paths.recommended.relativeFilePath);
|
|
4584
|
+
logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
|
|
4585
|
+
}
|
|
4586
|
+
const fileContent = await readFileContent(filePath);
|
|
4587
|
+
let parsed;
|
|
4588
|
+
try {
|
|
4589
|
+
parsed = parseJsonc(fileContent);
|
|
4590
|
+
if (!isRecord$1(parsed)) throw new Error("Expected a JSON object.");
|
|
4591
|
+
if (validate) {
|
|
4592
|
+
const result = RulesyncMcpFileSchema.safeParse(parsed);
|
|
4593
|
+
if (!result.success) throw result.error;
|
|
4594
|
+
}
|
|
4595
|
+
} catch (error) {
|
|
4596
|
+
throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
|
|
4597
|
+
}
|
|
4598
|
+
rootSources.push({
|
|
4599
|
+
record: parsed,
|
|
4600
|
+
outputRoot: parent,
|
|
4601
|
+
relativeDirPath: candidate.relativeDirPath,
|
|
4602
|
+
relativeFilePath: candidate.relativeFilePath,
|
|
4603
|
+
fileContent
|
|
4604
|
+
});
|
|
4605
|
+
}
|
|
4606
|
+
if (rootSources.length === 0) {
|
|
4607
|
+
const primary = inputRoots[0];
|
|
4608
|
+
return this.fromFile({
|
|
4609
|
+
outputRoot: dirname(primary),
|
|
4610
|
+
relativeDirPath: basename(primary),
|
|
4611
|
+
validate,
|
|
4612
|
+
logger
|
|
4613
|
+
});
|
|
4614
|
+
}
|
|
4615
|
+
const onlySource = rootSources.length === 1 ? rootSources[0] : void 0;
|
|
4616
|
+
if (onlySource !== void 0) return new RulesyncMcp({
|
|
4617
|
+
outputRoot: onlySource.outputRoot,
|
|
4618
|
+
relativeDirPath: onlySource.relativeDirPath,
|
|
4619
|
+
relativeFilePath: onlySource.relativeFilePath,
|
|
4620
|
+
fileContent: onlySource.fileContent,
|
|
4621
|
+
validate
|
|
4622
|
+
});
|
|
4623
|
+
const merged = rootSources.reduce((acc, next) => mergeMcpJsonOverlays({
|
|
4624
|
+
base: acc,
|
|
4625
|
+
overlay: next.record
|
|
4626
|
+
}), {});
|
|
4627
|
+
const primary = inputRoots[0];
|
|
4628
|
+
return new RulesyncMcp({
|
|
4629
|
+
outputRoot: dirname(primary),
|
|
4630
|
+
relativeDirPath: basename(primary),
|
|
4631
|
+
relativeFilePath: paths.recommended.relativeFilePath,
|
|
4632
|
+
fileContent: JSON.stringify(merged, null, 2),
|
|
4633
|
+
validate
|
|
4634
|
+
});
|
|
4635
|
+
}
|
|
4636
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true, logger }) {
|
|
4034
4637
|
const paths = this.getSettablePaths();
|
|
4638
|
+
const overrideDirPath = relativeDirPath;
|
|
4035
4639
|
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
4036
|
-
const
|
|
4640
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
4641
|
+
const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
4037
4642
|
if (!await fileExists(filePath)) continue;
|
|
4038
4643
|
if (candidate.relativeFilePath === ".mcp.json") {
|
|
4039
|
-
const recommendedPath = join(outputRoot,
|
|
4644
|
+
const recommendedPath = join(outputRoot, candidateDirPath, paths.recommended.relativeFilePath);
|
|
4040
4645
|
logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
|
|
4041
4646
|
}
|
|
4042
4647
|
const fileContent = await readFileContent(filePath);
|
|
4043
4648
|
return new RulesyncMcp({
|
|
4044
4649
|
outputRoot,
|
|
4045
|
-
relativeDirPath:
|
|
4650
|
+
relativeDirPath: candidateDirPath,
|
|
4046
4651
|
relativeFilePath: candidate.relativeFilePath,
|
|
4047
4652
|
fileContent,
|
|
4048
4653
|
validate
|
|
4049
4654
|
});
|
|
4050
4655
|
}
|
|
4051
|
-
const
|
|
4656
|
+
const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
|
|
4657
|
+
const fileContent = await readFileContent(join(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath));
|
|
4052
4658
|
return new RulesyncMcp({
|
|
4053
4659
|
outputRoot,
|
|
4054
|
-
relativeDirPath:
|
|
4660
|
+
relativeDirPath: fallbackDirPath,
|
|
4055
4661
|
relativeFilePath: paths.recommended.relativeFilePath,
|
|
4056
4662
|
fileContent,
|
|
4057
4663
|
validate
|
|
@@ -5246,15 +5852,17 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
|
|
|
5246
5852
|
error: null
|
|
5247
5853
|
};
|
|
5248
5854
|
}
|
|
5249
|
-
static async fromFile({ outputRoot = process.cwd(), validate = true }) {
|
|
5855
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
|
|
5250
5856
|
const paths = RulesyncPermissions.getSettablePaths();
|
|
5857
|
+
const overrideDirPath = relativeDirPath;
|
|
5251
5858
|
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
5252
|
-
const
|
|
5859
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
5860
|
+
const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
5253
5861
|
if (!await fileExists(filePath)) continue;
|
|
5254
5862
|
const fileContent = await readFileContent(filePath);
|
|
5255
5863
|
return new RulesyncPermissions({
|
|
5256
5864
|
outputRoot,
|
|
5257
|
-
relativeDirPath:
|
|
5865
|
+
relativeDirPath: candidateDirPath,
|
|
5258
5866
|
relativeFilePath: candidate.relativeFilePath,
|
|
5259
5867
|
fileContent,
|
|
5260
5868
|
validate
|
|
@@ -5418,8 +6026,9 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
5418
6026
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
5419
6027
|
};
|
|
5420
6028
|
}
|
|
5421
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
|
|
5422
|
-
const
|
|
6029
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
|
|
6030
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths().recommended.relativeDirPath;
|
|
6031
|
+
const filePath = join(outputRoot, dirPath, relativeFilePath);
|
|
5423
6032
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
|
|
5424
6033
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
5425
6034
|
const result = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
|
|
@@ -5432,7 +6041,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
5432
6041
|
};
|
|
5433
6042
|
return new RulesyncRule({
|
|
5434
6043
|
outputRoot,
|
|
5435
|
-
relativeDirPath:
|
|
6044
|
+
relativeDirPath: dirPath,
|
|
5436
6045
|
relativeFilePath,
|
|
5437
6046
|
frontmatter: validatedFrontmatter,
|
|
5438
6047
|
body: content.trim(),
|
|
@@ -5444,8 +6053,474 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
5444
6053
|
}
|
|
5445
6054
|
};
|
|
5446
6055
|
//#endregion
|
|
6056
|
+
//#region src/utils/concurrency.ts
|
|
6057
|
+
/**
|
|
6058
|
+
* Map over items with a bounded number of operations in flight.
|
|
6059
|
+
*
|
|
6060
|
+
* `Promise.all(items.map(...))` starts every operation at once, which is fine
|
|
6061
|
+
* for a handful of paths and not fine for a directory tree of unknown size: a
|
|
6062
|
+
* few thousand concurrent `realpath` calls queue on the libuv thread pool and
|
|
6063
|
+
* hold their closures alive while they wait. Results keep the input order.
|
|
6064
|
+
*
|
|
6065
|
+
* `withSemaphore` in `src/lib/github-utils.ts` bounds concurrency too, but it
|
|
6066
|
+
* wraps one call at a time: the caller still writes `Promise.all(items.map(…))`
|
|
6067
|
+
* around it, so every item's promise chain is allocated up front. This walks a
|
|
6068
|
+
* shared cursor with `limit` workers instead, so a list of unknown size costs
|
|
6069
|
+
* `limit` pending operations rather than one per item.
|
|
6070
|
+
*/
|
|
6071
|
+
async function mapWithConcurrency({ items, limit, mapper }) {
|
|
6072
|
+
const results = Array.from({ length: items.length });
|
|
6073
|
+
let nextIndex = 0;
|
|
6074
|
+
const runWorker = async () => {
|
|
6075
|
+
while (nextIndex < items.length) {
|
|
6076
|
+
const index = nextIndex;
|
|
6077
|
+
nextIndex += 1;
|
|
6078
|
+
const item = items[index];
|
|
6079
|
+
if (item === void 0) continue;
|
|
6080
|
+
results[index] = await mapper(item);
|
|
6081
|
+
}
|
|
6082
|
+
};
|
|
6083
|
+
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
6084
|
+
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
|
|
6085
|
+
return results;
|
|
6086
|
+
}
|
|
6087
|
+
//#endregion
|
|
5447
6088
|
//#region src/types/ai-dir.ts
|
|
5448
|
-
|
|
6089
|
+
/**
|
|
6090
|
+
* Directories that hold credentials. Excluding these protects something, so
|
|
6091
|
+
* their exclusion is reported rather than silent.
|
|
6092
|
+
*/
|
|
6093
|
+
const NEVER_CARRIED_CREDENTIAL_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
6094
|
+
".ssh",
|
|
6095
|
+
".aws",
|
|
6096
|
+
".gnupg"
|
|
6097
|
+
]);
|
|
6098
|
+
/**
|
|
6099
|
+
* Directories that are refused only when the path leaves the skill directory to
|
|
6100
|
+
* reach them. These are the per-application trees of a home directory, where
|
|
6101
|
+
* naming every credential file is a list always one release behind -- `gcloud`
|
|
6102
|
+
* alone writes `credentials.db` and `application_default_credentials.json`, and
|
|
6103
|
+
* `.config/anthropic/` holds an API key. A skill that ships a `.config/` of its
|
|
6104
|
+
* own still carries it: what is refused is a link that reaches the *user's*.
|
|
6105
|
+
*
|
|
6106
|
+
* A tool home is deliberately absent. `~/.claude/skills/` and `~/.codex/` hold
|
|
6107
|
+
* the global skills this feature exists to share, so refusing a link that
|
|
6108
|
+
* reaches one would refuse the ordinary case along with the bad one.
|
|
6109
|
+
*/
|
|
6110
|
+
const NEVER_CARRIED_ESCAPING_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
6111
|
+
".config",
|
|
6112
|
+
".local",
|
|
6113
|
+
".azure",
|
|
6114
|
+
".m2",
|
|
6115
|
+
".terraform.d",
|
|
6116
|
+
".docker",
|
|
6117
|
+
".kube",
|
|
6118
|
+
"keychains"
|
|
6119
|
+
]);
|
|
6120
|
+
/**
|
|
6121
|
+
* Whether an escaping real path passes through a directory only reachable by
|
|
6122
|
+
* escaping.
|
|
6123
|
+
*
|
|
6124
|
+
* Only the segments *past* the skill directory count. A global skill lives at
|
|
6125
|
+
* `~/.config/agents/skills/<name>` for Amp, Devin and Muse alike, so judging
|
|
6126
|
+
* the whole real path would refuse a link that never left the skills tree it
|
|
6127
|
+
* was already in -- the shared-directory case, reported as a credential.
|
|
6128
|
+
*/
|
|
6129
|
+
function escapesIntoCredentialDir({ realDirPath, realFilePath }) {
|
|
6130
|
+
const normalize = (segment) => normalizePathSegment(segment.toLowerCase());
|
|
6131
|
+
const dirSegments = splitPathSegments(realDirPath).map(normalize);
|
|
6132
|
+
const fileSegments = splitPathSegments(realFilePath).map(normalize);
|
|
6133
|
+
let shared = 0;
|
|
6134
|
+
while (shared < dirSegments.length && shared < fileSegments.length && dirSegments[shared] === fileSegments[shared]) shared += 1;
|
|
6135
|
+
return (dirSegments.length - shared > 1 ? fileSegments : fileSegments.slice(shared)).some((segment) => NEVER_CARRIED_ESCAPING_DIR_NAMES.has(segment));
|
|
6136
|
+
}
|
|
6137
|
+
/**
|
|
6138
|
+
* Directories that are never part of a skill for the ordinary reasons: a
|
|
6139
|
+
* nested repository, or a build/cache tree. Leaving these out is what a user
|
|
6140
|
+
* expects, so it happens quietly.
|
|
6141
|
+
*/
|
|
6142
|
+
const NEVER_CARRIED_NOISE_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
6143
|
+
".git",
|
|
6144
|
+
".hg",
|
|
6145
|
+
".svn",
|
|
6146
|
+
".cache",
|
|
6147
|
+
".venv",
|
|
6148
|
+
".tox",
|
|
6149
|
+
".mypy_cache",
|
|
6150
|
+
".pytest_cache",
|
|
6151
|
+
".ruff_cache",
|
|
6152
|
+
".gradle",
|
|
6153
|
+
".next",
|
|
6154
|
+
".nuxt",
|
|
6155
|
+
".turbo",
|
|
6156
|
+
".parcel-cache",
|
|
6157
|
+
".nyc_output",
|
|
6158
|
+
".terraform"
|
|
6159
|
+
]);
|
|
6160
|
+
/** Files that hold credentials. Compared lower-cased. */
|
|
6161
|
+
const NEVER_CARRIED_CREDENTIAL_FILE_NAMES = /* @__PURE__ */ new Set([
|
|
6162
|
+
".npmrc",
|
|
6163
|
+
".netrc",
|
|
6164
|
+
".git-credentials",
|
|
6165
|
+
".pgpass",
|
|
6166
|
+
".pypirc",
|
|
6167
|
+
".htpasswd",
|
|
6168
|
+
".dockercfg",
|
|
6169
|
+
".envrc"
|
|
6170
|
+
]);
|
|
6171
|
+
/** Files that are local noise. Compared lower-cased. */
|
|
6172
|
+
const NEVER_CARRIED_NOISE_FILE_NAMES = /* @__PURE__ */ new Set([".ds_store"]);
|
|
6173
|
+
/** Credential files whose parent directory is otherwise ordinary content. */
|
|
6174
|
+
const NEVER_CARRIED_PATH_SUFFIXES = [
|
|
6175
|
+
".docker/config.json",
|
|
6176
|
+
".kube/config",
|
|
6177
|
+
".config/gh/hosts.yml",
|
|
6178
|
+
".config/gcloud/credentials.db",
|
|
6179
|
+
".gem/credentials",
|
|
6180
|
+
"gcloud/application_default_credentials.json",
|
|
6181
|
+
".codex/auth.json",
|
|
6182
|
+
".gemini/oauth_creds.json"
|
|
6183
|
+
];
|
|
6184
|
+
/** Whether a path ends in one of the credential files named above. */
|
|
6185
|
+
function endsWithNeverCarriedSuffix(filePath) {
|
|
6186
|
+
const posixPath = toPosixPath(filePath).toLowerCase();
|
|
6187
|
+
return NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`));
|
|
6188
|
+
}
|
|
6189
|
+
/**
|
|
6190
|
+
* `.env.<suffix>` spellings that are templates rather than real values.
|
|
6191
|
+
* Everything else matching `.env*` is treated as holding secrets, because
|
|
6192
|
+
* `.env.production` is no less sensitive than `.env` itself.
|
|
6193
|
+
*/
|
|
6194
|
+
const ENV_TEMPLATE_SUFFIXES = /* @__PURE__ */ new Set([
|
|
6195
|
+
"example",
|
|
6196
|
+
"sample",
|
|
6197
|
+
"template",
|
|
6198
|
+
"dist",
|
|
6199
|
+
"defaults"
|
|
6200
|
+
]);
|
|
6201
|
+
/**
|
|
6202
|
+
* Kernel pseudo-filesystems, matched against the resolved real path. A link
|
|
6203
|
+
* into one of these does not reach a file at all: `/proc/self/environ` reads
|
|
6204
|
+
* back the entire environment of the running process, API keys included, and
|
|
6205
|
+
* `stat` reports it as an ordinary file. Nothing a skill carries lives here.
|
|
6206
|
+
*/
|
|
6207
|
+
const NEVER_CARRIED_REAL_PATH_ROOTS = [
|
|
6208
|
+
"/proc",
|
|
6209
|
+
"/sys",
|
|
6210
|
+
"/dev"
|
|
6211
|
+
];
|
|
6212
|
+
/**
|
|
6213
|
+
* Whether a directory of this name is pruned during the walk, so it is never
|
|
6214
|
+
* descended into at all. Derived from the directory names above so the pruning
|
|
6215
|
+
* and the path check below cannot drift apart.
|
|
6216
|
+
*/
|
|
6217
|
+
function isNeverCarriedDirName(dirName) {
|
|
6218
|
+
const normalized = normalizePathSegment(dirName.toLowerCase());
|
|
6219
|
+
return NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(normalized) || NEVER_CARRIED_NOISE_DIR_NAMES.has(normalized) || isCredentialFileName(normalized);
|
|
6220
|
+
}
|
|
6221
|
+
/**
|
|
6222
|
+
* Windows drops trailing dots and spaces from a name, so a file called `.env `
|
|
6223
|
+
* is written as `.env` once it lands in a tool directory there. Normalizing
|
|
6224
|
+
* before every comparison means the name is judged as what it becomes.
|
|
6225
|
+
*/
|
|
6226
|
+
function normalizePathSegment(segment) {
|
|
6227
|
+
const normalized = segment.replace(/[\s.]+$/, "");
|
|
6228
|
+
return normalized === "" ? segment : normalized;
|
|
6229
|
+
}
|
|
6230
|
+
/**
|
|
6231
|
+
* Why a path reaches something that is never skill content, or `undefined`
|
|
6232
|
+
* when it does not.
|
|
6233
|
+
*
|
|
6234
|
+
* Carrying hidden entries means a secret sitting in a skill directory would be
|
|
6235
|
+
* copied into every enabled tool root, multiplying the places it can be
|
|
6236
|
+
* committed from, and a `.venv` would be copied file by file into each of them.
|
|
6237
|
+
* None of these names is ever skill content, so excluding them costs nothing.
|
|
6238
|
+
*
|
|
6239
|
+
* The check is applied to the resolved real path as well as the literal one:
|
|
6240
|
+
* the names are what makes an entry dangerous, and a symbolic link named
|
|
6241
|
+
* `vendor` pointing at `~/.aws` is exactly as dangerous as a directory called
|
|
6242
|
+
* `.aws`. Comparison is lower-cased because macOS and Windows resolve `.SSH`
|
|
6243
|
+
* and `.ssh` to the same file.
|
|
6244
|
+
*/
|
|
6245
|
+
function classifyNeverCarried(relativePath) {
|
|
6246
|
+
const segments = toPosixPath(relativePath).toLowerCase().split("/").filter((segment) => segment !== "" && segment !== ".").map(normalizePathSegment);
|
|
6247
|
+
const fileName = segments.at(-1) ?? "";
|
|
6248
|
+
const posixPath = segments.join("/");
|
|
6249
|
+
if (segments.slice(0, -1).some((segment) => isCredentialFileName(segment))) return "credential";
|
|
6250
|
+
if (segments.some((segment) => NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(segment))) return "credential";
|
|
6251
|
+
if (segments.some((segment) => NEVER_CARRIED_NOISE_DIR_NAMES.has(segment))) return "noise";
|
|
6252
|
+
if (isCredentialFileName(fileName)) return "credential";
|
|
6253
|
+
if (NEVER_CARRIED_NOISE_FILE_NAMES.has(fileName)) return "noise";
|
|
6254
|
+
if (NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`))) return "credential";
|
|
6255
|
+
}
|
|
6256
|
+
/**
|
|
6257
|
+
* Whether a single path segment names a credential file, whether it is the file
|
|
6258
|
+
* itself or a directory somebody gave that name to.
|
|
6259
|
+
*/
|
|
6260
|
+
function isCredentialFileName(segment) {
|
|
6261
|
+
const name = normalizePathSegment(segment.toLowerCase());
|
|
6262
|
+
if (NEVER_CARRIED_CREDENTIAL_FILE_NAMES.has(name)) return true;
|
|
6263
|
+
for (const base of [".env", ".envrc"]) {
|
|
6264
|
+
if (name === base) return true;
|
|
6265
|
+
if (name.startsWith(`${base}.`)) {
|
|
6266
|
+
const lastPiece = name.split(".").at(-1) ?? "";
|
|
6267
|
+
return !ENV_TEMPLATE_SUFFIXES.has(lastPiece);
|
|
6268
|
+
}
|
|
6269
|
+
}
|
|
6270
|
+
return false;
|
|
6271
|
+
}
|
|
6272
|
+
/** Whether a resolved real path points into a kernel pseudo-filesystem. */
|
|
6273
|
+
function isSystemPseudoPath(absolutePath) {
|
|
6274
|
+
const posixPath = toPosixPath(absolutePath);
|
|
6275
|
+
return NEVER_CARRIED_REAL_PATH_ROOTS.some((root) => posixPath === root || posixPath.startsWith(`${root}/`));
|
|
6276
|
+
}
|
|
6277
|
+
/** How many links a chain may be followed before it is treated as a loop. */
|
|
6278
|
+
const MAX_LINK_CHAIN_HOPS = 40;
|
|
6279
|
+
/**
|
|
6280
|
+
* Whether reaching a path goes through a kernel pseudo-filesystem, even when it
|
|
6281
|
+
* does not end in one.
|
|
6282
|
+
*
|
|
6283
|
+
* Asking `realpath` alone is not enough, and the entries it is not enough for
|
|
6284
|
+
* are the dangerous ones: `/proc/<pid>/fd/N`, `exe`, `cwd` and `root` are magic
|
|
6285
|
+
* links, so resolving them lands *outside* `/proc`, on whatever file the
|
|
6286
|
+
* process happens to hold open — a private key another program is reading right
|
|
6287
|
+
* now would come back as an ordinary path and be carried. Following the chain a
|
|
6288
|
+
* hop at a time, and checking each hop, is what sees the `/proc` in the middle.
|
|
6289
|
+
*/
|
|
6290
|
+
async function resolvesThroughSystemPseudoPath(filePath) {
|
|
6291
|
+
let currentPath = resolve(filePath);
|
|
6292
|
+
let hops = 0;
|
|
6293
|
+
for (; hops < MAX_LINK_CHAIN_HOPS; hops++) {
|
|
6294
|
+
if (isSystemPseudoPath(currentPath)) return {
|
|
6295
|
+
throughPseudoPath: true,
|
|
6296
|
+
hops
|
|
6297
|
+
};
|
|
6298
|
+
try {
|
|
6299
|
+
if (isSystemPseudoPath(await realpath(dirname(currentPath)))) return {
|
|
6300
|
+
throughPseudoPath: true,
|
|
6301
|
+
hops
|
|
6302
|
+
};
|
|
6303
|
+
} catch {}
|
|
6304
|
+
let linkStats;
|
|
6305
|
+
try {
|
|
6306
|
+
linkStats = await lstat(currentPath);
|
|
6307
|
+
} catch {
|
|
6308
|
+
return {
|
|
6309
|
+
throughPseudoPath: false,
|
|
6310
|
+
hops
|
|
6311
|
+
};
|
|
6312
|
+
}
|
|
6313
|
+
if (!linkStats.isSymbolicLink()) break;
|
|
6314
|
+
let target;
|
|
6315
|
+
try {
|
|
6316
|
+
target = await readlink(currentPath);
|
|
6317
|
+
} catch {
|
|
6318
|
+
return {
|
|
6319
|
+
throughPseudoPath: false,
|
|
6320
|
+
hops
|
|
6321
|
+
};
|
|
6322
|
+
}
|
|
6323
|
+
currentPath = isAbsolute(target) ? target : resolve(dirname(currentPath), target);
|
|
6324
|
+
}
|
|
6325
|
+
return {
|
|
6326
|
+
throughPseudoPath: false,
|
|
6327
|
+
hops
|
|
6328
|
+
};
|
|
6329
|
+
}
|
|
6330
|
+
const MAX_CARRIED_FILES = 1e4;
|
|
6331
|
+
const MAX_CARRIED_DIRECTORIES = 1e4;
|
|
6332
|
+
/**
|
|
6333
|
+
* The bounds above limit what is *carried*; this one limits what is *looked at*.
|
|
6334
|
+
* A directory holding nothing but a few hundred thousand links to itself carries
|
|
6335
|
+
* no files and occupies no depth, and would still cost a `stat` apiece.
|
|
6336
|
+
*/
|
|
6337
|
+
const MAX_CARRIED_ENTRIES_EXAMINED = 2e5;
|
|
6338
|
+
const MAX_CARRIED_BYTES = 104857600;
|
|
6339
|
+
/** How many `realpath` calls the carried-file filter keeps in flight. */
|
|
6340
|
+
const CARRIED_REALPATH_CONCURRENCY = 32;
|
|
6341
|
+
/** Sort directory entries by name so a walk of the same tree is reproducible. */
|
|
6342
|
+
function compareByName(left, right) {
|
|
6343
|
+
if (left.name === right.name) return 0;
|
|
6344
|
+
return left.name < right.name ? -1 : 1;
|
|
6345
|
+
}
|
|
6346
|
+
/**
|
|
6347
|
+
* Order the routes that cross the same number of symbolic links: the one with
|
|
6348
|
+
* the fewest hidden segments first, so a named alias represents a shared tree
|
|
6349
|
+
* rather than a hidden one that a hidden-entry rule then refuses, taking the
|
|
6350
|
+
* named route's content with it.
|
|
6351
|
+
*/
|
|
6352
|
+
function comparePendingCarriedDirs(left, right) {
|
|
6353
|
+
if (left.hiddenSegments !== right.hiddenSegments) return left.hiddenSegments - right.hiddenSegments;
|
|
6354
|
+
if (left.depth !== right.depth) return left.depth - right.depth;
|
|
6355
|
+
if (left.dirPath === right.dirPath) return 0;
|
|
6356
|
+
return left.dirPath < right.dirPath ? -1 : 1;
|
|
6357
|
+
}
|
|
6358
|
+
/**
|
|
6359
|
+
* Collect the files under a carried directory, following symbolic links but
|
|
6360
|
+
* visiting each real directory exactly once, by its cheapest route.
|
|
6361
|
+
*
|
|
6362
|
+
* A glob walk cannot do this safely. Two links in one directory that both point
|
|
6363
|
+
* back at an ancestor double the paths walked per level, and the walker follows
|
|
6364
|
+
* them until the kernel's ELOOP limit (~40), so the path array alone exhausts
|
|
6365
|
+
* the heap long before anything reads a file — a depth bound only lowers the
|
|
6366
|
+
* exponent, while the base is whatever number of links the tree's author chose.
|
|
6367
|
+
* Remembering the real directories already visited removes the multiplication
|
|
6368
|
+
* itself: a cycle, and an alias for a directory already walked, both stop at the
|
|
6369
|
+
* entry that closes them.
|
|
6370
|
+
*
|
|
6371
|
+
* Which route represents a directory then matters, because the others are
|
|
6372
|
+
* dropped. The walk proceeds in rounds by the number of symbolic links crossed:
|
|
6373
|
+
* everything reachable without crossing one, then everything one link away, and
|
|
6374
|
+
* so on. A real location therefore always wins over an alias for it — at any
|
|
6375
|
+
* nesting depth, not just among siblings, which a depth-first walk could not
|
|
6376
|
+
* promise — and among aliases the named one wins over a hidden one.
|
|
6377
|
+
*
|
|
6378
|
+
* A broken link is skipped: it resolves to nothing to read.
|
|
6379
|
+
*/
|
|
6380
|
+
async function walkCarriedFiles(dirPath, { skipHiddenRoutes = false } = {}) {
|
|
6381
|
+
const filePaths = [];
|
|
6382
|
+
const visitedRealDirPaths = /* @__PURE__ */ new Set();
|
|
6383
|
+
const truncations = /* @__PURE__ */ new Set();
|
|
6384
|
+
const unreadablePaths = /* @__PURE__ */ new Set();
|
|
6385
|
+
const pseudoPaths = /* @__PURE__ */ new Set();
|
|
6386
|
+
const depthStoppedRealDirPaths = /* @__PURE__ */ new Set();
|
|
6387
|
+
let deferredLinkedDirs = [];
|
|
6388
|
+
/** Whether the walk has hit a bound that ends it rather than one branch of it. */
|
|
6389
|
+
let examinedEntries = 0;
|
|
6390
|
+
const isFull = () => truncations.has("count") || truncations.has("directories") || truncations.has("entries");
|
|
6391
|
+
const addFile = (filePath) => {
|
|
6392
|
+
if (filePaths.length >= 1e4) {
|
|
6393
|
+
truncations.add("count");
|
|
6394
|
+
return;
|
|
6395
|
+
}
|
|
6396
|
+
filePaths.push(filePath);
|
|
6397
|
+
};
|
|
6398
|
+
/** Carry what a symbolic link names, or hold its directory for the next round. */
|
|
6399
|
+
const routeLinkedEntry = async (child, entryName) => {
|
|
6400
|
+
let targetStats;
|
|
6401
|
+
try {
|
|
6402
|
+
targetStats = await stat(child.dirPath);
|
|
6403
|
+
} catch {
|
|
6404
|
+
return;
|
|
6405
|
+
}
|
|
6406
|
+
if (targetStats.isFile()) {
|
|
6407
|
+
addFile(child.dirPath);
|
|
6408
|
+
return;
|
|
6409
|
+
}
|
|
6410
|
+
if (!targetStats.isDirectory() || isNeverCarriedDirName(entryName)) return;
|
|
6411
|
+
const { throughPseudoPath, hops } = await resolvesThroughSystemPseudoPath(child.dirPath);
|
|
6412
|
+
examinedEntries += hops;
|
|
6413
|
+
if (throughPseudoPath) {
|
|
6414
|
+
pseudoPaths.add(child.dirPath);
|
|
6415
|
+
return;
|
|
6416
|
+
}
|
|
6417
|
+
deferredLinkedDirs.push(child);
|
|
6418
|
+
};
|
|
6419
|
+
/** Walk one directory and everything below it that no symbolic link leads to. */
|
|
6420
|
+
const walkWithoutCrossingLinks = async (pending) => {
|
|
6421
|
+
if (isFull()) return;
|
|
6422
|
+
let realCurrentPath;
|
|
6423
|
+
try {
|
|
6424
|
+
realCurrentPath = await realpath(pending.dirPath);
|
|
6425
|
+
} catch {
|
|
6426
|
+
unreadablePaths.add(pending.dirPath);
|
|
6427
|
+
return;
|
|
6428
|
+
}
|
|
6429
|
+
if (visitedRealDirPaths.has(realCurrentPath)) return;
|
|
6430
|
+
if (isSystemPseudoPath(realCurrentPath)) {
|
|
6431
|
+
pseudoPaths.add(pending.dirPath);
|
|
6432
|
+
return;
|
|
6433
|
+
}
|
|
6434
|
+
if (pending.depth > 12) {
|
|
6435
|
+
depthStoppedRealDirPaths.add(realCurrentPath);
|
|
6436
|
+
return;
|
|
6437
|
+
}
|
|
6438
|
+
if (visitedRealDirPaths.size >= 1e4) {
|
|
6439
|
+
truncations.add("directories");
|
|
6440
|
+
return;
|
|
6441
|
+
}
|
|
6442
|
+
visitedRealDirPaths.add(realCurrentPath);
|
|
6443
|
+
let entries;
|
|
6444
|
+
try {
|
|
6445
|
+
entries = await readdir(pending.dirPath, { withFileTypes: true });
|
|
6446
|
+
} catch {
|
|
6447
|
+
unreadablePaths.add(pending.dirPath);
|
|
6448
|
+
return;
|
|
6449
|
+
}
|
|
6450
|
+
const realSubDirs = [];
|
|
6451
|
+
for (const entry of entries.toSorted(compareByName)) {
|
|
6452
|
+
if (isFull()) return;
|
|
6453
|
+
examinedEntries += 1;
|
|
6454
|
+
if (examinedEntries > 2e5) {
|
|
6455
|
+
truncations.add("entries");
|
|
6456
|
+
return;
|
|
6457
|
+
}
|
|
6458
|
+
if (skipHiddenRoutes && isHiddenPathSegment(entry.name)) continue;
|
|
6459
|
+
const entryPath = join(pending.dirPath, entry.name);
|
|
6460
|
+
const child = {
|
|
6461
|
+
dirPath: entryPath,
|
|
6462
|
+
depth: pending.depth + 1,
|
|
6463
|
+
hiddenSegments: pending.hiddenSegments + (isHiddenPathSegment(entry.name) ? 1 : 0)
|
|
6464
|
+
};
|
|
6465
|
+
if (entry.isFile()) {
|
|
6466
|
+
addFile(entryPath);
|
|
6467
|
+
continue;
|
|
6468
|
+
}
|
|
6469
|
+
if (entry.isDirectory()) {
|
|
6470
|
+
if (!isNeverCarriedDirName(entry.name)) realSubDirs.push(child);
|
|
6471
|
+
continue;
|
|
6472
|
+
}
|
|
6473
|
+
if (!entry.isSymbolicLink()) continue;
|
|
6474
|
+
await routeLinkedEntry(child, entry.name);
|
|
6475
|
+
}
|
|
6476
|
+
for (const realSubDir of realSubDirs) {
|
|
6477
|
+
if (isFull()) return;
|
|
6478
|
+
await walkWithoutCrossingLinks(realSubDir);
|
|
6479
|
+
}
|
|
6480
|
+
};
|
|
6481
|
+
let round = [{
|
|
6482
|
+
dirPath,
|
|
6483
|
+
depth: 0,
|
|
6484
|
+
hiddenSegments: 0
|
|
6485
|
+
}];
|
|
6486
|
+
while (round.length > 0 && !isFull()) {
|
|
6487
|
+
deferredLinkedDirs = [];
|
|
6488
|
+
for (const pending of round.toSorted(comparePendingCarriedDirs)) {
|
|
6489
|
+
if (isFull()) break;
|
|
6490
|
+
await walkWithoutCrossingLinks(pending);
|
|
6491
|
+
}
|
|
6492
|
+
round = deferredLinkedDirs;
|
|
6493
|
+
}
|
|
6494
|
+
for (const realDirPath of depthStoppedRealDirPaths) if (!visitedRealDirPaths.has(realDirPath)) {
|
|
6495
|
+
truncations.add("depth");
|
|
6496
|
+
break;
|
|
6497
|
+
}
|
|
6498
|
+
return {
|
|
6499
|
+
filePaths: filePaths.toSorted(),
|
|
6500
|
+
truncations,
|
|
6501
|
+
unreadablePaths: [...unreadablePaths],
|
|
6502
|
+
pseudoPaths: [...pseudoPaths]
|
|
6503
|
+
};
|
|
6504
|
+
}
|
|
6505
|
+
/** Render a set of refused or noteworthy paths for one warning line. */
|
|
6506
|
+
function formatReportedPaths(paths) {
|
|
6507
|
+
const sorted = [...paths].toSorted();
|
|
6508
|
+
const named = sorted.slice(0, 10).map((filePath) => stripControlCharacters(toPosixPath(filePath))).join(", ");
|
|
6509
|
+
const remaining = sorted.length - 10;
|
|
6510
|
+
return {
|
|
6511
|
+
count: sorted.length,
|
|
6512
|
+
list: `${named}${remaining > 0 ? `, and ${remaining} more` : ""}`
|
|
6513
|
+
};
|
|
6514
|
+
}
|
|
6515
|
+
/** "entry" or "entries", so the warnings below read as sentences. */
|
|
6516
|
+
function entryWord(count) {
|
|
6517
|
+
return count === 1 ? "entry" : "entries";
|
|
6518
|
+
}
|
|
6519
|
+
/** "resolves" or "resolve", to agree with the entry count it follows. */
|
|
6520
|
+
function resolveWord(count) {
|
|
6521
|
+
return count === 1 ? "resolves" : "resolve";
|
|
6522
|
+
}
|
|
6523
|
+
var AiDir = class AiDir {
|
|
5449
6524
|
/**
|
|
5450
6525
|
* @example "."
|
|
5451
6526
|
*/
|
|
@@ -5495,8 +6570,7 @@ var AiDir = class {
|
|
|
5495
6570
|
const fullPath = path.join(this.outputRoot, this.relativeDirPath, this.dirName);
|
|
5496
6571
|
const resolvedFull = resolve(fullPath);
|
|
5497
6572
|
const resolvedBase = resolve(this.outputRoot);
|
|
5498
|
-
|
|
5499
|
-
if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
|
|
6573
|
+
if (pathEscapesRoot(relative(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
|
|
5500
6574
|
return fullPath;
|
|
5501
6575
|
}
|
|
5502
6576
|
getMainFile() {
|
|
@@ -5522,9 +6596,227 @@ var AiDir = class {
|
|
|
5522
6596
|
};
|
|
5523
6597
|
}
|
|
5524
6598
|
/**
|
|
6599
|
+
* A nested repository inside a carried directory is the one exclusion worth
|
|
6600
|
+
* reporting: unlike `.DS_Store`, it is there on purpose, and the tree it
|
|
6601
|
+
* points at is simply not reproduced on generate. Only the top level is
|
|
6602
|
+
* checked, which is where a submodule or a stray `git init` puts it, so the
|
|
6603
|
+
* check costs one stat per directory rather than a second walk. `fileExists`
|
|
6604
|
+
* is a bare `stat`, so it answers for a submodule pointer file and for a real
|
|
6605
|
+
* `.git` directory alike.
|
|
6606
|
+
*/
|
|
6607
|
+
static async warnOnNestedGitDirectory(dirPath) {
|
|
6608
|
+
const gitEntryPath = join(dirPath, ".git");
|
|
6609
|
+
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.`);
|
|
6610
|
+
}
|
|
6611
|
+
/** Whether any segment of a relative path is dot-prefixed. */
|
|
6612
|
+
static hasHiddenSegment(relativePath) {
|
|
6613
|
+
return splitPathSegments(relativePath).some(isHiddenPathSegment);
|
|
6614
|
+
}
|
|
6615
|
+
/**
|
|
6616
|
+
* Whether the entry a path ends at is itself hidden, its ancestors aside.
|
|
6617
|
+
* A shared skill tree usually lives under a dot-directory, so an ancestor
|
|
6618
|
+
* says nothing about the file; its own name is what was chosen for it.
|
|
6619
|
+
*/
|
|
6620
|
+
static hasHiddenName(relativePath) {
|
|
6621
|
+
return isHiddenPathSegment(splitPathSegments(relativePath).at(-1) ?? "");
|
|
6622
|
+
}
|
|
6623
|
+
/**
|
|
6624
|
+
* Drop the entries a skill directory must not carry.
|
|
6625
|
+
*
|
|
6626
|
+
* Three rules. `classifyNeverCarried` comes first, evaluated against the
|
|
6627
|
+
* resolved real path as well as the literal one: names that are never skill
|
|
6628
|
+
* content stay out however they are reached, so renaming a symbolic link
|
|
6629
|
+
* does not turn `~/.aws` into content a skill carries. Next, a real path
|
|
6630
|
+
* inside a kernel pseudo-filesystem is refused outright — that is process
|
|
6631
|
+
* state, not a file.
|
|
6632
|
+
*
|
|
6633
|
+
* The third concerns hidden entries a symbolic link reaches outside the
|
|
6634
|
+
* directory. Following symlinks out of a source tree is deliberate and
|
|
6635
|
+
* documented — it is how a shared skill is referenced from several projects
|
|
6636
|
+
* without being duplicated (issue #1707), and the trust boundary is the tree
|
|
6637
|
+
* you point Rulesync at. Carrying hidden entries changes what that costs,
|
|
6638
|
+
* though: one ordinary-looking link to a home directory would pull in every
|
|
6639
|
+
* dotfile under it, and those are the entries with credential value. What
|
|
6640
|
+
* decides is the name in the skill directory, not what the link resolves
|
|
6641
|
+
* through: a named file keeps its documented behavior even when the target
|
|
6642
|
+
* sits under a dot-directory such as `~/.dotfiles`, because somebody chose
|
|
6643
|
+
* that name. What the link resolves *to* still counts at the end of the path,
|
|
6644
|
+
* though — `notes.md` pointing at `~/.claude/.credentials.json` reaches a
|
|
6645
|
+
* file nobody named for a skill — so a hidden final segment is refused on
|
|
6646
|
+
* either side. Reaching outside is reported either way, since content from
|
|
6647
|
+
* outside the tree is about to be copied into every enabled tool root.
|
|
6648
|
+
*
|
|
6649
|
+
* A path that cannot be resolved is kept: `realpath` fails on a broken link
|
|
6650
|
+
* or a race, and neither is a reason to silently drop a file.
|
|
6651
|
+
*/
|
|
6652
|
+
/** Report what a carried directory left out, once both walks have had their say. */
|
|
6653
|
+
static warnOnRefusedCarriedFiles(dirPath, carried) {
|
|
6654
|
+
const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
|
|
6655
|
+
if (carried.refusedCredentials.size > 0) {
|
|
6656
|
+
const { count, list } = formatReportedPaths(carried.refusedCredentials);
|
|
6657
|
+
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.`);
|
|
6658
|
+
}
|
|
6659
|
+
if (carried.refusedPseudoPaths.size > 0) {
|
|
6660
|
+
const { count, list } = formatReportedPaths(carried.refusedPseudoPaths);
|
|
6661
|
+
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.`);
|
|
6662
|
+
}
|
|
6663
|
+
if (carried.refusedNoiseAliases.size > 0) {
|
|
6664
|
+
const { count, list } = formatReportedPaths(carried.refusedNoiseAliases);
|
|
6665
|
+
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.`);
|
|
6666
|
+
}
|
|
6667
|
+
if (carried.refusedEscapedHidden.size > 0) {
|
|
6668
|
+
const { count, list } = formatReportedPaths(carried.refusedEscapedHidden);
|
|
6669
|
+
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.`);
|
|
6670
|
+
}
|
|
6671
|
+
if (carried.carriedFromOutside.size > 0) {
|
|
6672
|
+
const { count, list } = formatReportedPaths([...carried.carriedFromOutside].map((filePath) => {
|
|
6673
|
+
const realFilePath = carried.realFilePathByPath.get(filePath);
|
|
6674
|
+
return realFilePath === void 0 ? filePath : `${filePath} -> ${realFilePath}`;
|
|
6675
|
+
}));
|
|
6676
|
+
warnOnceWithFallback(void 0, `Carrying ${count} ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Their content is copied into every generated tool directory.`);
|
|
6677
|
+
}
|
|
6678
|
+
}
|
|
6679
|
+
static async filterCarriedFiles(dirPath, filePaths) {
|
|
6680
|
+
let realDirPath;
|
|
6681
|
+
try {
|
|
6682
|
+
realDirPath = await realpath(dirPath);
|
|
6683
|
+
} catch {
|
|
6684
|
+
realDirPath = resolve(dirPath);
|
|
6685
|
+
}
|
|
6686
|
+
const refusedCredentials = /* @__PURE__ */ new Set();
|
|
6687
|
+
const refusedPseudoPaths = /* @__PURE__ */ new Set();
|
|
6688
|
+
const refusedEscapedHidden = /* @__PURE__ */ new Set();
|
|
6689
|
+
const refusedNoiseAliases = /* @__PURE__ */ new Set();
|
|
6690
|
+
const carriedFromOutside = /* @__PURE__ */ new Set();
|
|
6691
|
+
const realFilePathByPath = /* @__PURE__ */ new Map();
|
|
6692
|
+
const verdicts = await mapWithConcurrency({
|
|
6693
|
+
items: filePaths,
|
|
6694
|
+
limit: CARRIED_REALPATH_CONCURRENCY,
|
|
6695
|
+
mapper: async (filePath) => {
|
|
6696
|
+
const literalPath = relative(dirPath, filePath);
|
|
6697
|
+
const literalReason = classifyNeverCarried(literalPath);
|
|
6698
|
+
if (literalReason !== void 0) {
|
|
6699
|
+
if (literalReason === "credential") refusedCredentials.add(filePath);
|
|
6700
|
+
return false;
|
|
6701
|
+
}
|
|
6702
|
+
let realFilePath;
|
|
6703
|
+
try {
|
|
6704
|
+
realFilePath = await realpath(filePath);
|
|
6705
|
+
} catch {
|
|
6706
|
+
return true;
|
|
6707
|
+
}
|
|
6708
|
+
realFilePathByPath.set(filePath, realFilePath);
|
|
6709
|
+
if (isSystemPseudoPath(realFilePath)) {
|
|
6710
|
+
refusedPseudoPaths.add(filePath);
|
|
6711
|
+
return false;
|
|
6712
|
+
}
|
|
6713
|
+
const realPath = relative(realDirPath, realFilePath);
|
|
6714
|
+
const realReason = classifyNeverCarried(realPath);
|
|
6715
|
+
if (realReason !== void 0) {
|
|
6716
|
+
if (realReason === "credential") refusedCredentials.add(filePath);
|
|
6717
|
+
else refusedNoiseAliases.add(filePath);
|
|
6718
|
+
return false;
|
|
6719
|
+
}
|
|
6720
|
+
if (!pathEscapesRoot(realPath)) return true;
|
|
6721
|
+
if ((await resolvesThroughSystemPseudoPath(filePath)).throughPseudoPath) {
|
|
6722
|
+
refusedPseudoPaths.add(filePath);
|
|
6723
|
+
return false;
|
|
6724
|
+
}
|
|
6725
|
+
if (endsWithNeverCarriedSuffix(realFilePath)) {
|
|
6726
|
+
refusedCredentials.add(filePath);
|
|
6727
|
+
return false;
|
|
6728
|
+
}
|
|
6729
|
+
if (escapesIntoCredentialDir({
|
|
6730
|
+
realDirPath,
|
|
6731
|
+
realFilePath
|
|
6732
|
+
})) {
|
|
6733
|
+
refusedCredentials.add(filePath);
|
|
6734
|
+
return false;
|
|
6735
|
+
}
|
|
6736
|
+
if (AiDir.hasHiddenSegment(literalPath) || AiDir.hasHiddenName(realPath)) {
|
|
6737
|
+
refusedEscapedHidden.add(filePath);
|
|
6738
|
+
return false;
|
|
6739
|
+
}
|
|
6740
|
+
carriedFromOutside.add(filePath);
|
|
6741
|
+
return true;
|
|
6742
|
+
}
|
|
6743
|
+
});
|
|
6744
|
+
return {
|
|
6745
|
+
filePaths: filePaths.filter((_filePath, index) => verdicts[index]),
|
|
6746
|
+
realFilePathByPath,
|
|
6747
|
+
refusedCredentials,
|
|
6748
|
+
refusedPseudoPaths,
|
|
6749
|
+
refusedEscapedHidden,
|
|
6750
|
+
refusedNoiseAliases,
|
|
6751
|
+
carriedFromOutside
|
|
6752
|
+
};
|
|
6753
|
+
}
|
|
6754
|
+
/**
|
|
6755
|
+
* Report what the walk had to leave behind, so a skill that silently lost
|
|
6756
|
+
* files says so rather than generating a directory that is quietly short.
|
|
6757
|
+
*/
|
|
6758
|
+
static warnOnCarriedWalkLimits({ reportedDirPath, truncations, unreadablePaths }) {
|
|
6759
|
+
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.`);
|
|
6760
|
+
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.`);
|
|
6761
|
+
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.`);
|
|
6762
|
+
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.`);
|
|
6763
|
+
if (unreadablePaths.length > 0) {
|
|
6764
|
+
const { count, list } = formatReportedPaths(unreadablePaths);
|
|
6765
|
+
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.`);
|
|
6766
|
+
}
|
|
6767
|
+
}
|
|
6768
|
+
/**
|
|
6769
|
+
* Walk the directory once more with the hidden routes pruned, and take the
|
|
6770
|
+
* files the first walk had to leave behind because the route that reached
|
|
6771
|
+
* them ran through a hidden directory. This can only add files, and only ones
|
|
6772
|
+
* a fully named route reaches.
|
|
6773
|
+
*/
|
|
6774
|
+
static async recoverCarriedFilesFromNamedRoutes({ dirPath, carried, carriedPaths, carriedRealPaths, walk }) {
|
|
6775
|
+
const named = await walkCarriedFiles(dirPath, { skipHiddenRoutes: true });
|
|
6776
|
+
const recovered = await AiDir.filterCarriedFiles(dirPath, named.filePaths);
|
|
6777
|
+
for (const truncation of named.truncations) walk.truncations.add(truncation);
|
|
6778
|
+
for (const unreadablePath of named.unreadablePaths) if (!walk.unreadablePaths.includes(unreadablePath)) walk.unreadablePaths.push(unreadablePath);
|
|
6779
|
+
for (const pseudoPath of named.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
|
|
6780
|
+
for (const refused of recovered.refusedCredentials) carried.refusedCredentials.add(refused);
|
|
6781
|
+
for (const refused of recovered.refusedPseudoPaths) carried.refusedPseudoPaths.add(refused);
|
|
6782
|
+
for (const refused of recovered.refusedNoiseAliases) carried.refusedNoiseAliases.add(refused);
|
|
6783
|
+
for (const refused of recovered.refusedEscapedHidden) carried.refusedEscapedHidden.add(refused);
|
|
6784
|
+
for (const filePath of recovered.filePaths) {
|
|
6785
|
+
const realFilePath = recovered.realFilePathByPath.get(filePath) ?? filePath;
|
|
6786
|
+
if (carriedRealPaths.has(realFilePath)) continue;
|
|
6787
|
+
if (carriedPaths.length >= 1e4) {
|
|
6788
|
+
walk.truncations.add("count");
|
|
6789
|
+
break;
|
|
6790
|
+
}
|
|
6791
|
+
carriedRealPaths.add(realFilePath);
|
|
6792
|
+
carriedPaths.push(filePath);
|
|
6793
|
+
carried.realFilePathByPath.set(filePath, realFilePath);
|
|
6794
|
+
if (recovered.carriedFromOutside.has(filePath)) carried.carriedFromOutside.add(filePath);
|
|
6795
|
+
}
|
|
6796
|
+
for (const filePath of carried.refusedEscapedHidden) {
|
|
6797
|
+
const realFilePath = carried.realFilePathByPath.get(filePath) ?? filePath;
|
|
6798
|
+
if (carriedRealPaths.has(realFilePath)) carried.refusedEscapedHidden.delete(filePath);
|
|
6799
|
+
}
|
|
6800
|
+
}
|
|
6801
|
+
/**
|
|
5525
6802
|
* Recursively collects all files from a directory, excluding the specified main file.
|
|
5526
6803
|
* This is a common utility for loading additional files alongside the main file.
|
|
5527
6804
|
*
|
|
6805
|
+
* Hidden entries are included. The directories this walks are skill trees,
|
|
6806
|
+
* whose specification says a skill directory "may contain any files and
|
|
6807
|
+
* directories beyond the required `SKILL.md`" — a `.env.example` beside the
|
|
6808
|
+
* scripts that read it is content, not noise, and dropping it silently on
|
|
6809
|
+
* both import and generate loses part of the skill. What is left out is the
|
|
6810
|
+
* set of entries that are never skill content — a nested repository's `.git`,
|
|
6811
|
+
* the macOS Finder's `.DS_Store`, credential stores, build and cache trees —
|
|
6812
|
+
* as decided by `classifyNeverCarried`. Whole directories from that set are
|
|
6813
|
+
* pruned during the walk too, so it never descends into them at all.
|
|
6814
|
+
*
|
|
6815
|
+
* The walk is bounded and cycle-aware — see `walkCarriedFiles` — because the
|
|
6816
|
+
* tree may contain symbolic links that somebody else chose.
|
|
6817
|
+
*
|
|
6818
|
+
* @see https://agentskills.io/specification
|
|
6819
|
+
*
|
|
5528
6820
|
* @param outputRoot - The base directory path
|
|
5529
6821
|
* @param relativeDirPath - The relative path to the directory containing the skill
|
|
5530
6822
|
* @param dirName - The name of the directory
|
|
@@ -5533,14 +6825,57 @@ var AiDir = class {
|
|
|
5533
6825
|
*/
|
|
5534
6826
|
static async collectOtherFiles(outputRoot, relativeDirPath, dirName, excludeFileName) {
|
|
5535
6827
|
const dirPath = join(outputRoot, relativeDirPath, dirName);
|
|
5536
|
-
const
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
6828
|
+
const walk = await walkCarriedFiles(dirPath);
|
|
6829
|
+
const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
|
|
6830
|
+
await AiDir.warnOnNestedGitDirectory(dirPath);
|
|
6831
|
+
const carried = await AiDir.filterCarriedFiles(dirPath, walk.filePaths);
|
|
6832
|
+
const carriedPaths = [...carried.filePaths];
|
|
6833
|
+
const carriedRealPaths = new Set(carriedPaths.map((filePath) => carried.realFilePathByPath.get(filePath) ?? filePath));
|
|
6834
|
+
if (carried.refusedEscapedHidden.size > 0) await AiDir.recoverCarriedFilesFromNamedRoutes({
|
|
6835
|
+
dirPath,
|
|
6836
|
+
carried,
|
|
6837
|
+
carriedPaths,
|
|
6838
|
+
carriedRealPaths,
|
|
6839
|
+
walk
|
|
6840
|
+
});
|
|
6841
|
+
for (const pseudoPath of walk.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
|
|
6842
|
+
AiDir.warnOnCarriedWalkLimits({
|
|
6843
|
+
reportedDirPath,
|
|
6844
|
+
truncations: walk.truncations,
|
|
6845
|
+
unreadablePaths: walk.unreadablePaths
|
|
6846
|
+
});
|
|
6847
|
+
AiDir.warnOnRefusedCarriedFiles(dirPath, carried);
|
|
6848
|
+
const filteredPaths = carriedPaths.toSorted().filter((filePath) => basename(filePath) !== excludeFileName);
|
|
6849
|
+
const files = [];
|
|
6850
|
+
let carriedBytes = 0;
|
|
6851
|
+
for (const [index, filePath] of filteredPaths.entries()) {
|
|
6852
|
+
const classifiedPath = carried.realFilePathByPath.get(filePath) ?? filePath;
|
|
6853
|
+
let fileHandle;
|
|
6854
|
+
try {
|
|
6855
|
+
fileHandle = await open(classifiedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
6856
|
+
} catch (error) {
|
|
6857
|
+
warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
|
|
6858
|
+
continue;
|
|
6859
|
+
}
|
|
6860
|
+
try {
|
|
6861
|
+
const fileSize = (await fileHandle.stat()).size;
|
|
6862
|
+
if (carriedBytes + fileSize > 104857600) {
|
|
6863
|
+
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.`);
|
|
6864
|
+
break;
|
|
6865
|
+
}
|
|
6866
|
+
const fileBuffer = await fileHandle.readFile();
|
|
6867
|
+
carriedBytes += fileBuffer.byteLength;
|
|
6868
|
+
files.push({
|
|
6869
|
+
relativeFilePathToDirPath: relative(dirPath, filePath),
|
|
6870
|
+
fileBuffer
|
|
6871
|
+
});
|
|
6872
|
+
} catch (error) {
|
|
6873
|
+
warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
|
|
6874
|
+
} finally {
|
|
6875
|
+
await fileHandle.close();
|
|
6876
|
+
}
|
|
6877
|
+
}
|
|
6878
|
+
return files;
|
|
5544
6879
|
}
|
|
5545
6880
|
};
|
|
5546
6881
|
const RulesyncSkillFrontmatterSchema = z.looseObject({
|
|
@@ -5680,7 +7015,11 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
|
|
|
5680
7015
|
"disable-model-invocation": z.optional(z.boolean()),
|
|
5681
7016
|
"user-invocable": z.optional(z.boolean()),
|
|
5682
7017
|
enabled: z.optional(z.boolean()),
|
|
5683
|
-
"allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
|
|
7018
|
+
"allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
|
|
7019
|
+
license: z.optional(z.unknown()),
|
|
7020
|
+
compatibility: z.optional(z.unknown()),
|
|
7021
|
+
metadata: z.optional(z.unknown()),
|
|
7022
|
+
version: z.optional(z.unknown())
|
|
5684
7023
|
})),
|
|
5685
7024
|
grokcli: z.optional(z.looseObject({
|
|
5686
7025
|
"disable-model-invocation": z.optional(z.boolean()),
|
|
@@ -5763,7 +7102,7 @@ var RulesyncSkill = class RulesyncSkill extends AiDir {
|
|
|
5763
7102
|
const skillDirPath = join(outputRoot, relativeDirPath, dirName);
|
|
5764
7103
|
const skillFilePath = join(skillDirPath, SKILL_FILE_NAME);
|
|
5765
7104
|
if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
|
|
5766
|
-
const { frontmatter, body: content, hasFrontmatter } =
|
|
7105
|
+
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
|
|
5767
7106
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${skillFilePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
5768
7107
|
const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter);
|
|
5769
7108
|
if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
|
|
@@ -5856,8 +7195,9 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
5856
7195
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
5857
7196
|
};
|
|
5858
7197
|
}
|
|
5859
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
|
|
5860
|
-
const
|
|
7198
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
7199
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
|
|
7200
|
+
const filePath = join(outputRoot, dirPath, relativeFilePath);
|
|
5861
7201
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
|
|
5862
7202
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
5863
7203
|
const result = RulesyncSubagentFrontmatterSchema.safeParse(frontmatter);
|
|
@@ -5865,7 +7205,7 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
5865
7205
|
const filename = basename(relativeFilePath);
|
|
5866
7206
|
return new RulesyncSubagent({
|
|
5867
7207
|
outputRoot,
|
|
5868
|
-
relativeDirPath:
|
|
7208
|
+
relativeDirPath: dirPath,
|
|
5869
7209
|
relativeFilePath: filename,
|
|
5870
7210
|
frontmatter: result.data,
|
|
5871
7211
|
body: content.trim()
|
|
@@ -5875,16 +7215,18 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
5875
7215
|
//#endregion
|
|
5876
7216
|
//#region src/features/skills/skills-utils.ts
|
|
5877
7217
|
/**
|
|
5878
|
-
* Returns the set of local skill directory names (excluding `.curated`)
|
|
7218
|
+
* Returns the set of local skill directory names (excluding `.curated`)
|
|
7219
|
+
* from a rulesync source tree (e.g. `/repo/.rulesync` or
|
|
7220
|
+
* `/repo/.rulesync.local`).
|
|
5879
7221
|
*/
|
|
5880
|
-
async function getLocalSkillDirNames(
|
|
5881
|
-
const skillsDir = join(
|
|
7222
|
+
async function getLocalSkillDirNames(sourceTree) {
|
|
7223
|
+
const skillsDir = join(sourceTree, SKILLS_FEATURE_SUBDIR);
|
|
5882
7224
|
const names = /* @__PURE__ */ new Set();
|
|
5883
7225
|
if (!await directoryExists(skillsDir)) return names;
|
|
5884
7226
|
const dirPaths = await findFilesByGlobs(join(skillsDir, "*"), { type: "dir" });
|
|
5885
7227
|
for (const dirPath of dirPaths) {
|
|
5886
7228
|
const name = basename(dirPath);
|
|
5887
|
-
if (name === basename(
|
|
7229
|
+
if (name === basename(CURATED_SKILLS_FEATURE_SUBDIR)) continue;
|
|
5888
7230
|
names.add(name);
|
|
5889
7231
|
}
|
|
5890
7232
|
return names;
|
|
@@ -6200,12 +7542,25 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
|
|
|
6200
7542
|
//#region src/types/feature-processor.ts
|
|
6201
7543
|
var FeatureProcessor = class {
|
|
6202
7544
|
outputRoot;
|
|
6203
|
-
|
|
7545
|
+
/**
|
|
7546
|
+
* Ordered, non-empty list of rulesync source-tree directories. Each entry
|
|
7547
|
+
* is a source tree itself — the directory that directly contains feature
|
|
7548
|
+
* subdirectories (`rules/`, `commands/`, …) and single-file features
|
|
7549
|
+
* (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence in
|
|
7550
|
+
* per-feature merges. Defaults to `[join(process.cwd(), ".rulesync")]`.
|
|
7551
|
+
*
|
|
7552
|
+
* The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
|
|
7553
|
+
* `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
|
|
7554
|
+
* and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
|
|
7555
|
+
* reaches a processor — every internal consumer only ever sees the
|
|
7556
|
+
* plural form, with the source tree already resolved.
|
|
7557
|
+
*/
|
|
7558
|
+
inputRoots;
|
|
6204
7559
|
dryRun;
|
|
6205
7560
|
logger;
|
|
6206
|
-
constructor({ outputRoot = process.cwd(),
|
|
7561
|
+
constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, logger }) {
|
|
6207
7562
|
this.outputRoot = outputRoot;
|
|
6208
|
-
this.
|
|
7563
|
+
this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
|
|
6209
7564
|
this.dryRun = dryRun;
|
|
6210
7565
|
this.logger = logger;
|
|
6211
7566
|
}
|
|
@@ -6272,6 +7627,198 @@ var FeatureProcessor = class {
|
|
|
6272
7627
|
return orphanFiles.length;
|
|
6273
7628
|
}
|
|
6274
7629
|
};
|
|
7630
|
+
/**
|
|
7631
|
+
* Messages already reported for a given logger.
|
|
7632
|
+
*
|
|
7633
|
+
* One `generate` run constructs a single-file processor per tool target and
|
|
7634
|
+
* per output root — more than twenty times for `--targets "*"` — and each one
|
|
7635
|
+
* re-resolves the same roots. Keying on the logger — created once per run, and
|
|
7636
|
+
* once per test — keeps the shadowing warning to a single line instead of
|
|
7637
|
+
* repeating it for every target. `--watch` reuses one logger across runs, so
|
|
7638
|
+
* {@link resetRootShadowingWarnings} clears the set at the start of each one.
|
|
7639
|
+
*/
|
|
7640
|
+
const warnedRootShadowingByLogger = /* @__PURE__ */ new WeakMap();
|
|
7641
|
+
/**
|
|
7642
|
+
* Forget which shadowing warnings have already been reported.
|
|
7643
|
+
*
|
|
7644
|
+
* `generate` calls this once per run. Without it, `--watch` reuses one logger
|
|
7645
|
+
* for the whole session, so the warning would be printed on the first
|
|
7646
|
+
* generation and never again — the opposite of why it is a warning, since
|
|
7647
|
+
* `--watch` is exactly when an overlay is most likely to be added or edited.
|
|
7648
|
+
*/
|
|
7649
|
+
function resetRootShadowingWarnings({ logger }) {
|
|
7650
|
+
warnedRootShadowingByLogger.delete(logger);
|
|
7651
|
+
}
|
|
7652
|
+
/**
|
|
7653
|
+
* Return the last input root that contains any of the given `relativePaths`,
|
|
7654
|
+
* or `undefined` when none of the roots has any of them. Used by single-file
|
|
7655
|
+
* features (hooks, permissions, ignore) to implement the "later root wins
|
|
7656
|
+
* the whole file" merge policy without materializing the file contents.
|
|
7657
|
+
*
|
|
7658
|
+
* `relativePaths` accepts a small list so features that historically read
|
|
7659
|
+
* either a recommended path or a legacy alias (e.g. `.rulesync/mcp.jsonc`
|
|
7660
|
+
* plus `.rulesync/mcp.json`) can preserve that resolution order per root.
|
|
7661
|
+
* A root counts as "having" the file as long as at least one candidate path
|
|
7662
|
+
* is present.
|
|
7663
|
+
*/
|
|
7664
|
+
async function pickLastRootWithFile({ inputRoots, relativePaths, logger, artifactName }) {
|
|
7665
|
+
let winner;
|
|
7666
|
+
const rootsWithFile = [];
|
|
7667
|
+
for (const root of inputRoots) for (const relativePath of relativePaths) if (await fileExists(join(root, relativePath))) {
|
|
7668
|
+
winner = root;
|
|
7669
|
+
rootsWithFile.push(root);
|
|
7670
|
+
break;
|
|
7671
|
+
}
|
|
7672
|
+
if (rootsWithFile.length > 1 && winner !== void 0) {
|
|
7673
|
+
const shadowed = rootsWithFile.slice(0, -1);
|
|
7674
|
+
const message = `${artifactName} is provided by more than one input root; '${stripControlCharacters(winner)}' replaces the whole file from ${shadowed.map((root) => `'${stripControlCharacters(root)}'`).join(", ")}.`;
|
|
7675
|
+
let warnedMessages = warnedRootShadowingByLogger.get(logger);
|
|
7676
|
+
if (warnedMessages === void 0) {
|
|
7677
|
+
warnedMessages = /* @__PURE__ */ new Set();
|
|
7678
|
+
warnedRootShadowingByLogger.set(logger, warnedMessages);
|
|
7679
|
+
}
|
|
7680
|
+
if (!warnedMessages.has(message)) {
|
|
7681
|
+
warnedMessages.add(message);
|
|
7682
|
+
logger.warn(message);
|
|
7683
|
+
}
|
|
7684
|
+
}
|
|
7685
|
+
return winner;
|
|
7686
|
+
}
|
|
7687
|
+
/**
|
|
7688
|
+
* Merge per-root result lists into a single ordered list, keeping the
|
|
7689
|
+
* later root's entry when two roots produced an item with the same
|
|
7690
|
+
* identity. Identity is intentionally provided by the caller so
|
|
7691
|
+
* per-feature nuances (case-insensitive filesystems, directory names for
|
|
7692
|
+
* skills, server names for MCP) live next to the feature that owns them.
|
|
7693
|
+
*
|
|
7694
|
+
* The returned list preserves the FIRST appearance order of each identity —
|
|
7695
|
+
* items in the earliest root keep their position, but their content is
|
|
7696
|
+
* replaced by the last root that provided the same identity. This matches
|
|
7697
|
+
* the "overlay" mental model: an overlay changes content, not order.
|
|
7698
|
+
*/
|
|
7699
|
+
function mergeByIdentity({ perRoot, identity }) {
|
|
7700
|
+
const order = [];
|
|
7701
|
+
const winnerByKey = /* @__PURE__ */ new Map();
|
|
7702
|
+
for (const rootItems of perRoot) for (const item of rootItems) {
|
|
7703
|
+
const key = identity(item);
|
|
7704
|
+
if (!winnerByKey.has(key)) order.push(key);
|
|
7705
|
+
winnerByKey.set(key, item);
|
|
7706
|
+
}
|
|
7707
|
+
return order.map((key) => winnerByKey.get(key));
|
|
7708
|
+
}
|
|
7709
|
+
/**
|
|
7710
|
+
* The key two spellings share when a case-insensitive filesystem would give
|
|
7711
|
+
* them one file. `toLowerCase()` is locale-independent (unlike
|
|
7712
|
+
* `toLocaleLowerCase`, it does not turn `I` into the Turkish `ı` under a Turkish
|
|
7713
|
+
* locale), and the NFC pass folds the composed and decomposed spellings of an
|
|
7714
|
+
* accented name — which macOS also resolves to a single directory —
|
|
7715
|
+
* onto each other.
|
|
7716
|
+
*
|
|
7717
|
+
* This is simple lowercasing rather than full Unicode case folding, so it is
|
|
7718
|
+
* deliberately narrower than what a filesystem considers one file: a Greek
|
|
7719
|
+
* final sigma, a Turkish `ı` under NTFS's upcasing, and a Win32 name whose
|
|
7720
|
+
* trailing dot is stripped all still produce distinct keys. Those pairs keep
|
|
7721
|
+
* the pre-existing behavior (both are imported, and the later one wins on the
|
|
7722
|
+
* filesystem); folding them here would instead drop names that a
|
|
7723
|
+
* case-sensitive filesystem keeps genuinely apart.
|
|
7724
|
+
*/
|
|
7725
|
+
function caseFoldIdentity(identity) {
|
|
7726
|
+
return identity.normalize("NFC").toLowerCase();
|
|
7727
|
+
}
|
|
7728
|
+
/**
|
|
7729
|
+
* Group spellings by their case-folded identity, keeping every original
|
|
7730
|
+
* spelling. On a case-sensitive filesystem one identity can cover several
|
|
7731
|
+
* spellings at once, and the caller needs them all to describe a collision
|
|
7732
|
+
* accurately.
|
|
7733
|
+
*/
|
|
7734
|
+
function groupSpellingsByCaseFoldedIdentity(spellings) {
|
|
7735
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
7736
|
+
for (const spelling of spellings) {
|
|
7737
|
+
const identity = caseFoldIdentity(spelling);
|
|
7738
|
+
const existing = grouped.get(identity);
|
|
7739
|
+
if (existing === void 0) grouped.set(identity, [spelling]);
|
|
7740
|
+
else existing.push(spelling);
|
|
7741
|
+
}
|
|
7742
|
+
return grouped;
|
|
7743
|
+
}
|
|
7744
|
+
/**
|
|
7745
|
+
* Build the warning emitted when a `.curated/` entry and a local entry in the
|
|
7746
|
+
* same tree differ only in case.
|
|
7747
|
+
*
|
|
7748
|
+
* `.curated/` is expanded from a declarative source (an external Git repository
|
|
7749
|
+
* or npm package), so its names are untrusted input; both sides are stripped of
|
|
7750
|
+
* control characters before they reach the terminal.
|
|
7751
|
+
*
|
|
7752
|
+
* The winning local spelling is the LAST one, matching the precedence
|
|
7753
|
+
* {@link mergeByCaseInsensitiveIdentity} applies afterwards; any other spelling
|
|
7754
|
+
* that folds onto the same identity is listed too, so the message never names a
|
|
7755
|
+
* spelling that loses.
|
|
7756
|
+
*/
|
|
7757
|
+
function formatCuratedCaseCollisionWarning({ artifactKind, entryNoun, treeDirPath, curatedSpelling, localSpellings }) {
|
|
7758
|
+
const winner = localSpellings[localSpellings.length - 1] ?? "";
|
|
7759
|
+
const shadowed = localSpellings.slice(0, -1);
|
|
7760
|
+
const shadowedSuffix = shadowed.length === 0 ? "" : ` Other local spellings that fold onto the same identity: ${shadowed.map((spelling) => `'${stripControlCharacters(spelling)}'`).join(", ")}.`;
|
|
7761
|
+
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;
|
|
7762
|
+
}
|
|
7763
|
+
/**
|
|
7764
|
+
* Merge artifacts whose filenames are case-insensitive identities, warning
|
|
7765
|
+
* when distinct spellings collapse to the same key. Exact-name overlays are
|
|
7766
|
+
* intentional and remain quiet; only case-only ambiguity is diagnosed.
|
|
7767
|
+
*
|
|
7768
|
+
* Precedence here is the opposite of {@link ClaimedIdentities}: this merges
|
|
7769
|
+
* overlay roots, where the LAST entry wins, while the tool-side loaders keep
|
|
7770
|
+
* the FIRST root to claim a name.
|
|
7771
|
+
*/
|
|
7772
|
+
function mergeByCaseInsensitiveIdentity({ perRoot, identity, artifactName, logger }) {
|
|
7773
|
+
const spellingByKey = /* @__PURE__ */ new Map();
|
|
7774
|
+
const warnedKeys = /* @__PURE__ */ new Set();
|
|
7775
|
+
return mergeByIdentity({
|
|
7776
|
+
perRoot,
|
|
7777
|
+
identity: (item) => {
|
|
7778
|
+
const spelling = identity(item);
|
|
7779
|
+
const key = caseFoldIdentity(spelling);
|
|
7780
|
+
const previousSpelling = spellingByKey.get(key);
|
|
7781
|
+
if (previousSpelling !== void 0 && previousSpelling !== spelling && !warnedKeys.has(key)) {
|
|
7782
|
+
logger.warn(`Case-insensitive ${artifactName} collision: '${stripControlCharacters(previousSpelling)}' and '${stripControlCharacters(spelling)}' resolve to the same identity. The later entry wins.`);
|
|
7783
|
+
warnedKeys.add(key);
|
|
7784
|
+
}
|
|
7785
|
+
if (previousSpelling === void 0) spellingByKey.set(key, spelling);
|
|
7786
|
+
return key;
|
|
7787
|
+
}
|
|
7788
|
+
});
|
|
7789
|
+
}
|
|
7790
|
+
/**
|
|
7791
|
+
* Tracks the import identities already claimed while scanning, folding case
|
|
7792
|
+
* through {@link caseFoldIdentity}.
|
|
7793
|
+
*
|
|
7794
|
+
* The tool-side loaders scan several roots in precedence order and keep the
|
|
7795
|
+
* first spelling of each identity. Comparing those identities exactly lets
|
|
7796
|
+
* `.junie/skills/dup-skill` and `.agents/skills/Dup-Skill` both through, and
|
|
7797
|
+
* since macOS and Windows resolve the two written-back directories to a
|
|
7798
|
+
* single one, the shared Agent Skills copy lands last and overwrites the
|
|
7799
|
+
* tool-specific one — inverting the precedence the roots were ordered by.
|
|
7800
|
+
*
|
|
7801
|
+
* The FIRST claimer wins, which is the opposite of the overlay precedence in
|
|
7802
|
+
* {@link mergeByCaseInsensitiveIdentity}: roots are passed in precedence
|
|
7803
|
+
* order, so the earliest one to name a skill is the one that should keep it.
|
|
7804
|
+
*/
|
|
7805
|
+
var ClaimedIdentities = class {
|
|
7806
|
+
claimByKey = /* @__PURE__ */ new Map();
|
|
7807
|
+
/**
|
|
7808
|
+
* Claim `identity` on behalf of `source`. Returns `null` when nothing held
|
|
7809
|
+
* it yet, or the standing claim when something did.
|
|
7810
|
+
*/
|
|
7811
|
+
claim({ identity, source }) {
|
|
7812
|
+
const key = caseFoldIdentity(identity);
|
|
7813
|
+
const claimed = this.claimByKey.get(key);
|
|
7814
|
+
if (claimed !== void 0) return claimed;
|
|
7815
|
+
this.claimByKey.set(key, {
|
|
7816
|
+
spelling: identity,
|
|
7817
|
+
source
|
|
7818
|
+
});
|
|
7819
|
+
return null;
|
|
7820
|
+
}
|
|
7821
|
+
};
|
|
6275
7822
|
//#endregion
|
|
6276
7823
|
//#region src/constants/amp-paths.ts
|
|
6277
7824
|
const AMP_DIR = ".amp";
|
|
@@ -8594,10 +10141,10 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8594
10141
|
toolTarget;
|
|
8595
10142
|
global;
|
|
8596
10143
|
getFactory;
|
|
8597
|
-
constructor({ outputRoot = process.cwd(),
|
|
10144
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
|
|
8598
10145
|
super({
|
|
8599
10146
|
outputRoot,
|
|
8600
|
-
|
|
10147
|
+
inputRoots,
|
|
8601
10148
|
dryRun,
|
|
8602
10149
|
logger
|
|
8603
10150
|
});
|
|
@@ -8634,11 +10181,15 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8634
10181
|
return toolFiles.filter((file) => file instanceof ToolCheck).flatMap((toolCheck) => toolCheck.toRulesyncChecks());
|
|
8635
10182
|
}
|
|
8636
10183
|
/**
|
|
8637
|
-
*
|
|
8638
|
-
*
|
|
10184
|
+
* Load check files from a single source-tree's `checks/` subtree.
|
|
10185
|
+
* `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
|
|
10186
|
+
* `/repo/.rulesync.local`).
|
|
8639
10187
|
*/
|
|
8640
|
-
async
|
|
8641
|
-
const
|
|
10188
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
10189
|
+
const treeParent = dirname(sourceTree);
|
|
10190
|
+
const treeName = basename(sourceTree);
|
|
10191
|
+
const treeChecksDirPath = join(treeName, CHECKS_FEATURE_SUBDIR);
|
|
10192
|
+
const checksDir = join(sourceTree, CHECKS_FEATURE_SUBDIR);
|
|
8642
10193
|
if (!await directoryExists(checksDir)) {
|
|
8643
10194
|
this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
|
|
8644
10195
|
return [];
|
|
@@ -8654,7 +10205,8 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8654
10205
|
const filepath = join(checksDir, mdFile);
|
|
8655
10206
|
try {
|
|
8656
10207
|
const rulesyncCheck = await RulesyncCheck.fromFile({
|
|
8657
|
-
outputRoot:
|
|
10208
|
+
outputRoot: treeParent,
|
|
10209
|
+
relativeDirPath: treeChecksDirPath,
|
|
8658
10210
|
relativeFilePath: mdFile,
|
|
8659
10211
|
validate: true
|
|
8660
10212
|
});
|
|
@@ -8665,6 +10217,21 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8665
10217
|
continue;
|
|
8666
10218
|
}
|
|
8667
10219
|
}
|
|
10220
|
+
return rulesyncChecks;
|
|
10221
|
+
}
|
|
10222
|
+
/**
|
|
10223
|
+
* Implementation of abstract method from Processor
|
|
10224
|
+
* Load and parse rulesync check files from every configured input root's
|
|
10225
|
+
* `.rulesync/checks/` directory, merging by relative file path so a check
|
|
10226
|
+
* from a later root replaces the earlier root's copy.
|
|
10227
|
+
*/
|
|
10228
|
+
async loadRulesyncFiles() {
|
|
10229
|
+
const rulesyncChecks = mergeByCaseInsensitiveIdentity({
|
|
10230
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
10231
|
+
identity: (check) => check.getRelativeFilePath(),
|
|
10232
|
+
artifactName: "check",
|
|
10233
|
+
logger: this.logger
|
|
10234
|
+
});
|
|
8668
10235
|
this.logger.debug(`Successfully loaded ${rulesyncChecks.length} rulesync checks`);
|
|
8669
10236
|
return rulesyncChecks;
|
|
8670
10237
|
}
|
|
@@ -10143,18 +11710,23 @@ function commandSlug(relativeFilePath) {
|
|
|
10143
11710
|
return basename(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
10144
11711
|
}
|
|
10145
11712
|
/**
|
|
10146
|
-
* Whether a rulesync command exists whose slug matches `dirName
|
|
11713
|
+
* Whether a rulesync command exists whose slug matches `dirName` in any of
|
|
11714
|
+
* the configured input roots.
|
|
11715
|
+
*
|
|
11716
|
+
* `inputRoots[i]` is a source tree itself (e.g. `/repo/.rulesync` or
|
|
11717
|
+
* `/repo/.rulesync.local`), so commands live directly under
|
|
11718
|
+
* `<sourceTree>/commands/`.
|
|
10147
11719
|
*
|
|
10148
11720
|
* Used by the skills-surface `isDirOwned` hooks of tools whose commands are
|
|
10149
11721
|
* emitted as `<slug>/SKILL.md` into the skills tree: a directory matching a
|
|
10150
11722
|
* current command slug is owned by the commands feature, so the skills
|
|
10151
11723
|
* feature must neither import it as a skill nor delete it as an orphan
|
|
10152
|
-
* skill. Once the command is removed from
|
|
10153
|
-
* directory stops matching and the skills feature cleans
|
|
10154
|
-
* orphan.
|
|
11724
|
+
* skill. Once the command is removed from every source tree's `commands/`
|
|
11725
|
+
* directory, the directory stops matching and the skills feature cleans
|
|
11726
|
+
* it up as a regular orphan.
|
|
10155
11727
|
*/
|
|
10156
|
-
async function rulesyncCommandSlugExists({
|
|
10157
|
-
return (await findFilesByGlobs(join(
|
|
11728
|
+
async function rulesyncCommandSlugExists({ inputRoots, dirName }) {
|
|
11729
|
+
return (await Promise.all(inputRoots.map((root) => findFilesByGlobs(join(root, COMMANDS_FEATURE_SUBDIR, "**", "*.md"))))).flat().some((filePath) => commandSlug(basename(filePath)) === dirName);
|
|
10158
11730
|
}
|
|
10159
11731
|
//#endregion
|
|
10160
11732
|
//#region src/features/commands/devin-command.ts
|
|
@@ -10844,11 +12416,10 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
|
|
|
10844
12416
|
* output is lost — so this warns rather than failing the run the way the
|
|
10845
12417
|
* Hermes check does, where the two surfaces really do write the same path.
|
|
10846
12418
|
*/
|
|
10847
|
-
static async validateRulesyncCommands({
|
|
12419
|
+
static async validateRulesyncCommands({ inputRoots, rulesyncCommands, logger }) {
|
|
10848
12420
|
const commandNames = new Set(rulesyncCommands.filter((command) => this.isTargetedByRulesyncCommand(command)).map((command) => basename(command.getRelativeFilePath(), ".md")));
|
|
10849
12421
|
if (commandNames.size === 0) return;
|
|
10850
|
-
const
|
|
10851
|
-
const shadowed = (await findFilesByGlobs(join(skillsRoot, "**", "SKILL.md"))).map((filePath) => basename(dirname(filePath))).filter((skillName) => commandNames.has(skillName));
|
|
12422
|
+
const shadowed = (await Promise.all(inputRoots.map((root) => findFilesByGlobs(join(root, SKILLS_FEATURE_SUBDIR, "**", "SKILL.md"))))).flat().map((filePath) => basename(dirname(filePath))).filter((skillName) => commandNames.has(skillName));
|
|
10852
12423
|
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.`);
|
|
10853
12424
|
}
|
|
10854
12425
|
static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
|
|
@@ -10894,6 +12465,26 @@ function toolSkillSearchRoots(paths) {
|
|
|
10894
12465
|
* https://agentskills.io/client-implementation/adding-skills-support
|
|
10895
12466
|
*/
|
|
10896
12467
|
const AGENT_SKILLS_INTEROP_ROOTS = /* @__PURE__ */ new Set([toPosixPath(AGENTSMD_SKILLS_DIR_PATH), toPosixPath(AMP_SKILLS_GLOBAL_DIR)]);
|
|
12468
|
+
/**
|
|
12469
|
+
* The one spec violation both sides of the conversion report. Generation warns
|
|
12470
|
+
* about it before writing the file; import warns about it because a conformant
|
|
12471
|
+
* client would skip the skill entirely, and a user who never sees that has no
|
|
12472
|
+
* reason to fix it.
|
|
12473
|
+
*/
|
|
12474
|
+
const EMPTY_SKILL_DESCRIPTION_VIOLATION = "`description` is required and must not be empty; conformant clients skip a skill without one";
|
|
12475
|
+
/**
|
|
12476
|
+
* Report a skill read from disk whose `description` is empty. This lives on the
|
|
12477
|
+
* read rather than on any one tool class because several targets share one
|
|
12478
|
+
* `.agents/skills` tree: which of them reports the skill must not depend on
|
|
12479
|
+
* which target the user happened to enable. Every loader calls it — the two
|
|
12480
|
+
* directory loaders (`loadSkillDirContent`, `SimulatedSkill.fromDirDefault`)
|
|
12481
|
+
* and the flat-file one — so the form a tool stores its skills in does not
|
|
12482
|
+
* decide whether the problem is reported either.
|
|
12483
|
+
*/
|
|
12484
|
+
function warnOnEmptyLoadedDescription({ skillFilePath, description }) {
|
|
12485
|
+
if (typeof description !== "string" || description.length > 0) return;
|
|
12486
|
+
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.`);
|
|
12487
|
+
}
|
|
10897
12488
|
function isAgentSkillsInteropRoot(relativeDirPath) {
|
|
10898
12489
|
return AGENT_SKILLS_INTEROP_ROOTS.has(toPosixPath(relativeDirPath));
|
|
10899
12490
|
}
|
|
@@ -11006,7 +12597,11 @@ var ToolSkill = class extends AiDir {
|
|
|
11006
12597
|
const skillDirPath = join(outputRoot, actualRelativeDirPath, dirName);
|
|
11007
12598
|
const skillFilePath = join(skillDirPath, SKILL_FILE_NAME);
|
|
11008
12599
|
if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
|
|
11009
|
-
const { frontmatter, body: content } =
|
|
12600
|
+
const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
|
|
12601
|
+
warnOnEmptyLoadedDescription({
|
|
12602
|
+
skillFilePath,
|
|
12603
|
+
description: frontmatter.description
|
|
12604
|
+
});
|
|
11010
12605
|
const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
|
|
11011
12606
|
return {
|
|
11012
12607
|
outputRoot,
|
|
@@ -11131,6 +12726,10 @@ function toSpecConformantAgentSkillFields(section, { coerceMetadata = true } = {
|
|
|
11131
12726
|
...allowedTools !== void 0 && allowedTools.length > 0 && { "allowed-tools": allowedTools }
|
|
11132
12727
|
};
|
|
11133
12728
|
}
|
|
12729
|
+
/** The `SKILL.md` a diagnostic should point at, in the scope it is written to. */
|
|
12730
|
+
function agentSkillFilePath({ outputRoot, relativeDirPath, dirName }) {
|
|
12731
|
+
return join(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
|
|
12732
|
+
}
|
|
11134
12733
|
/**
|
|
11135
12734
|
* Collect the normative violations the Agent Skills spec defines for a skill
|
|
11136
12735
|
* about to be written. These are reported as warnings rather than errors:
|
|
@@ -11157,7 +12756,7 @@ function collectAgentSkillViolations({ frontmatter, dirName, sourceAllowedTools
|
|
|
11157
12756
|
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`);
|
|
11158
12757
|
if (name !== dirName) violations.push(`\`name\` "${name}" must match its parent directory name "${dirName}"; conformant clients require them to be equal`);
|
|
11159
12758
|
}
|
|
11160
|
-
if (description.length === 0) violations.push(
|
|
12759
|
+
if (description.length === 0) violations.push(EMPTY_SKILL_DESCRIPTION_VIOLATION);
|
|
11161
12760
|
else if (description.length > DESCRIPTION_MAX_LENGTH) violations.push(`\`description\` is ${description.length} characters; the Agent Skills spec allows at most ${DESCRIPTION_MAX_LENGTH}`);
|
|
11162
12761
|
const { compatibility } = frontmatter;
|
|
11163
12762
|
if (typeof compatibility !== "string" && compatibility !== void 0) violations.push("`compatibility` must be a string; the Agent Skills spec does not allow a mapping here");
|
|
@@ -11276,12 +12875,16 @@ var AgentsSkillsSkill = class AgentsSkillsSkill extends ToolSkill {
|
|
|
11276
12875
|
* directory rather than a same-named project path.
|
|
11277
12876
|
*/
|
|
11278
12877
|
static reportSpecViolations({ outputRoot, relativeDirPath, dirName, frontmatter, sourceAllowedTools, logger }) {
|
|
11279
|
-
const skillPath =
|
|
12878
|
+
const skillPath = agentSkillFilePath({
|
|
12879
|
+
outputRoot,
|
|
12880
|
+
relativeDirPath,
|
|
12881
|
+
dirName
|
|
12882
|
+
});
|
|
11280
12883
|
for (const violation of collectAgentSkillViolations({
|
|
11281
12884
|
frontmatter,
|
|
11282
12885
|
dirName,
|
|
11283
12886
|
sourceAllowedTools
|
|
11284
|
-
})) warnWithFallback(logger, `${skillPath}: ${violation}`);
|
|
12887
|
+
})) warnWithFallback(logger, `${stripControlCharacters(toPosixPath(skillPath))}: ${violation}`);
|
|
11285
12888
|
}
|
|
11286
12889
|
static isTargetedByRulesyncSkill(rulesyncSkill) {
|
|
11287
12890
|
const targets = rulesyncSkill.getFrontmatter().targets;
|
|
@@ -11620,7 +13223,7 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
|
|
|
11620
13223
|
static getExtraSharedWritePaths() {
|
|
11621
13224
|
return getHermesagentSharedConfigWritePaths();
|
|
11622
13225
|
}
|
|
11623
|
-
static async validateRulesyncCommands({
|
|
13226
|
+
static async validateRulesyncCommands({ inputRoots, rulesyncCommands }) {
|
|
11624
13227
|
const commandSlugs = /* @__PURE__ */ new Set();
|
|
11625
13228
|
const commandOrigins = /* @__PURE__ */ new Map();
|
|
11626
13229
|
for (const command of rulesyncCommands.filter((candidate) => this.isTargetedByRulesyncCommand(candidate))) {
|
|
@@ -11632,13 +13235,25 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
|
|
|
11632
13235
|
commandOrigins.set(slug, origin);
|
|
11633
13236
|
commandSlugs.add(slug);
|
|
11634
13237
|
}
|
|
11635
|
-
const
|
|
11636
|
-
const
|
|
11637
|
-
|
|
11638
|
-
|
|
11639
|
-
|
|
11640
|
-
|
|
11641
|
-
|
|
13238
|
+
const skillsByName = /* @__PURE__ */ new Map();
|
|
13239
|
+
for (const rootPath of inputRoots) {
|
|
13240
|
+
const skillsRoot = join(rootPath, SKILLS_FEATURE_SUBDIR);
|
|
13241
|
+
const skillFiles = await findFilesByGlobs(join(skillsRoot, "**", "SKILL.md"));
|
|
13242
|
+
const loaded = await Promise.all(skillFiles.map(async (filePath) => {
|
|
13243
|
+
const dirName = toPosixPath(relative(skillsRoot, dirname(filePath)));
|
|
13244
|
+
return {
|
|
13245
|
+
rulesyncSkill: await RulesyncSkill.fromDir({
|
|
13246
|
+
outputRoot: rootPath,
|
|
13247
|
+
relativeDirPath: SKILLS_FEATURE_SUBDIR,
|
|
13248
|
+
dirName
|
|
13249
|
+
}),
|
|
13250
|
+
dirName,
|
|
13251
|
+
rootPath
|
|
13252
|
+
};
|
|
13253
|
+
}));
|
|
13254
|
+
for (const entry of loaded) skillsByName.set(caseFoldIdentity(entry.dirName), entry);
|
|
13255
|
+
}
|
|
13256
|
+
const collisions = [...skillsByName.values()].map(({ rulesyncSkill }) => rulesyncSkill).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
|
|
11642
13257
|
if (collisions.length > 0) throw new Error(`Hermes command and skill slash-name collision: ${[...new Set(collisions)].toSorted().join(", ")}`);
|
|
11643
13258
|
}
|
|
11644
13259
|
static async getAuxiliaryFiles({ toolCommands, outputRoot, global = false, forDeletion = false }) {
|
|
@@ -11771,6 +13386,8 @@ const JUNIE_PERMISSIONS_FILE_NAME = "allowlist.json";
|
|
|
11771
13386
|
const JUNIE_IGNORE_FILE_NAME = ".aiignore";
|
|
11772
13387
|
const JUNIE_RULE_FILE_NAME = "AGENTS.md";
|
|
11773
13388
|
const JUNIE_LEGACY_RULE_FILE_NAME = "guidelines.md";
|
|
13389
|
+
const JUNIE_RULES_DIR_NAME = "rules";
|
|
13390
|
+
const JUNIE_PLAYBOOK_FILE_NAME = "playbook.md";
|
|
11774
13391
|
//#endregion
|
|
11775
13392
|
//#region src/features/commands/junie-command.ts
|
|
11776
13393
|
const JunieCommandFrontmatterSchema = z.looseObject({ description: z.optional(z.string()) });
|
|
@@ -13731,10 +15348,10 @@ var CommandsProcessor = class extends FeatureProcessor {
|
|
|
13731
15348
|
global;
|
|
13732
15349
|
getFactory;
|
|
13733
15350
|
flattenedCommandNaming;
|
|
13734
|
-
constructor({ outputRoot = process.cwd(),
|
|
15351
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
|
|
13735
15352
|
super({
|
|
13736
15353
|
outputRoot,
|
|
13737
|
-
|
|
15354
|
+
inputRoots,
|
|
13738
15355
|
dryRun,
|
|
13739
15356
|
logger
|
|
13740
15357
|
});
|
|
@@ -13749,7 +15366,7 @@ var CommandsProcessor = class extends FeatureProcessor {
|
|
|
13749
15366
|
const rulesyncCommands = rulesyncFiles.filter((file) => file instanceof RulesyncCommand);
|
|
13750
15367
|
const factory = this.getFactory(this.toolTarget);
|
|
13751
15368
|
await factory.class.validateRulesyncCommands?.({
|
|
13752
|
-
|
|
15369
|
+
inputRoots: this.inputRoots,
|
|
13753
15370
|
rulesyncCommands,
|
|
13754
15371
|
logger: this.logger
|
|
13755
15372
|
});
|
|
@@ -13801,16 +15418,36 @@ var CommandsProcessor = class extends FeatureProcessor {
|
|
|
13801
15418
|
return rel;
|
|
13802
15419
|
}
|
|
13803
15420
|
/**
|
|
13804
|
-
*
|
|
13805
|
-
*
|
|
15421
|
+
* Load rulesync command files from a single source-tree's `commands/`
|
|
15422
|
+
* subtree. `sourceTree` is the source tree itself (e.g.
|
|
15423
|
+
* `/repo/.rulesync` or `/repo/.rulesync.local`).
|
|
13806
15424
|
*/
|
|
13807
|
-
async
|
|
13808
|
-
const
|
|
15425
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
15426
|
+
const treeParent = dirname(sourceTree);
|
|
15427
|
+
const treeName = basename(sourceTree);
|
|
15428
|
+
const treeCommandsDirPath = join(treeName, COMMANDS_FEATURE_SUBDIR);
|
|
15429
|
+
const basePath = join(sourceTree, COMMANDS_FEATURE_SUBDIR);
|
|
13809
15430
|
const rulesyncCommandPaths = await findFilesByGlobs(join(basePath, "**", "*.md"));
|
|
13810
|
-
|
|
13811
|
-
outputRoot:
|
|
15431
|
+
return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
|
|
15432
|
+
outputRoot: treeParent,
|
|
15433
|
+
relativeDirPath: treeCommandsDirPath,
|
|
13812
15434
|
relativeFilePath: this.safeRelativePath(basePath, path)
|
|
13813
15435
|
})));
|
|
15436
|
+
}
|
|
15437
|
+
/**
|
|
15438
|
+
* Implementation of abstract method from FeatureProcessor
|
|
15439
|
+
* Load and parse rulesync command files from every configured input root's
|
|
15440
|
+
* `.rulesync/commands/` directory, merging by relative path so a command
|
|
15441
|
+
* with the same target path from a later root replaces the earlier root's
|
|
15442
|
+
* copy.
|
|
15443
|
+
*/
|
|
15444
|
+
async loadRulesyncFiles() {
|
|
15445
|
+
const rulesyncCommands = mergeByCaseInsensitiveIdentity({
|
|
15446
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
15447
|
+
identity: (command) => command.getRelativeFilePath(),
|
|
15448
|
+
artifactName: "command",
|
|
15449
|
+
logger: this.logger
|
|
15450
|
+
});
|
|
13814
15451
|
this.logger.debug(`Successfully loaded ${rulesyncCommands.length} rulesync commands`);
|
|
13815
15452
|
return rulesyncCommands;
|
|
13816
15453
|
}
|
|
@@ -19450,6 +21087,7 @@ function vibeEntryToCanonicalDef(raw) {
|
|
|
19450
21087
|
const entry = raw;
|
|
19451
21088
|
const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
|
|
19452
21089
|
if (vibeEvent === void 0) return null;
|
|
21090
|
+
if (isPrototypePollutionKey(vibeEvent)) return null;
|
|
19453
21091
|
const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
|
|
19454
21092
|
const def = { type: "command" };
|
|
19455
21093
|
if (typeof entry.command === "string") def.command = entry.command;
|
|
@@ -19998,10 +21636,10 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
|
|
|
19998
21636
|
var HooksProcessor = class extends FeatureProcessor {
|
|
19999
21637
|
toolTarget;
|
|
20000
21638
|
global;
|
|
20001
|
-
constructor({ outputRoot = process.cwd(),
|
|
21639
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
|
|
20002
21640
|
super({
|
|
20003
21641
|
outputRoot,
|
|
20004
|
-
|
|
21642
|
+
inputRoots,
|
|
20005
21643
|
dryRun,
|
|
20006
21644
|
logger
|
|
20007
21645
|
});
|
|
@@ -20011,9 +21649,17 @@ var HooksProcessor = class extends FeatureProcessor {
|
|
|
20011
21649
|
this.global = global;
|
|
20012
21650
|
}
|
|
20013
21651
|
async loadRulesyncFiles() {
|
|
21652
|
+
const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncHooks.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
|
|
21653
|
+
const sourceTree = await pickLastRootWithFile({
|
|
21654
|
+
inputRoots: this.inputRoots,
|
|
21655
|
+
relativePaths,
|
|
21656
|
+
logger: this.logger,
|
|
21657
|
+
artifactName: "The hooks file"
|
|
21658
|
+
}) ?? this.inputRoots[0];
|
|
20014
21659
|
try {
|
|
20015
21660
|
return [await RulesyncHooks.fromFile({
|
|
20016
|
-
outputRoot:
|
|
21661
|
+
outputRoot: dirname(sourceTree),
|
|
21662
|
+
relativeDirPath: basename(sourceTree),
|
|
20017
21663
|
validate: true
|
|
20018
21664
|
})];
|
|
20019
21665
|
} catch (error) {
|
|
@@ -21555,10 +23201,10 @@ var IgnoreProcessor = class extends FeatureProcessor {
|
|
|
21555
23201
|
getFactory;
|
|
21556
23202
|
featureOptions;
|
|
21557
23203
|
global;
|
|
21558
|
-
constructor({ outputRoot = process.cwd(),
|
|
23204
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
|
|
21559
23205
|
super({
|
|
21560
23206
|
outputRoot,
|
|
21561
|
-
|
|
23207
|
+
inputRoots,
|
|
21562
23208
|
dryRun,
|
|
21563
23209
|
logger
|
|
21564
23210
|
});
|
|
@@ -21575,11 +23221,35 @@ var IgnoreProcessor = class extends FeatureProcessor {
|
|
|
21575
23221
|
}
|
|
21576
23222
|
/**
|
|
21577
23223
|
* Implementation of abstract method from FeatureProcessor
|
|
21578
|
-
*
|
|
23224
|
+
*
|
|
23225
|
+
* Load and parse the rulesync ignore file. `inputRoots[i]` is a source
|
|
23226
|
+
* tree itself (e.g. `/repo/.rulesync.local`); the recommended `.aiignore`
|
|
23227
|
+
* lives directly inside it. The legacy `.rulesyncignore` is shared at the
|
|
23228
|
+
* project root, so it is intentionally not considered when choosing which
|
|
23229
|
+
* source tree wins; `RulesyncIgnore.fromFile` still uses it as a fallback
|
|
23230
|
+
* for the chosen tree.
|
|
23231
|
+
*
|
|
23232
|
+
* When multiple input roots are configured, the last root that provides
|
|
23233
|
+
* an ignore file wins entirely (whole-file replacement — no line-level
|
|
23234
|
+
* merge in this slice; see the "Deliberately out of scope" section of
|
|
23235
|
+
* the inputRoots plan for context). If no root has the file, fall back
|
|
23236
|
+
* to the primary root's path so the underlying `RulesyncIgnore.fromFile`
|
|
23237
|
+
* surfaces the same missing-file error it would in the single-root case.
|
|
21579
23238
|
*/
|
|
21580
23239
|
async loadRulesyncFiles() {
|
|
23240
|
+
const paths = RulesyncIgnore.getSettablePaths();
|
|
23241
|
+
const relativePaths = getRulesyncSourceCandidates({ paths }).filter((candidate) => candidate.relativeDirPath === paths.recommended.relativeDirPath).map((candidate) => candidate.relativeFilePath);
|
|
23242
|
+
const sourceTree = await pickLastRootWithFile({
|
|
23243
|
+
inputRoots: this.inputRoots,
|
|
23244
|
+
relativePaths,
|
|
23245
|
+
logger: this.logger,
|
|
23246
|
+
artifactName: "The ignore file (.aiignore)"
|
|
23247
|
+
}) ?? this.inputRoots[0];
|
|
21581
23248
|
try {
|
|
21582
|
-
return [await RulesyncIgnore.fromFile({
|
|
23249
|
+
return [await RulesyncIgnore.fromFile({
|
|
23250
|
+
outputRoot: dirname(sourceTree),
|
|
23251
|
+
relativeDirPath: basename(sourceTree)
|
|
23252
|
+
})];
|
|
21583
23253
|
} catch (error) {
|
|
21584
23254
|
this.logger.error(`Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`);
|
|
21585
23255
|
return [];
|
|
@@ -26614,11 +28284,86 @@ function disabledNamesOf(config) {
|
|
|
26614
28284
|
return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
|
|
26615
28285
|
}
|
|
26616
28286
|
/**
|
|
28287
|
+
* The value `mcp.mcpConfigPath` needs so Rovo Dev reads the project-scope
|
|
28288
|
+
* `.rovodev/mcp.json`. A config-file value, not a filesystem path, so it is
|
|
28289
|
+
* always POSIX-separated.
|
|
28290
|
+
*/
|
|
28291
|
+
const ROVODEV_PROJECT_MCP_CONFIG_POINTER = posix.join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME);
|
|
28292
|
+
/**
|
|
28293
|
+
* The keys that make an entry in `mcp.json` something Rovo Dev can start:
|
|
28294
|
+
* a local process, or a remote endpoint under either spelling the canonical
|
|
28295
|
+
* config accepts.
|
|
28296
|
+
*/
|
|
28297
|
+
const MCP_SERVER_ENDPOINT_KEYS = [
|
|
28298
|
+
"command",
|
|
28299
|
+
"url",
|
|
28300
|
+
"httpUrl"
|
|
28301
|
+
];
|
|
28302
|
+
function normalizeMcpConfigPathValue(value) {
|
|
28303
|
+
return toPosixPath(value).replace(/^\.\//, "");
|
|
28304
|
+
}
|
|
28305
|
+
/**
|
|
28306
|
+
* Point `mcp.mcpConfigPath` at the project-scope `mcp.json` rulesync writes,
|
|
28307
|
+
* and report whether the block gained a value it did not already carry.
|
|
28308
|
+
*
|
|
28309
|
+
* Rovo Dev's `mcpConfigPath` defaults to a file under the user's home
|
|
28310
|
+
* directory, so a repo-committed `.rovodev/mcp.json` is inert until the active
|
|
28311
|
+
* config points at it — the Bitbucket Agentic Pipelines guide documents
|
|
28312
|
+
* registering the server and setting the pointer as two required steps. Global
|
|
28313
|
+
* scope is left alone: there the default already resolves to the file rulesync
|
|
28314
|
+
* writes.
|
|
28315
|
+
*
|
|
28316
|
+
* The pointer names one config rather than merging with the default, so it is
|
|
28317
|
+
* written only when this project actually has a Rovo Dev server to run — a
|
|
28318
|
+
* server that targets `rovodev` and is not disabled. Otherwise `mcp.json` is
|
|
28319
|
+
* generated empty, and pointing at it would take away the user's global
|
|
28320
|
+
* servers for this repository in exchange for nothing.
|
|
28321
|
+
*
|
|
28322
|
+
* That condition can also stop holding after the fact, once the last server is
|
|
28323
|
+
* removed from the canonical config or switched off. Rulesync does not take
|
|
28324
|
+
* the pointer back out — it cannot tell its own past value from a user who
|
|
28325
|
+
* typed the same string — but it says so, since the file is now a live setting
|
|
28326
|
+
* that resolves to nothing.
|
|
28327
|
+
*
|
|
28328
|
+
* A pointer the user aimed somewhere else is theirs, not ours: overwriting it
|
|
28329
|
+
* would silently redirect Rovo Dev away from a file they chose. It is named in
|
|
28330
|
+
* a warning instead, because the generated `mcp.json` is unread while it
|
|
28331
|
+
* stands.
|
|
28332
|
+
*
|
|
28333
|
+
* Whatever the outcome, it is logged: writing the pointer turns servers that
|
|
28334
|
+
* were generated-but-never-read into servers Rovo Dev actually spawns, and
|
|
28335
|
+
* points it away from the global MCP config, so it is not something to do
|
|
28336
|
+
* quietly.
|
|
28337
|
+
*
|
|
28338
|
+
* @see https://support.atlassian.com/bitbucket-cloud/docs/rovo-dev-advanced-agentic-configuration/
|
|
28339
|
+
* @see https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/
|
|
28340
|
+
*/
|
|
28341
|
+
function applyProjectMcpConfigPointer({ existingMcp, global, hasLiveServers, logger }) {
|
|
28342
|
+
if (global) return false;
|
|
28343
|
+
const existing = existingMcp.mcpConfigPath;
|
|
28344
|
+
const pointsAtGeneratedFile = typeof existing === "string" && normalizeMcpConfigPathValue(existing) === ROVODEV_PROJECT_MCP_CONFIG_POINTER;
|
|
28345
|
+
if (!hasLiveServers) {
|
|
28346
|
+
if (pointsAtGeneratedFile) logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${join(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)} points at ${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.`);
|
|
28347
|
+
return false;
|
|
28348
|
+
}
|
|
28349
|
+
if (existing === void 0) {
|
|
28350
|
+
existingMcp.mcpConfigPath = ROVODEV_PROJECT_MCP_CONFIG_POINTER;
|
|
28351
|
+
logger?.info(`Rovo Dev MCP: setting mcp.mcpConfigPath to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}" in ${join(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev will now launch the servers in ${join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} for this project instead of the ones in the default global MCP config.`);
|
|
28352
|
+
return true;
|
|
28353
|
+
}
|
|
28354
|
+
if (pointsAtGeneratedFile) return false;
|
|
28355
|
+
logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${JSON.stringify(existing)} in ${join(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev reads MCP servers from that path, so the generated ${join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} is unused until it is set to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}".`);
|
|
28356
|
+
return false;
|
|
28357
|
+
}
|
|
28358
|
+
/**
|
|
26617
28359
|
* Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
|
|
26618
28360
|
* `~/.rovodev/config.yml` (global). Carries `disabledMcpServers` — the key
|
|
26619
|
-
* Rovo Dev actually consults to switch a server off —
|
|
26620
|
-
*
|
|
26621
|
-
*
|
|
28361
|
+
* Rovo Dev actually consults to switch a server off — plus `mcpConfigPath`,
|
|
28362
|
+
* which rulesync authors in project scope when the key is absent and this
|
|
28363
|
+
* project has a server to run (see `applyProjectMcpConfigPointer`). The block
|
|
28364
|
+
* is recomputed from the existing one, so user keys (`allowedMcpServers`,
|
|
28365
|
+
* ...), a `mcpConfigPath` the user aimed elsewhere, and disabled names for
|
|
28366
|
+
* servers rulesync does not manage all survive.
|
|
26622
28367
|
*/
|
|
26623
28368
|
var RovodevMcpConfigYaml = class extends ToolFile {
|
|
26624
28369
|
isDeletable() {
|
|
@@ -26751,7 +28496,17 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
|
|
|
26751
28496
|
const mergedDisabled = [...existingDisabled.filter((name) => !managedNameSet.has(name)), ...disabledNames].toSorted();
|
|
26752
28497
|
if (mergedDisabled.length > 0) existingMcp.disabledMcpServers = mergedDisabled;
|
|
26753
28498
|
else delete existingMcp.disabledMcpServers;
|
|
26754
|
-
|
|
28499
|
+
const wrotePointer = applyProjectMcpConfigPointer({
|
|
28500
|
+
existingMcp,
|
|
28501
|
+
global,
|
|
28502
|
+
hasLiveServers: managedNames.filter((name) => {
|
|
28503
|
+
if (disabledNames.includes(name)) return false;
|
|
28504
|
+
const server = servers[name];
|
|
28505
|
+
return isRecord$1(server) && MCP_SERVER_ENDPOINT_KEYS.some((endpointKey) => server[endpointKey] !== void 0);
|
|
28506
|
+
}).length > 0,
|
|
28507
|
+
logger
|
|
28508
|
+
});
|
|
28509
|
+
if (mergedDisabled.length === 0 && !wrotePointer && existingContent.trim() === "") return [];
|
|
26755
28510
|
const fileContent = applySharedConfigPatch({
|
|
26756
28511
|
fileKey: ROVODEV_CONFIG_SHARED_FILE_KEY,
|
|
26757
28512
|
feature: "mcp",
|
|
@@ -27844,10 +29599,10 @@ var McpProcessor = class extends FeatureProcessor {
|
|
|
27844
29599
|
toolTarget;
|
|
27845
29600
|
global;
|
|
27846
29601
|
getFactory;
|
|
27847
|
-
constructor({ outputRoot = process.cwd(),
|
|
29602
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
|
|
27848
29603
|
super({
|
|
27849
29604
|
outputRoot,
|
|
27850
|
-
|
|
29605
|
+
inputRoots,
|
|
27851
29606
|
dryRun,
|
|
27852
29607
|
logger
|
|
27853
29608
|
});
|
|
@@ -27863,7 +29618,10 @@ var McpProcessor = class extends FeatureProcessor {
|
|
|
27863
29618
|
*/
|
|
27864
29619
|
async loadRulesyncFiles() {
|
|
27865
29620
|
try {
|
|
27866
|
-
return [await RulesyncMcp.
|
|
29621
|
+
return [await RulesyncMcp.fromRoots({
|
|
29622
|
+
inputRoots: this.inputRoots,
|
|
29623
|
+
logger: this.logger
|
|
29624
|
+
})];
|
|
27867
29625
|
} catch (error) {
|
|
27868
29626
|
this.logger.error(`Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`);
|
|
27869
29627
|
return [];
|
|
@@ -29405,27 +31163,6 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
|
|
|
29405
31163
|
return { permission };
|
|
29406
31164
|
}
|
|
29407
31165
|
//#endregion
|
|
29408
|
-
//#region src/utils/control-characters.ts
|
|
29409
|
-
/**
|
|
29410
|
-
* Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
|
|
29411
|
-
* introducer U+009B), the bidirectional overrides and isolates, and the Unicode
|
|
29412
|
-
* line and paragraph separators, and the plain LRM/RLM marks. A name or value
|
|
29413
|
-
* copied out of an untrusted config file, a fetched repository, or a tool's own
|
|
29414
|
-
* settings file must never reach the terminal with these intact: they let the
|
|
29415
|
-
* text forge log lines, reorder what is printed around them, or inject escape
|
|
29416
|
-
* sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
|
|
29417
|
-
* neutral characters beside them, so they go too — a diagnostic line is not the
|
|
29418
|
-
* place to preserve the typography of a right-to-left name.
|
|
29419
|
-
*/
|
|
29420
|
-
const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
|
|
29421
|
-
/**
|
|
29422
|
-
* Removes every control character from `text` so it is safe to splice into a
|
|
29423
|
-
* log line or other terminal output.
|
|
29424
|
-
*/
|
|
29425
|
-
function stripControlCharacters(text) {
|
|
29426
|
-
return text.replace(CONTROL_CHARACTERS_PATTERN, "");
|
|
29427
|
-
}
|
|
29428
|
-
//#endregion
|
|
29429
31166
|
//#region src/features/permissions/claudecode-permissions.ts
|
|
29430
31167
|
/**
|
|
29431
31168
|
* Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
|
|
@@ -29511,8 +31248,8 @@ function deepMergeRecords(base, patch) {
|
|
|
29511
31248
|
*
|
|
29512
31249
|
* Deliberately NOT listed:
|
|
29513
31250
|
* - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
|
|
29514
|
-
* `
|
|
29515
|
-
* emitting them under `--global`.
|
|
31251
|
+
* `CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL` refuses them in both scopes
|
|
31252
|
+
* rather than emitting them under `--global`.
|
|
29516
31253
|
* - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
|
|
29517
31254
|
* unit is the individual entry's mode, not the settings key, and the same
|
|
29518
31255
|
* lists carry `deny` entries that project settings *do* honor — dropping a
|
|
@@ -29531,6 +31268,30 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
|
|
|
29531
31268
|
["allowAppleEvents"]
|
|
29532
31269
|
];
|
|
29533
31270
|
/**
|
|
31271
|
+
* `sandbox.*` paths documented with a `Managed` scope: Claude Code reads them
|
|
31272
|
+
* only from the settings file an organization deploys. Neither file rulesync
|
|
31273
|
+
* writes is that file, so they are dropped in **both** scopes — the `sandbox`
|
|
31274
|
+
* counterpart of {@link CLAUDECODE_UNHONORED_KEY_SOURCES}, which does the same
|
|
31275
|
+
* for top-level `Managed` keys.
|
|
31276
|
+
*
|
|
31277
|
+
* Both only ever *narrow* the policy — they stop a lower-scoped file from
|
|
31278
|
+
* re-opening what managed settings blocked — so neither is trust-widening. They
|
|
31279
|
+
* are dropped rather than written for the opposite reason: written into a
|
|
31280
|
+
* project or user file they do nothing at all, and a `sandbox` block that reads
|
|
31281
|
+
* as though it locked the policy to managed values while Claude Code ignores it
|
|
31282
|
+
* is the more dangerous of the two failure modes.
|
|
31283
|
+
*
|
|
31284
|
+
* Import keeps them, unlike the command-executing paths: the value in an
|
|
31285
|
+
* existing `settings.json` was hand-written to be honored somewhere, and
|
|
31286
|
+
* round-tripping it preserves the author's intent for the day it moves into a
|
|
31287
|
+
* managed file. The cost is a warning on every generate until it is removed,
|
|
31288
|
+
* which the refusal message points at.
|
|
31289
|
+
*
|
|
31290
|
+
* @see https://code.claude.com/docs/en/settings-reference#sandbox-filesystem-allowmanagedreadpathsonly
|
|
31291
|
+
* — "Scope: `Managed`"; the `network` entry says the same.
|
|
31292
|
+
*/
|
|
31293
|
+
const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadPathsOnly"], ["network", "allowManagedDomainsOnly"]];
|
|
31294
|
+
/**
|
|
29534
31295
|
* Walks `segments` from `root`, returning the record they name or `undefined` if
|
|
29535
31296
|
* any step is missing or not a record. Shared by everything below that addresses
|
|
29536
31297
|
* a `sandbox` path, so a nested path added to one of the tables is actually
|
|
@@ -29577,6 +31338,18 @@ function deleteSandboxPath({ target, path }) {
|
|
|
29577
31338
|
return true;
|
|
29578
31339
|
}
|
|
29579
31340
|
/**
|
|
31341
|
+
* The one warning that names every trust-affecting setting this generate wrote
|
|
31342
|
+
* to `relativeFilePath`. Emitted once per file: the individual reasons are what
|
|
31343
|
+
* matter, but the "review this as you would a hook" framing only needs saying
|
|
31344
|
+
* once, and repeating it per key buries the reasons in boilerplate.
|
|
31345
|
+
*/
|
|
31346
|
+
function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
|
|
31347
|
+
if (entries.length === 0) return;
|
|
31348
|
+
const one = entries.length === 1;
|
|
31349
|
+
const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
|
|
31350
|
+
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}.`);
|
|
31351
|
+
}
|
|
31352
|
+
/**
|
|
29580
31353
|
* The `permissions.defaultMode` values that start a session with fewer prompts
|
|
29581
31354
|
* than the default. `plan` and `default` are absent because they do not widen
|
|
29582
31355
|
* anything.
|
|
@@ -29589,14 +31362,22 @@ const CLAUDECODE_WIDENING_DEFAULT_MODES = {
|
|
|
29589
31362
|
/**
|
|
29590
31363
|
* The `permissions` fields that widen rather than restrict: a `defaultMode` that
|
|
29591
31364
|
* removes prompts, and `additionalDirectories`, which moves the
|
|
29592
|
-
* working-directory boundary.
|
|
29593
|
-
* shareable permissions file should not loosen the permission system quietly.
|
|
31365
|
+
* working-directory boundary. Reported for the same reason `disableAllHooks` is:
|
|
31366
|
+
* a shareable permissions file should not loosen the permission system quietly.
|
|
29594
31367
|
*/
|
|
29595
|
-
function
|
|
31368
|
+
function collectWideningPermissionFields({ fields }) {
|
|
31369
|
+
const entries = [];
|
|
29596
31370
|
const defaultMode = fields.defaultMode;
|
|
29597
|
-
if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode))
|
|
31371
|
+
if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) entries.push({
|
|
31372
|
+
label: `permissions.defaultMode: "${defaultMode}"`,
|
|
31373
|
+
reason: CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]
|
|
31374
|
+
});
|
|
29598
31375
|
const additionalDirectories = fields.additionalDirectories;
|
|
29599
|
-
if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0))
|
|
31376
|
+
if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) entries.push({
|
|
31377
|
+
label: "permissions.additionalDirectories",
|
|
31378
|
+
reason: "moves the boundary of what Claude Code may read and edit outside the project"
|
|
31379
|
+
});
|
|
31380
|
+
return entries;
|
|
29600
31381
|
}
|
|
29601
31382
|
/**
|
|
29602
31383
|
* `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
|
|
@@ -29613,6 +31394,18 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
|
|
|
29613
31394
|
["socatPath"]
|
|
29614
31395
|
];
|
|
29615
31396
|
/**
|
|
31397
|
+
* The predicates the "which value actually widens?" tables are built from.
|
|
31398
|
+
* Each names the value that does *not* widen and reports everything else, never
|
|
31399
|
+
* the reverse: the override is authored JSONC, so a key can carry any value at
|
|
31400
|
+
* all, and one Claude Code coerces is still honored. Reporting an off-type value
|
|
31401
|
+
* keeps the warning fail-safe — silence has to mean "this cannot loosen
|
|
31402
|
+
* anything", not "this is not the type the table expected".
|
|
31403
|
+
*/
|
|
31404
|
+
const isNotFalse = (value) => value !== false;
|
|
31405
|
+
const isNotTrue = (value) => value !== true;
|
|
31406
|
+
const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
|
|
31407
|
+
const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
|
|
31408
|
+
/**
|
|
29616
31409
|
* `sandbox` paths that loosen the sandbox rather than naming something to run:
|
|
29617
31410
|
* they let commands out of it, weaken the isolation it provides, or redirect
|
|
29618
31411
|
* where its traffic goes. They are written like `env` is — the ordinary uses are
|
|
@@ -29632,77 +31425,77 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
|
|
|
29632
31425
|
{
|
|
29633
31426
|
path: ["allowAppleEvents"],
|
|
29634
31427
|
reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
|
|
29635
|
-
widens:
|
|
31428
|
+
widens: isNotFalse
|
|
29636
31429
|
},
|
|
29637
31430
|
{
|
|
29638
31431
|
path: ["allowUnsandboxedCommands"],
|
|
29639
31432
|
reason: "controls whether Claude may retry a blocked command outside the sandbox",
|
|
29640
|
-
widens:
|
|
31433
|
+
widens: isNotFalse
|
|
29641
31434
|
},
|
|
29642
31435
|
{
|
|
29643
31436
|
path: ["autoAllowBashIfSandboxed"],
|
|
29644
31437
|
reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
|
|
29645
|
-
widens:
|
|
31438
|
+
widens: isNotFalse
|
|
29646
31439
|
},
|
|
29647
31440
|
{
|
|
29648
31441
|
path: ["enableWeakerNestedSandbox"],
|
|
29649
31442
|
reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
|
|
29650
|
-
widens:
|
|
31443
|
+
widens: isNotFalse
|
|
29651
31444
|
},
|
|
29652
31445
|
{
|
|
29653
31446
|
path: ["enableWeakerNetworkIsolation"],
|
|
29654
31447
|
reason: "weakens the sandbox's network isolation on macOS",
|
|
29655
|
-
widens:
|
|
31448
|
+
widens: isNotFalse
|
|
29656
31449
|
},
|
|
29657
31450
|
{
|
|
29658
31451
|
path: ["enabled"],
|
|
29659
31452
|
reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
|
|
29660
|
-
widens:
|
|
31453
|
+
widens: isNotFalse
|
|
29661
31454
|
},
|
|
29662
31455
|
{
|
|
29663
31456
|
path: ["excludedCommands"],
|
|
29664
31457
|
reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
|
|
29665
|
-
widens:
|
|
31458
|
+
widens: isNonEmptyList
|
|
29666
31459
|
},
|
|
29667
31460
|
{
|
|
29668
31461
|
path: ["filesystem", "allowRead"],
|
|
29669
31462
|
reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
|
|
29670
|
-
widens:
|
|
31463
|
+
widens: isNonEmptyList
|
|
29671
31464
|
},
|
|
29672
31465
|
{
|
|
29673
31466
|
path: ["filesystem", "allowWrite"],
|
|
29674
31467
|
reason: "adds paths sandboxed commands may write to, outside the working directory",
|
|
29675
|
-
widens:
|
|
31468
|
+
widens: isNonEmptyList
|
|
29676
31469
|
},
|
|
29677
31470
|
{
|
|
29678
31471
|
path: ["ignoreViolations"],
|
|
29679
31472
|
reason: "hides the sandbox violations it names, so a blocked access stops being reported",
|
|
29680
|
-
widens: (value) =>
|
|
31473
|
+
widens: (value) => isNonEmptyMap(value) && isNotFalse(value)
|
|
29681
31474
|
},
|
|
29682
31475
|
{
|
|
29683
31476
|
path: ["network", "allowAllUnixSockets"],
|
|
29684
31477
|
reason: "lets sandboxed commands connect to every Unix socket",
|
|
29685
|
-
widens:
|
|
31478
|
+
widens: isNotFalse
|
|
29686
31479
|
},
|
|
29687
31480
|
{
|
|
29688
31481
|
path: ["network", "allowedDomains"],
|
|
29689
31482
|
reason: "pre-allows domains sandboxed commands may reach without a prompt",
|
|
29690
|
-
widens:
|
|
31483
|
+
widens: isNonEmptyList
|
|
29691
31484
|
},
|
|
29692
31485
|
{
|
|
29693
31486
|
path: ["network", "allowLocalBinding"],
|
|
29694
31487
|
reason: "lets sandboxed commands bind local ports",
|
|
29695
|
-
widens:
|
|
31488
|
+
widens: isNotFalse
|
|
29696
31489
|
},
|
|
29697
31490
|
{
|
|
29698
31491
|
path: ["network", "allowMachLookup"],
|
|
29699
31492
|
reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
|
|
29700
|
-
widens:
|
|
31493
|
+
widens: isNonEmptyList
|
|
29701
31494
|
},
|
|
29702
31495
|
{
|
|
29703
31496
|
path: ["network", "allowUnixSockets"],
|
|
29704
31497
|
reason: "names Unix sockets sandboxed commands may reach, and one such as `/var/run/docker.sock` is host access",
|
|
29705
|
-
widens:
|
|
31498
|
+
widens: isNonEmptyList
|
|
29706
31499
|
},
|
|
29707
31500
|
{
|
|
29708
31501
|
path: ["network", "httpProxyPort"],
|
|
@@ -29716,11 +31509,12 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
|
|
|
29716
31509
|
}
|
|
29717
31510
|
];
|
|
29718
31511
|
/**
|
|
29719
|
-
*
|
|
29720
|
-
*
|
|
29721
|
-
*
|
|
31512
|
+
* Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
|
|
31513
|
+
* the values are written, just not silently. Called on the filtered `sandbox`
|
|
31514
|
+
* so it never claims to be writing a path the scope filters dropped.
|
|
29722
31515
|
*/
|
|
29723
|
-
function
|
|
31516
|
+
function collectTrustAffectingSandboxPaths({ sandbox }) {
|
|
31517
|
+
const entries = [];
|
|
29724
31518
|
for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
|
|
29725
31519
|
const leaf = path.at(-1);
|
|
29726
31520
|
if (leaf === void 0) continue;
|
|
@@ -29731,38 +31525,49 @@ function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger })
|
|
|
29731
31525
|
if (parent === void 0) continue;
|
|
29732
31526
|
const value = parent[leaf];
|
|
29733
31527
|
if (value === void 0 || !widens(value)) continue;
|
|
29734
|
-
|
|
31528
|
+
entries.push({
|
|
31529
|
+
label: `sandbox.${path.join(".")}`,
|
|
31530
|
+
reason
|
|
31531
|
+
});
|
|
29735
31532
|
}
|
|
31533
|
+
return entries;
|
|
29736
31534
|
}
|
|
31535
|
+
/** Paths that name an executable Claude Code runs. Refused in both scopes. */
|
|
31536
|
+
const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
|
|
31537
|
+
paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
|
|
31538
|
+
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.`
|
|
31539
|
+
};
|
|
29737
31540
|
/**
|
|
29738
|
-
*
|
|
29739
|
-
*
|
|
31541
|
+
* Paths Claude Code honors only from managed settings. Refused in both scopes,
|
|
31542
|
+
* like the command-executing paths, because managed settings are not a file
|
|
31543
|
+
* rulesync writes in either of them.
|
|
29740
31544
|
*/
|
|
29741
|
-
|
|
29742
|
-
|
|
29743
|
-
|
|
29744
|
-
|
|
29745
|
-
|
|
29746
|
-
|
|
29747
|
-
|
|
29748
|
-
|
|
29749
|
-
|
|
29750
|
-
return filtered;
|
|
29751
|
-
}
|
|
31545
|
+
const CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL = {
|
|
31546
|
+
paths: CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS,
|
|
31547
|
+
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.`
|
|
31548
|
+
};
|
|
31549
|
+
/** Paths Claude Code honors only above project scope. Refused at project scope. */
|
|
31550
|
+
const CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL = {
|
|
31551
|
+
paths: CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS,
|
|
31552
|
+
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.`
|
|
31553
|
+
};
|
|
29752
31554
|
/**
|
|
29753
|
-
* Copy of the authored `sandbox` override with
|
|
29754
|
-
* removed, warning once per dropped path. Only the override copy is
|
|
29755
|
-
* a value already hand-written in the target file is left untouched,
|
|
29756
|
-
* the `qwencode` `security.allowPrivateNetworkHooks` precedent.
|
|
31555
|
+
* Copy of the authored `sandbox` override with every path of every passed
|
|
31556
|
+
* refusal removed, warning once per dropped path. Only the override copy is
|
|
31557
|
+
* filtered — a value already hand-written in the target file is left untouched,
|
|
31558
|
+
* matching the `qwencode` `security.allowPrivateNetworkHooks` precedent.
|
|
29757
31559
|
*/
|
|
29758
|
-
function
|
|
31560
|
+
function stripSandboxPaths({ sandbox, refusals, relativeFilePath, logger }) {
|
|
29759
31561
|
const filtered = structuredClone(sandbox);
|
|
29760
|
-
for (const path of
|
|
31562
|
+
for (const { paths, warn } of refusals) for (const path of paths) {
|
|
29761
31563
|
if (!deleteSandboxPath({
|
|
29762
31564
|
target: filtered,
|
|
29763
31565
|
path
|
|
29764
31566
|
})) continue;
|
|
29765
|
-
logger?.warn(
|
|
31567
|
+
logger?.warn(warn({
|
|
31568
|
+
label: `sandbox.${path.join(".")}`,
|
|
31569
|
+
relativeFilePath
|
|
31570
|
+
}));
|
|
29766
31571
|
}
|
|
29767
31572
|
return filtered;
|
|
29768
31573
|
}
|
|
@@ -29786,7 +31591,7 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
|
|
|
29786
31591
|
* such entries to `deny`), so the "reads as masked but isn't" state this guards
|
|
29787
31592
|
* against cannot slip through a differently-spelled value.
|
|
29788
31593
|
*
|
|
29789
|
-
* Like `
|
|
31594
|
+
* Like `stripSandboxPaths`, only the override copy is filtered — a
|
|
29790
31595
|
* value already in the target file is left untouched, which is why the warning
|
|
29791
31596
|
* points at it.
|
|
29792
31597
|
*
|
|
@@ -29953,6 +31758,9 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
|
|
|
29953
31758
|
allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
|
|
29954
31759
|
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",
|
|
29955
31760
|
autoMode: "auto-approves shell commands with a classifier rather than with a prompt",
|
|
31761
|
+
claudeMdExcludes: "skips the CLAUDE.md files its patterns match, so the instructions a repository relies on can be dropped from every session",
|
|
31762
|
+
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",
|
|
31763
|
+
crossSessionInbound: "decides what a session does with messages arriving from your other Claude Code sessions, and `accept` delivers them straight to Claude",
|
|
29956
31764
|
disableAllHooks: "controls whether hooks run at all",
|
|
29957
31765
|
disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
|
|
29958
31766
|
enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
|
|
@@ -29961,17 +31769,58 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
|
|
|
29961
31769
|
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",
|
|
29962
31770
|
extraKnownMarketplaces: "registers plugin marketplace sources",
|
|
29963
31771
|
httpHookAllowedEnvVars: "controls which environment variables an HTTP hook may put in a request header, credentials included",
|
|
31772
|
+
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",
|
|
29964
31773
|
outputStyle: "replaces the system prompt every session runs with",
|
|
31774
|
+
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",
|
|
31775
|
+
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",
|
|
29965
31776
|
skipAutoPermissionPrompt: "removes the confirmation shown before auto-approval mode starts",
|
|
29966
|
-
skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts"
|
|
31777
|
+
skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts",
|
|
31778
|
+
skipWebFetchPreflight: "turns off the WebFetch domain safety check, so WebFetch retrieves any URL without consulting Anthropic's blocklist"
|
|
29967
31779
|
};
|
|
29968
31780
|
/**
|
|
29969
31781
|
* The keys from the table above that only widen at one particular value.
|
|
29970
31782
|
* `disableSkillShellExecution: true` turns inline shell execution off, which
|
|
29971
|
-
* restricts;
|
|
29972
|
-
* use to undo a user setting, so only
|
|
29973
|
-
|
|
29974
|
-
|
|
31783
|
+
* restricts; anything else re-opens it, and that is what a fetched override
|
|
31784
|
+
* could use to undo a user setting. The rest default to off, so only a value
|
|
31785
|
+
* other than the one that leaves them off is worth a line — and for the
|
|
31786
|
+
* list-valued and map-valued keys, only a non-empty one, since an empty list or
|
|
31787
|
+
* map excludes, announces and overrides nothing.
|
|
31788
|
+
*/
|
|
31789
|
+
const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = {
|
|
31790
|
+
claudeMdExcludes: isNonEmptyList,
|
|
31791
|
+
companyAnnouncements: isNonEmptyList,
|
|
31792
|
+
crossSessionInbound: (value) => value !== "hold" && value !== "refuse",
|
|
31793
|
+
disableSkillShellExecution: isNotTrue,
|
|
31794
|
+
modelOverrides: isNonEmptyMap,
|
|
31795
|
+
remoteControlAtStartup: isNotFalse,
|
|
31796
|
+
skipWebFetchPreflight: isNotFalse
|
|
31797
|
+
};
|
|
31798
|
+
/**
|
|
31799
|
+
* Top-level keys a project-scoped `.claude/settings.json` honors at one value
|
|
31800
|
+
* but ignores at another — the value-level counterpart of
|
|
31801
|
+
* {@link CLAUDECODE_USER_SCOPE_ONLY_KEYS}, which is scoped per key. The ignored
|
|
31802
|
+
* value is dropped at project scope for the same reason a wholly unhonored key
|
|
31803
|
+
* is: committing it would read as a policy that never applies.
|
|
31804
|
+
*
|
|
31805
|
+
* `remoteControlAtStartup` is the only entry whose honored value can be decided
|
|
31806
|
+
* from the value alone. Claude Code honors a `false` from project or local
|
|
31807
|
+
* settings — a repository may turn auto-connect off for its own checkout — but
|
|
31808
|
+
* ignores a `true`, so that a checked-in file cannot turn Remote Control on for
|
|
31809
|
+
* everyone who opens the repository.
|
|
31810
|
+
*
|
|
31811
|
+
* `crossSessionInbound` is on the same documented list but deliberately absent
|
|
31812
|
+
* here: it is a ladder (`accept` < `hold` < `refuse`) whose project value is
|
|
31813
|
+
* honored only when it is stricter than the one above it, which no per-value
|
|
31814
|
+
* predicate can decide without reading the user's own settings. It is warned
|
|
31815
|
+
* about through {@link CLAUDECODE_TRUST_AFFECTING_KEYS} instead, since under
|
|
31816
|
+
* `--global` its loosening value is honored outright.
|
|
31817
|
+
*
|
|
31818
|
+
* @see https://code.claude.com/docs/en/settings#security-keys-where-the-stricter-value-applies
|
|
31819
|
+
*/
|
|
31820
|
+
const CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES = { remoteControlAtStartup: {
|
|
31821
|
+
ignored: isNotFalse,
|
|
31822
|
+
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"
|
|
31823
|
+
} };
|
|
29975
31824
|
/**
|
|
29976
31825
|
* A key name is authored data that ends up in a log line, so strip the control
|
|
29977
31826
|
* characters that would let it forge a line or hide the warnings beside it, and
|
|
@@ -29999,12 +31848,13 @@ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
|
|
|
29999
31848
|
/**
|
|
30000
31849
|
* Copy of the authored top-level passthrough with the keys the target file
|
|
30001
31850
|
* cannot honor removed, warning once per dropped key. Like
|
|
30002
|
-
* `
|
|
31851
|
+
* `stripSandboxPaths`, only the override copy is filtered — a value
|
|
30003
31852
|
* already hand-written in the target file is left untouched, which is why the
|
|
30004
31853
|
* warning points at it.
|
|
30005
31854
|
*/
|
|
30006
31855
|
function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
|
|
30007
31856
|
const filtered = {};
|
|
31857
|
+
const trustAffecting = [];
|
|
30008
31858
|
for (const [key, value] of Object.entries(overrides)) {
|
|
30009
31859
|
const shown = displayKey(key);
|
|
30010
31860
|
const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
|
|
@@ -30020,11 +31870,22 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
|
|
|
30020
31870
|
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.`);
|
|
30021
31871
|
continue;
|
|
30022
31872
|
}
|
|
30023
|
-
const
|
|
30024
|
-
if (
|
|
31873
|
+
const projectIgnored = Object.hasOwn(CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES, canonicalKey) ? CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES[canonicalKey] : void 0;
|
|
31874
|
+
if (!global && projectIgnored !== void 0 && projectIgnored.ignored(value)) {
|
|
31875
|
+
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.`);
|
|
31876
|
+
continue;
|
|
31877
|
+
}
|
|
31878
|
+
const widensAtValue = Object.hasOwn(CLAUDECODE_TRUST_KEY_WIDENING_VALUES, canonicalKey) ? CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey] : void 0;
|
|
31879
|
+
if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) trustAffecting.push({
|
|
31880
|
+
label: shown,
|
|
31881
|
+
reason: CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]
|
|
31882
|
+
});
|
|
30025
31883
|
filtered[key] = value;
|
|
30026
31884
|
}
|
|
30027
|
-
return
|
|
31885
|
+
return {
|
|
31886
|
+
filtered,
|
|
31887
|
+
trustAffecting
|
|
31888
|
+
};
|
|
30028
31889
|
}
|
|
30029
31890
|
const CLAUDE_PATH_RULE_ALIASES = {
|
|
30030
31891
|
Write: "Edit",
|
|
@@ -30094,15 +31955,12 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
30094
31955
|
config,
|
|
30095
31956
|
logger
|
|
30096
31957
|
});
|
|
31958
|
+
const trustAffecting = [];
|
|
30097
31959
|
const overridePermissions = config.claudecode?.permissions;
|
|
30098
31960
|
if (overridePermissions && typeof overridePermissions === "object") {
|
|
30099
31961
|
const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
|
|
30100
31962
|
const nonListFields = Object.fromEntries(Object.entries(rest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
|
|
30101
|
-
|
|
30102
|
-
fields: nonListFields,
|
|
30103
|
-
relativeFilePath: paths.relativeFilePath,
|
|
30104
|
-
logger
|
|
30105
|
-
});
|
|
31963
|
+
trustAffecting.push(...collectWideningPermissionFields({ fields: nonListFields }));
|
|
30106
31964
|
settings.permissions = {
|
|
30107
31965
|
...settings.permissions,
|
|
30108
31966
|
...nonListFields
|
|
@@ -30110,25 +31968,22 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
30110
31968
|
}
|
|
30111
31969
|
const overrideSandbox = config.claudecode?.sandbox;
|
|
30112
31970
|
if (isPlainRecord(overrideSandbox)) {
|
|
30113
|
-
const
|
|
31971
|
+
const honorableSandbox = stripSandboxPaths({
|
|
30114
31972
|
sandbox: overrideSandbox,
|
|
31973
|
+
refusals: [
|
|
31974
|
+
CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL,
|
|
31975
|
+
CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL,
|
|
31976
|
+
...global ? [] : [CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL]
|
|
31977
|
+
],
|
|
30115
31978
|
relativeFilePath: paths.relativeFilePath,
|
|
30116
31979
|
logger
|
|
30117
31980
|
});
|
|
30118
|
-
const scopedSandbox = global ?
|
|
30119
|
-
sandbox:
|
|
30120
|
-
sandbox: executableFreeSandbox,
|
|
30121
|
-
relativeFilePath: paths.relativeFilePath,
|
|
30122
|
-
logger
|
|
30123
|
-
}),
|
|
30124
|
-
relativeFilePath: paths.relativeFilePath,
|
|
30125
|
-
logger
|
|
30126
|
-
});
|
|
30127
|
-
warnOnTrustAffectingSandboxPaths({
|
|
30128
|
-
sandbox: scopedSandbox,
|
|
31981
|
+
const scopedSandbox = global ? honorableSandbox : stripProjectIgnoredMaskEntries({
|
|
31982
|
+
sandbox: honorableSandbox,
|
|
30129
31983
|
relativeFilePath: paths.relativeFilePath,
|
|
30130
31984
|
logger
|
|
30131
31985
|
});
|
|
31986
|
+
trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
|
|
30132
31987
|
if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
|
|
30133
31988
|
}
|
|
30134
31989
|
const overrideTopLevel = {};
|
|
@@ -30138,13 +31993,19 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
30138
31993
|
if (value === void 0) continue;
|
|
30139
31994
|
overrideTopLevel[key] = value;
|
|
30140
31995
|
}
|
|
30141
|
-
const scopedTopLevel = stripUnhonoredTopLevelKeys({
|
|
31996
|
+
const { filtered: scopedTopLevel, trustAffecting: trustAffectingTopLevel } = stripUnhonoredTopLevelKeys({
|
|
30142
31997
|
overrides: overrideTopLevel,
|
|
30143
31998
|
global,
|
|
30144
31999
|
relativeFilePath: paths.relativeFilePath,
|
|
30145
32000
|
logger
|
|
30146
32001
|
});
|
|
32002
|
+
trustAffecting.push(...trustAffectingTopLevel);
|
|
30147
32003
|
if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
|
|
32004
|
+
warnOnTrustAffectingEntries({
|
|
32005
|
+
entries: trustAffecting,
|
|
32006
|
+
relativeFilePath: paths.relativeFilePath,
|
|
32007
|
+
logger
|
|
32008
|
+
});
|
|
30148
32009
|
const managedToolNames = managedClaudeToolNames(config);
|
|
30149
32010
|
const merged = applyPermissions({
|
|
30150
32011
|
settings,
|
|
@@ -35122,11 +36983,8 @@ const TOOL_KEY_TO_CATEGORY = {
|
|
|
35122
36983
|
updateConfluencePage: "edit"
|
|
35123
36984
|
};
|
|
35124
36985
|
const MANAGED_TOOL_KEYS = [.../* @__PURE__ */ new Set([...Object.values(CATEGORY_TO_TOOL_KEYS).flat(), ...Object.keys(TOOL_KEY_TO_CATEGORY)])];
|
|
35125
|
-
const OWNED_TOOL_PERMISSION_KEYS = [
|
|
35126
|
-
|
|
35127
|
-
"allowedExternalPaths",
|
|
35128
|
-
"default"
|
|
35129
|
-
];
|
|
36986
|
+
const OWNED_TOOL_PERMISSION_KEYS = ["allowedExternalPaths", "default"];
|
|
36987
|
+
const MANAGED_BASH_KEYS = ["default", "commands"];
|
|
35130
36988
|
/**
|
|
35131
36989
|
* Permissions adapter for Rovo Dev CLI.
|
|
35132
36990
|
*
|
|
@@ -35262,6 +37120,22 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
|
|
|
35262
37120
|
}
|
|
35263
37121
|
};
|
|
35264
37122
|
/**
|
|
37123
|
+
* Report a `toolPermissions.bash.runInSandbox: false` that this generate is
|
|
37124
|
+
* about to carry through.
|
|
37125
|
+
*
|
|
37126
|
+
* The unmanaged `bash` siblings survive untouched, which for this one means a
|
|
37127
|
+
* generate is not the reset a user might read it as: every command the agent
|
|
37128
|
+
* runs stays outside the sandbox. Rulesync never authors the key and will not
|
|
37129
|
+
* start owning it, but a setting that persists on a committed file and loosens
|
|
37130
|
+
* containment should not do so without saying anything — least of all on the
|
|
37131
|
+
* path where the user just tightened `.rulesync/permissions.*`.
|
|
37132
|
+
*/
|
|
37133
|
+
function warnAboutPreservedSandboxOptOut({ existingToolPermissions, filePath, logger }) {
|
|
37134
|
+
const existingBash = existingToolPermissions.bash;
|
|
37135
|
+
if (!isRecord$1(existingBash) || existingBash.runInSandbox !== false) return;
|
|
37136
|
+
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.`);
|
|
37137
|
+
}
|
|
37138
|
+
/**
|
|
35265
37139
|
* Resolve the `toolPermissions` block to write, merging the generated levels
|
|
35266
37140
|
* over the existing file. Every other top-level key of `config.yml` is the
|
|
35267
37141
|
* caller's to preserve; inside this block, keys rulesync manages are owned and
|
|
@@ -35269,6 +37143,11 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
|
|
|
35269
37143
|
*/
|
|
35270
37144
|
function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
|
|
35271
37145
|
const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
|
|
37146
|
+
warnAboutPreservedSandboxOptOut({
|
|
37147
|
+
existingToolPermissions,
|
|
37148
|
+
filePath,
|
|
37149
|
+
logger
|
|
37150
|
+
});
|
|
35272
37151
|
if (Object.keys(generated).length === 0 && sourceStatesRules) {
|
|
35273
37152
|
if (!isRecord$1(existing)) return;
|
|
35274
37153
|
const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
|
|
@@ -35277,9 +37156,12 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
|
|
|
35277
37156
|
}
|
|
35278
37157
|
const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
|
|
35279
37158
|
const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
|
|
37159
|
+
const hasExistingBashRecord = isRecord$1(existingToolPermissions.bash);
|
|
37160
|
+
const existingBash = hasExistingBashRecord ? { ...existingToolPermissions.bash } : {};
|
|
35280
37161
|
warnAboutDroppedOwnedKeys({
|
|
35281
37162
|
existingToolPermissions,
|
|
35282
37163
|
existingTools,
|
|
37164
|
+
existingBash,
|
|
35283
37165
|
generated,
|
|
35284
37166
|
filePath,
|
|
35285
37167
|
logger
|
|
@@ -35289,15 +37171,22 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
|
|
|
35289
37171
|
delete existingToolPermissions[toolKey];
|
|
35290
37172
|
delete existingTools[toolKey];
|
|
35291
37173
|
}
|
|
37174
|
+
for (const bashKey of MANAGED_BASH_KEYS) delete existingBash[bashKey];
|
|
35292
37175
|
const tools = {
|
|
35293
37176
|
...existingTools,
|
|
35294
37177
|
...generated.tools
|
|
35295
37178
|
};
|
|
37179
|
+
const bash = {
|
|
37180
|
+
...existingBash,
|
|
37181
|
+
...generated.bash
|
|
37182
|
+
};
|
|
35296
37183
|
if (hasExistingToolsRecord) delete existingToolPermissions.tools;
|
|
37184
|
+
if (hasExistingBashRecord) delete existingToolPermissions.bash;
|
|
35297
37185
|
return {
|
|
35298
37186
|
...existingToolPermissions,
|
|
35299
37187
|
...generated,
|
|
35300
|
-
...Object.keys(tools).length > 0 ? { tools } : {}
|
|
37188
|
+
...Object.keys(tools).length > 0 ? { tools } : {},
|
|
37189
|
+
...Object.keys(bash).length > 0 ? { bash } : {}
|
|
35301
37190
|
};
|
|
35302
37191
|
}
|
|
35303
37192
|
/**
|
|
@@ -35306,9 +37195,14 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
|
|
|
35306
37195
|
* `/directories` and by an "always allow" answer to a prompt — so their removal
|
|
35307
37196
|
* must not be silent.
|
|
35308
37197
|
*/
|
|
35309
|
-
function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, generated, filePath, logger }) {
|
|
37198
|
+
function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, existingBash, generated, filePath, logger }) {
|
|
35310
37199
|
const newTools = generated.tools ?? {};
|
|
35311
|
-
const
|
|
37200
|
+
const newBash = generated.bash ?? {};
|
|
37201
|
+
const droppedKeys = [
|
|
37202
|
+
...OWNED_TOOL_PERMISSION_KEYS.filter((ownedKey) => existingToolPermissions[ownedKey] !== void 0 && generated[ownedKey] === void 0),
|
|
37203
|
+
...MANAGED_TOOL_KEYS.filter((toolKey) => existingTools[toolKey] !== void 0 && newTools[toolKey] === void 0).map((toolKey) => `tools.${toolKey}`),
|
|
37204
|
+
...MANAGED_BASH_KEYS.filter((bashKey) => existingBash[bashKey] !== void 0 && newBash[bashKey] === void 0).map((bashKey) => `bash.${bashKey}`)
|
|
37205
|
+
];
|
|
35312
37206
|
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.`);
|
|
35313
37207
|
}
|
|
35314
37208
|
/**
|
|
@@ -37416,10 +39310,10 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
|
|
|
37416
39310
|
var PermissionsProcessor = class extends FeatureProcessor {
|
|
37417
39311
|
toolTarget;
|
|
37418
39312
|
global;
|
|
37419
|
-
constructor({ outputRoot = process.cwd(),
|
|
39313
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
|
|
37420
39314
|
super({
|
|
37421
39315
|
outputRoot,
|
|
37422
|
-
|
|
39316
|
+
inputRoots,
|
|
37423
39317
|
dryRun,
|
|
37424
39318
|
logger
|
|
37425
39319
|
});
|
|
@@ -37429,9 +39323,17 @@ var PermissionsProcessor = class extends FeatureProcessor {
|
|
|
37429
39323
|
this.global = global;
|
|
37430
39324
|
}
|
|
37431
39325
|
async loadRulesyncFiles() {
|
|
39326
|
+
const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncPermissions.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
|
|
39327
|
+
const sourceTree = await pickLastRootWithFile({
|
|
39328
|
+
inputRoots: this.inputRoots,
|
|
39329
|
+
relativePaths,
|
|
39330
|
+
logger: this.logger,
|
|
39331
|
+
artifactName: "The permissions file"
|
|
39332
|
+
}) ?? this.inputRoots[0];
|
|
37432
39333
|
try {
|
|
37433
39334
|
return [await RulesyncPermissions.fromFile({
|
|
37434
|
-
outputRoot:
|
|
39335
|
+
outputRoot: dirname(sourceTree),
|
|
39336
|
+
relativeDirPath: basename(sourceTree),
|
|
37435
39337
|
validate: true
|
|
37436
39338
|
})];
|
|
37437
39339
|
} catch (error) {
|
|
@@ -37579,9 +39481,13 @@ var SimulatedSkill = class extends ToolSkill {
|
|
|
37579
39481
|
const skillDirPath = join(outputRoot, actualRelativeDirPath, dirName);
|
|
37580
39482
|
const skillFilePath = join(skillDirPath, SKILL_FILE_NAME);
|
|
37581
39483
|
if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
|
|
37582
|
-
const { frontmatter, body: content } =
|
|
39484
|
+
const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
|
|
37583
39485
|
const result = SimulatedSkillFrontmatterSchema.safeParse(frontmatter);
|
|
37584
39486
|
if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
|
|
39487
|
+
warnOnEmptyLoadedDescription({
|
|
39488
|
+
skillFilePath,
|
|
39489
|
+
description: result.data.description
|
|
39490
|
+
});
|
|
37585
39491
|
const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
|
|
37586
39492
|
return {
|
|
37587
39493
|
outputRoot,
|
|
@@ -37855,13 +39761,26 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
|
|
|
37855
39761
|
//#region src/types/dir-feature-processor.ts
|
|
37856
39762
|
var DirFeatureProcessor = class {
|
|
37857
39763
|
outputRoot;
|
|
37858
|
-
|
|
39764
|
+
/**
|
|
39765
|
+
* Ordered, non-empty list of rulesync source-tree directories. Each entry
|
|
39766
|
+
* is a source tree itself — the directory that directly contains
|
|
39767
|
+
* feature subdirectories (`rules/`, `skills/`, …) and single-file
|
|
39768
|
+
* features (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence
|
|
39769
|
+
* when two trees supply the same relative path. Defaults to
|
|
39770
|
+
* `[join(process.cwd(), ".rulesync")]`.
|
|
39771
|
+
*
|
|
39772
|
+
* The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
|
|
39773
|
+
* `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
|
|
39774
|
+
* and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
|
|
39775
|
+
* reaches a processor.
|
|
39776
|
+
*/
|
|
39777
|
+
inputRoots;
|
|
37859
39778
|
dryRun;
|
|
37860
39779
|
avoidBlockScalars;
|
|
37861
39780
|
logger;
|
|
37862
|
-
constructor({ outputRoot = process.cwd(),
|
|
39781
|
+
constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, avoidBlockScalars = false, logger }) {
|
|
37863
39782
|
this.outputRoot = outputRoot;
|
|
37864
|
-
this.
|
|
39783
|
+
this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
|
|
37865
39784
|
this.dryRun = dryRun;
|
|
37866
39785
|
this.avoidBlockScalars = avoidBlockScalars;
|
|
37867
39786
|
this.logger = logger;
|
|
@@ -40120,9 +42039,9 @@ var DevinSkill = class DevinSkill extends ToolSkill {
|
|
|
40120
42039
|
* slug is owned by the commands feature: it must not be imported as a
|
|
40121
42040
|
* skill nor deleted as an orphan skill.
|
|
40122
42041
|
*/
|
|
40123
|
-
static async isDirOwned({ dirName,
|
|
42042
|
+
static async isDirOwned({ dirName, inputRoots }) {
|
|
40124
42043
|
return !await rulesyncCommandSlugExists({
|
|
40125
|
-
|
|
42044
|
+
inputRoots,
|
|
40126
42045
|
dirName
|
|
40127
42046
|
});
|
|
40128
42047
|
}
|
|
@@ -40172,7 +42091,11 @@ const FactorydroidSkillFrontmatterSchema = z.looseObject({
|
|
|
40172
42091
|
"user-invocable": z.optional(z.boolean()),
|
|
40173
42092
|
"disable-model-invocation": z.optional(z.boolean()),
|
|
40174
42093
|
enabled: z.optional(z.boolean()),
|
|
40175
|
-
"allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
|
|
42094
|
+
"allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
|
|
42095
|
+
license: z.optional(z.unknown()),
|
|
42096
|
+
compatibility: z.optional(z.unknown()),
|
|
42097
|
+
metadata: z.optional(z.unknown()),
|
|
42098
|
+
version: z.optional(z.unknown())
|
|
40176
42099
|
});
|
|
40177
42100
|
/**
|
|
40178
42101
|
* Represents a Factory Droid skill directory.
|
|
@@ -40225,16 +42148,10 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
|
|
|
40225
42148
|
};
|
|
40226
42149
|
}
|
|
40227
42150
|
toRulesyncSkill() {
|
|
40228
|
-
const
|
|
40229
|
-
const factorydroidBlock = {
|
|
40230
|
-
...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
|
|
40231
|
-
...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
|
|
40232
|
-
...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
|
|
40233
|
-
...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
|
|
40234
|
-
};
|
|
42151
|
+
const { name, description, ...factorydroidBlock } = this.getFrontmatter();
|
|
40235
42152
|
const rulesyncFrontmatter = {
|
|
40236
|
-
name
|
|
40237
|
-
description
|
|
42153
|
+
name,
|
|
42154
|
+
description,
|
|
40238
42155
|
targets: ["*"],
|
|
40239
42156
|
...Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }
|
|
40240
42157
|
};
|
|
@@ -40261,13 +42178,13 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
|
|
|
40261
42178
|
rootFrontmatter: rulesyncFrontmatter,
|
|
40262
42179
|
section: factorydroidSection
|
|
40263
42180
|
});
|
|
42181
|
+
const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
|
|
40264
42182
|
const factorydroidFrontmatter = {
|
|
40265
42183
|
name: rulesyncFrontmatter.name,
|
|
40266
42184
|
description: rulesyncFrontmatter.description,
|
|
42185
|
+
...section,
|
|
40267
42186
|
...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
|
|
40268
|
-
...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
|
|
40269
|
-
...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
|
|
40270
|
-
...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
|
|
42187
|
+
...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
|
|
40271
42188
|
};
|
|
40272
42189
|
return new FactorydroidSkill({
|
|
40273
42190
|
outputRoot,
|
|
@@ -40679,7 +42596,10 @@ var JunieSkill = class JunieSkill extends ToolSkill {
|
|
|
40679
42596
|
}
|
|
40680
42597
|
}
|
|
40681
42598
|
static getSettablePaths(_options) {
|
|
40682
|
-
return {
|
|
42599
|
+
return {
|
|
42600
|
+
relativeDirPath: JUNIE_SKILLS_DIR_PATH,
|
|
42601
|
+
importOnlySkillRoots: [AGENTSMD_SKILLS_DIR_PATH]
|
|
42602
|
+
};
|
|
40683
42603
|
}
|
|
40684
42604
|
getFrontmatter() {
|
|
40685
42605
|
return JunieSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
|
|
@@ -41095,7 +43015,7 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
|
|
|
41095
43015
|
}
|
|
41096
43016
|
static async fromFlatFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
|
|
41097
43017
|
const filePath = join(outputRoot, relativeDirPath, relativeFilePath);
|
|
41098
|
-
const { frontmatter, body } =
|
|
43018
|
+
const { frontmatter, body } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath);
|
|
41099
43019
|
const result = KimiCodeFlatSkillFrontmatterSchema.safeParse(frontmatter);
|
|
41100
43020
|
if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
|
|
41101
43021
|
const fileName = basename(relativeFilePath, extname(relativeFilePath));
|
|
@@ -41105,6 +43025,10 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
|
|
|
41105
43025
|
name: result.data.name ?? fileName,
|
|
41106
43026
|
description: result.data.description ?? firstBodyLine?.slice(0, 240) ?? "No description provided."
|
|
41107
43027
|
};
|
|
43028
|
+
warnOnEmptyLoadedDescription({
|
|
43029
|
+
skillFilePath: filePath,
|
|
43030
|
+
description: normalizedFrontmatter.description
|
|
43031
|
+
});
|
|
41108
43032
|
return new KimiCodeSkill({
|
|
41109
43033
|
outputRoot,
|
|
41110
43034
|
relativeDirPath,
|
|
@@ -42108,7 +44032,7 @@ var ReasonixSkill = class ReasonixSkill extends ToolSkill {
|
|
|
42108
44032
|
static async isDirOwned({ outputRoot, relativeDirPath, dirName }) {
|
|
42109
44033
|
const skillFilePath = join(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
|
|
42110
44034
|
try {
|
|
42111
|
-
const { frontmatter } =
|
|
44035
|
+
const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath, { quiet: true });
|
|
42112
44036
|
return frontmatter["runAs"] !== REASONIX_SUBAGENT_RUN_AS;
|
|
42113
44037
|
} catch {
|
|
42114
44038
|
return true;
|
|
@@ -42477,8 +44401,7 @@ var TaktSkill = class TaktSkill extends ToolSkill {
|
|
|
42477
44401
|
const fullPath = join(this.outputRoot, this.relativeDirPath);
|
|
42478
44402
|
const resolvedFull = resolve(fullPath);
|
|
42479
44403
|
const resolvedBase = resolve(this.outputRoot);
|
|
42480
|
-
|
|
42481
|
-
if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
|
|
44404
|
+
if (pathEscapesRoot(relative(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
|
|
42482
44405
|
return fullPath;
|
|
42483
44406
|
}
|
|
42484
44407
|
getRelativePathFromCwd() {
|
|
@@ -42841,9 +44764,9 @@ var WarpSkill = class WarpSkill extends ToolSkill {
|
|
|
42841
44764
|
* slug is owned by the commands feature: it must not be imported as a
|
|
42842
44765
|
* skill nor deleted as an orphan skill.
|
|
42843
44766
|
*/
|
|
42844
|
-
static async isDirOwned({ dirName,
|
|
44767
|
+
static async isDirOwned({ dirName, inputRoots }) {
|
|
42845
44768
|
return !await rulesyncCommandSlugExists({
|
|
42846
|
-
|
|
44769
|
+
inputRoots,
|
|
42847
44770
|
dirName
|
|
42848
44771
|
});
|
|
42849
44772
|
}
|
|
@@ -43401,10 +45324,10 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43401
45324
|
toolTarget;
|
|
43402
45325
|
global;
|
|
43403
45326
|
getFactory;
|
|
43404
|
-
constructor({ outputRoot = process.cwd(),
|
|
45327
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
|
|
43405
45328
|
super({
|
|
43406
45329
|
outputRoot,
|
|
43407
|
-
|
|
45330
|
+
inputRoots,
|
|
43408
45331
|
dryRun,
|
|
43409
45332
|
avoidBlockScalars: toolTarget === "cursor",
|
|
43410
45333
|
logger
|
|
@@ -43442,39 +45365,67 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43442
45365
|
return rulesyncSkills;
|
|
43443
45366
|
}
|
|
43444
45367
|
/**
|
|
43445
|
-
*
|
|
43446
|
-
*
|
|
43447
|
-
*
|
|
43448
|
-
*
|
|
45368
|
+
* Load rulesync skill directories from a single source-tree's `skills/`
|
|
45369
|
+
* (and `skills/.curated/`) subtree. `sourceTree` is the source tree
|
|
45370
|
+
* itself (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`). Intra-tree:
|
|
45371
|
+
* local skills take precedence over curated skills with the same name.
|
|
43449
45372
|
*/
|
|
43450
|
-
async
|
|
43451
|
-
const
|
|
45373
|
+
async loadRulesyncDirsForRoot(sourceTree) {
|
|
45374
|
+
const treeParent = dirname(sourceTree);
|
|
45375
|
+
const treeName = basename(sourceTree);
|
|
45376
|
+
const treeSkillsDirPath = join(treeName, SKILLS_FEATURE_SUBDIR);
|
|
45377
|
+
const treeCuratedSkillsDirPath = join(treeName, CURATED_SKILLS_FEATURE_SUBDIR);
|
|
45378
|
+
const localDirNames = [...await getLocalSkillDirNames(sourceTree)];
|
|
43452
45379
|
const localSkills = await Promise.all(localDirNames.map((dirName) => RulesyncSkill.fromDir({
|
|
43453
|
-
outputRoot:
|
|
45380
|
+
outputRoot: treeParent,
|
|
45381
|
+
relativeDirPath: treeSkillsDirPath,
|
|
43454
45382
|
dirName,
|
|
43455
45383
|
global: this.global
|
|
43456
45384
|
})));
|
|
43457
|
-
const
|
|
43458
|
-
const curatedDirPath = join(
|
|
45385
|
+
const localSkillNamesByIdentity = groupSpellingsByCaseFoldedIdentity(localDirNames);
|
|
45386
|
+
const curatedDirPath = join(sourceTree, CURATED_SKILLS_FEATURE_SUBDIR);
|
|
43459
45387
|
let curatedSkills = [];
|
|
43460
45388
|
if (await directoryExists(curatedDirPath)) {
|
|
43461
45389
|
const nonConflicting = (await findFilesByGlobs(join(curatedDirPath, "*"), { type: "dir" })).map((path) => basename(path)).filter((name) => {
|
|
43462
|
-
|
|
43463
|
-
|
|
43464
|
-
|
|
43465
|
-
|
|
43466
|
-
|
|
45390
|
+
const spellings = localSkillNamesByIdentity.get(caseFoldIdentity(name));
|
|
45391
|
+
if (spellings === void 0) return true;
|
|
45392
|
+
if (spellings.includes(name)) this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
|
|
45393
|
+
else this.logger.warn(formatCuratedCaseCollisionWarning({
|
|
45394
|
+
artifactKind: "skill",
|
|
45395
|
+
entryNoun: "skill",
|
|
45396
|
+
treeDirPath: treeSkillsDirPath,
|
|
45397
|
+
curatedSpelling: name,
|
|
45398
|
+
localSpellings: spellings
|
|
45399
|
+
}));
|
|
45400
|
+
return false;
|
|
43467
45401
|
});
|
|
43468
|
-
const curatedRelativeDirPath = RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH;
|
|
43469
45402
|
curatedSkills = await Promise.all(nonConflicting.map((dirName) => RulesyncSkill.fromDir({
|
|
43470
|
-
outputRoot:
|
|
43471
|
-
relativeDirPath:
|
|
45403
|
+
outputRoot: treeParent,
|
|
45404
|
+
relativeDirPath: treeCuratedSkillsDirPath,
|
|
43472
45405
|
dirName,
|
|
43473
45406
|
global: this.global
|
|
43474
45407
|
})));
|
|
43475
45408
|
}
|
|
43476
|
-
|
|
43477
|
-
|
|
45409
|
+
return [...localSkills, ...curatedSkills];
|
|
45410
|
+
}
|
|
45411
|
+
/**
|
|
45412
|
+
* Implementation of abstract method from DirFeatureProcessor.
|
|
45413
|
+
*
|
|
45414
|
+
* Load and parse rulesync skill directories from every configured input
|
|
45415
|
+
* root's `.rulesync/skills/` tree (each root also honours its own
|
|
45416
|
+
* `.curated/` subdirectory). When two roots supply a skill with the same
|
|
45417
|
+
* directory name, the later root's skill replaces the earlier root's copy
|
|
45418
|
+
* atomically (companion files included) — an overlay always ships a whole
|
|
45419
|
+
* skill directory, never a partial patch.
|
|
45420
|
+
*/
|
|
45421
|
+
async loadRulesyncDirs() {
|
|
45422
|
+
const allSkills = mergeByCaseInsensitiveIdentity({
|
|
45423
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncDirsForRoot(root))),
|
|
45424
|
+
identity: (skill) => skill.getDirName(),
|
|
45425
|
+
artifactName: "skill",
|
|
45426
|
+
logger: this.logger
|
|
45427
|
+
});
|
|
45428
|
+
this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills`);
|
|
43478
45429
|
return allSkills;
|
|
43479
45430
|
}
|
|
43480
45431
|
/**
|
|
@@ -43490,7 +45441,17 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43490
45441
|
}) : [];
|
|
43491
45442
|
const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
|
|
43492
45443
|
const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
|
|
43493
|
-
const
|
|
45444
|
+
const claimedSkillNames = new ClaimedIdentities();
|
|
45445
|
+
const claimSkillName = ({ skill, relativeDirPath, sourcePath }) => {
|
|
45446
|
+
const skillName = skill.getImportIdentity();
|
|
45447
|
+
const claimed = claimedSkillNames.claim({
|
|
45448
|
+
identity: skillName,
|
|
45449
|
+
source: relativeDirPath
|
|
45450
|
+
});
|
|
45451
|
+
if (claimed === null) return true;
|
|
45452
|
+
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.`);
|
|
45453
|
+
return false;
|
|
45454
|
+
};
|
|
43494
45455
|
const toolSkills = [];
|
|
43495
45456
|
for (const root of roots) {
|
|
43496
45457
|
const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
|
|
@@ -43506,54 +45467,60 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43506
45467
|
outputRoot: rootOutputRoot,
|
|
43507
45468
|
relativeDirPath,
|
|
43508
45469
|
dirName,
|
|
43509
|
-
|
|
45470
|
+
inputRoots: this.inputRoots
|
|
43510
45471
|
})) continue;
|
|
43511
45472
|
ownedDirNames.push(dirName);
|
|
43512
45473
|
}
|
|
43513
45474
|
const directorySkills = (await Promise.all(ownedDirNames.map(async (dirName) => {
|
|
45475
|
+
const sourcePath = join(relativeDirPath, dirName);
|
|
43514
45476
|
try {
|
|
43515
|
-
return
|
|
43516
|
-
|
|
43517
|
-
|
|
43518
|
-
|
|
43519
|
-
|
|
43520
|
-
|
|
45477
|
+
return {
|
|
45478
|
+
skill: await factory.class.fromDir({
|
|
45479
|
+
outputRoot: rootOutputRoot,
|
|
45480
|
+
relativeDirPath,
|
|
45481
|
+
dirName,
|
|
45482
|
+
global: this.global
|
|
45483
|
+
}),
|
|
45484
|
+
sourcePath
|
|
45485
|
+
};
|
|
43521
45486
|
} catch (error) {
|
|
43522
45487
|
if (!isLenientRoot) throw error;
|
|
43523
|
-
this.logger.warn(`Skipping ${
|
|
45488
|
+
this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
|
|
43524
45489
|
return null;
|
|
43525
45490
|
}
|
|
43526
|
-
}))).filter((
|
|
43527
|
-
for (const skill of directorySkills) {
|
|
43528
|
-
|
|
43529
|
-
|
|
43530
|
-
|
|
43531
|
-
|
|
43532
|
-
}
|
|
45491
|
+
}))).filter((loaded) => loaded !== null);
|
|
45492
|
+
for (const { skill, sourcePath } of directorySkills) if (claimSkillName({
|
|
45493
|
+
skill,
|
|
45494
|
+
relativeDirPath,
|
|
45495
|
+
sourcePath
|
|
45496
|
+
})) toolSkills.push(skill);
|
|
43533
45497
|
if (!factory.class.fromFlatFile) continue;
|
|
43534
45498
|
const fromFlatFile = factory.class.fromFlatFile;
|
|
43535
45499
|
const directoryStems = new Set(ownedDirNames);
|
|
43536
45500
|
const flatFilePaths = (await findFilesByGlobs(join(skillsDirPath, "*.md"), { type: "file" })).filter((filePath) => !directoryStems.has(basename(filePath, ".md")));
|
|
43537
45501
|
const flatSkills = (await Promise.all(flatFilePaths.map(async (filePath) => {
|
|
45502
|
+
const sourcePath = join(relativeDirPath, basename(filePath));
|
|
43538
45503
|
try {
|
|
43539
|
-
return
|
|
43540
|
-
|
|
43541
|
-
|
|
43542
|
-
|
|
43543
|
-
|
|
43544
|
-
|
|
45504
|
+
return {
|
|
45505
|
+
skill: await fromFlatFile({
|
|
45506
|
+
outputRoot: rootOutputRoot,
|
|
45507
|
+
relativeDirPath,
|
|
45508
|
+
relativeFilePath: basename(filePath),
|
|
45509
|
+
global: this.global
|
|
45510
|
+
}),
|
|
45511
|
+
sourcePath
|
|
45512
|
+
};
|
|
43545
45513
|
} catch (error) {
|
|
43546
45514
|
if (!isLenientRoot) throw error;
|
|
43547
|
-
this.logger.warn(`Skipping ${
|
|
45515
|
+
this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
|
|
43548
45516
|
return null;
|
|
43549
45517
|
}
|
|
43550
|
-
}))).filter((
|
|
43551
|
-
for (const skill of flatSkills) {
|
|
43552
|
-
|
|
43553
|
-
|
|
43554
|
-
|
|
43555
|
-
|
|
43556
|
-
}
|
|
45518
|
+
}))).filter((loaded) => loaded !== null);
|
|
45519
|
+
for (const { skill, sourcePath } of flatSkills) if (claimSkillName({
|
|
45520
|
+
skill,
|
|
45521
|
+
relativeDirPath,
|
|
45522
|
+
sourcePath
|
|
45523
|
+
})) toolSkills.push(skill);
|
|
43557
45524
|
}
|
|
43558
45525
|
this.logger.debug(`Successfully loaded ${toolSkills.length} skills from ${roots.length} root(s)`);
|
|
43559
45526
|
return toolSkills;
|
|
@@ -43583,7 +45550,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43583
45550
|
outputRoot: this.outputRoot,
|
|
43584
45551
|
relativeDirPath: root,
|
|
43585
45552
|
dirName,
|
|
43586
|
-
|
|
45553
|
+
inputRoots: this.inputRoots
|
|
43587
45554
|
})) continue;
|
|
43588
45555
|
const toolSkill = factory.class.forDeletion({
|
|
43589
45556
|
outputRoot: this.outputRoot,
|
|
@@ -46341,6 +48308,7 @@ const JunieSubagentFrontmatterSchema = z.looseObject({
|
|
|
46341
48308
|
disallowedTools: z.optional(z.union([z.string(), z.array(z.string())])),
|
|
46342
48309
|
mcpServers: z.optional(z.union([z.string(), z.array(z.string())])),
|
|
46343
48310
|
model: z.optional(z.string()),
|
|
48311
|
+
permissionMode: z.optional(z.string()),
|
|
46344
48312
|
reasoningLevel: z.optional(z.string()),
|
|
46345
48313
|
maxTurns: z.optional(z.number()),
|
|
46346
48314
|
skills: z.optional(z.union([z.string(), z.array(z.string())])),
|
|
@@ -47400,7 +49368,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
|
|
|
47400
49368
|
const paths = this.getSettablePaths({ global });
|
|
47401
49369
|
const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
|
|
47402
49370
|
const fileContent = await readFileContent(filePath);
|
|
47403
|
-
const { frontmatter, body: content } =
|
|
49371
|
+
const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(fileContent, filePath);
|
|
47404
49372
|
const result = ReasonixSubagentFrontmatterSchema.safeParse(frontmatter);
|
|
47405
49373
|
if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
|
|
47406
49374
|
return new ReasonixSubagent({
|
|
@@ -47429,7 +49397,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
|
|
|
47429
49397
|
static async isFileOwned({ outputRoot, relativeDirPath, relativeFilePath }) {
|
|
47430
49398
|
const filePath = join(outputRoot, relativeDirPath, relativeFilePath);
|
|
47431
49399
|
try {
|
|
47432
|
-
const { frontmatter } =
|
|
49400
|
+
const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath, { quiet: true });
|
|
47433
49401
|
return frontmatter["runAs"] === REASONIX_SUBAGENT_RUN_AS;
|
|
47434
49402
|
} catch {
|
|
47435
49403
|
return false;
|
|
@@ -48321,14 +50289,25 @@ const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((targe
|
|
|
48321
50289
|
const subagentsProcessorToolTargetsGlobal = allToolTargetKeys$1.filter((target) => {
|
|
48322
50290
|
return toolSubagentFactories.get(target)?.meta.supportsGlobal ?? false;
|
|
48323
50291
|
});
|
|
50292
|
+
/**
|
|
50293
|
+
* Stands in for a discovery root when a subagent came from a tool's own config
|
|
50294
|
+
* file rather than a directory (see `loadAdditionalImportFiles`). The angle
|
|
50295
|
+
* brackets keep it from ever matching a real relative directory path.
|
|
50296
|
+
*/
|
|
50297
|
+
const INLINE_SOURCE = "<inline>";
|
|
50298
|
+
/**
|
|
50299
|
+
* The single "root" of the post-conversion output guard, which de-duplicates
|
|
50300
|
+
* `.rulesync/subagents/` paths rather than discovery roots.
|
|
50301
|
+
*/
|
|
50302
|
+
const OUTPUT_SOURCE = "<output>";
|
|
48324
50303
|
var SubagentsProcessor = class extends FeatureProcessor {
|
|
48325
50304
|
toolTarget;
|
|
48326
50305
|
global;
|
|
48327
50306
|
getFactory;
|
|
48328
|
-
constructor({ outputRoot = process.cwd(),
|
|
50307
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
|
|
48329
50308
|
super({
|
|
48330
50309
|
outputRoot,
|
|
48331
|
-
|
|
50310
|
+
inputRoots,
|
|
48332
50311
|
dryRun,
|
|
48333
50312
|
logger
|
|
48334
50313
|
});
|
|
@@ -48374,24 +50353,31 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48374
50353
|
rulesyncSubagents.push(toolSubagent.toRulesyncSubagent());
|
|
48375
50354
|
}
|
|
48376
50355
|
const uniqueRulesyncSubagents = [];
|
|
48377
|
-
const
|
|
50356
|
+
const claimedOutputPaths = new ClaimedIdentities();
|
|
48378
50357
|
for (const rulesyncSubagent of rulesyncSubagents) {
|
|
48379
50358
|
const outputPath = join(rulesyncSubagent.getRelativeDirPath(), rulesyncSubagent.getRelativeFilePath());
|
|
48380
|
-
|
|
48381
|
-
|
|
50359
|
+
const claimed = claimedOutputPaths.claim({
|
|
50360
|
+
identity: outputPath,
|
|
50361
|
+
source: OUTPUT_SOURCE
|
|
50362
|
+
});
|
|
50363
|
+
if (claimed !== null) {
|
|
50364
|
+
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.`);
|
|
48382
50365
|
continue;
|
|
48383
50366
|
}
|
|
48384
|
-
seenOutputPaths.add(outputPath);
|
|
48385
50367
|
uniqueRulesyncSubagents.push(rulesyncSubagent);
|
|
48386
50368
|
}
|
|
48387
50369
|
return uniqueRulesyncSubagents;
|
|
48388
50370
|
}
|
|
48389
50371
|
/**
|
|
48390
|
-
*
|
|
48391
|
-
*
|
|
50372
|
+
* Load subagent files from a single source-tree's `subagents/` subtree.
|
|
50373
|
+
* `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
|
|
50374
|
+
* `/repo/.rulesync.local`).
|
|
48392
50375
|
*/
|
|
48393
|
-
async
|
|
48394
|
-
const
|
|
50376
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
50377
|
+
const treeParent = dirname(sourceTree);
|
|
50378
|
+
const treeName = basename(sourceTree);
|
|
50379
|
+
const treeSubagentsDirPath = join(treeName, SUBAGENTS_FEATURE_SUBDIR);
|
|
50380
|
+
const subagentsDir = join(sourceTree, SUBAGENTS_FEATURE_SUBDIR);
|
|
48395
50381
|
if (!await directoryExists(subagentsDir)) {
|
|
48396
50382
|
this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
|
|
48397
50383
|
return [];
|
|
@@ -48407,7 +50393,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48407
50393
|
const filepath = join(subagentsDir, mdFile);
|
|
48408
50394
|
try {
|
|
48409
50395
|
const rulesyncSubagent = await RulesyncSubagent.fromFile({
|
|
48410
|
-
outputRoot:
|
|
50396
|
+
outputRoot: treeParent,
|
|
50397
|
+
relativeDirPath: treeSubagentsDirPath,
|
|
48411
50398
|
relativeFilePath: mdFile,
|
|
48412
50399
|
validate: true
|
|
48413
50400
|
});
|
|
@@ -48418,8 +50405,24 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48418
50405
|
continue;
|
|
48419
50406
|
}
|
|
48420
50407
|
}
|
|
50408
|
+
return rulesyncSubagents;
|
|
50409
|
+
}
|
|
50410
|
+
/**
|
|
50411
|
+
* Implementation of abstract method from Processor
|
|
50412
|
+
* Load and parse rulesync subagent files from every configured input root's
|
|
50413
|
+
* `.rulesync/subagents/` directory, merging by relative file path so a
|
|
50414
|
+
* subagent with the same target path from a later root replaces the
|
|
50415
|
+
* earlier root's copy.
|
|
50416
|
+
*/
|
|
50417
|
+
async loadRulesyncFiles() {
|
|
50418
|
+
const rulesyncSubagents = mergeByCaseInsensitiveIdentity({
|
|
50419
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
50420
|
+
identity: (subagent) => subagent.getRelativeFilePath(),
|
|
50421
|
+
artifactName: "subagent",
|
|
50422
|
+
logger: this.logger
|
|
50423
|
+
});
|
|
48421
50424
|
if (rulesyncSubagents.length === 0) {
|
|
48422
|
-
this.logger.debug(`No valid subagents found
|
|
50425
|
+
this.logger.debug(`No valid subagents found`);
|
|
48423
50426
|
return [];
|
|
48424
50427
|
}
|
|
48425
50428
|
this.logger.debug(`Successfully loaded ${rulesyncSubagents.length} rulesync subagents`);
|
|
@@ -48434,7 +50437,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48434
50437
|
const paths = factory.class.getSettablePaths({ global: this.global });
|
|
48435
50438
|
const roots = forDeletion ? [paths.relativeDirPath] : [paths.relativeDirPath, ...paths.importDirPaths ?? []];
|
|
48436
50439
|
const toolSubagents = [];
|
|
48437
|
-
const
|
|
50440
|
+
const claimedRelativeFilePaths = new ClaimedIdentities();
|
|
48438
50441
|
for (const root of roots) {
|
|
48439
50442
|
const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
|
|
48440
50443
|
const dirPath = typeof root === "string" ? root : root.relativeDirPath;
|
|
@@ -48473,37 +50476,77 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
48473
50476
|
relativeFilePath: toRelativeFilePath(path),
|
|
48474
50477
|
global: this.global
|
|
48475
50478
|
})));
|
|
48476
|
-
|
|
48477
|
-
|
|
48478
|
-
|
|
48479
|
-
|
|
48480
|
-
|
|
48481
|
-
continue;
|
|
48482
|
-
}
|
|
48483
|
-
seenRelativeFilePaths.add(key);
|
|
48484
|
-
deduped.push(subagent);
|
|
48485
|
-
}
|
|
48486
|
-
toolSubagents.push(...deduped);
|
|
50479
|
+
toolSubagents.push(...this.claimStandaloneSubagents({
|
|
50480
|
+
loaded,
|
|
50481
|
+
dirPath,
|
|
50482
|
+
claimedRelativeFilePaths
|
|
50483
|
+
}));
|
|
48487
50484
|
}
|
|
48488
50485
|
if (!forDeletion && factory.class.loadAdditionalImportFiles) {
|
|
48489
50486
|
const additionalSubagents = await factory.class.loadAdditionalImportFiles({
|
|
48490
50487
|
outputRoot: this.outputRoot,
|
|
48491
50488
|
global: this.global
|
|
48492
50489
|
});
|
|
48493
|
-
|
|
48494
|
-
|
|
48495
|
-
|
|
48496
|
-
|
|
48497
|
-
continue;
|
|
48498
|
-
}
|
|
48499
|
-
seenRelativeFilePaths.add(key);
|
|
48500
|
-
toolSubagents.push(subagent);
|
|
48501
|
-
}
|
|
50490
|
+
toolSubagents.push(...this.claimInlineSubagents({
|
|
50491
|
+
additionalSubagents,
|
|
50492
|
+
claimedRelativeFilePaths
|
|
50493
|
+
}));
|
|
48502
50494
|
}
|
|
48503
50495
|
this.logger.debug(`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${roots.length} root(s)`);
|
|
48504
50496
|
return toolSubagents;
|
|
48505
50497
|
}
|
|
48506
50498
|
/**
|
|
50499
|
+
* Keeps the subagents from one discovery root whose import identity is still
|
|
50500
|
+
* unclaimed, warning about each copy that loses. Split out of
|
|
50501
|
+
* `loadToolFiles` so the two de-duplication passes stay readable side by
|
|
50502
|
+
* side (and so that method stays within the linter's complexity budget).
|
|
50503
|
+
*
|
|
50504
|
+
* When more than one discovery root is scanned (e.g. Junie's `.junie/agents/`
|
|
50505
|
+
* plus `.agents/`), two roots can hold a subagent with the same relative
|
|
50506
|
+
* path. Downstream conversion keys by that path, so a later one would
|
|
50507
|
+
* silently overwrite an earlier one. Warn instead of failing, keeping the
|
|
50508
|
+
* earlier (higher-precedence) root's file.
|
|
50509
|
+
*/
|
|
50510
|
+
claimStandaloneSubagents({ loaded, dirPath, claimedRelativeFilePaths }) {
|
|
50511
|
+
const deduped = [];
|
|
50512
|
+
for (const subagent of loaded) {
|
|
50513
|
+
const key = subagent.getImportIdentity();
|
|
50514
|
+
const claimed = claimedRelativeFilePaths.claim({
|
|
50515
|
+
identity: key,
|
|
50516
|
+
source: dirPath
|
|
50517
|
+
});
|
|
50518
|
+
if (claimed === null) {
|
|
50519
|
+
deduped.push(subagent);
|
|
50520
|
+
continue;
|
|
50521
|
+
}
|
|
50522
|
+
const keptFrom = claimed.source === dirPath ? `the earlier one in ${dirPath}` : `the one from the higher-precedence ${claimed.source}`;
|
|
50523
|
+
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.`);
|
|
50524
|
+
}
|
|
50525
|
+
return deduped;
|
|
50526
|
+
}
|
|
50527
|
+
/**
|
|
50528
|
+
* The same claim-or-warn pass for subagents defined inline in a tool's own
|
|
50529
|
+
* config file (see `loadAdditionalImportFiles`), which are scanned after
|
|
50530
|
+
* every standalone file so a Markdown file of the same name wins.
|
|
50531
|
+
*/
|
|
50532
|
+
claimInlineSubagents({ additionalSubagents, claimedRelativeFilePaths }) {
|
|
50533
|
+
const deduped = [];
|
|
50534
|
+
for (const subagent of additionalSubagents) {
|
|
50535
|
+
const key = subagent.getImportIdentity();
|
|
50536
|
+
const claimed = claimedRelativeFilePaths.claim({
|
|
50537
|
+
identity: key,
|
|
50538
|
+
source: INLINE_SOURCE
|
|
50539
|
+
});
|
|
50540
|
+
if (claimed === null) {
|
|
50541
|
+
deduped.push(subagent);
|
|
50542
|
+
continue;
|
|
50543
|
+
}
|
|
50544
|
+
const kept = claimed.source === INLINE_SOURCE ? "the earlier inline definition" : `the standalone file in ${claimed.source}`;
|
|
50545
|
+
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.`);
|
|
50546
|
+
}
|
|
50547
|
+
return deduped;
|
|
50548
|
+
}
|
|
50549
|
+
/**
|
|
48507
50550
|
* Implementation of abstract method from FeatureProcessor
|
|
48508
50551
|
* Return the tool targets that this processor supports
|
|
48509
50552
|
*/
|
|
@@ -51433,17 +53476,32 @@ var HermesagentRule = class HermesagentRule extends ToolRule {
|
|
|
51433
53476
|
* Rule generator for JetBrains Junie AI coding agent
|
|
51434
53477
|
*
|
|
51435
53478
|
* Generates `.junie/AGENTS.md` files based on rulesync rule content. Junie CLI
|
|
51436
|
-
* resolves project guidelines
|
|
51437
|
-
* `AGENTS.md`
|
|
51438
|
-
*
|
|
51439
|
-
*
|
|
51440
|
-
*
|
|
51441
|
-
*
|
|
51442
|
-
*
|
|
53479
|
+
* resolves project guidelines in this order: `.junie/AGENTS.md` → root
|
|
53480
|
+
* `AGENTS.md` **combined with `.junie/playbook.md` and every
|
|
53481
|
+
* `.junie/rules/*.md`** → legacy `.junie/guidelines.md` / `.junie/guidelines/`.
|
|
53482
|
+
* The multi-file branch exists, but it is unreachable while `.junie/AGENTS.md`
|
|
53483
|
+
* is present: that file "is used exclusively and no other guidelines files are
|
|
53484
|
+
* combined with it". So emitting `.junie/rules/*.md` next to the root file
|
|
53485
|
+
* rulesync writes would produce files Junie never reads, and moving the root
|
|
53486
|
+
* output to project-root `AGENTS.md` would both change every existing output
|
|
53487
|
+
* path and collide with the `agentsmd` target. Non-root rules therefore stay
|
|
53488
|
+
* folded into the single root `.junie/AGENTS.md` by the RulesProcessor
|
|
53489
|
+
* (`nonRoot` is `undefined`, mirroring the warp / deepagents targets) — the
|
|
53490
|
+
* fold is lossless, since Junie loads that one file in full. The original
|
|
53491
|
+
* rationale for this shape was recorded in issue #2211 and re-confirmed
|
|
53492
|
+
* against the 2026-08-21 docs revision in issue #2728. The legacy
|
|
51443
53493
|
* `.junie/guidelines.md` is still accepted as an import fallback, but
|
|
51444
53494
|
* generation always targets `.junie/AGENTS.md`. Junie uses plain markdown
|
|
51445
53495
|
* without frontmatter requirements.
|
|
51446
53496
|
*
|
|
53497
|
+
* The multi-file branch is still read on **import**, though: a repo that
|
|
53498
|
+
* authors `.junie/rules/*.md` or `.junie/playbook.md` by hand — the live
|
|
53499
|
+
* layout whenever `.junie/AGENTS.md` is absent, which is exactly the state a
|
|
53500
|
+
* first `rulesync import` finds — declares those paths as `importOnlyRoots`
|
|
53501
|
+
* and imports them as non-root rules. They are never written back there:
|
|
53502
|
+
* generating `.junie/AGENTS.md` moves Junie onto the first branch, where the
|
|
53503
|
+
* folded root file carries the same content.
|
|
53504
|
+
*
|
|
51447
53505
|
* Global (user) scope writes a single `~/.junie/AGENTS.md` file. Junie merges
|
|
51448
53506
|
* these user-scope guidelines with the project guidelines (both are included
|
|
51449
53507
|
* and marked clearly).
|
|
@@ -51465,6 +53523,14 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51465
53523
|
alternativeRoots: [{
|
|
51466
53524
|
relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
|
|
51467
53525
|
relativeFilePath: JUNIE_LEGACY_RULE_FILE_NAME
|
|
53526
|
+
}],
|
|
53527
|
+
importOnlyRoots: [{
|
|
53528
|
+
relativeDirPath: buildToolPath(JUNIE_DIR, JUNIE_RULES_DIR_NAME, excludeToolDir),
|
|
53529
|
+
onlyWhenRootAbsent: true
|
|
53530
|
+
}, {
|
|
53531
|
+
relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
|
|
53532
|
+
relativeFilePath: JUNIE_PLAYBOOK_FILE_NAME,
|
|
53533
|
+
onlyWhenRootAbsent: true
|
|
51468
53534
|
}]
|
|
51469
53535
|
};
|
|
51470
53536
|
}
|
|
@@ -51475,7 +53541,7 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51475
53541
|
static isRootRelativeFilePath(relativeFilePath) {
|
|
51476
53542
|
return relativeFilePath === "AGENTS.md" || relativeFilePath === "guidelines.md";
|
|
51477
53543
|
}
|
|
51478
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
|
|
53544
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath: relativeDirPathParam, relativeFilePath, validate = true, global = false }) {
|
|
51479
53545
|
if (global) {
|
|
51480
53546
|
const paths = this.getSettablePaths({ global: true });
|
|
51481
53547
|
if (!("root" in paths) || !paths.root) throw new Error("JunieRule global settable paths must include a root path");
|
|
@@ -51489,7 +53555,8 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51489
53555
|
root: true
|
|
51490
53556
|
});
|
|
51491
53557
|
}
|
|
51492
|
-
const
|
|
53558
|
+
const settablePaths = this.getSettablePaths();
|
|
53559
|
+
const relativeDirPath = relativeDirPathParam ?? settablePaths.root.relativeDirPath;
|
|
51493
53560
|
const relativePath = join(relativeDirPath, relativeFilePath);
|
|
51494
53561
|
const fileContent = await readFileContent(join(outputRoot, relativePath));
|
|
51495
53562
|
return new JunieRule({
|
|
@@ -51498,7 +53565,7 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51498
53565
|
relativeFilePath,
|
|
51499
53566
|
fileContent,
|
|
51500
53567
|
validate,
|
|
51501
|
-
root: JunieRule.isRootRelativeFilePath(relativeFilePath)
|
|
53568
|
+
root: relativeDirPath === settablePaths.root.relativeDirPath && JunieRule.isRootRelativeFilePath(relativeFilePath)
|
|
51502
53569
|
});
|
|
51503
53570
|
}
|
|
51504
53571
|
static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
|
|
@@ -53850,10 +55917,23 @@ const defaultGetFactory = (target) => {
|
|
|
53850
55917
|
if (!factory) throw new Error(`Unsupported tool target: ${target}`);
|
|
53851
55918
|
return factory;
|
|
53852
55919
|
};
|
|
53853
|
-
|
|
53854
|
-
|
|
55920
|
+
/**
|
|
55921
|
+
* How many skipped import-only paths a single warning names before it
|
|
55922
|
+
* summarizes the rest. Keeps one line readable when a rules directory holds
|
|
55923
|
+
* dozens of files.
|
|
55924
|
+
*/
|
|
55925
|
+
const MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS = 10;
|
|
55926
|
+
/**
|
|
55927
|
+
* Fall back to a tool's legacy roots when its primary root file is absent.
|
|
55928
|
+
*
|
|
55929
|
+
* The primary hits are passed in rather than globbed here, so that callers
|
|
55930
|
+
* which need "the root file Rulesync generates" — rather than "whatever root
|
|
55931
|
+
* the tool will read" — can keep the two apart. A legacy root is a file
|
|
55932
|
+
* Rulesync reads but never writes, and the difference matters to them.
|
|
55933
|
+
*/
|
|
55934
|
+
const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob) => {
|
|
53855
55935
|
if (primaryFilePaths.length > 0) return primaryFilePaths;
|
|
53856
|
-
if (alternativeRoots) return findFilesByGlobs(alternativeRoots.map(buildAltGlob));
|
|
55936
|
+
if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
|
|
53857
55937
|
return [];
|
|
53858
55938
|
};
|
|
53859
55939
|
var RulesProcessor = class extends FeatureProcessor {
|
|
@@ -53865,10 +55945,10 @@ var RulesProcessor = class extends FeatureProcessor {
|
|
|
53865
55945
|
getFactory;
|
|
53866
55946
|
skills;
|
|
53867
55947
|
featureOptions;
|
|
53868
|
-
constructor({ outputRoot = process.cwd(),
|
|
55948
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
|
|
53869
55949
|
super({
|
|
53870
55950
|
outputRoot,
|
|
53871
|
-
|
|
55951
|
+
inputRoots,
|
|
53872
55952
|
dryRun,
|
|
53873
55953
|
logger
|
|
53874
55954
|
});
|
|
@@ -54243,25 +56323,45 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54243
56323
|
claimedBy.set(target.toLowerCase(), source);
|
|
54244
56324
|
continue;
|
|
54245
56325
|
}
|
|
54246
|
-
this.logger.warn(`Both ${previous} and ${source} import to ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, target)} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
|
|
56326
|
+
this.logger.warn(`Both ${stripControlCharacters(previous)} and ${stripControlCharacters(source)} import to ${stripControlCharacters(join(RULESYNC_RULES_RELATIVE_DIR_PATH, target))} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
|
|
54247
56327
|
}
|
|
54248
56328
|
return rulesyncRules;
|
|
54249
56329
|
}
|
|
54250
56330
|
/**
|
|
54251
|
-
*
|
|
54252
|
-
*
|
|
56331
|
+
* Load rulesync rule files from a single source-tree's `rules/` (and
|
|
56332
|
+
* `rules/.curated/`) subtree. `sourceTree` is the source tree itself
|
|
56333
|
+
* (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`), NOT its parent.
|
|
56334
|
+
*
|
|
56335
|
+
* Intra-tree behavior — the local-wins-over-curated rule and the
|
|
56336
|
+
* case-insensitive collision handling — is preserved from the previous
|
|
56337
|
+
* single-root implementation. See `loadRulesyncFiles` for how the
|
|
56338
|
+
* per-root results are combined into the effective set.
|
|
54253
56339
|
*/
|
|
54254
|
-
async
|
|
54255
|
-
const
|
|
54256
|
-
const
|
|
56340
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
56341
|
+
const treeParent = dirname(sourceTree);
|
|
56342
|
+
const treeName = basename(sourceTree);
|
|
56343
|
+
const treeRulesDirPath = join(treeName, RULES_FEATURE_SUBDIR);
|
|
56344
|
+
const rulesyncOutputRoot = join(sourceTree, RULES_FEATURE_SUBDIR);
|
|
56345
|
+
const curatedOutputRoot = join(sourceTree, CURATED_RULES_FEATURE_SUBDIR);
|
|
54257
56346
|
const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([findFilesByGlobs(join(rulesyncOutputRoot, "**", "*.md")), findFilesByGlobs(join(curatedOutputRoot, "**", "*.md"))]);
|
|
54258
56347
|
const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
|
|
54259
56348
|
const localFiles = files.filter((file) => !relative(rulesyncOutputRoot, file).startsWith(`.curated${sep}`));
|
|
54260
|
-
const
|
|
56349
|
+
const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => relative(rulesyncOutputRoot, file)));
|
|
54261
56350
|
const curatedFiles = files.filter((file) => relative(rulesyncOutputRoot, file).startsWith(`.curated${sep}`)).map((file) => ({
|
|
54262
56351
|
file,
|
|
54263
56352
|
relativeFilePath: relative(curatedOutputRoot, file)
|
|
54264
|
-
})).filter(({ relativeFilePath }) =>
|
|
56353
|
+
})).filter(({ relativeFilePath }) => {
|
|
56354
|
+
const spellings = localRelativePathsByIdentity.get(caseFoldIdentity(relativeFilePath));
|
|
56355
|
+
if (spellings === void 0) return true;
|
|
56356
|
+
if (!spellings.includes(relativeFilePath)) this.logger.warn(formatCuratedCaseCollisionWarning({
|
|
56357
|
+
artifactKind: "rule",
|
|
56358
|
+
entryNoun: "file",
|
|
56359
|
+
treeDirPath: treeRulesDirPath,
|
|
56360
|
+
curatedSpelling: join(".curated", relativeFilePath),
|
|
56361
|
+
localSpellings: spellings
|
|
56362
|
+
}));
|
|
56363
|
+
return false;
|
|
56364
|
+
});
|
|
54265
56365
|
const selectedFiles = [...localFiles.map((file) => ({
|
|
54266
56366
|
file,
|
|
54267
56367
|
sourceRelativeFilePath: relative(rulesyncOutputRoot, file),
|
|
@@ -54271,25 +56371,41 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54271
56371
|
sourceRelativeFilePath: join(".curated", relativeFilePath),
|
|
54272
56372
|
relativeFilePath
|
|
54273
56373
|
}))];
|
|
54274
|
-
this.logger.debug(`Found ${selectedFiles.length} rulesync files`);
|
|
54275
|
-
|
|
56374
|
+
this.logger.debug(`Found ${selectedFiles.length} rulesync files under ${rulesyncOutputRoot}`);
|
|
56375
|
+
return await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
|
|
54276
56376
|
checkPathTraversal({
|
|
54277
56377
|
relativePath: sourceRelativeFilePath,
|
|
54278
56378
|
intendedRootDir: rulesyncOutputRoot
|
|
54279
56379
|
});
|
|
54280
56380
|
const rule = await RulesyncRule.fromFile({
|
|
54281
|
-
outputRoot:
|
|
56381
|
+
outputRoot: treeParent,
|
|
56382
|
+
relativeDirPath: treeRulesDirPath,
|
|
54282
56383
|
relativeFilePath: sourceRelativeFilePath
|
|
54283
56384
|
});
|
|
54284
56385
|
if (sourceRelativeFilePath === relativeFilePath) return rule;
|
|
54285
56386
|
return new RulesyncRule({
|
|
54286
|
-
outputRoot:
|
|
54287
|
-
relativeDirPath:
|
|
56387
|
+
outputRoot: treeParent,
|
|
56388
|
+
relativeDirPath: treeRulesDirPath,
|
|
54288
56389
|
relativeFilePath,
|
|
54289
56390
|
frontmatter: rule.getFrontmatter(),
|
|
54290
56391
|
body: rule.getBody()
|
|
54291
56392
|
});
|
|
54292
56393
|
}));
|
|
56394
|
+
}
|
|
56395
|
+
/**
|
|
56396
|
+
* Implementation of abstract method from FeatureProcessor
|
|
56397
|
+
* Load and parse rulesync rule files from every configured input root's
|
|
56398
|
+
* `.rulesync/rules/` directory, merging by relative path so that a rule
|
|
56399
|
+
* with the same target path from a later root replaces the earlier root's
|
|
56400
|
+
* copy (case-insensitive, matching the intra-root collision handling).
|
|
56401
|
+
*/
|
|
56402
|
+
async loadRulesyncFiles() {
|
|
56403
|
+
const rulesyncRules = mergeByCaseInsensitiveIdentity({
|
|
56404
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
56405
|
+
identity: (rule) => rule.getRelativeFilePath(),
|
|
56406
|
+
artifactName: "rule",
|
|
56407
|
+
logger: this.logger
|
|
56408
|
+
});
|
|
54293
56409
|
const factory = this.getFactory(this.toolTarget);
|
|
54294
56410
|
const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
|
|
54295
56411
|
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}.`);
|
|
@@ -54383,23 +56499,42 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54383
56499
|
});
|
|
54384
56500
|
}).filter((rule) => rule.isDeletable());
|
|
54385
56501
|
};
|
|
56502
|
+
/**
|
|
56503
|
+
* Import counterpart of {@link buildDeletionRulesFromPaths} for the
|
|
56504
|
+
* root-shaped scans (root, legacy roots, and read-only roots), whose
|
|
56505
|
+
* paths all sit at a directory Rulesync resolves from the file itself.
|
|
56506
|
+
*/
|
|
56507
|
+
const buildImportRulesFromPaths = (filePaths) => Promise.all(filePaths.map((filePath) => {
|
|
56508
|
+
const relativeDirPath = resolveRelativeDirPath(filePath);
|
|
56509
|
+
checkPathTraversal({
|
|
56510
|
+
relativePath: relativeDirPath,
|
|
56511
|
+
intendedRootDir: this.outputRoot
|
|
56512
|
+
});
|
|
56513
|
+
return factory.class.fromFile({
|
|
56514
|
+
outputRoot: this.outputRoot,
|
|
56515
|
+
relativeDirPath,
|
|
56516
|
+
relativeFilePath: basename(filePath),
|
|
56517
|
+
global: this.global
|
|
56518
|
+
});
|
|
56519
|
+
}));
|
|
56520
|
+
/**
|
|
56521
|
+
* The tool's own root file, as opposed to whichever root
|
|
56522
|
+
* `rootToolRules` ends up resolving. A legacy root reached through
|
|
56523
|
+
* `alternativeRoots` is deliberately not counted here: it is a
|
|
56524
|
+
* hand-authored file Rulesync never writes, so it has folded nothing in,
|
|
56525
|
+
* and in Junie's resolution order it ranks *below* the multi-file layout
|
|
56526
|
+
* that `importOnlyRoots` describes. Gating those roots on it would drop
|
|
56527
|
+
* exactly the files the tool is really reading.
|
|
56528
|
+
*
|
|
56529
|
+
* Resolved once, up front, so the two blocks that need it do not depend
|
|
56530
|
+
* on each other's evaluation order.
|
|
56531
|
+
*/
|
|
56532
|
+
const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath)) : [];
|
|
54386
56533
|
const rootToolRules = await (async () => {
|
|
54387
56534
|
if (!settablePaths.root) return [];
|
|
54388
|
-
const uniqueRootFilePaths = await findFilesWithFallback(
|
|
56535
|
+
const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
|
|
54389
56536
|
if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
|
|
54390
|
-
return await
|
|
54391
|
-
const relativeDirPath = resolveRelativeDirPath(filePath);
|
|
54392
|
-
checkPathTraversal({
|
|
54393
|
-
relativePath: relativeDirPath,
|
|
54394
|
-
intendedRootDir: this.outputRoot
|
|
54395
|
-
});
|
|
54396
|
-
return factory.class.fromFile({
|
|
54397
|
-
outputRoot: this.outputRoot,
|
|
54398
|
-
relativeFilePath: basename(filePath),
|
|
54399
|
-
relativeDirPath,
|
|
54400
|
-
global: this.global
|
|
54401
|
-
});
|
|
54402
|
-
}));
|
|
56537
|
+
return await buildImportRulesFromPaths(uniqueRootFilePaths);
|
|
54403
56538
|
})();
|
|
54404
56539
|
this.logger.debug(`Found ${rootToolRules.length} root tool rule files`);
|
|
54405
56540
|
const localRootToolRules = await (async () => {
|
|
@@ -54411,7 +56546,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54411
56546
|
fileName
|
|
54412
56547
|
}));
|
|
54413
56548
|
if (!settablePaths.root) return [];
|
|
54414
|
-
return await findFilesWithFallback(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName), settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, fileName));
|
|
56549
|
+
return await findFilesWithFallback(await findFilesByGlobs(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName)), settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, fileName));
|
|
54415
56550
|
})();
|
|
54416
56551
|
if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
|
|
54417
56552
|
return (await Promise.all(filePaths.map(async (filePath) => {
|
|
@@ -54487,6 +56622,29 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54487
56622
|
}));
|
|
54488
56623
|
})();
|
|
54489
56624
|
this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`);
|
|
56625
|
+
const importOnlyToolRules = await (async () => {
|
|
56626
|
+
const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
|
|
56627
|
+
if (forDeletion || !importOnlyRoots || importOnlyRoots.length === 0) return [];
|
|
56628
|
+
const rootFilePath = primaryRootFilePaths[0];
|
|
56629
|
+
const scannedPaths = [];
|
|
56630
|
+
const skippedPaths = [];
|
|
56631
|
+
for (const importOnlyRoot of importOnlyRoots) {
|
|
56632
|
+
const matchedPaths = await findFilesByGlobs(join(this.outputRoot, importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), { type: "file" });
|
|
56633
|
+
if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
|
|
56634
|
+
skippedPaths.push(...matchedPaths);
|
|
56635
|
+
continue;
|
|
56636
|
+
}
|
|
56637
|
+
scannedPaths.push(...matchedPaths);
|
|
56638
|
+
}
|
|
56639
|
+
if (skippedPaths.length > 0 && rootFilePath !== void 0) {
|
|
56640
|
+
const skippedNames = skippedPaths.map((filePath) => stripControlCharacters(relative(this.outputRoot, filePath)));
|
|
56641
|
+
const listedNames = skippedNames.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
|
|
56642
|
+
const remainingCount = skippedNames.length - listedNames.length;
|
|
56643
|
+
this.logger.warn(`Not importing ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} for ${this.toolTarget}: ${stripControlCharacters(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.`);
|
|
56644
|
+
}
|
|
56645
|
+
return await buildImportRulesFromPaths(scannedPaths);
|
|
56646
|
+
})();
|
|
56647
|
+
this.logger.debug(`Found ${importOnlyToolRules.length} import-only tool rule files`);
|
|
54490
56648
|
const nonRootToolRules = await (async () => {
|
|
54491
56649
|
if (!settablePaths.nonRoot) return [];
|
|
54492
56650
|
const nonRootOutputRoot = join(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
|
|
@@ -54519,6 +56677,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54519
56677
|
})();
|
|
54520
56678
|
this.logger.debug(`Found ${nonRootToolRules.length} non-root tool rule files`);
|
|
54521
56679
|
return [
|
|
56680
|
+
...importOnlyToolRules,
|
|
54522
56681
|
...rootToolRules,
|
|
54523
56682
|
...localRootToolRules,
|
|
54524
56683
|
...rootMirrorDeletionRules,
|
|
@@ -54632,6 +56791,7 @@ function resolveToolOutputRoot({ outputRoot, toolTarget, global }) {
|
|
|
54632
56791
|
* `.rulesync/` files to disk. Rulesync file instances live in memory only.
|
|
54633
56792
|
*/
|
|
54634
56793
|
async function convertFromTool(params) {
|
|
56794
|
+
resetWarnedOnceMessages();
|
|
54635
56795
|
const packagingTarget = [params.fromTool, ...params.toTools].find(isPackagingToolTarget);
|
|
54636
56796
|
if (packagingTarget) throw new Error(`Plugin packaging target '${packagingTarget}' is not supported by convert. Use import and generate with explicit plugin directories.`);
|
|
54637
56797
|
const ctx = params;
|
|
@@ -55326,14 +57486,53 @@ function warnUnsupportedTargets(params) {
|
|
|
55326
57486
|
}
|
|
55327
57487
|
}
|
|
55328
57488
|
/**
|
|
55329
|
-
*
|
|
55330
|
-
*
|
|
55331
|
-
*
|
|
55332
|
-
*
|
|
55333
|
-
*
|
|
57489
|
+
* Inspect every configured input-root path. The first entry is the required
|
|
57490
|
+
* base source tree; later entries are optional overlays and may be absent.
|
|
57491
|
+
* Each existing entry is a rulesync source tree itself (the directory that
|
|
57492
|
+
* directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). Existing empty
|
|
57493
|
+
* directories are valid because delete and check workflows still need to
|
|
57494
|
+
* inspect generated outputs.
|
|
55334
57495
|
*/
|
|
55335
|
-
async function
|
|
55336
|
-
|
|
57496
|
+
async function inspectInputRoots(inputRoots) {
|
|
57497
|
+
const existing = [];
|
|
57498
|
+
const missing = [];
|
|
57499
|
+
const invalidOverlays = [];
|
|
57500
|
+
const nonDirectories = /* @__PURE__ */ new Set();
|
|
57501
|
+
for (const [index, root] of inputRoots.entries()) if (await directoryExists(root)) existing.push(root);
|
|
57502
|
+
else {
|
|
57503
|
+
missing.push(root);
|
|
57504
|
+
if (await fileExists(root)) {
|
|
57505
|
+
nonDirectories.add(root);
|
|
57506
|
+
if (index > 0) invalidOverlays.push(root);
|
|
57507
|
+
}
|
|
57508
|
+
}
|
|
57509
|
+
const primaryRoot = inputRoots[0];
|
|
57510
|
+
const displayPrimaryRoot = stripControlCharacters(primaryRoot ?? "");
|
|
57511
|
+
if (primaryRoot === void 0 || existing.includes(primaryRoot)) {
|
|
57512
|
+
const invalidOverlay = invalidOverlays[0];
|
|
57513
|
+
return {
|
|
57514
|
+
existing,
|
|
57515
|
+
missing,
|
|
57516
|
+
message: invalidOverlay === void 0 ? void 0 : `Configured optional input root '${stripControlCharacters(invalidOverlay)}' exists but is not a directory.`
|
|
57517
|
+
};
|
|
57518
|
+
}
|
|
57519
|
+
const defaultRoot = join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH);
|
|
57520
|
+
if (primaryRoot === defaultRoot && !nonDirectories.has(primaryRoot)) return {
|
|
57521
|
+
existing,
|
|
57522
|
+
missing,
|
|
57523
|
+
message: `Rulesync source directory '${defaultRoot}' does not exist. Run 'rulesync init' first.`
|
|
57524
|
+
};
|
|
57525
|
+
const settingHint = `your input root setting ('inputRoots', or the deprecated 'inputRoot')`;
|
|
57526
|
+
if (nonDirectories.has(primaryRoot)) return {
|
|
57527
|
+
existing,
|
|
57528
|
+
missing,
|
|
57529
|
+
message: `Configured primary input root '${displayPrimaryRoot}' exists but is not a directory. Point ${settingHint} at a directory.`
|
|
57530
|
+
};
|
|
57531
|
+
return {
|
|
57532
|
+
existing,
|
|
57533
|
+
missing,
|
|
57534
|
+
message: `Configured primary input root '${displayPrimaryRoot}' does not exist. Create the directory or update ${settingHint}.`
|
|
57535
|
+
};
|
|
55337
57536
|
}
|
|
55338
57537
|
function dependsOnReachable(byId, from, target) {
|
|
55339
57538
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -55464,7 +57663,7 @@ async function warnSkillSubagentNameCollisions(params) {
|
|
|
55464
57663
|
const subagentsDirPath = subagentFactory.class.getSettablePaths({ global }).relativeDirPath;
|
|
55465
57664
|
if (subagentsDirPath !== skillFactory.class.getSettablePaths({ global }).relativeDirPath) continue;
|
|
55466
57665
|
const subagentsProcessor = new SubagentsProcessor({
|
|
55467
|
-
|
|
57666
|
+
inputRoots: config.getInputRoots(),
|
|
55468
57667
|
toolTarget,
|
|
55469
57668
|
global,
|
|
55470
57669
|
logger
|
|
@@ -55472,7 +57671,7 @@ async function warnSkillSubagentNameCollisions(params) {
|
|
|
55472
57671
|
const subagentNames = new Set((await subagentsProcessor.loadRulesyncFiles()).filter((file) => file instanceof RulesyncSubagent).filter((file) => subagentFactory.class.isTargetedByRulesyncSubagent(file)).map((file) => basename(file.getRelativeFilePath(), extname(file.getRelativeFilePath()))));
|
|
55473
57672
|
if (subagentNames.size === 0) continue;
|
|
55474
57673
|
const skillNames = (await new SkillsProcessor({
|
|
55475
|
-
|
|
57674
|
+
inputRoots: config.getInputRoots(),
|
|
55476
57675
|
toolTarget,
|
|
55477
57676
|
global,
|
|
55478
57677
|
logger
|
|
@@ -55521,6 +57720,8 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
|
|
|
55521
57720
|
*/
|
|
55522
57721
|
async function generate(params) {
|
|
55523
57722
|
const { config, logger } = params;
|
|
57723
|
+
resetRootShadowingWarnings({ logger });
|
|
57724
|
+
resetWarnedOnceMessages();
|
|
55524
57725
|
for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
|
|
55525
57726
|
toolTarget,
|
|
55526
57727
|
outputRoot
|
|
@@ -55659,7 +57860,7 @@ async function generateRulesCore(params) {
|
|
|
55659
57860
|
toolTarget,
|
|
55660
57861
|
global: config.getGlobal()
|
|
55661
57862
|
}),
|
|
55662
|
-
|
|
57863
|
+
inputRoots: config.getInputRoots(),
|
|
55663
57864
|
toolTarget,
|
|
55664
57865
|
global: config.getGlobal(),
|
|
55665
57866
|
simulateCommands: config.getSimulateCommands(),
|
|
@@ -55709,7 +57910,7 @@ async function generateIgnoreCore(params) {
|
|
|
55709
57910
|
for (const outputRoot of config.getOutputRoots(toolTarget)) try {
|
|
55710
57911
|
const processor = new IgnoreProcessor({
|
|
55711
57912
|
outputRoot,
|
|
55712
|
-
|
|
57913
|
+
inputRoots: config.getInputRoots(),
|
|
55713
57914
|
toolTarget,
|
|
55714
57915
|
global,
|
|
55715
57916
|
dryRun: config.isPreviewMode(),
|
|
@@ -55756,7 +57957,7 @@ async function generateMcpCore(params) {
|
|
|
55756
57957
|
toolTarget,
|
|
55757
57958
|
global: config.getGlobal()
|
|
55758
57959
|
}),
|
|
55759
|
-
|
|
57960
|
+
inputRoots: config.getInputRoots(),
|
|
55760
57961
|
toolTarget,
|
|
55761
57962
|
global: config.getGlobal(),
|
|
55762
57963
|
dryRun: config.isPreviewMode(),
|
|
@@ -55802,7 +58003,7 @@ async function generateCommandsCore(params) {
|
|
|
55802
58003
|
toolTarget,
|
|
55803
58004
|
global: config.getGlobal()
|
|
55804
58005
|
}),
|
|
55805
|
-
|
|
58006
|
+
inputRoots: config.getInputRoots(),
|
|
55806
58007
|
toolTarget,
|
|
55807
58008
|
global: config.getGlobal(),
|
|
55808
58009
|
dryRun: config.isPreviewMode(),
|
|
@@ -55849,7 +58050,7 @@ async function generateSubagentsCore(params) {
|
|
|
55849
58050
|
toolTarget,
|
|
55850
58051
|
global: config.getGlobal()
|
|
55851
58052
|
}),
|
|
55852
|
-
|
|
58053
|
+
inputRoots: config.getInputRoots(),
|
|
55853
58054
|
toolTarget,
|
|
55854
58055
|
global: config.getGlobal(),
|
|
55855
58056
|
dryRun: config.isPreviewMode(),
|
|
@@ -55896,7 +58097,7 @@ async function generateSkillsCore(params) {
|
|
|
55896
58097
|
toolTarget,
|
|
55897
58098
|
global: config.getGlobal()
|
|
55898
58099
|
}),
|
|
55899
|
-
|
|
58100
|
+
inputRoots: config.getInputRoots(),
|
|
55900
58101
|
toolTarget,
|
|
55901
58102
|
global: config.getGlobal(),
|
|
55902
58103
|
dryRun: config.isPreviewMode(),
|
|
@@ -55941,7 +58142,7 @@ async function generateHooksCore(params) {
|
|
|
55941
58142
|
toolTarget,
|
|
55942
58143
|
global: config.getGlobal()
|
|
55943
58144
|
}),
|
|
55944
|
-
|
|
58145
|
+
inputRoots: config.getInputRoots(),
|
|
55945
58146
|
toolTarget,
|
|
55946
58147
|
global: config.getGlobal(),
|
|
55947
58148
|
dryRun: config.isPreviewMode(),
|
|
@@ -55983,7 +58184,7 @@ async function generatePermissionsCore(params) {
|
|
|
55983
58184
|
toolTarget,
|
|
55984
58185
|
global: config.getGlobal()
|
|
55985
58186
|
}),
|
|
55986
|
-
|
|
58187
|
+
inputRoots: config.getInputRoots(),
|
|
55987
58188
|
toolTarget,
|
|
55988
58189
|
global: config.getGlobal(),
|
|
55989
58190
|
dryRun: config.isPreviewMode(),
|
|
@@ -56029,7 +58230,7 @@ async function generateChecksCore(params) {
|
|
|
56029
58230
|
toolTarget,
|
|
56030
58231
|
global: config.getGlobal()
|
|
56031
58232
|
}),
|
|
56032
|
-
|
|
58233
|
+
inputRoots: config.getInputRoots(),
|
|
56033
58234
|
toolTarget,
|
|
56034
58235
|
global: config.getGlobal(),
|
|
56035
58236
|
dryRun: config.isPreviewMode(),
|
|
@@ -56080,6 +58281,7 @@ function getToolOutputRoot({ config, tool }) {
|
|
|
56080
58281
|
*/
|
|
56081
58282
|
async function importFromTool(params) {
|
|
56082
58283
|
const { config, tool, logger } = params;
|
|
58284
|
+
resetWarnedOnceMessages();
|
|
56083
58285
|
await assertPluginRootSafe({
|
|
56084
58286
|
toolTarget: tool,
|
|
56085
58287
|
outputRoot: getToolOutputRoot({
|
|
@@ -56401,6 +58603,6 @@ async function importChecksCore(params) {
|
|
|
56401
58603
|
return writtenCount;
|
|
56402
58604
|
}
|
|
56403
58605
|
//#endregion
|
|
56404
|
-
export {
|
|
58606
|
+
export { SourceEntrySchema as $, RULESYNC_MCP_LEGACY_FILE_NAME as $t, RulesyncRuleFrontmatterSchema as A, runWithDirectoryRollback as At, RulesyncCheck as B, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Bt, CODEXCLI_DIR as C, readFileContentOrNull as Ct, RulesyncSkill as D, removeFileStrict as Dt, RulesyncSubagentFrontmatterSchema as E, removeFile as Et, getRulesyncSourceCandidates as F, ALL_TOOL_TARGETS_WITH_WILDCARD as Ft, SHARED_USER_MANAGED_CONFIG_PATHS as G, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Gt, stringifyFrontmatter as H, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Ht, resolveRulesyncSourceWritePath as I, PACKAGING_TOOL_TARGETS as It, mergeInputRootConfigs as J, RULESYNC_HOOKS_LEGACY_FILE_NAME as Jt, SKILL_FILE_NAME as K, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Kt, parseJsonc as L, ToolTargetSchema as Lt, RulesyncMcp as M, writeFileBuffer as Mt, RulesyncIgnore as N, writeFileContent as Nt, RulesyncSkillFrontmatterSchema as O, removeTempDirectory as Ot, RulesyncHooks as P, ALL_TOOL_TARGETS as Pt, GITIGNORE_DESTINATION_KEY as Q, RULESYNC_MCP_FILE_NAME as Qt, RulesyncCommand as R, MAX_FILE_SIZE as Rt, CODEXCLI_BASH_RULES_FILE_NAME as S, readFileContent as St, RulesyncSubagent as T, removeDirectoryStrict as Tt, loadYaml as U, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Ut, RulesyncCheckFrontmatterSchema as V, RULESYNC_CHECKS_RELATIVE_DIR_PATH as Vt, stripControlCharacters as W, RULESYNC_CONFIG_SCHEMA_URL as Wt, CONFLICTING_TARGET_PAIRS as X, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Xt, resolveEffectiveInputRoots as Y, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Yt, ConfigFileSchema as Z, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Zt, CLAUDECODE_LOCAL_RULE_FILE_NAME as _, getFileSize as _t, convertFromTool as a, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as an, resetWarnedOnceMessages as at, CLAUDECODE_SKILLS_DIR_PATH as b, listDirectoryFiles as bt, SubagentsProcessor as c, RULESYNC_RULES_RELATIVE_DIR_PATH as cn, assertDirectoryIfExists as ct, IgnoreProcessor as d, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as dn, checkPathTraversal as dt, RULESYNC_MCP_RELATIVE_FILE_PATH as en, findControlCharacter as et, HooksProcessor as f, ALL_FEATURES as fn, createTempDirectory as ft, CLAUDECODE_DIR as g, findFilesByGlobs as gt, QWENCODE_LOCAL_RULE_FILE_NAME as h, formatError as hn, fileExists as ht, getProcessorRegistryEntry as i, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as in, warnOnConflictingFlags as it, RulesyncPermissions as j, toPosixPath as jt, RulesyncRule as k, resolvePath as kt, SkillsProcessor as l, RULESYNC_SKILLS_RELATIVE_DIR_PATH as ln, assertTreeContainsNoSymlinks as lt, QWENCODE_DIR as m, DEPRECATED_FEATURE_REPLACEMENTS as mn, ensureDir as mt, generate as n, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as nn, JsonLogger as nt, isPackagingToolTarget as o, RULESYNC_PERMISSIONS_SCHEMA_URL as on, CLIError as ot, CommandsProcessor as p, ALL_FEATURES_WITH_WILDCARD as pn, directoryExists as pt, ConfigResolver as q, RULESYNC_HOOKS_FILE_NAME as qt, inspectInputRoots as r, RULESYNC_PERMISSIONS_FILE_NAME as rn, fallbackLogger as rt, RulesProcessor as s, RULESYNC_RELATIVE_DIR_PATH as sn, ErrorCodes as st, importFromTool as t, RULESYNC_MCP_SCHEMA_URL as tn, ConsoleLogger as tt, McpProcessor as u, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as un, assertWritablePathInsideRoot as ut, CLAUDECODE_MEMORIES_DIR_NAME as v, getHomeDirectory as vt, getLocalSkillDirNames as w, removeDirectory as wt, ChecksProcessor as x, pathEscapesRoot as xt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as y, isSymlink as yt, RulesyncCommandFrontmatterSchema as z, RULESYNC_AIIGNORE_FILE_NAME as zt };
|
|
56405
58607
|
|
|
56406
|
-
//# sourceMappingURL=import-
|
|
58608
|
+
//# sourceMappingURL=import-u8yswsGB.js.map
|