rulesync 16.14.0 → 16.16.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 +206 -70
- package/dist/cli/index.js +204 -68
- package/dist/cli/index.js.map +1 -1
- package/dist/{import-Ds0QeVp6.js → import-DimKwtQ6.js} +2115 -344
- package/dist/import-DimKwtQ6.js.map +1 -0
- package/dist/{import-DsiSzsJH.cjs → import-NiPCjH6E.cjs} +2145 -356
- package/dist/index.cjs +4 -3
- package/dist/index.d.cts +26 -4
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +26 -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 +15 -15
- package/dist/import-Ds0QeVp6.js.map +0 -1
|
@@ -134,27 +134,34 @@ const isFeatureValueEnabled = (value) => {
|
|
|
134
134
|
};
|
|
135
135
|
//#endregion
|
|
136
136
|
//#region src/constants/rulesync-paths.ts
|
|
137
|
-
const { join: join$
|
|
137
|
+
const { join: join$291 } = node_path.posix;
|
|
138
138
|
const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
|
|
139
139
|
const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
|
|
140
140
|
const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
join$
|
|
150
|
-
const
|
|
151
|
-
const
|
|
141
|
+
const RULES_FEATURE_SUBDIR = "rules";
|
|
142
|
+
const CURATED_RULES_FEATURE_SUBDIR = join$291(RULES_FEATURE_SUBDIR, ".curated");
|
|
143
|
+
const COMMANDS_FEATURE_SUBDIR = "commands";
|
|
144
|
+
const SUBAGENTS_FEATURE_SUBDIR = "subagents";
|
|
145
|
+
const CHECKS_FEATURE_SUBDIR = "checks";
|
|
146
|
+
const SKILLS_FEATURE_SUBDIR = "skills";
|
|
147
|
+
const CURATED_SKILLS_FEATURE_SUBDIR = join$291(SKILLS_FEATURE_SUBDIR, ".curated");
|
|
148
|
+
const RULESYNC_RULES_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
|
|
149
|
+
const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
|
|
150
|
+
const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
|
|
151
|
+
const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
|
|
152
|
+
const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
|
|
153
|
+
const RULESYNC_MCP_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
|
|
154
|
+
const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
|
|
155
|
+
const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
|
|
156
|
+
join$291(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
|
|
157
|
+
const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
|
|
158
|
+
const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
|
|
152
159
|
const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
|
|
153
|
-
const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$
|
|
160
|
+
const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
|
|
154
161
|
const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
|
|
155
162
|
const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
|
|
156
|
-
const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$
|
|
157
|
-
const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$
|
|
163
|
+
const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
|
|
164
|
+
const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
|
|
158
165
|
const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
|
|
159
166
|
const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
|
|
160
167
|
const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
|
|
@@ -1142,6 +1149,7 @@ const ConfigParamsSchema = zod_mini.z.object({
|
|
|
1142
1149
|
dryRun: (0, zod_mini.optional)(zod_mini.z.boolean()),
|
|
1143
1150
|
check: (0, zod_mini.optional)(zod_mini.z.boolean()),
|
|
1144
1151
|
inputRoot: (0, zod_mini.optional)(zod_mini.z.string()),
|
|
1152
|
+
inputRoots: (0, zod_mini.optional)(zod_mini.z.array(zod_mini.z.string()).check((0, zod_mini.minLength)(1, "inputRoots must be non-empty"))),
|
|
1145
1153
|
sources: (0, zod_mini.optional)(zod_mini.z.array(SourceEntrySchema))
|
|
1146
1154
|
});
|
|
1147
1155
|
zod_mini.z.partial(ConfigParamsSchema);
|
|
@@ -1154,14 +1162,39 @@ zod_mini.z.required(ConfigParamsSchema);
|
|
|
1154
1162
|
* Normalizes the configuration file location to an absolute path.
|
|
1155
1163
|
*
|
|
1156
1164
|
* `ConfigResolver` always supplies the path it actually loaded; the fallback
|
|
1157
|
-
* only covers direct programmatic construction
|
|
1158
|
-
*
|
|
1165
|
+
* only covers direct programmatic construction. `anchorDir` is the directory
|
|
1166
|
+
* the config file lives next to — for the default `.rulesync/` layout this
|
|
1167
|
+
* is the parent of the primary source tree.
|
|
1159
1168
|
*/
|
|
1160
|
-
function normalizeConfigFilePath({ configFilePath,
|
|
1161
|
-
if (configFilePath === void 0) return (0, node_path.join)(
|
|
1169
|
+
function normalizeConfigFilePath({ configFilePath, anchorDir }) {
|
|
1170
|
+
if (configFilePath === void 0) return (0, node_path.join)(anchorDir, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
|
|
1162
1171
|
return (0, node_path.isAbsolute)(configFilePath) ? configFilePath : (0, node_path.resolve)(configFilePath);
|
|
1163
1172
|
}
|
|
1164
1173
|
/**
|
|
1174
|
+
* Resolves any accepted input-root shape (`inputRoot`, `inputRoots`, or
|
|
1175
|
+
* neither) to the canonical non-empty tuple of absolute paths that
|
|
1176
|
+
* `Config` stores. Relative entries are resolved against the current
|
|
1177
|
+
* working directory at call time.
|
|
1178
|
+
*
|
|
1179
|
+
* Semantics (post-refactor):
|
|
1180
|
+
* - Each entry in `inputRoots` is a rulesync **source tree** (the directory
|
|
1181
|
+
* that directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). No implicit
|
|
1182
|
+
* `.rulesync/` join is applied.
|
|
1183
|
+
* - The legacy singular `inputRoot` is a shorthand for "parent of the
|
|
1184
|
+
* default `.rulesync/` source tree", and is expanded to
|
|
1185
|
+
* `[join(inputRoot, ".rulesync")]` before hitting any consumer. This is
|
|
1186
|
+
* the ONLY place `.rulesync` is appended by convention.
|
|
1187
|
+
* - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
|
|
1188
|
+
* so existing projects with a single `.rulesync/` tree keep working
|
|
1189
|
+
* unchanged.
|
|
1190
|
+
*
|
|
1191
|
+
* Callers must have already run `assertInputRootFieldsExclusive`.
|
|
1192
|
+
*/
|
|
1193
|
+
function normalizeInputRoots({ inputRoot, inputRoots }) {
|
|
1194
|
+
const resolved = (inputRoots !== void 0 && inputRoots.length > 0 ? inputRoots : inputRoot !== void 0 ? [(0, node_path.join)(inputRoot, RULESYNC_RELATIVE_DIR_PATH)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)]).map((entry) => (0, node_path.isAbsolute)(entry) ? entry : (0, node_path.resolve)(entry));
|
|
1195
|
+
return [resolved[0], ...resolved.slice(1)];
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1165
1198
|
* Conflicting target pairs that cannot be used together.
|
|
1166
1199
|
* Exported so `rulesync doctor` can report the same conflicts as diagnostics
|
|
1167
1200
|
* without duplicating the list.
|
|
@@ -1198,6 +1231,28 @@ const assertTargetsFeaturesExclusive = ({ targets, features }) => {
|
|
|
1198
1231
|
if (targets !== void 0 && !Array.isArray(targets) && features !== void 0) throw new Error("Invalid config: when 'targets' is in object form, 'features' must be omitted. Declare per-target features inside the 'targets' object instead.");
|
|
1199
1232
|
};
|
|
1200
1233
|
/**
|
|
1234
|
+
* Rejects a single user-authored config file (or a single programmatic
|
|
1235
|
+
* construction) that defines both `inputRoot` and `inputRoots` — the two
|
|
1236
|
+
* fields express the same setting at singular vs. list level and cannot
|
|
1237
|
+
* be combined within one file without ambiguity.
|
|
1238
|
+
*
|
|
1239
|
+
* The check is intentionally per-file: base and local config files can each
|
|
1240
|
+
* be valid in isolation and merge into a state where both survive, and the
|
|
1241
|
+
* resolver picks `inputRoots` in that case (see `resolveEffectiveInputRoots`).
|
|
1242
|
+
* Only a single file declaring both is a genuine authoring error.
|
|
1243
|
+
*/
|
|
1244
|
+
const assertInputRootFieldsExclusive = ({ inputRoot, inputRoots }) => {
|
|
1245
|
+
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.");
|
|
1246
|
+
};
|
|
1247
|
+
/**
|
|
1248
|
+
* Rejects an explicitly supplied empty `inputRoots` list. Omitting the field
|
|
1249
|
+
* selects the conventional default, while an empty list has no meaningful
|
|
1250
|
+
* source-tree semantics and must not be treated as an absent override.
|
|
1251
|
+
*/
|
|
1252
|
+
const assertInputRootsNonEmpty = ({ inputRoots }) => {
|
|
1253
|
+
if (inputRoots !== void 0 && inputRoots.length === 0) throw new Error("Invalid config: 'inputRoots' must be non-empty.");
|
|
1254
|
+
};
|
|
1255
|
+
/**
|
|
1201
1256
|
* Normalizes a post-resolution `ConfigParams` input by rejecting the case
|
|
1202
1257
|
* where both `targets` and `features` are undefined — a degenerate state
|
|
1203
1258
|
* that would silently produce a no-op config (no targets, no features).
|
|
@@ -1233,14 +1288,35 @@ var Config = class Config {
|
|
|
1233
1288
|
gitignoreDestination;
|
|
1234
1289
|
dryRun;
|
|
1235
1290
|
check;
|
|
1236
|
-
|
|
1291
|
+
/**
|
|
1292
|
+
* Ordered, absolute-path list of rulesync source trees. Each entry is a
|
|
1293
|
+
* source tree itself — the directory that directly contains `rules/`,
|
|
1294
|
+
* `skills/`, `mcp.jsonc`, etc. No implicit `.rulesync/` join is applied.
|
|
1295
|
+
*
|
|
1296
|
+
* Always non-empty by construction — the constructor either normalizes
|
|
1297
|
+
* an `inputRoot`/`inputRoots` input or falls back to a single-element
|
|
1298
|
+
* list containing `join(<cwd>, ".rulesync")`.
|
|
1299
|
+
*
|
|
1300
|
+
* `inputRoot` (singular) is a deprecated backward-compatibility alias
|
|
1301
|
+
* that expands to `[join(inputRoot, ".rulesync")]`.
|
|
1302
|
+
*
|
|
1303
|
+
* Typed as a non-empty tuple so the one internal caller that legitimately
|
|
1304
|
+
* needs "the primary root" (`normalizeConfigFilePath` fallback) can index
|
|
1305
|
+
* `[0]` without a runtime null-check.
|
|
1306
|
+
*/
|
|
1307
|
+
inputRoots;
|
|
1237
1308
|
configFilePath;
|
|
1238
1309
|
sources;
|
|
1239
|
-
constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, configFilePath, sources, configFileTargets }) {
|
|
1310
|
+
constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
|
|
1240
1311
|
assertTargetsFeaturesExclusive({
|
|
1241
1312
|
targets,
|
|
1242
1313
|
features
|
|
1243
1314
|
});
|
|
1315
|
+
assertInputRootFieldsExclusive({
|
|
1316
|
+
inputRoot,
|
|
1317
|
+
inputRoots
|
|
1318
|
+
});
|
|
1319
|
+
assertInputRootsNonEmpty({ inputRoots });
|
|
1244
1320
|
assertTargetsOrFeaturesProvided({
|
|
1245
1321
|
targets,
|
|
1246
1322
|
features
|
|
@@ -1268,10 +1344,13 @@ var Config = class Config {
|
|
|
1268
1344
|
this.gitignoreDestination = gitignoreDestination ?? "gitignore";
|
|
1269
1345
|
this.dryRun = dryRun ?? false;
|
|
1270
1346
|
this.check = check ?? false;
|
|
1271
|
-
this.
|
|
1347
|
+
this.inputRoots = normalizeInputRoots({
|
|
1348
|
+
inputRoot,
|
|
1349
|
+
inputRoots
|
|
1350
|
+
});
|
|
1272
1351
|
this.configFilePath = normalizeConfigFilePath({
|
|
1273
1352
|
configFilePath,
|
|
1274
|
-
|
|
1353
|
+
anchorDir: (0, node_path.dirname)(this.inputRoots[0])
|
|
1275
1354
|
});
|
|
1276
1355
|
this.sources = sources ?? [];
|
|
1277
1356
|
}
|
|
@@ -1455,16 +1534,22 @@ var Config = class Config {
|
|
|
1455
1534
|
return this.check;
|
|
1456
1535
|
}
|
|
1457
1536
|
/**
|
|
1458
|
-
* Returns the
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1537
|
+
* Returns the ordered list of rulesync source trees. Each entry is the
|
|
1538
|
+
* source tree itself — the directory that directly contains `rules/`,
|
|
1539
|
+
* `skills/`, `mcp.jsonc`, etc. Values are absolute paths captured at
|
|
1540
|
+
* config-construction time, so this accessor is pure and never depends on
|
|
1541
|
+
* a live `process.cwd()` read.
|
|
1461
1542
|
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1543
|
+
* The returned tuple is always non-empty: when no `inputRoot`/`inputRoots`
|
|
1544
|
+
* was supplied, `[join(process.cwd(), ".rulesync")]` is snapshotted once
|
|
1545
|
+
* during construction. The first entry is the required base source tree.
|
|
1546
|
+
* Later entries are optional overlays and may be absent; when present, they
|
|
1547
|
+
* take precedence when the same relative source path exists in more than
|
|
1548
|
+
* one root (see per-feature merge policies in the processor
|
|
1549
|
+
* `loadRulesync*` methods).
|
|
1465
1550
|
*/
|
|
1466
|
-
|
|
1467
|
-
return this.
|
|
1551
|
+
getInputRoots() {
|
|
1552
|
+
return this.inputRoots;
|
|
1468
1553
|
}
|
|
1469
1554
|
/**
|
|
1470
1555
|
* Returns the absolute path of the configuration file this config was
|
|
@@ -1505,6 +1590,7 @@ const getDefaults = () => ({
|
|
|
1505
1590
|
dryRun: false,
|
|
1506
1591
|
check: false,
|
|
1507
1592
|
inputRoot: void 0,
|
|
1593
|
+
inputRoots: void 0,
|
|
1508
1594
|
sources: []
|
|
1509
1595
|
});
|
|
1510
1596
|
const loadConfigFromFile = async (filePath) => {
|
|
@@ -1516,8 +1602,23 @@ const loadConfigFromFile = async (filePath) => {
|
|
|
1516
1602
|
targets: configParams.targets,
|
|
1517
1603
|
features: configParams.features
|
|
1518
1604
|
});
|
|
1605
|
+
try {
|
|
1606
|
+
assertInputRootFieldsExclusive({
|
|
1607
|
+
inputRoot: configParams.inputRoot,
|
|
1608
|
+
inputRoots: configParams.inputRoots
|
|
1609
|
+
});
|
|
1610
|
+
} catch (error) {
|
|
1611
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1612
|
+
throw new Error(`${detail} (in ${JSON.stringify(filePath)})`, { cause: error });
|
|
1613
|
+
}
|
|
1519
1614
|
return configParams;
|
|
1520
1615
|
};
|
|
1616
|
+
function mergeInputRootConfigs({ baseConfig, localConfig }) {
|
|
1617
|
+
return {
|
|
1618
|
+
inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
|
|
1619
|
+
inputRoots: localConfig.inputRoots ?? baseConfig.inputRoots
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1521
1622
|
const mergeConfigs = (baseConfig, localConfig) => {
|
|
1522
1623
|
return {
|
|
1523
1624
|
targets: localConfig.targets ?? baseConfig.targets,
|
|
@@ -1535,7 +1636,10 @@ const mergeConfigs = (baseConfig, localConfig) => {
|
|
|
1535
1636
|
gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination,
|
|
1536
1637
|
dryRun: localConfig.dryRun ?? baseConfig.dryRun,
|
|
1537
1638
|
check: localConfig.check ?? baseConfig.check,
|
|
1538
|
-
|
|
1639
|
+
...mergeInputRootConfigs({
|
|
1640
|
+
baseConfig,
|
|
1641
|
+
localConfig
|
|
1642
|
+
}),
|
|
1539
1643
|
sources: localConfig.sources ?? baseConfig.sources
|
|
1540
1644
|
};
|
|
1541
1645
|
};
|
|
@@ -1564,13 +1668,14 @@ function assertMergedTargetsFeaturesExclusive({ configByFile, validatedConfigPat
|
|
|
1564
1668
|
}
|
|
1565
1669
|
}
|
|
1566
1670
|
/**
|
|
1567
|
-
* Resolve the effective `global` flag. When an
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1671
|
+
* Resolve the effective `global` flag. When an input root (singular
|
|
1672
|
+
* `inputRoot` or plural `inputRoots`) is in play the user is decoupling
|
|
1673
|
+
* source from output, so a config-file `global: true` is dropped (unless
|
|
1674
|
+
* the caller also explicitly passes `global`); a warning is emitted in
|
|
1570
1675
|
* that case. Returns the resolved boolean `global`.
|
|
1571
1676
|
*/
|
|
1572
1677
|
function resolveGlobal({ logger, resolvedInputRoot, global, configByFile, validatedConfigPath }) {
|
|
1573
|
-
if (resolvedInputRoot !== void 0 && global === void 0 && configByFile.global === true) warnWithFallback(logger, `Ignoring "global: true" from ${JSON.stringify(validatedConfigPath)} because an
|
|
1678
|
+
if (resolvedInputRoot !== void 0 && global === void 0 && configByFile.global === true) warnWithFallback(logger, `Ignoring "global: true" from ${JSON.stringify(validatedConfigPath)} because an input root was configured; pass global=true (CLI: --global) to keep user-scope output. Output will be project-scope (global=false).`);
|
|
1574
1679
|
return pick({
|
|
1575
1680
|
cli: global,
|
|
1576
1681
|
file: resolvedInputRoot !== void 0 ? false : configByFile.global,
|
|
@@ -1594,17 +1699,81 @@ function resolveFeaturesAndTargets({ features, targets, configByFile }) {
|
|
|
1594
1699
|
resolvedTargets: userProvidedTargets ?? getDefaults().targets
|
|
1595
1700
|
};
|
|
1596
1701
|
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Resolve the effective, non-empty, absolute-path list of source-tree roots
|
|
1704
|
+
* by applying CLI > file > default precedence and preferring `inputRoots`
|
|
1705
|
+
* over `inputRoot` when both survive the base+local merge. Duplicates (after
|
|
1706
|
+
* normalization to absolute paths) are removed silently so overlapping
|
|
1707
|
+
* base/local declarations do not double-count the same tree.
|
|
1708
|
+
*
|
|
1709
|
+
* Semantics (post-refactor):
|
|
1710
|
+
* - `inputRoots` entries are the source trees themselves (each holds
|
|
1711
|
+
* `rules/`, `skills/`, `mcp.jsonc`, etc.); they are passed through
|
|
1712
|
+
* unchanged.
|
|
1713
|
+
* - `inputRoot` (legacy singular) is a shorthand for "parent of the
|
|
1714
|
+
* `.rulesync/` source tree" and is expanded to `join(inputRoot,
|
|
1715
|
+
* ".rulesync")` before it hits any consumer.
|
|
1716
|
+
* - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
|
|
1717
|
+
* so existing projects keep working unchanged.
|
|
1718
|
+
*
|
|
1719
|
+
* When both the merged file config has `inputRoots` and the CLI supplied
|
|
1720
|
+
* `inputRoot` (or vice versa), CLI wins outright — matching how every
|
|
1721
|
+
* other field is resolved. When only the file config supplies both, the
|
|
1722
|
+
* plural wins over the singular and the drop is logged at debug level.
|
|
1723
|
+
*/
|
|
1724
|
+
function resolveEffectiveInputRoots({ cliInputRoot, cliInputRoots, configByFile, cwd, logger }) {
|
|
1725
|
+
let source;
|
|
1726
|
+
let field;
|
|
1727
|
+
if (cliInputRoots !== void 0 && cliInputRoots.length > 0) {
|
|
1728
|
+
source = cliInputRoots;
|
|
1729
|
+
field = "inputRoots";
|
|
1730
|
+
} else if (cliInputRoot !== void 0) {
|
|
1731
|
+
source = [(0, node_path.join)(cliInputRoot, RULESYNC_RELATIVE_DIR_PATH)];
|
|
1732
|
+
field = "inputRoot";
|
|
1733
|
+
} else if (configByFile.inputRoots !== void 0 && configByFile.inputRoots.length > 0) {
|
|
1734
|
+
source = configByFile.inputRoots;
|
|
1735
|
+
field = "inputRoots";
|
|
1736
|
+
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.`);
|
|
1737
|
+
} else if (configByFile.inputRoot !== void 0) {
|
|
1738
|
+
source = [(0, node_path.join)(configByFile.inputRoot, RULESYNC_RELATIVE_DIR_PATH)];
|
|
1739
|
+
field = "inputRoot";
|
|
1740
|
+
} else source = [(0, node_path.join)(cwd, RULESYNC_RELATIVE_DIR_PATH)];
|
|
1741
|
+
const candidates = source.map((entry) => (0, node_path.resolve)(cwd, entry));
|
|
1742
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1743
|
+
const resolved = [];
|
|
1744
|
+
for (const absolute of candidates) {
|
|
1745
|
+
if (seen.has(absolute)) continue;
|
|
1746
|
+
seen.add(absolute);
|
|
1747
|
+
resolved.push(absolute);
|
|
1748
|
+
}
|
|
1749
|
+
return {
|
|
1750
|
+
inputRoots: [resolved[0], ...resolved.slice(1)],
|
|
1751
|
+
candidates,
|
|
1752
|
+
field
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1597
1755
|
var ConfigResolver = class {
|
|
1598
|
-
static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot }, { logger } = {}) {
|
|
1756
|
+
static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
|
|
1599
1757
|
const cwd = (0, node_path.resolve)(process.cwd());
|
|
1758
|
+
assertInputRootFieldsExclusive({
|
|
1759
|
+
inputRoot,
|
|
1760
|
+
inputRoots
|
|
1761
|
+
});
|
|
1762
|
+
assertInputRootsNonEmpty({ inputRoots });
|
|
1600
1763
|
if (inputRoot !== void 0) validateOutputRoot(inputRoot);
|
|
1601
|
-
|
|
1764
|
+
if (inputRoots !== void 0) for (const entry of inputRoots) validateOutputRoot(entry);
|
|
1765
|
+
const cliConfigAnchor = inputRoot;
|
|
1766
|
+
const hasCliInputRootOverride = inputRoot !== void 0 || inputRoots !== void 0;
|
|
1767
|
+
const validatedConfigPath = resolvePath(configPath, (0, node_path.resolve)(cliConfigAnchor ?? cwd));
|
|
1602
1768
|
const baseConfig = await loadConfigFromFile(validatedConfigPath);
|
|
1603
1769
|
const configDir = (0, node_path.dirname)(validatedConfigPath);
|
|
1604
1770
|
const localConfigPath = (0, node_path.join)(configDir, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH);
|
|
1605
1771
|
const localConfig = await loadConfigFromFile(localConfigPath);
|
|
1606
1772
|
const configByFile = mergeConfigs(baseConfig, localConfig);
|
|
1607
|
-
if (
|
|
1773
|
+
if (!hasCliInputRootOverride) {
|
|
1774
|
+
if (configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
|
|
1775
|
+
if (configByFile.inputRoots !== void 0) for (const entry of configByFile.inputRoots) validateOutputRoot(entry);
|
|
1776
|
+
}
|
|
1608
1777
|
assertMergedTargetsFeaturesExclusive({
|
|
1609
1778
|
configByFile,
|
|
1610
1779
|
validatedConfigPath,
|
|
@@ -1630,10 +1799,16 @@ var ConfigResolver = class {
|
|
|
1630
1799
|
silent: resolvedSilent
|
|
1631
1800
|
});
|
|
1632
1801
|
}
|
|
1633
|
-
const
|
|
1802
|
+
const resolvedInputRoots = resolveEffectiveInputRoots({
|
|
1803
|
+
cliInputRoot: inputRoot,
|
|
1804
|
+
cliInputRoots: inputRoots,
|
|
1805
|
+
configByFile,
|
|
1806
|
+
cwd,
|
|
1807
|
+
logger
|
|
1808
|
+
}).inputRoots;
|
|
1634
1809
|
const resolvedGlobal = resolveGlobal({
|
|
1635
1810
|
logger,
|
|
1636
|
-
resolvedInputRoot,
|
|
1811
|
+
resolvedInputRoot: inputRoot !== void 0 || inputRoots !== void 0 || configByFile.inputRoot !== void 0 || configByFile.inputRoots !== void 0 ? resolvedInputRoots[0] : void 0,
|
|
1637
1812
|
global,
|
|
1638
1813
|
configByFile,
|
|
1639
1814
|
validatedConfigPath
|
|
@@ -1697,7 +1872,7 @@ var ConfigResolver = class {
|
|
|
1697
1872
|
file: configByFile.check,
|
|
1698
1873
|
fallback: getDefaults().check
|
|
1699
1874
|
}),
|
|
1700
|
-
|
|
1875
|
+
inputRoots: resolvedInputRoots,
|
|
1701
1876
|
configFilePath: validatedConfigPath,
|
|
1702
1877
|
sources: configByFile.sources ?? getDefaults().sources,
|
|
1703
1878
|
flattenedCommandNaming: configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming,
|
|
@@ -2097,8 +2272,9 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
|
|
|
2097
2272
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
2098
2273
|
};
|
|
2099
2274
|
}
|
|
2100
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
|
|
2101
|
-
const
|
|
2275
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
2276
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
|
|
2277
|
+
const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
|
|
2102
2278
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
|
|
2103
2279
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
2104
2280
|
const result = RulesyncCheckFrontmatterSchema.safeParse(frontmatter);
|
|
@@ -2106,7 +2282,7 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
|
|
|
2106
2282
|
const filename = (0, node_path.basename)(relativeFilePath);
|
|
2107
2283
|
return new RulesyncCheck({
|
|
2108
2284
|
outputRoot,
|
|
2109
|
-
relativeDirPath:
|
|
2285
|
+
relativeDirPath: dirPath,
|
|
2110
2286
|
relativeFilePath: filename,
|
|
2111
2287
|
frontmatter: result.data,
|
|
2112
2288
|
body: content.trim()
|
|
@@ -2177,8 +2353,9 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
|
|
|
2177
2353
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
2178
2354
|
};
|
|
2179
2355
|
}
|
|
2180
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
|
|
2181
|
-
const
|
|
2356
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
2357
|
+
const dirPath = relativeDirPath ?? RulesyncCommand.getSettablePaths().relativeDirPath;
|
|
2358
|
+
const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
|
|
2182
2359
|
const fileContent = await readFileContent(filePath);
|
|
2183
2360
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(fileContent, filePath);
|
|
2184
2361
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
@@ -2186,7 +2363,7 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
|
|
|
2186
2363
|
if (!result.success) throw new Error(`Invalid frontmatter in ${relativeFilePath}: ${formatError(result.error)}`);
|
|
2187
2364
|
return new RulesyncCommand({
|
|
2188
2365
|
outputRoot,
|
|
2189
|
-
relativeDirPath:
|
|
2366
|
+
relativeDirPath: dirPath,
|
|
2190
2367
|
relativeFilePath,
|
|
2191
2368
|
frontmatter: result.data,
|
|
2192
2369
|
body: content.trim(),
|
|
@@ -3575,8 +3752,19 @@ const CANONICAL_TO_VIBE_EVENT_NAMES = {
|
|
|
3575
3752
|
};
|
|
3576
3753
|
/**
|
|
3577
3754
|
* Map Mistral Vibe snake_case event names to canonical camelCase.
|
|
3755
|
+
*
|
|
3756
|
+
* The pre-2.21.0 spellings are accepted alongside the current ones. Vibe's
|
|
3757
|
+
* strict `HookType` enum rejects a file that still uses them, so such a file is
|
|
3758
|
+
* already dead on disk; reading it here and emitting the renamed spelling is
|
|
3759
|
+
* what repairs it, whereas leaving the old name unmapped would route the hook
|
|
3760
|
+
* into a tool override block and lose the event.
|
|
3578
3761
|
*/
|
|
3579
|
-
const VIBE_TO_CANONICAL_EVENT_NAMES =
|
|
3762
|
+
const VIBE_TO_CANONICAL_EVENT_NAMES = {
|
|
3763
|
+
...Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k])),
|
|
3764
|
+
before_tool: "preToolUse",
|
|
3765
|
+
after_tool: "postToolUse",
|
|
3766
|
+
post_agent_turn: "stop"
|
|
3767
|
+
};
|
|
3580
3768
|
/**
|
|
3581
3769
|
* Map canonical camelCase event names to Qwen Code PascalCase.
|
|
3582
3770
|
*
|
|
@@ -3797,15 +3985,17 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
|
|
|
3797
3985
|
error: null
|
|
3798
3986
|
};
|
|
3799
3987
|
}
|
|
3800
|
-
static async fromFile({ outputRoot = process.cwd(), validate = true }) {
|
|
3988
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
|
|
3801
3989
|
const paths = RulesyncHooks.getSettablePaths();
|
|
3990
|
+
const overrideDirPath = relativeDirPath;
|
|
3802
3991
|
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
3803
|
-
const
|
|
3992
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
3993
|
+
const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
3804
3994
|
if (!await fileExists(filePath)) continue;
|
|
3805
3995
|
const fileContent = await readFileContent(filePath);
|
|
3806
3996
|
return new RulesyncHooks({
|
|
3807
3997
|
outputRoot,
|
|
3808
|
-
relativeDirPath:
|
|
3998
|
+
relativeDirPath: candidateDirPath,
|
|
3809
3999
|
relativeFilePath: candidate.relativeFilePath,
|
|
3810
4000
|
fileContent,
|
|
3811
4001
|
validate
|
|
@@ -3832,21 +4022,23 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
|
|
|
3832
4022
|
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
|
|
3833
4023
|
relativeFilePath: RULESYNC_AIIGNORE_FILE_NAME
|
|
3834
4024
|
},
|
|
3835
|
-
legacy: {
|
|
4025
|
+
legacy: [{
|
|
3836
4026
|
relativeDirPath: ".",
|
|
3837
4027
|
relativeFilePath: RULESYNC_IGNORE_RELATIVE_FILE_PATH
|
|
3838
|
-
}
|
|
4028
|
+
}]
|
|
3839
4029
|
};
|
|
3840
4030
|
}
|
|
3841
|
-
static async fromFile({ outputRoot = process.cwd() } = {}) {
|
|
4031
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath } = {}) {
|
|
3842
4032
|
const paths = this.getSettablePaths();
|
|
3843
|
-
const
|
|
3844
|
-
const
|
|
4033
|
+
const recommendedDirPath = relativeDirPath ?? paths.recommended.relativeDirPath;
|
|
4034
|
+
const recommendedPath = (0, node_path.join)(outputRoot, recommendedDirPath, paths.recommended.relativeFilePath);
|
|
4035
|
+
const [legacy] = paths.legacy;
|
|
4036
|
+
const legacyPath = (0, node_path.join)(outputRoot, legacy.relativeDirPath, legacy.relativeFilePath);
|
|
3845
4037
|
if (await fileExists(recommendedPath)) {
|
|
3846
4038
|
const fileContent = await readFileContent(recommendedPath);
|
|
3847
4039
|
return new RulesyncIgnore({
|
|
3848
4040
|
outputRoot,
|
|
3849
|
-
relativeDirPath:
|
|
4041
|
+
relativeDirPath: recommendedDirPath,
|
|
3850
4042
|
relativeFilePath: paths.recommended.relativeFilePath,
|
|
3851
4043
|
fileContent
|
|
3852
4044
|
});
|
|
@@ -3855,15 +4047,15 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
|
|
|
3855
4047
|
const fileContent = await readFileContent(legacyPath);
|
|
3856
4048
|
return new RulesyncIgnore({
|
|
3857
4049
|
outputRoot,
|
|
3858
|
-
relativeDirPath:
|
|
3859
|
-
relativeFilePath:
|
|
4050
|
+
relativeDirPath: legacy.relativeDirPath,
|
|
4051
|
+
relativeFilePath: legacy.relativeFilePath,
|
|
3860
4052
|
fileContent
|
|
3861
4053
|
});
|
|
3862
4054
|
}
|
|
3863
4055
|
const fileContent = await readFileContent(recommendedPath);
|
|
3864
4056
|
return new RulesyncIgnore({
|
|
3865
4057
|
outputRoot,
|
|
3866
|
-
relativeDirPath:
|
|
4058
|
+
relativeDirPath: recommendedDirPath,
|
|
3867
4059
|
relativeFilePath: paths.recommended.relativeFilePath,
|
|
3868
4060
|
fileContent
|
|
3869
4061
|
});
|
|
@@ -4019,6 +4211,118 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
|
|
|
4019
4211
|
warp: zod_mini.z.optional(toolScopedMcpSchema),
|
|
4020
4212
|
zed: zod_mini.z.optional(toolScopedMcpSchema)
|
|
4021
4213
|
});
|
|
4214
|
+
/**
|
|
4215
|
+
* The tool-scoped block keys that carry a `{toolname}.mcpServers` sub-map.
|
|
4216
|
+
* Derived from `RulesyncMcpFileSchema`'s own shape so this set can never drift
|
|
4217
|
+
* from the schema — every tool-scoped block declared above is treated as a
|
|
4218
|
+
* "merge servers by name" site by `mergeMcpJsonOverlays`, and everything else
|
|
4219
|
+
* (including `$schema` and top-level Kimi Code timeout fields) is replaced
|
|
4220
|
+
* atomically.
|
|
4221
|
+
*/
|
|
4222
|
+
const TOOL_SCOPED_MCP_KEYS = new Set(Object.keys(RulesyncMcpFileSchema.def.shape).filter((key) => key !== "$schema" && key !== "mcpServers"));
|
|
4223
|
+
/**
|
|
4224
|
+
* Return the first candidate path (recommended, then legacy variants) that
|
|
4225
|
+
* exists under `outputRoot`, or `undefined` when none is present. Shared
|
|
4226
|
+
* between `fromFile` (single-root) and `fromRoots` (multi-root) so both
|
|
4227
|
+
* paths honour the same intra-root resolution order.
|
|
4228
|
+
*
|
|
4229
|
+
* When `overrideDirPath` is provided it replaces the candidates'
|
|
4230
|
+
* class-level `relativeDirPath` (which defaults to `.rulesync/`) so the
|
|
4231
|
+
* caller can point at a non-default source tree (e.g. `.rulesync.local/`).
|
|
4232
|
+
* Also returned is the effective `relativeDirPath` for the winning
|
|
4233
|
+
* candidate so the caller can reconstruct a `RulesyncMcp` with matching
|
|
4234
|
+
* anchor fields.
|
|
4235
|
+
*/
|
|
4236
|
+
async function findFirstExistingCandidate({ paths, outputRoot, overrideDirPath }) {
|
|
4237
|
+
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
4238
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
4239
|
+
const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
4240
|
+
if (await fileExists(filePath)) return {
|
|
4241
|
+
filePath,
|
|
4242
|
+
candidate: {
|
|
4243
|
+
relativeDirPath: candidateDirPath,
|
|
4244
|
+
relativeFilePath: candidate.relativeFilePath
|
|
4245
|
+
}
|
|
4246
|
+
};
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Merge two parsed MCP JSON objects with the one-level policy from the
|
|
4251
|
+
* inputRoots plan: the top-level `mcpServers` map and each
|
|
4252
|
+
* `<toolname>.mcpServers` sub-map are merged by server name (later wins per
|
|
4253
|
+
* key). Every other value — individual server configs, other top-level keys
|
|
4254
|
+
* — is replaced atomically. This keeps the merge predictable: an overlay can
|
|
4255
|
+
* add or replace whole shared servers, but a partial patch of one server's
|
|
4256
|
+
* `args`/`env` is deliberately not supported.
|
|
4257
|
+
*/
|
|
4258
|
+
function getRecordField({ value, path }) {
|
|
4259
|
+
if (value === void 0) return {};
|
|
4260
|
+
if (!isRecord$1(value)) throw new Error(`Invalid MCP overlay: '${path}' must be an object.`);
|
|
4261
|
+
return value;
|
|
4262
|
+
}
|
|
4263
|
+
/**
|
|
4264
|
+
* Overlay one record onto another, dropping any overlay key that could reach
|
|
4265
|
+
* `Object.prototype`. Every overlay merge goes through this so a `__proto__`
|
|
4266
|
+
* entry cannot enter the merged config from any depth — top-level
|
|
4267
|
+
* `mcpServers`, a tool-scoped block, or that block's own `mcpServers`.
|
|
4268
|
+
*/
|
|
4269
|
+
function mergeRecordsSkippingPollutionKeys({ base, overlay }) {
|
|
4270
|
+
const merged = { ...base };
|
|
4271
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
4272
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
4273
|
+
merged[key] = value;
|
|
4274
|
+
}
|
|
4275
|
+
return merged;
|
|
4276
|
+
}
|
|
4277
|
+
function mergeMcpJsonOverlays({ base, overlay }) {
|
|
4278
|
+
const merged = { ...base };
|
|
4279
|
+
for (const [key, overlayValue] of Object.entries(overlay)) {
|
|
4280
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
4281
|
+
if (key === "mcpServers") {
|
|
4282
|
+
merged.mcpServers = mergeRecordsSkippingPollutionKeys({
|
|
4283
|
+
base: getRecordField({
|
|
4284
|
+
value: base.mcpServers,
|
|
4285
|
+
path: "mcpServers"
|
|
4286
|
+
}),
|
|
4287
|
+
overlay: getRecordField({
|
|
4288
|
+
value: overlayValue,
|
|
4289
|
+
path: "mcpServers"
|
|
4290
|
+
})
|
|
4291
|
+
});
|
|
4292
|
+
continue;
|
|
4293
|
+
}
|
|
4294
|
+
if (TOOL_SCOPED_MCP_KEYS.has(key)) {
|
|
4295
|
+
const baseBlock = getRecordField({
|
|
4296
|
+
value: base[key],
|
|
4297
|
+
path: key
|
|
4298
|
+
});
|
|
4299
|
+
const overlayBlock = getRecordField({
|
|
4300
|
+
value: overlayValue,
|
|
4301
|
+
path: key
|
|
4302
|
+
});
|
|
4303
|
+
const mergedBlock = mergeRecordsSkippingPollutionKeys({
|
|
4304
|
+
base: baseBlock,
|
|
4305
|
+
overlay: overlayBlock
|
|
4306
|
+
});
|
|
4307
|
+
const baseServers = getRecordField({
|
|
4308
|
+
value: baseBlock.mcpServers,
|
|
4309
|
+
path: `${key}.mcpServers`
|
|
4310
|
+
});
|
|
4311
|
+
const overlayServers = getRecordField({
|
|
4312
|
+
value: overlayBlock.mcpServers,
|
|
4313
|
+
path: `${key}.mcpServers`
|
|
4314
|
+
});
|
|
4315
|
+
if (Object.keys(baseServers).length > 0 || Object.keys(overlayServers).length > 0) mergedBlock.mcpServers = mergeRecordsSkippingPollutionKeys({
|
|
4316
|
+
base: baseServers,
|
|
4317
|
+
overlay: overlayServers
|
|
4318
|
+
});
|
|
4319
|
+
merged[key] = mergedBlock;
|
|
4320
|
+
continue;
|
|
4321
|
+
}
|
|
4322
|
+
merged[key] = overlayValue;
|
|
4323
|
+
}
|
|
4324
|
+
return merged;
|
|
4325
|
+
}
|
|
4022
4326
|
var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
|
|
4023
4327
|
json;
|
|
4024
4328
|
constructor(params) {
|
|
@@ -4055,28 +4359,136 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
|
|
|
4055
4359
|
error: null
|
|
4056
4360
|
};
|
|
4057
4361
|
}
|
|
4058
|
-
|
|
4362
|
+
/**
|
|
4363
|
+
* Load and merge MCP source files across the configured input roots.
|
|
4364
|
+
*
|
|
4365
|
+
* `inputRoots` entries are the source trees themselves (e.g.
|
|
4366
|
+
* `/repo/.rulesync`, `/repo/.rulesync.local`). Per-root behavior mirrors
|
|
4367
|
+
* `fromFile`: each root's own candidate paths (recommended `mcp.jsonc`,
|
|
4368
|
+
* legacy `mcp.json`, deprecated `.mcp.json`) are checked INSIDE that
|
|
4369
|
+
* source tree and the first hit is loaded. Roots that have no candidate
|
|
4370
|
+
* contribute nothing.
|
|
4371
|
+
*
|
|
4372
|
+
* Cross-root behavior: the parsed JSON objects are folded left-to-right
|
|
4373
|
+
* with `mergeMcpJsonOverlays`, so later roots overlay earlier ones by
|
|
4374
|
+
* server name (one level deep) and replace every other value atomically.
|
|
4375
|
+
* With one root, this delegates to `fromFile` so JSONC formatting and the
|
|
4376
|
+
* actual candidate path are preserved. With multiple roots, each source is
|
|
4377
|
+
* parsed and schema-validated before merging so failures name the originating
|
|
4378
|
+
* file. The merged object is necessarily synthetic, serialized JSON anchored
|
|
4379
|
+
* to the first root's recommended path.
|
|
4380
|
+
*
|
|
4381
|
+
* A multi-root configuration where only one root actually supplies a file is
|
|
4382
|
+
* treated as the single-root case: nothing is merged, so the original file
|
|
4383
|
+
* content is kept verbatim (preserving JSONC comments) and the instance is
|
|
4384
|
+
* anchored at the root that supplied it rather than at the primary root's
|
|
4385
|
+
* recommended path.
|
|
4386
|
+
*
|
|
4387
|
+
* When no root supplies any candidate, this falls back to reading the
|
|
4388
|
+
* primary root's recommended path so the underlying file-not-found error
|
|
4389
|
+
* matches the single-root behavior of `fromFile`.
|
|
4390
|
+
*/
|
|
4391
|
+
static async fromRoots({ inputRoots, validate = true, logger }) {
|
|
4392
|
+
if (inputRoots.length === 1) {
|
|
4393
|
+
const [primary] = inputRoots;
|
|
4394
|
+
return this.fromFile({
|
|
4395
|
+
outputRoot: (0, node_path.dirname)(primary),
|
|
4396
|
+
relativeDirPath: (0, node_path.basename)(primary),
|
|
4397
|
+
validate,
|
|
4398
|
+
logger
|
|
4399
|
+
});
|
|
4400
|
+
}
|
|
4401
|
+
const paths = this.getSettablePaths();
|
|
4402
|
+
const rootSources = [];
|
|
4403
|
+
for (const root of inputRoots) {
|
|
4404
|
+
const parent = (0, node_path.dirname)(root);
|
|
4405
|
+
const treeName = (0, node_path.basename)(root);
|
|
4406
|
+
const found = await findFirstExistingCandidate({
|
|
4407
|
+
paths,
|
|
4408
|
+
outputRoot: parent,
|
|
4409
|
+
overrideDirPath: treeName
|
|
4410
|
+
});
|
|
4411
|
+
if (found === void 0) continue;
|
|
4412
|
+
const { filePath, candidate } = found;
|
|
4413
|
+
if (filePath.endsWith(".mcp.json")) {
|
|
4414
|
+
const recommendedPath = (0, node_path.join)(parent, treeName, paths.recommended.relativeFilePath);
|
|
4415
|
+
logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
|
|
4416
|
+
}
|
|
4417
|
+
const fileContent = await readFileContent(filePath);
|
|
4418
|
+
let parsed;
|
|
4419
|
+
try {
|
|
4420
|
+
parsed = parseJsonc$8(fileContent);
|
|
4421
|
+
if (!isRecord$1(parsed)) throw new Error("Expected a JSON object.");
|
|
4422
|
+
if (validate) {
|
|
4423
|
+
const result = RulesyncMcpFileSchema.safeParse(parsed);
|
|
4424
|
+
if (!result.success) throw result.error;
|
|
4425
|
+
}
|
|
4426
|
+
} catch (error) {
|
|
4427
|
+
throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
|
|
4428
|
+
}
|
|
4429
|
+
rootSources.push({
|
|
4430
|
+
record: parsed,
|
|
4431
|
+
outputRoot: parent,
|
|
4432
|
+
relativeDirPath: candidate.relativeDirPath,
|
|
4433
|
+
relativeFilePath: candidate.relativeFilePath,
|
|
4434
|
+
fileContent
|
|
4435
|
+
});
|
|
4436
|
+
}
|
|
4437
|
+
if (rootSources.length === 0) {
|
|
4438
|
+
const primary = inputRoots[0];
|
|
4439
|
+
return this.fromFile({
|
|
4440
|
+
outputRoot: (0, node_path.dirname)(primary),
|
|
4441
|
+
relativeDirPath: (0, node_path.basename)(primary),
|
|
4442
|
+
validate,
|
|
4443
|
+
logger
|
|
4444
|
+
});
|
|
4445
|
+
}
|
|
4446
|
+
const onlySource = rootSources.length === 1 ? rootSources[0] : void 0;
|
|
4447
|
+
if (onlySource !== void 0) return new RulesyncMcp({
|
|
4448
|
+
outputRoot: onlySource.outputRoot,
|
|
4449
|
+
relativeDirPath: onlySource.relativeDirPath,
|
|
4450
|
+
relativeFilePath: onlySource.relativeFilePath,
|
|
4451
|
+
fileContent: onlySource.fileContent,
|
|
4452
|
+
validate
|
|
4453
|
+
});
|
|
4454
|
+
const merged = rootSources.reduce((acc, next) => mergeMcpJsonOverlays({
|
|
4455
|
+
base: acc,
|
|
4456
|
+
overlay: next.record
|
|
4457
|
+
}), {});
|
|
4458
|
+
const primary = inputRoots[0];
|
|
4459
|
+
return new RulesyncMcp({
|
|
4460
|
+
outputRoot: (0, node_path.dirname)(primary),
|
|
4461
|
+
relativeDirPath: (0, node_path.basename)(primary),
|
|
4462
|
+
relativeFilePath: paths.recommended.relativeFilePath,
|
|
4463
|
+
fileContent: JSON.stringify(merged, null, 2),
|
|
4464
|
+
validate
|
|
4465
|
+
});
|
|
4466
|
+
}
|
|
4467
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true, logger }) {
|
|
4059
4468
|
const paths = this.getSettablePaths();
|
|
4469
|
+
const overrideDirPath = relativeDirPath;
|
|
4060
4470
|
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
4061
|
-
const
|
|
4471
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
4472
|
+
const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
4062
4473
|
if (!await fileExists(filePath)) continue;
|
|
4063
4474
|
if (candidate.relativeFilePath === ".mcp.json") {
|
|
4064
|
-
const recommendedPath = (0, node_path.join)(outputRoot,
|
|
4475
|
+
const recommendedPath = (0, node_path.join)(outputRoot, candidateDirPath, paths.recommended.relativeFilePath);
|
|
4065
4476
|
logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
|
|
4066
4477
|
}
|
|
4067
4478
|
const fileContent = await readFileContent(filePath);
|
|
4068
4479
|
return new RulesyncMcp({
|
|
4069
4480
|
outputRoot,
|
|
4070
|
-
relativeDirPath:
|
|
4481
|
+
relativeDirPath: candidateDirPath,
|
|
4071
4482
|
relativeFilePath: candidate.relativeFilePath,
|
|
4072
4483
|
fileContent,
|
|
4073
4484
|
validate
|
|
4074
4485
|
});
|
|
4075
4486
|
}
|
|
4076
|
-
const
|
|
4487
|
+
const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
|
|
4488
|
+
const fileContent = await readFileContent((0, node_path.join)(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath));
|
|
4077
4489
|
return new RulesyncMcp({
|
|
4078
4490
|
outputRoot,
|
|
4079
|
-
relativeDirPath:
|
|
4491
|
+
relativeDirPath: fallbackDirPath,
|
|
4080
4492
|
relativeFilePath: paths.recommended.relativeFilePath,
|
|
4081
4493
|
fileContent,
|
|
4082
4494
|
validate
|
|
@@ -4422,11 +4834,26 @@ const KiloPermissionsOverrideSchema = zod_mini.z.looseObject({
|
|
|
4422
4834
|
* rather than which commands are permitted — so it is a loose passthrough on
|
|
4423
4835
|
* the same terms, merged into the top level of `.claude/settings.json`.
|
|
4424
4836
|
*
|
|
4837
|
+
* Any other key is a plain top-level `settings.json` key (`editorMode`, `env`,
|
|
4838
|
+
* `model`, ...), deep-merged into the generated file verbatim so settings
|
|
4839
|
+
* Claude Code adds faster than an allowlist can track stay authorable.
|
|
4840
|
+
* The exceptions are the keys another feature owns (`hooks`) and `$schema`.
|
|
4841
|
+
* Keys the target file cannot honor — `Managed`-only and `~/.claude.json`-only
|
|
4842
|
+
* keys in either scope, plus user-scope keys at project scope — are dropped
|
|
4843
|
+
* with a warning rather than written where they would never apply. So are the
|
|
4844
|
+
* keys whose value is a command Claude Code executes (`apiKeyHelper`,
|
|
4845
|
+
* `statusLine`, ...): this file is shareable via `rulesync fetch`, and a file
|
|
4846
|
+
* named for restricting things is not where a command belongs — author those
|
|
4847
|
+
* in `.rulesync/hooks.jsonc` instead.
|
|
4848
|
+
*
|
|
4425
4849
|
* @example
|
|
4426
4850
|
* { "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] } }
|
|
4427
4851
|
* @example
|
|
4428
4852
|
* { "sandbox": { "network": { "allowedDomains": ["example.com"], "strictAllowlist": true } } }
|
|
4853
|
+
* @example
|
|
4854
|
+
* { "editorMode": "vim", "env": { "MY_VAR": "1" } }
|
|
4429
4855
|
* @see https://code.claude.com/docs/en/sandboxing
|
|
4856
|
+
* @see https://code.claude.com/docs/en/settings-reference
|
|
4430
4857
|
*/
|
|
4431
4858
|
const ClaudecodePermissionsOverrideSchema = zod_mini.z.looseObject({
|
|
4432
4859
|
permission: zod_mini.z.optional(ToolScopedPermissionSchema),
|
|
@@ -5101,6 +5528,15 @@ const CodexcliPermissionsOverrideSchema = zod_mini.z.looseObject({
|
|
|
5101
5528
|
sandbox_workspace_write: zod_mini.z.optional(zod_mini.z.looseObject({})),
|
|
5102
5529
|
apps: zod_mini.z.optional(zod_mini.z.looseObject({})),
|
|
5103
5530
|
approvals_reviewer: zod_mini.z.optional(zod_mini.z.union([CodexApprovalsReviewerSchema, zod_mini.z.looseObject({})])),
|
|
5531
|
+
/**
|
|
5532
|
+
* The `[tui]` table of `config.toml` (e.g. `vim_mode_default`, `keymap.*`).
|
|
5533
|
+
* Not a permission surface, but like `apps` it is a top-level table with no
|
|
5534
|
+
* canonical category, and Codex adds keys to it faster than an explicit model
|
|
5535
|
+
* could track — so it is a loose passthrough written verbatim.
|
|
5536
|
+
*
|
|
5537
|
+
* @see https://developers.openai.com/codex/config-reference
|
|
5538
|
+
*/
|
|
5539
|
+
tui: zod_mini.z.optional(zod_mini.z.looseObject({})),
|
|
5104
5540
|
git_write_rules: zod_mini.z.optional(zod_mini.z.boolean())
|
|
5105
5541
|
});
|
|
5106
5542
|
/**
|
|
@@ -5247,15 +5683,17 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
|
|
|
5247
5683
|
error: null
|
|
5248
5684
|
};
|
|
5249
5685
|
}
|
|
5250
|
-
static async fromFile({ outputRoot = process.cwd(), validate = true }) {
|
|
5686
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
|
|
5251
5687
|
const paths = RulesyncPermissions.getSettablePaths();
|
|
5688
|
+
const overrideDirPath = relativeDirPath;
|
|
5252
5689
|
for (const candidate of getRulesyncSourceCandidates({ paths })) {
|
|
5253
|
-
const
|
|
5690
|
+
const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
|
|
5691
|
+
const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
|
|
5254
5692
|
if (!await fileExists(filePath)) continue;
|
|
5255
5693
|
const fileContent = await readFileContent(filePath);
|
|
5256
5694
|
return new RulesyncPermissions({
|
|
5257
5695
|
outputRoot,
|
|
5258
|
-
relativeDirPath:
|
|
5696
|
+
relativeDirPath: candidateDirPath,
|
|
5259
5697
|
relativeFilePath: candidate.relativeFilePath,
|
|
5260
5698
|
fileContent,
|
|
5261
5699
|
validate
|
|
@@ -5419,8 +5857,9 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
5419
5857
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
5420
5858
|
};
|
|
5421
5859
|
}
|
|
5422
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
|
|
5423
|
-
const
|
|
5860
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
|
|
5861
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths().recommended.relativeDirPath;
|
|
5862
|
+
const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
|
|
5424
5863
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
|
|
5425
5864
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
5426
5865
|
const result = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
|
|
@@ -5433,7 +5872,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
5433
5872
|
};
|
|
5434
5873
|
return new RulesyncRule({
|
|
5435
5874
|
outputRoot,
|
|
5436
|
-
relativeDirPath:
|
|
5875
|
+
relativeDirPath: dirPath,
|
|
5437
5876
|
relativeFilePath,
|
|
5438
5877
|
frontmatter: validatedFrontmatter,
|
|
5439
5878
|
body: content.trim(),
|
|
@@ -5681,7 +6120,11 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
|
|
|
5681
6120
|
"disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
5682
6121
|
"user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
5683
6122
|
enabled: zod_mini.z.optional(zod_mini.z.boolean()),
|
|
5684
|
-
"allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
|
|
6123
|
+
"allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
6124
|
+
license: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
6125
|
+
compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
6126
|
+
metadata: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
6127
|
+
version: zod_mini.z.optional(zod_mini.z.unknown())
|
|
5685
6128
|
})),
|
|
5686
6129
|
grokcli: zod_mini.z.optional(zod_mini.z.looseObject({
|
|
5687
6130
|
"disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
@@ -5857,8 +6300,9 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
5857
6300
|
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
5858
6301
|
};
|
|
5859
6302
|
}
|
|
5860
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
|
|
5861
|
-
const
|
|
6303
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
6304
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
|
|
6305
|
+
const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
|
|
5862
6306
|
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
|
|
5863
6307
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
5864
6308
|
const result = RulesyncSubagentFrontmatterSchema.safeParse(frontmatter);
|
|
@@ -5866,7 +6310,7 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
5866
6310
|
const filename = (0, node_path.basename)(relativeFilePath);
|
|
5867
6311
|
return new RulesyncSubagent({
|
|
5868
6312
|
outputRoot,
|
|
5869
|
-
relativeDirPath:
|
|
6313
|
+
relativeDirPath: dirPath,
|
|
5870
6314
|
relativeFilePath: filename,
|
|
5871
6315
|
frontmatter: result.data,
|
|
5872
6316
|
body: content.trim()
|
|
@@ -5876,16 +6320,18 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
5876
6320
|
//#endregion
|
|
5877
6321
|
//#region src/features/skills/skills-utils.ts
|
|
5878
6322
|
/**
|
|
5879
|
-
* Returns the set of local skill directory names (excluding `.curated`)
|
|
6323
|
+
* Returns the set of local skill directory names (excluding `.curated`)
|
|
6324
|
+
* from a rulesync source tree (e.g. `/repo/.rulesync` or
|
|
6325
|
+
* `/repo/.rulesync.local`).
|
|
5880
6326
|
*/
|
|
5881
|
-
async function getLocalSkillDirNames(
|
|
5882
|
-
const skillsDir = (0, node_path.join)(
|
|
6327
|
+
async function getLocalSkillDirNames(sourceTree) {
|
|
6328
|
+
const skillsDir = (0, node_path.join)(sourceTree, SKILLS_FEATURE_SUBDIR);
|
|
5883
6329
|
const names = /* @__PURE__ */ new Set();
|
|
5884
6330
|
if (!await directoryExists(skillsDir)) return names;
|
|
5885
6331
|
const dirPaths = await findFilesByGlobs((0, node_path.join)(skillsDir, "*"), { type: "dir" });
|
|
5886
6332
|
for (const dirPath of dirPaths) {
|
|
5887
6333
|
const name = (0, node_path.basename)(dirPath);
|
|
5888
|
-
if (name === (0, node_path.basename)(
|
|
6334
|
+
if (name === (0, node_path.basename)(CURATED_SKILLS_FEATURE_SUBDIR)) continue;
|
|
5889
6335
|
names.add(name);
|
|
5890
6336
|
}
|
|
5891
6337
|
return names;
|
|
@@ -6198,15 +6644,49 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
|
|
|
6198
6644
|
return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
|
|
6199
6645
|
}
|
|
6200
6646
|
//#endregion
|
|
6647
|
+
//#region src/utils/control-characters.ts
|
|
6648
|
+
/**
|
|
6649
|
+
* Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
|
|
6650
|
+
* introducer U+009B), the bidirectional overrides and isolates, and the Unicode
|
|
6651
|
+
* line and paragraph separators, and the plain LRM/RLM marks. A name or value
|
|
6652
|
+
* copied out of an untrusted config file, a fetched repository, or a tool's own
|
|
6653
|
+
* settings file must never reach the terminal with these intact: they let the
|
|
6654
|
+
* text forge log lines, reorder what is printed around them, or inject escape
|
|
6655
|
+
* sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
|
|
6656
|
+
* neutral characters beside them, so they go too — a diagnostic line is not the
|
|
6657
|
+
* place to preserve the typography of a right-to-left name.
|
|
6658
|
+
*/
|
|
6659
|
+
const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
|
|
6660
|
+
/**
|
|
6661
|
+
* Removes every control character from `text` so it is safe to splice into a
|
|
6662
|
+
* log line or other terminal output.
|
|
6663
|
+
*/
|
|
6664
|
+
function stripControlCharacters(text) {
|
|
6665
|
+
return text.replace(CONTROL_CHARACTERS_PATTERN, "");
|
|
6666
|
+
}
|
|
6667
|
+
//#endregion
|
|
6201
6668
|
//#region src/types/feature-processor.ts
|
|
6202
6669
|
var FeatureProcessor = class {
|
|
6203
6670
|
outputRoot;
|
|
6204
|
-
|
|
6671
|
+
/**
|
|
6672
|
+
* Ordered, non-empty list of rulesync source-tree directories. Each entry
|
|
6673
|
+
* is a source tree itself — the directory that directly contains feature
|
|
6674
|
+
* subdirectories (`rules/`, `commands/`, …) and single-file features
|
|
6675
|
+
* (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence in
|
|
6676
|
+
* per-feature merges. Defaults to `[join(process.cwd(), ".rulesync")]`.
|
|
6677
|
+
*
|
|
6678
|
+
* The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
|
|
6679
|
+
* `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
|
|
6680
|
+
* and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
|
|
6681
|
+
* reaches a processor — every internal consumer only ever sees the
|
|
6682
|
+
* plural form, with the source tree already resolved.
|
|
6683
|
+
*/
|
|
6684
|
+
inputRoots;
|
|
6205
6685
|
dryRun;
|
|
6206
6686
|
logger;
|
|
6207
|
-
constructor({ outputRoot = process.cwd(),
|
|
6687
|
+
constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, logger }) {
|
|
6208
6688
|
this.outputRoot = outputRoot;
|
|
6209
|
-
this.
|
|
6689
|
+
this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
|
|
6210
6690
|
this.dryRun = dryRun;
|
|
6211
6691
|
this.logger = logger;
|
|
6212
6692
|
}
|
|
@@ -6273,6 +6753,198 @@ var FeatureProcessor = class {
|
|
|
6273
6753
|
return orphanFiles.length;
|
|
6274
6754
|
}
|
|
6275
6755
|
};
|
|
6756
|
+
/**
|
|
6757
|
+
* Messages already reported for a given logger.
|
|
6758
|
+
*
|
|
6759
|
+
* One `generate` run constructs a single-file processor per tool target and
|
|
6760
|
+
* per output root — more than twenty times for `--targets "*"` — and each one
|
|
6761
|
+
* re-resolves the same roots. Keying on the logger — created once per run, and
|
|
6762
|
+
* once per test — keeps the shadowing warning to a single line instead of
|
|
6763
|
+
* repeating it for every target. `--watch` reuses one logger across runs, so
|
|
6764
|
+
* {@link resetRootShadowingWarnings} clears the set at the start of each one.
|
|
6765
|
+
*/
|
|
6766
|
+
const warnedRootShadowingByLogger = /* @__PURE__ */ new WeakMap();
|
|
6767
|
+
/**
|
|
6768
|
+
* Forget which shadowing warnings have already been reported.
|
|
6769
|
+
*
|
|
6770
|
+
* `generate` calls this once per run. Without it, `--watch` reuses one logger
|
|
6771
|
+
* for the whole session, so the warning would be printed on the first
|
|
6772
|
+
* generation and never again — the opposite of why it is a warning, since
|
|
6773
|
+
* `--watch` is exactly when an overlay is most likely to be added or edited.
|
|
6774
|
+
*/
|
|
6775
|
+
function resetRootShadowingWarnings({ logger }) {
|
|
6776
|
+
warnedRootShadowingByLogger.delete(logger);
|
|
6777
|
+
}
|
|
6778
|
+
/**
|
|
6779
|
+
* Return the last input root that contains any of the given `relativePaths`,
|
|
6780
|
+
* or `undefined` when none of the roots has any of them. Used by single-file
|
|
6781
|
+
* features (hooks, permissions, ignore) to implement the "later root wins
|
|
6782
|
+
* the whole file" merge policy without materializing the file contents.
|
|
6783
|
+
*
|
|
6784
|
+
* `relativePaths` accepts a small list so features that historically read
|
|
6785
|
+
* either a recommended path or a legacy alias (e.g. `.rulesync/mcp.jsonc`
|
|
6786
|
+
* plus `.rulesync/mcp.json`) can preserve that resolution order per root.
|
|
6787
|
+
* A root counts as "having" the file as long as at least one candidate path
|
|
6788
|
+
* is present.
|
|
6789
|
+
*/
|
|
6790
|
+
async function pickLastRootWithFile({ inputRoots, relativePaths, logger, artifactName }) {
|
|
6791
|
+
let winner;
|
|
6792
|
+
const rootsWithFile = [];
|
|
6793
|
+
for (const root of inputRoots) for (const relativePath of relativePaths) if (await fileExists((0, node_path.join)(root, relativePath))) {
|
|
6794
|
+
winner = root;
|
|
6795
|
+
rootsWithFile.push(root);
|
|
6796
|
+
break;
|
|
6797
|
+
}
|
|
6798
|
+
if (rootsWithFile.length > 1 && winner !== void 0) {
|
|
6799
|
+
const shadowed = rootsWithFile.slice(0, -1);
|
|
6800
|
+
const message = `${artifactName} is provided by more than one input root; '${stripControlCharacters(winner)}' replaces the whole file from ${shadowed.map((root) => `'${stripControlCharacters(root)}'`).join(", ")}.`;
|
|
6801
|
+
let warnedMessages = warnedRootShadowingByLogger.get(logger);
|
|
6802
|
+
if (warnedMessages === void 0) {
|
|
6803
|
+
warnedMessages = /* @__PURE__ */ new Set();
|
|
6804
|
+
warnedRootShadowingByLogger.set(logger, warnedMessages);
|
|
6805
|
+
}
|
|
6806
|
+
if (!warnedMessages.has(message)) {
|
|
6807
|
+
warnedMessages.add(message);
|
|
6808
|
+
logger.warn(message);
|
|
6809
|
+
}
|
|
6810
|
+
}
|
|
6811
|
+
return winner;
|
|
6812
|
+
}
|
|
6813
|
+
/**
|
|
6814
|
+
* Merge per-root result lists into a single ordered list, keeping the
|
|
6815
|
+
* later root's entry when two roots produced an item with the same
|
|
6816
|
+
* identity. Identity is intentionally provided by the caller so
|
|
6817
|
+
* per-feature nuances (case-insensitive filesystems, directory names for
|
|
6818
|
+
* skills, server names for MCP) live next to the feature that owns them.
|
|
6819
|
+
*
|
|
6820
|
+
* The returned list preserves the FIRST appearance order of each identity —
|
|
6821
|
+
* items in the earliest root keep their position, but their content is
|
|
6822
|
+
* replaced by the last root that provided the same identity. This matches
|
|
6823
|
+
* the "overlay" mental model: an overlay changes content, not order.
|
|
6824
|
+
*/
|
|
6825
|
+
function mergeByIdentity({ perRoot, identity }) {
|
|
6826
|
+
const order = [];
|
|
6827
|
+
const winnerByKey = /* @__PURE__ */ new Map();
|
|
6828
|
+
for (const rootItems of perRoot) for (const item of rootItems) {
|
|
6829
|
+
const key = identity(item);
|
|
6830
|
+
if (!winnerByKey.has(key)) order.push(key);
|
|
6831
|
+
winnerByKey.set(key, item);
|
|
6832
|
+
}
|
|
6833
|
+
return order.map((key) => winnerByKey.get(key));
|
|
6834
|
+
}
|
|
6835
|
+
/**
|
|
6836
|
+
* The key two spellings share when a case-insensitive filesystem would give
|
|
6837
|
+
* them one file. `toLowerCase()` is locale-independent (unlike
|
|
6838
|
+
* `toLocaleLowerCase`, it does not turn `I` into the Turkish `ı` under a Turkish
|
|
6839
|
+
* locale), and the NFC pass folds the composed and decomposed spellings of an
|
|
6840
|
+
* accented name — which macOS also resolves to a single directory —
|
|
6841
|
+
* onto each other.
|
|
6842
|
+
*
|
|
6843
|
+
* This is simple lowercasing rather than full Unicode case folding, so it is
|
|
6844
|
+
* deliberately narrower than what a filesystem considers one file: a Greek
|
|
6845
|
+
* final sigma, a Turkish `ı` under NTFS's upcasing, and a Win32 name whose
|
|
6846
|
+
* trailing dot is stripped all still produce distinct keys. Those pairs keep
|
|
6847
|
+
* the pre-existing behavior (both are imported, and the later one wins on the
|
|
6848
|
+
* filesystem); folding them here would instead drop names that a
|
|
6849
|
+
* case-sensitive filesystem keeps genuinely apart.
|
|
6850
|
+
*/
|
|
6851
|
+
function caseFoldIdentity(identity) {
|
|
6852
|
+
return identity.normalize("NFC").toLowerCase();
|
|
6853
|
+
}
|
|
6854
|
+
/**
|
|
6855
|
+
* Group spellings by their case-folded identity, keeping every original
|
|
6856
|
+
* spelling. On a case-sensitive filesystem one identity can cover several
|
|
6857
|
+
* spellings at once, and the caller needs them all to describe a collision
|
|
6858
|
+
* accurately.
|
|
6859
|
+
*/
|
|
6860
|
+
function groupSpellingsByCaseFoldedIdentity(spellings) {
|
|
6861
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
6862
|
+
for (const spelling of spellings) {
|
|
6863
|
+
const identity = caseFoldIdentity(spelling);
|
|
6864
|
+
const existing = grouped.get(identity);
|
|
6865
|
+
if (existing === void 0) grouped.set(identity, [spelling]);
|
|
6866
|
+
else existing.push(spelling);
|
|
6867
|
+
}
|
|
6868
|
+
return grouped;
|
|
6869
|
+
}
|
|
6870
|
+
/**
|
|
6871
|
+
* Build the warning emitted when a `.curated/` entry and a local entry in the
|
|
6872
|
+
* same tree differ only in case.
|
|
6873
|
+
*
|
|
6874
|
+
* `.curated/` is expanded from a declarative source (an external Git repository
|
|
6875
|
+
* or npm package), so its names are untrusted input; both sides are stripped of
|
|
6876
|
+
* control characters before they reach the terminal.
|
|
6877
|
+
*
|
|
6878
|
+
* The winning local spelling is the LAST one, matching the precedence
|
|
6879
|
+
* {@link mergeByCaseInsensitiveIdentity} applies afterwards; any other spelling
|
|
6880
|
+
* that folds onto the same identity is listed too, so the message never names a
|
|
6881
|
+
* spelling that loses.
|
|
6882
|
+
*/
|
|
6883
|
+
function formatCuratedCaseCollisionWarning({ artifactKind, entryNoun, treeDirPath, curatedSpelling, localSpellings }) {
|
|
6884
|
+
const winner = localSpellings[localSpellings.length - 1] ?? "";
|
|
6885
|
+
const shadowed = localSpellings.slice(0, -1);
|
|
6886
|
+
const shadowedSuffix = shadowed.length === 0 ? "" : ` Other local spellings that fold onto the same identity: ${shadowed.map((spelling) => `'${stripControlCharacters(spelling)}'`).join(", ")}.`;
|
|
6887
|
+
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;
|
|
6888
|
+
}
|
|
6889
|
+
/**
|
|
6890
|
+
* Merge artifacts whose filenames are case-insensitive identities, warning
|
|
6891
|
+
* when distinct spellings collapse to the same key. Exact-name overlays are
|
|
6892
|
+
* intentional and remain quiet; only case-only ambiguity is diagnosed.
|
|
6893
|
+
*
|
|
6894
|
+
* Precedence here is the opposite of {@link ClaimedIdentities}: this merges
|
|
6895
|
+
* overlay roots, where the LAST entry wins, while the tool-side loaders keep
|
|
6896
|
+
* the FIRST root to claim a name.
|
|
6897
|
+
*/
|
|
6898
|
+
function mergeByCaseInsensitiveIdentity({ perRoot, identity, artifactName, logger }) {
|
|
6899
|
+
const spellingByKey = /* @__PURE__ */ new Map();
|
|
6900
|
+
const warnedKeys = /* @__PURE__ */ new Set();
|
|
6901
|
+
return mergeByIdentity({
|
|
6902
|
+
perRoot,
|
|
6903
|
+
identity: (item) => {
|
|
6904
|
+
const spelling = identity(item);
|
|
6905
|
+
const key = caseFoldIdentity(spelling);
|
|
6906
|
+
const previousSpelling = spellingByKey.get(key);
|
|
6907
|
+
if (previousSpelling !== void 0 && previousSpelling !== spelling && !warnedKeys.has(key)) {
|
|
6908
|
+
logger.warn(`Case-insensitive ${artifactName} collision: '${stripControlCharacters(previousSpelling)}' and '${stripControlCharacters(spelling)}' resolve to the same identity. The later entry wins.`);
|
|
6909
|
+
warnedKeys.add(key);
|
|
6910
|
+
}
|
|
6911
|
+
if (previousSpelling === void 0) spellingByKey.set(key, spelling);
|
|
6912
|
+
return key;
|
|
6913
|
+
}
|
|
6914
|
+
});
|
|
6915
|
+
}
|
|
6916
|
+
/**
|
|
6917
|
+
* Tracks the import identities already claimed while scanning, folding case
|
|
6918
|
+
* through {@link caseFoldIdentity}.
|
|
6919
|
+
*
|
|
6920
|
+
* The tool-side loaders scan several roots in precedence order and keep the
|
|
6921
|
+
* first spelling of each identity. Comparing those identities exactly lets
|
|
6922
|
+
* `.junie/skills/dup-skill` and `.agents/skills/Dup-Skill` both through, and
|
|
6923
|
+
* since macOS and Windows resolve the two written-back directories to a
|
|
6924
|
+
* single one, the shared Agent Skills copy lands last and overwrites the
|
|
6925
|
+
* tool-specific one — inverting the precedence the roots were ordered by.
|
|
6926
|
+
*
|
|
6927
|
+
* The FIRST claimer wins, which is the opposite of the overlay precedence in
|
|
6928
|
+
* {@link mergeByCaseInsensitiveIdentity}: roots are passed in precedence
|
|
6929
|
+
* order, so the earliest one to name a skill is the one that should keep it.
|
|
6930
|
+
*/
|
|
6931
|
+
var ClaimedIdentities = class {
|
|
6932
|
+
claimByKey = /* @__PURE__ */ new Map();
|
|
6933
|
+
/**
|
|
6934
|
+
* Claim `identity` on behalf of `source`. Returns `null` when nothing held
|
|
6935
|
+
* it yet, or the standing claim when something did.
|
|
6936
|
+
*/
|
|
6937
|
+
claim({ identity, source }) {
|
|
6938
|
+
const key = caseFoldIdentity(identity);
|
|
6939
|
+
const claimed = this.claimByKey.get(key);
|
|
6940
|
+
if (claimed !== void 0) return claimed;
|
|
6941
|
+
this.claimByKey.set(key, {
|
|
6942
|
+
spelling: identity,
|
|
6943
|
+
source
|
|
6944
|
+
});
|
|
6945
|
+
return null;
|
|
6946
|
+
}
|
|
6947
|
+
};
|
|
6276
6948
|
//#endregion
|
|
6277
6949
|
//#region src/constants/amp-paths.ts
|
|
6278
6950
|
const AMP_DIR = ".amp";
|
|
@@ -7488,7 +8160,8 @@ const CODEXCLI_OVERRIDE_KEYS = [
|
|
|
7488
8160
|
"sandbox_mode",
|
|
7489
8161
|
"sandbox_workspace_write",
|
|
7490
8162
|
"apps",
|
|
7491
|
-
"approvals_reviewer"
|
|
8163
|
+
"approvals_reviewer",
|
|
8164
|
+
"tui"
|
|
7492
8165
|
];
|
|
7493
8166
|
//#endregion
|
|
7494
8167
|
//#region src/features/shared/shared-config-gateway.ts
|
|
@@ -8594,10 +9267,10 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8594
9267
|
toolTarget;
|
|
8595
9268
|
global;
|
|
8596
9269
|
getFactory;
|
|
8597
|
-
constructor({ outputRoot = process.cwd(),
|
|
9270
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
|
|
8598
9271
|
super({
|
|
8599
9272
|
outputRoot,
|
|
8600
|
-
|
|
9273
|
+
inputRoots,
|
|
8601
9274
|
dryRun,
|
|
8602
9275
|
logger
|
|
8603
9276
|
});
|
|
@@ -8634,11 +9307,15 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8634
9307
|
return toolFiles.filter((file) => file instanceof ToolCheck).flatMap((toolCheck) => toolCheck.toRulesyncChecks());
|
|
8635
9308
|
}
|
|
8636
9309
|
/**
|
|
8637
|
-
*
|
|
8638
|
-
*
|
|
9310
|
+
* Load check files from a single source-tree's `checks/` subtree.
|
|
9311
|
+
* `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
|
|
9312
|
+
* `/repo/.rulesync.local`).
|
|
8639
9313
|
*/
|
|
8640
|
-
async
|
|
8641
|
-
const
|
|
9314
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
9315
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
9316
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
9317
|
+
const treeChecksDirPath = (0, node_path.join)(treeName, CHECKS_FEATURE_SUBDIR);
|
|
9318
|
+
const checksDir = (0, node_path.join)(sourceTree, CHECKS_FEATURE_SUBDIR);
|
|
8642
9319
|
if (!await directoryExists(checksDir)) {
|
|
8643
9320
|
this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
|
|
8644
9321
|
return [];
|
|
@@ -8654,7 +9331,8 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8654
9331
|
const filepath = (0, node_path.join)(checksDir, mdFile);
|
|
8655
9332
|
try {
|
|
8656
9333
|
const rulesyncCheck = await RulesyncCheck.fromFile({
|
|
8657
|
-
outputRoot:
|
|
9334
|
+
outputRoot: treeParent,
|
|
9335
|
+
relativeDirPath: treeChecksDirPath,
|
|
8658
9336
|
relativeFilePath: mdFile,
|
|
8659
9337
|
validate: true
|
|
8660
9338
|
});
|
|
@@ -8665,6 +9343,21 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
8665
9343
|
continue;
|
|
8666
9344
|
}
|
|
8667
9345
|
}
|
|
9346
|
+
return rulesyncChecks;
|
|
9347
|
+
}
|
|
9348
|
+
/**
|
|
9349
|
+
* Implementation of abstract method from Processor
|
|
9350
|
+
* Load and parse rulesync check files from every configured input root's
|
|
9351
|
+
* `.rulesync/checks/` directory, merging by relative file path so a check
|
|
9352
|
+
* from a later root replaces the earlier root's copy.
|
|
9353
|
+
*/
|
|
9354
|
+
async loadRulesyncFiles() {
|
|
9355
|
+
const rulesyncChecks = mergeByCaseInsensitiveIdentity({
|
|
9356
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
9357
|
+
identity: (check) => check.getRelativeFilePath(),
|
|
9358
|
+
artifactName: "check",
|
|
9359
|
+
logger: this.logger
|
|
9360
|
+
});
|
|
8668
9361
|
this.logger.debug(`Successfully loaded ${rulesyncChecks.length} rulesync checks`);
|
|
8669
9362
|
return rulesyncChecks;
|
|
8670
9363
|
}
|
|
@@ -10143,18 +10836,23 @@ function commandSlug(relativeFilePath) {
|
|
|
10143
10836
|
return (0, node_path.basename)(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
10144
10837
|
}
|
|
10145
10838
|
/**
|
|
10146
|
-
* Whether a rulesync command exists whose slug matches `dirName
|
|
10839
|
+
* Whether a rulesync command exists whose slug matches `dirName` in any of
|
|
10840
|
+
* the configured input roots.
|
|
10841
|
+
*
|
|
10842
|
+
* `inputRoots[i]` is a source tree itself (e.g. `/repo/.rulesync` or
|
|
10843
|
+
* `/repo/.rulesync.local`), so commands live directly under
|
|
10844
|
+
* `<sourceTree>/commands/`.
|
|
10147
10845
|
*
|
|
10148
10846
|
* Used by the skills-surface `isDirOwned` hooks of tools whose commands are
|
|
10149
10847
|
* emitted as `<slug>/SKILL.md` into the skills tree: a directory matching a
|
|
10150
10848
|
* current command slug is owned by the commands feature, so the skills
|
|
10151
10849
|
* 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.
|
|
10850
|
+
* skill. Once the command is removed from every source tree's `commands/`
|
|
10851
|
+
* directory, the directory stops matching and the skills feature cleans
|
|
10852
|
+
* it up as a regular orphan.
|
|
10155
10853
|
*/
|
|
10156
|
-
async function rulesyncCommandSlugExists({
|
|
10157
|
-
return (await findFilesByGlobs((0, node_path.join)(
|
|
10854
|
+
async function rulesyncCommandSlugExists({ inputRoots, dirName }) {
|
|
10855
|
+
return (await Promise.all(inputRoots.map((root) => findFilesByGlobs((0, node_path.join)(root, COMMANDS_FEATURE_SUBDIR, "**", "*.md"))))).flat().some((filePath) => commandSlug((0, node_path.basename)(filePath)) === dirName);
|
|
10158
10856
|
}
|
|
10159
10857
|
//#endregion
|
|
10160
10858
|
//#region src/features/commands/devin-command.ts
|
|
@@ -10844,11 +11542,10 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
|
|
|
10844
11542
|
* output is lost — so this warns rather than failing the run the way the
|
|
10845
11543
|
* Hermes check does, where the two surfaces really do write the same path.
|
|
10846
11544
|
*/
|
|
10847
|
-
static async validateRulesyncCommands({
|
|
11545
|
+
static async validateRulesyncCommands({ inputRoots, rulesyncCommands, logger }) {
|
|
10848
11546
|
const commandNames = new Set(rulesyncCommands.filter((command) => this.isTargetedByRulesyncCommand(command)).map((command) => (0, node_path.basename)(command.getRelativeFilePath(), ".md")));
|
|
10849
11547
|
if (commandNames.size === 0) return;
|
|
10850
|
-
const
|
|
10851
|
-
const shadowed = (await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"))).map((filePath) => (0, node_path.basename)((0, node_path.dirname)(filePath))).filter((skillName) => commandNames.has(skillName));
|
|
11548
|
+
const shadowed = (await Promise.all(inputRoots.map((root) => findFilesByGlobs((0, node_path.join)(root, SKILLS_FEATURE_SUBDIR, "**", "SKILL.md"))))).flat().map((filePath) => (0, node_path.basename)((0, node_path.dirname)(filePath))).filter((skillName) => commandNames.has(skillName));
|
|
10852
11549
|
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
11550
|
}
|
|
10854
11551
|
static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
|
|
@@ -11620,7 +12317,7 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
|
|
|
11620
12317
|
static getExtraSharedWritePaths() {
|
|
11621
12318
|
return getHermesagentSharedConfigWritePaths();
|
|
11622
12319
|
}
|
|
11623
|
-
static async validateRulesyncCommands({
|
|
12320
|
+
static async validateRulesyncCommands({ inputRoots, rulesyncCommands }) {
|
|
11624
12321
|
const commandSlugs = /* @__PURE__ */ new Set();
|
|
11625
12322
|
const commandOrigins = /* @__PURE__ */ new Map();
|
|
11626
12323
|
for (const command of rulesyncCommands.filter((candidate) => this.isTargetedByRulesyncCommand(candidate))) {
|
|
@@ -11632,13 +12329,25 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
|
|
|
11632
12329
|
commandOrigins.set(slug, origin);
|
|
11633
12330
|
commandSlugs.add(slug);
|
|
11634
12331
|
}
|
|
11635
|
-
const
|
|
11636
|
-
const
|
|
11637
|
-
|
|
11638
|
-
|
|
11639
|
-
|
|
11640
|
-
|
|
11641
|
-
|
|
12332
|
+
const skillsByName = /* @__PURE__ */ new Map();
|
|
12333
|
+
for (const rootPath of inputRoots) {
|
|
12334
|
+
const skillsRoot = (0, node_path.join)(rootPath, SKILLS_FEATURE_SUBDIR);
|
|
12335
|
+
const skillFiles = await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"));
|
|
12336
|
+
const loaded = await Promise.all(skillFiles.map(async (filePath) => {
|
|
12337
|
+
const dirName = toPosixPath((0, node_path.relative)(skillsRoot, (0, node_path.dirname)(filePath)));
|
|
12338
|
+
return {
|
|
12339
|
+
rulesyncSkill: await RulesyncSkill.fromDir({
|
|
12340
|
+
outputRoot: rootPath,
|
|
12341
|
+
relativeDirPath: SKILLS_FEATURE_SUBDIR,
|
|
12342
|
+
dirName
|
|
12343
|
+
}),
|
|
12344
|
+
dirName,
|
|
12345
|
+
rootPath
|
|
12346
|
+
};
|
|
12347
|
+
}));
|
|
12348
|
+
for (const entry of loaded) skillsByName.set(caseFoldIdentity(entry.dirName), entry);
|
|
12349
|
+
}
|
|
12350
|
+
const collisions = [...skillsByName.values()].map(({ rulesyncSkill }) => rulesyncSkill).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
|
|
11642
12351
|
if (collisions.length > 0) throw new Error(`Hermes command and skill slash-name collision: ${[...new Set(collisions)].toSorted().join(", ")}`);
|
|
11643
12352
|
}
|
|
11644
12353
|
static async getAuxiliaryFiles({ toolCommands, outputRoot, global = false, forDeletion = false }) {
|
|
@@ -11771,6 +12480,8 @@ const JUNIE_PERMISSIONS_FILE_NAME = "allowlist.json";
|
|
|
11771
12480
|
const JUNIE_IGNORE_FILE_NAME = ".aiignore";
|
|
11772
12481
|
const JUNIE_RULE_FILE_NAME = "AGENTS.md";
|
|
11773
12482
|
const JUNIE_LEGACY_RULE_FILE_NAME = "guidelines.md";
|
|
12483
|
+
const JUNIE_RULES_DIR_NAME = "rules";
|
|
12484
|
+
const JUNIE_PLAYBOOK_FILE_NAME = "playbook.md";
|
|
11774
12485
|
//#endregion
|
|
11775
12486
|
//#region src/features/commands/junie-command.ts
|
|
11776
12487
|
const JunieCommandFrontmatterSchema = zod_mini.z.looseObject({ description: zod_mini.z.optional(zod_mini.z.string()) });
|
|
@@ -13731,10 +14442,10 @@ var CommandsProcessor = class extends FeatureProcessor {
|
|
|
13731
14442
|
global;
|
|
13732
14443
|
getFactory;
|
|
13733
14444
|
flattenedCommandNaming;
|
|
13734
|
-
constructor({ outputRoot = process.cwd(),
|
|
14445
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
|
|
13735
14446
|
super({
|
|
13736
14447
|
outputRoot,
|
|
13737
|
-
|
|
14448
|
+
inputRoots,
|
|
13738
14449
|
dryRun,
|
|
13739
14450
|
logger
|
|
13740
14451
|
});
|
|
@@ -13749,7 +14460,7 @@ var CommandsProcessor = class extends FeatureProcessor {
|
|
|
13749
14460
|
const rulesyncCommands = rulesyncFiles.filter((file) => file instanceof RulesyncCommand);
|
|
13750
14461
|
const factory = this.getFactory(this.toolTarget);
|
|
13751
14462
|
await factory.class.validateRulesyncCommands?.({
|
|
13752
|
-
|
|
14463
|
+
inputRoots: this.inputRoots,
|
|
13753
14464
|
rulesyncCommands,
|
|
13754
14465
|
logger: this.logger
|
|
13755
14466
|
});
|
|
@@ -13801,16 +14512,36 @@ var CommandsProcessor = class extends FeatureProcessor {
|
|
|
13801
14512
|
return rel;
|
|
13802
14513
|
}
|
|
13803
14514
|
/**
|
|
13804
|
-
*
|
|
13805
|
-
*
|
|
14515
|
+
* Load rulesync command files from a single source-tree's `commands/`
|
|
14516
|
+
* subtree. `sourceTree` is the source tree itself (e.g.
|
|
14517
|
+
* `/repo/.rulesync` or `/repo/.rulesync.local`).
|
|
13806
14518
|
*/
|
|
13807
|
-
async
|
|
13808
|
-
const
|
|
14519
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
14520
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
14521
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
14522
|
+
const treeCommandsDirPath = (0, node_path.join)(treeName, COMMANDS_FEATURE_SUBDIR);
|
|
14523
|
+
const basePath = (0, node_path.join)(sourceTree, COMMANDS_FEATURE_SUBDIR);
|
|
13809
14524
|
const rulesyncCommandPaths = await findFilesByGlobs((0, node_path.join)(basePath, "**", "*.md"));
|
|
13810
|
-
|
|
13811
|
-
outputRoot:
|
|
14525
|
+
return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
|
|
14526
|
+
outputRoot: treeParent,
|
|
14527
|
+
relativeDirPath: treeCommandsDirPath,
|
|
13812
14528
|
relativeFilePath: this.safeRelativePath(basePath, path)
|
|
13813
14529
|
})));
|
|
14530
|
+
}
|
|
14531
|
+
/**
|
|
14532
|
+
* Implementation of abstract method from FeatureProcessor
|
|
14533
|
+
* Load and parse rulesync command files from every configured input root's
|
|
14534
|
+
* `.rulesync/commands/` directory, merging by relative path so a command
|
|
14535
|
+
* with the same target path from a later root replaces the earlier root's
|
|
14536
|
+
* copy.
|
|
14537
|
+
*/
|
|
14538
|
+
async loadRulesyncFiles() {
|
|
14539
|
+
const rulesyncCommands = mergeByCaseInsensitiveIdentity({
|
|
14540
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
14541
|
+
identity: (command) => command.getRelativeFilePath(),
|
|
14542
|
+
artifactName: "command",
|
|
14543
|
+
logger: this.logger
|
|
14544
|
+
});
|
|
13814
14545
|
this.logger.debug(`Successfully loaded ${rulesyncCommands.length} rulesync commands`);
|
|
13815
14546
|
return rulesyncCommands;
|
|
13816
14547
|
}
|
|
@@ -19450,6 +20181,7 @@ function vibeEntryToCanonicalDef(raw) {
|
|
|
19450
20181
|
const entry = raw;
|
|
19451
20182
|
const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
|
|
19452
20183
|
if (vibeEvent === void 0) return null;
|
|
20184
|
+
if (isPrototypePollutionKey(vibeEvent)) return null;
|
|
19453
20185
|
const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
|
|
19454
20186
|
const def = { type: "command" };
|
|
19455
20187
|
if (typeof entry.command === "string") def.command = entry.command;
|
|
@@ -19998,10 +20730,10 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
|
|
|
19998
20730
|
var HooksProcessor = class extends FeatureProcessor {
|
|
19999
20731
|
toolTarget;
|
|
20000
20732
|
global;
|
|
20001
|
-
constructor({ outputRoot = process.cwd(),
|
|
20733
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
|
|
20002
20734
|
super({
|
|
20003
20735
|
outputRoot,
|
|
20004
|
-
|
|
20736
|
+
inputRoots,
|
|
20005
20737
|
dryRun,
|
|
20006
20738
|
logger
|
|
20007
20739
|
});
|
|
@@ -20011,9 +20743,17 @@ var HooksProcessor = class extends FeatureProcessor {
|
|
|
20011
20743
|
this.global = global;
|
|
20012
20744
|
}
|
|
20013
20745
|
async loadRulesyncFiles() {
|
|
20746
|
+
const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncHooks.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
|
|
20747
|
+
const sourceTree = await pickLastRootWithFile({
|
|
20748
|
+
inputRoots: this.inputRoots,
|
|
20749
|
+
relativePaths,
|
|
20750
|
+
logger: this.logger,
|
|
20751
|
+
artifactName: "The hooks file"
|
|
20752
|
+
}) ?? this.inputRoots[0];
|
|
20014
20753
|
try {
|
|
20015
20754
|
return [await RulesyncHooks.fromFile({
|
|
20016
|
-
outputRoot:
|
|
20755
|
+
outputRoot: (0, node_path.dirname)(sourceTree),
|
|
20756
|
+
relativeDirPath: (0, node_path.basename)(sourceTree),
|
|
20017
20757
|
validate: true
|
|
20018
20758
|
})];
|
|
20019
20759
|
} catch (error) {
|
|
@@ -21555,10 +22295,10 @@ var IgnoreProcessor = class extends FeatureProcessor {
|
|
|
21555
22295
|
getFactory;
|
|
21556
22296
|
featureOptions;
|
|
21557
22297
|
global;
|
|
21558
|
-
constructor({ outputRoot = process.cwd(),
|
|
22298
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
|
|
21559
22299
|
super({
|
|
21560
22300
|
outputRoot,
|
|
21561
|
-
|
|
22301
|
+
inputRoots,
|
|
21562
22302
|
dryRun,
|
|
21563
22303
|
logger
|
|
21564
22304
|
});
|
|
@@ -21575,11 +22315,35 @@ var IgnoreProcessor = class extends FeatureProcessor {
|
|
|
21575
22315
|
}
|
|
21576
22316
|
/**
|
|
21577
22317
|
* Implementation of abstract method from FeatureProcessor
|
|
21578
|
-
*
|
|
22318
|
+
*
|
|
22319
|
+
* Load and parse the rulesync ignore file. `inputRoots[i]` is a source
|
|
22320
|
+
* tree itself (e.g. `/repo/.rulesync.local`); the recommended `.aiignore`
|
|
22321
|
+
* lives directly inside it. The legacy `.rulesyncignore` is shared at the
|
|
22322
|
+
* project root, so it is intentionally not considered when choosing which
|
|
22323
|
+
* source tree wins; `RulesyncIgnore.fromFile` still uses it as a fallback
|
|
22324
|
+
* for the chosen tree.
|
|
22325
|
+
*
|
|
22326
|
+
* When multiple input roots are configured, the last root that provides
|
|
22327
|
+
* an ignore file wins entirely (whole-file replacement — no line-level
|
|
22328
|
+
* merge in this slice; see the "Deliberately out of scope" section of
|
|
22329
|
+
* the inputRoots plan for context). If no root has the file, fall back
|
|
22330
|
+
* to the primary root's path so the underlying `RulesyncIgnore.fromFile`
|
|
22331
|
+
* surfaces the same missing-file error it would in the single-root case.
|
|
21579
22332
|
*/
|
|
21580
22333
|
async loadRulesyncFiles() {
|
|
22334
|
+
const paths = RulesyncIgnore.getSettablePaths();
|
|
22335
|
+
const relativePaths = getRulesyncSourceCandidates({ paths }).filter((candidate) => candidate.relativeDirPath === paths.recommended.relativeDirPath).map((candidate) => candidate.relativeFilePath);
|
|
22336
|
+
const sourceTree = await pickLastRootWithFile({
|
|
22337
|
+
inputRoots: this.inputRoots,
|
|
22338
|
+
relativePaths,
|
|
22339
|
+
logger: this.logger,
|
|
22340
|
+
artifactName: "The ignore file (.aiignore)"
|
|
22341
|
+
}) ?? this.inputRoots[0];
|
|
21581
22342
|
try {
|
|
21582
|
-
return [await RulesyncIgnore.fromFile({
|
|
22343
|
+
return [await RulesyncIgnore.fromFile({
|
|
22344
|
+
outputRoot: (0, node_path.dirname)(sourceTree),
|
|
22345
|
+
relativeDirPath: (0, node_path.basename)(sourceTree)
|
|
22346
|
+
})];
|
|
21583
22347
|
} catch (error) {
|
|
21584
22348
|
this.logger.error(`Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`);
|
|
21585
22349
|
return [];
|
|
@@ -26614,11 +27378,86 @@ function disabledNamesOf(config) {
|
|
|
26614
27378
|
return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
|
|
26615
27379
|
}
|
|
26616
27380
|
/**
|
|
27381
|
+
* The value `mcp.mcpConfigPath` needs so Rovo Dev reads the project-scope
|
|
27382
|
+
* `.rovodev/mcp.json`. A config-file value, not a filesystem path, so it is
|
|
27383
|
+
* always POSIX-separated.
|
|
27384
|
+
*/
|
|
27385
|
+
const ROVODEV_PROJECT_MCP_CONFIG_POINTER = node_path.posix.join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME);
|
|
27386
|
+
/**
|
|
27387
|
+
* The keys that make an entry in `mcp.json` something Rovo Dev can start:
|
|
27388
|
+
* a local process, or a remote endpoint under either spelling the canonical
|
|
27389
|
+
* config accepts.
|
|
27390
|
+
*/
|
|
27391
|
+
const MCP_SERVER_ENDPOINT_KEYS = [
|
|
27392
|
+
"command",
|
|
27393
|
+
"url",
|
|
27394
|
+
"httpUrl"
|
|
27395
|
+
];
|
|
27396
|
+
function normalizeMcpConfigPathValue(value) {
|
|
27397
|
+
return toPosixPath(value).replace(/^\.\//, "");
|
|
27398
|
+
}
|
|
27399
|
+
/**
|
|
27400
|
+
* Point `mcp.mcpConfigPath` at the project-scope `mcp.json` rulesync writes,
|
|
27401
|
+
* and report whether the block gained a value it did not already carry.
|
|
27402
|
+
*
|
|
27403
|
+
* Rovo Dev's `mcpConfigPath` defaults to a file under the user's home
|
|
27404
|
+
* directory, so a repo-committed `.rovodev/mcp.json` is inert until the active
|
|
27405
|
+
* config points at it — the Bitbucket Agentic Pipelines guide documents
|
|
27406
|
+
* registering the server and setting the pointer as two required steps. Global
|
|
27407
|
+
* scope is left alone: there the default already resolves to the file rulesync
|
|
27408
|
+
* writes.
|
|
27409
|
+
*
|
|
27410
|
+
* The pointer names one config rather than merging with the default, so it is
|
|
27411
|
+
* written only when this project actually has a Rovo Dev server to run — a
|
|
27412
|
+
* server that targets `rovodev` and is not disabled. Otherwise `mcp.json` is
|
|
27413
|
+
* generated empty, and pointing at it would take away the user's global
|
|
27414
|
+
* servers for this repository in exchange for nothing.
|
|
27415
|
+
*
|
|
27416
|
+
* That condition can also stop holding after the fact, once the last server is
|
|
27417
|
+
* removed from the canonical config or switched off. Rulesync does not take
|
|
27418
|
+
* the pointer back out — it cannot tell its own past value from a user who
|
|
27419
|
+
* typed the same string — but it says so, since the file is now a live setting
|
|
27420
|
+
* that resolves to nothing.
|
|
27421
|
+
*
|
|
27422
|
+
* A pointer the user aimed somewhere else is theirs, not ours: overwriting it
|
|
27423
|
+
* would silently redirect Rovo Dev away from a file they chose. It is named in
|
|
27424
|
+
* a warning instead, because the generated `mcp.json` is unread while it
|
|
27425
|
+
* stands.
|
|
27426
|
+
*
|
|
27427
|
+
* Whatever the outcome, it is logged: writing the pointer turns servers that
|
|
27428
|
+
* were generated-but-never-read into servers Rovo Dev actually spawns, and
|
|
27429
|
+
* points it away from the global MCP config, so it is not something to do
|
|
27430
|
+
* quietly.
|
|
27431
|
+
*
|
|
27432
|
+
* @see https://support.atlassian.com/bitbucket-cloud/docs/rovo-dev-advanced-agentic-configuration/
|
|
27433
|
+
* @see https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/
|
|
27434
|
+
*/
|
|
27435
|
+
function applyProjectMcpConfigPointer({ existingMcp, global, hasLiveServers, logger }) {
|
|
27436
|
+
if (global) return false;
|
|
27437
|
+
const existing = existingMcp.mcpConfigPath;
|
|
27438
|
+
const pointsAtGeneratedFile = typeof existing === "string" && normalizeMcpConfigPathValue(existing) === ROVODEV_PROJECT_MCP_CONFIG_POINTER;
|
|
27439
|
+
if (!hasLiveServers) {
|
|
27440
|
+
if (pointsAtGeneratedFile) logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)} points at ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)}, which now has no enabled server. Rovo Dev reads that file instead of the global MCP config, so this project has no MCP servers at all until one targeting rovodev is added back — remove the mcp.mcpConfigPath line to fall back to the global config.`);
|
|
27441
|
+
return false;
|
|
27442
|
+
}
|
|
27443
|
+
if (existing === void 0) {
|
|
27444
|
+
existingMcp.mcpConfigPath = ROVODEV_PROJECT_MCP_CONFIG_POINTER;
|
|
27445
|
+
logger?.info(`Rovo Dev MCP: setting mcp.mcpConfigPath to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}" in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev will now launch the servers in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} for this project instead of the ones in the default global MCP config.`);
|
|
27446
|
+
return true;
|
|
27447
|
+
}
|
|
27448
|
+
if (pointsAtGeneratedFile) return false;
|
|
27449
|
+
logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${JSON.stringify(existing)} in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev reads MCP servers from that path, so the generated ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} is unused until it is set to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}".`);
|
|
27450
|
+
return false;
|
|
27451
|
+
}
|
|
27452
|
+
/**
|
|
26617
27453
|
* Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
|
|
26618
27454
|
* `~/.rovodev/config.yml` (global). Carries `disabledMcpServers` — the key
|
|
26619
|
-
* Rovo Dev actually consults to switch a server off —
|
|
26620
|
-
*
|
|
26621
|
-
*
|
|
27455
|
+
* Rovo Dev actually consults to switch a server off — plus `mcpConfigPath`,
|
|
27456
|
+
* which rulesync authors in project scope when the key is absent and this
|
|
27457
|
+
* project has a server to run (see `applyProjectMcpConfigPointer`). The block
|
|
27458
|
+
* is recomputed from the existing one, so user keys (`allowedMcpServers`,
|
|
27459
|
+
* ...), a `mcpConfigPath` the user aimed elsewhere, and disabled names for
|
|
27460
|
+
* servers rulesync does not manage all survive.
|
|
26622
27461
|
*/
|
|
26623
27462
|
var RovodevMcpConfigYaml = class extends ToolFile {
|
|
26624
27463
|
isDeletable() {
|
|
@@ -26751,7 +27590,17 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
|
|
|
26751
27590
|
const mergedDisabled = [...existingDisabled.filter((name) => !managedNameSet.has(name)), ...disabledNames].toSorted();
|
|
26752
27591
|
if (mergedDisabled.length > 0) existingMcp.disabledMcpServers = mergedDisabled;
|
|
26753
27592
|
else delete existingMcp.disabledMcpServers;
|
|
26754
|
-
|
|
27593
|
+
const wrotePointer = applyProjectMcpConfigPointer({
|
|
27594
|
+
existingMcp,
|
|
27595
|
+
global,
|
|
27596
|
+
hasLiveServers: managedNames.filter((name) => {
|
|
27597
|
+
if (disabledNames.includes(name)) return false;
|
|
27598
|
+
const server = servers[name];
|
|
27599
|
+
return isRecord$1(server) && MCP_SERVER_ENDPOINT_KEYS.some((endpointKey) => server[endpointKey] !== void 0);
|
|
27600
|
+
}).length > 0,
|
|
27601
|
+
logger
|
|
27602
|
+
});
|
|
27603
|
+
if (mergedDisabled.length === 0 && !wrotePointer && existingContent.trim() === "") return [];
|
|
26755
27604
|
const fileContent = applySharedConfigPatch({
|
|
26756
27605
|
fileKey: ROVODEV_CONFIG_SHARED_FILE_KEY,
|
|
26757
27606
|
feature: "mcp",
|
|
@@ -27844,10 +28693,10 @@ var McpProcessor = class extends FeatureProcessor {
|
|
|
27844
28693
|
toolTarget;
|
|
27845
28694
|
global;
|
|
27846
28695
|
getFactory;
|
|
27847
|
-
constructor({ outputRoot = process.cwd(),
|
|
28696
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
|
|
27848
28697
|
super({
|
|
27849
28698
|
outputRoot,
|
|
27850
|
-
|
|
28699
|
+
inputRoots,
|
|
27851
28700
|
dryRun,
|
|
27852
28701
|
logger
|
|
27853
28702
|
});
|
|
@@ -27863,7 +28712,10 @@ var McpProcessor = class extends FeatureProcessor {
|
|
|
27863
28712
|
*/
|
|
27864
28713
|
async loadRulesyncFiles() {
|
|
27865
28714
|
try {
|
|
27866
|
-
return [await RulesyncMcp.
|
|
28715
|
+
return [await RulesyncMcp.fromRoots({
|
|
28716
|
+
inputRoots: this.inputRoots,
|
|
28717
|
+
logger: this.logger
|
|
28718
|
+
})];
|
|
27867
28719
|
} catch (error) {
|
|
27868
28720
|
this.logger.error(`Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`);
|
|
27869
28721
|
return [];
|
|
@@ -29489,8 +30341,9 @@ function deepMergeRecords(base, patch) {
|
|
|
29489
30341
|
* emits them only under `--global`.
|
|
29490
30342
|
*
|
|
29491
30343
|
* Deliberately NOT listed:
|
|
29492
|
-
* - `bwrapPath` / `socatPath`:
|
|
29493
|
-
*
|
|
30344
|
+
* - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
|
|
30345
|
+
* `CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL` refuses them in both scopes
|
|
30346
|
+
* rather than emitting them under `--global`.
|
|
29494
30347
|
* - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
|
|
29495
30348
|
* unit is the individual entry's mode, not the settings key, and the same
|
|
29496
30349
|
* lists carry `deny` entries that project settings *do* honor — dropping a
|
|
@@ -29498,7 +30351,6 @@ function deepMergeRecords(base, patch) {
|
|
|
29498
30351
|
* filters those lists per entry instead.
|
|
29499
30352
|
*
|
|
29500
30353
|
* @see https://code.claude.com/docs/en/sandboxing
|
|
29501
|
-
* @see https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md — v2.1.232 scoped `sandbox.ripgrep`
|
|
29502
30354
|
*/
|
|
29503
30355
|
const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
|
|
29504
30356
|
["filesystem", "disabled"],
|
|
@@ -29507,37 +30359,309 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
|
|
|
29507
30359
|
["credentials", "allowPlaintextInject"],
|
|
29508
30360
|
["credentials", "awsPairs"],
|
|
29509
30361
|
["credentials", "sigv4"],
|
|
29510
|
-
["allowAppleEvents"]
|
|
29511
|
-
|
|
30362
|
+
["allowAppleEvents"]
|
|
30363
|
+
];
|
|
30364
|
+
/**
|
|
30365
|
+
* `sandbox.*` paths documented with a `Managed` scope: Claude Code reads them
|
|
30366
|
+
* only from the settings file an organization deploys. Neither file rulesync
|
|
30367
|
+
* writes is that file, so they are dropped in **both** scopes — the `sandbox`
|
|
30368
|
+
* counterpart of {@link CLAUDECODE_UNHONORED_KEY_SOURCES}, which does the same
|
|
30369
|
+
* for top-level `Managed` keys.
|
|
30370
|
+
*
|
|
30371
|
+
* Both only ever *narrow* the policy — they stop a lower-scoped file from
|
|
30372
|
+
* re-opening what managed settings blocked — so neither is trust-widening. They
|
|
30373
|
+
* are dropped rather than written for the opposite reason: written into a
|
|
30374
|
+
* project or user file they do nothing at all, and a `sandbox` block that reads
|
|
30375
|
+
* as though it locked the policy to managed values while Claude Code ignores it
|
|
30376
|
+
* is the more dangerous of the two failure modes.
|
|
30377
|
+
*
|
|
30378
|
+
* Import keeps them, unlike the command-executing paths: the value in an
|
|
30379
|
+
* existing `settings.json` was hand-written to be honored somewhere, and
|
|
30380
|
+
* round-tripping it preserves the author's intent for the day it moves into a
|
|
30381
|
+
* managed file. The cost is a warning on every generate until it is removed,
|
|
30382
|
+
* which the refusal message points at.
|
|
30383
|
+
*
|
|
30384
|
+
* @see https://code.claude.com/docs/en/settings-reference#sandbox-filesystem-allowmanagedreadpathsonly
|
|
30385
|
+
* — "Scope: `Managed`"; the `network` entry says the same.
|
|
30386
|
+
*/
|
|
30387
|
+
const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadPathsOnly"], ["network", "allowManagedDomainsOnly"]];
|
|
30388
|
+
/**
|
|
30389
|
+
* Walks `segments` from `root`, returning the record they name or `undefined` if
|
|
30390
|
+
* any step is missing or not a record. Shared by everything below that addresses
|
|
30391
|
+
* a `sandbox` path, so a nested path added to one of the tables is actually
|
|
30392
|
+
* traversed rather than silently skipped.
|
|
30393
|
+
*/
|
|
30394
|
+
function resolveSandboxParent({ root, segments }) {
|
|
30395
|
+
let parent = root;
|
|
30396
|
+
for (const segment of segments) {
|
|
30397
|
+
const next = parent[segment];
|
|
30398
|
+
if (!isPlainRecord(next)) return void 0;
|
|
30399
|
+
parent = next;
|
|
30400
|
+
}
|
|
30401
|
+
return parent;
|
|
30402
|
+
}
|
|
30403
|
+
/**
|
|
30404
|
+
* Deletes `path` from `target` in place and reports whether anything was there,
|
|
30405
|
+
* dropping a container the removal emptied so no `"network": {}` noise is left
|
|
30406
|
+
* behind.
|
|
30407
|
+
*/
|
|
30408
|
+
function deleteSandboxPath({ target, path }) {
|
|
30409
|
+
const leaf = path.at(-1);
|
|
30410
|
+
if (leaf === void 0) return false;
|
|
30411
|
+
const parentPath = path.slice(0, -1);
|
|
30412
|
+
const parent = resolveSandboxParent({
|
|
30413
|
+
root: target,
|
|
30414
|
+
segments: parentPath
|
|
30415
|
+
});
|
|
30416
|
+
if (parent === void 0 || parent[leaf] === void 0) return false;
|
|
30417
|
+
delete parent[leaf];
|
|
30418
|
+
for (let depth = parentPath.length; depth > 0; depth--) {
|
|
30419
|
+
const container = resolveSandboxParent({
|
|
30420
|
+
root: target,
|
|
30421
|
+
segments: parentPath.slice(0, depth)
|
|
30422
|
+
});
|
|
30423
|
+
if (container === void 0 || Object.keys(container).length > 0) break;
|
|
30424
|
+
const holder = resolveSandboxParent({
|
|
30425
|
+
root: target,
|
|
30426
|
+
segments: parentPath.slice(0, depth - 1)
|
|
30427
|
+
});
|
|
30428
|
+
const name = parentPath[depth - 1];
|
|
30429
|
+
if (holder === void 0 || name === void 0) break;
|
|
30430
|
+
delete holder[name];
|
|
30431
|
+
}
|
|
30432
|
+
return true;
|
|
30433
|
+
}
|
|
30434
|
+
/**
|
|
30435
|
+
* The one warning that names every trust-affecting setting this generate wrote
|
|
30436
|
+
* to `relativeFilePath`. Emitted once per file: the individual reasons are what
|
|
30437
|
+
* matter, but the "review this as you would a hook" framing only needs saying
|
|
30438
|
+
* once, and repeating it per key buries the reasons in boilerplate.
|
|
30439
|
+
*/
|
|
30440
|
+
function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
|
|
30441
|
+
if (entries.length === 0) return;
|
|
30442
|
+
const one = entries.length === 1;
|
|
30443
|
+
const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
|
|
30444
|
+
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}.`);
|
|
30445
|
+
}
|
|
30446
|
+
/**
|
|
30447
|
+
* The `permissions.defaultMode` values that start a session with fewer prompts
|
|
30448
|
+
* than the default. `plan` and `default` are absent because they do not widen
|
|
30449
|
+
* anything.
|
|
30450
|
+
*/
|
|
30451
|
+
const CLAUDECODE_WIDENING_DEFAULT_MODES = {
|
|
30452
|
+
acceptEdits: "every file edit is then applied without a prompt",
|
|
30453
|
+
auto: "shell commands are then auto-approved by a classifier rather than by you",
|
|
30454
|
+
bypassPermissions: "every session then starts with no permission prompts at all"
|
|
30455
|
+
};
|
|
30456
|
+
/**
|
|
30457
|
+
* The `permissions` fields that widen rather than restrict: a `defaultMode` that
|
|
30458
|
+
* removes prompts, and `additionalDirectories`, which moves the
|
|
30459
|
+
* working-directory boundary. Reported for the same reason `disableAllHooks` is:
|
|
30460
|
+
* a shareable permissions file should not loosen the permission system quietly.
|
|
30461
|
+
*/
|
|
30462
|
+
function collectWideningPermissionFields({ fields }) {
|
|
30463
|
+
const entries = [];
|
|
30464
|
+
const defaultMode = fields.defaultMode;
|
|
30465
|
+
if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) entries.push({
|
|
30466
|
+
label: `permissions.defaultMode: "${defaultMode}"`,
|
|
30467
|
+
reason: CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]
|
|
30468
|
+
});
|
|
30469
|
+
const additionalDirectories = fields.additionalDirectories;
|
|
30470
|
+
if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) entries.push({
|
|
30471
|
+
label: "permissions.additionalDirectories",
|
|
30472
|
+
reason: "moves the boundary of what Claude Code may read and edit outside the project"
|
|
30473
|
+
});
|
|
30474
|
+
return entries;
|
|
30475
|
+
}
|
|
30476
|
+
/**
|
|
30477
|
+
* `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
|
|
30478
|
+
* own merge branch, so the top-level refusal in `stripUnhonoredTopLevelKeys`
|
|
30479
|
+
* never sees them — they are refused here on the same grounds, in both scopes:
|
|
30480
|
+
* a fetched `.rulesync/permissions.jsonc` must not be able to point Claude Code
|
|
30481
|
+
* at an executable of its choosing.
|
|
30482
|
+
*
|
|
30483
|
+
* @see https://code.claude.com/docs/en/sandboxing
|
|
30484
|
+
*/
|
|
30485
|
+
const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
|
|
30486
|
+
["ripgrep"],
|
|
30487
|
+
["bwrapPath"],
|
|
30488
|
+
["socatPath"]
|
|
29512
30489
|
];
|
|
29513
30490
|
/**
|
|
29514
|
-
*
|
|
29515
|
-
*
|
|
29516
|
-
*
|
|
29517
|
-
*
|
|
30491
|
+
* The predicates the "which value actually widens?" tables are built from.
|
|
30492
|
+
* Each names the value that does *not* widen and reports everything else, never
|
|
30493
|
+
* the reverse: the override is authored JSONC, so a key can carry any value at
|
|
30494
|
+
* all, and one Claude Code coerces is still honored. Reporting an off-type value
|
|
30495
|
+
* keeps the warning fail-safe — silence has to mean "this cannot loosen
|
|
30496
|
+
* anything", not "this is not the type the table expected".
|
|
30497
|
+
*/
|
|
30498
|
+
const isNotFalse = (value) => value !== false;
|
|
30499
|
+
const isNotTrue = (value) => value !== true;
|
|
30500
|
+
const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
|
|
30501
|
+
const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
|
|
30502
|
+
/**
|
|
30503
|
+
* `sandbox` paths that loosen the sandbox rather than naming something to run:
|
|
30504
|
+
* they let commands out of it, weaken the isolation it provides, or redirect
|
|
30505
|
+
* where its traffic goes. They are written like `env` is — the ordinary uses are
|
|
30506
|
+
* too common to refuse — but never silently, because a fetched override should
|
|
30507
|
+
* not be able to open the sandbox without saying so. `widens` keeps the warning
|
|
30508
|
+
* to the value that actually loosens the policy, so authoring the restrictive
|
|
30509
|
+
* value (`allowUnsandboxedCommands: false`, an empty `excludedCommands`) stays
|
|
30510
|
+
* quiet. The `allow*` lists are here for a structural reason: Claude Code merges
|
|
30511
|
+
* a list across every settings scope rather than replacing it, so a project file
|
|
30512
|
+
* can only ever add to them. Their counterparts — `denyRead`, `denyWrite`,
|
|
30513
|
+
* `deniedDomains` — merge the same way, but adding to a deny list only ever
|
|
30514
|
+
* narrows the policy, so they are absent.
|
|
30515
|
+
*
|
|
30516
|
+
* @see https://code.claude.com/docs/en/sandboxing
|
|
29518
30517
|
*/
|
|
29519
|
-
|
|
29520
|
-
|
|
29521
|
-
|
|
30518
|
+
const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
|
|
30519
|
+
{
|
|
30520
|
+
path: ["allowAppleEvents"],
|
|
30521
|
+
reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
|
|
30522
|
+
widens: isNotFalse
|
|
30523
|
+
},
|
|
30524
|
+
{
|
|
30525
|
+
path: ["allowUnsandboxedCommands"],
|
|
30526
|
+
reason: "controls whether Claude may retry a blocked command outside the sandbox",
|
|
30527
|
+
widens: isNotFalse
|
|
30528
|
+
},
|
|
30529
|
+
{
|
|
30530
|
+
path: ["autoAllowBashIfSandboxed"],
|
|
30531
|
+
reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
|
|
30532
|
+
widens: isNotFalse
|
|
30533
|
+
},
|
|
30534
|
+
{
|
|
30535
|
+
path: ["enableWeakerNestedSandbox"],
|
|
30536
|
+
reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
|
|
30537
|
+
widens: isNotFalse
|
|
30538
|
+
},
|
|
30539
|
+
{
|
|
30540
|
+
path: ["enableWeakerNetworkIsolation"],
|
|
30541
|
+
reason: "weakens the sandbox's network isolation on macOS",
|
|
30542
|
+
widens: isNotFalse
|
|
30543
|
+
},
|
|
30544
|
+
{
|
|
30545
|
+
path: ["enabled"],
|
|
30546
|
+
reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
|
|
30547
|
+
widens: isNotFalse
|
|
30548
|
+
},
|
|
30549
|
+
{
|
|
30550
|
+
path: ["excludedCommands"],
|
|
30551
|
+
reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
|
|
30552
|
+
widens: isNonEmptyList
|
|
30553
|
+
},
|
|
30554
|
+
{
|
|
30555
|
+
path: ["filesystem", "allowRead"],
|
|
30556
|
+
reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
|
|
30557
|
+
widens: isNonEmptyList
|
|
30558
|
+
},
|
|
30559
|
+
{
|
|
30560
|
+
path: ["filesystem", "allowWrite"],
|
|
30561
|
+
reason: "adds paths sandboxed commands may write to, outside the working directory",
|
|
30562
|
+
widens: isNonEmptyList
|
|
30563
|
+
},
|
|
30564
|
+
{
|
|
30565
|
+
path: ["ignoreViolations"],
|
|
30566
|
+
reason: "hides the sandbox violations it names, so a blocked access stops being reported",
|
|
30567
|
+
widens: (value) => isNonEmptyMap(value) && isNotFalse(value)
|
|
30568
|
+
},
|
|
30569
|
+
{
|
|
30570
|
+
path: ["network", "allowAllUnixSockets"],
|
|
30571
|
+
reason: "lets sandboxed commands connect to every Unix socket",
|
|
30572
|
+
widens: isNotFalse
|
|
30573
|
+
},
|
|
30574
|
+
{
|
|
30575
|
+
path: ["network", "allowedDomains"],
|
|
30576
|
+
reason: "pre-allows domains sandboxed commands may reach without a prompt",
|
|
30577
|
+
widens: isNonEmptyList
|
|
30578
|
+
},
|
|
30579
|
+
{
|
|
30580
|
+
path: ["network", "allowLocalBinding"],
|
|
30581
|
+
reason: "lets sandboxed commands bind local ports",
|
|
30582
|
+
widens: isNotFalse
|
|
30583
|
+
},
|
|
30584
|
+
{
|
|
30585
|
+
path: ["network", "allowMachLookup"],
|
|
30586
|
+
reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
|
|
30587
|
+
widens: isNonEmptyList
|
|
30588
|
+
},
|
|
30589
|
+
{
|
|
30590
|
+
path: ["network", "allowUnixSockets"],
|
|
30591
|
+
reason: "names Unix sockets sandboxed commands may reach, and one such as `/var/run/docker.sock` is host access",
|
|
30592
|
+
widens: isNonEmptyList
|
|
30593
|
+
},
|
|
30594
|
+
{
|
|
30595
|
+
path: ["network", "httpProxyPort"],
|
|
30596
|
+
reason: "routes the sandbox's HTTP traffic through the port it names",
|
|
30597
|
+
widens: () => true
|
|
30598
|
+
},
|
|
30599
|
+
{
|
|
30600
|
+
path: ["network", "socksProxyPort"],
|
|
30601
|
+
reason: "routes the sandbox's SOCKS traffic through the port it names",
|
|
30602
|
+
widens: () => true
|
|
30603
|
+
}
|
|
30604
|
+
];
|
|
30605
|
+
/**
|
|
30606
|
+
* Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
|
|
30607
|
+
* the values are written, just not silently. Called on the filtered `sandbox`
|
|
30608
|
+
* so it never claims to be writing a path the scope filters dropped.
|
|
30609
|
+
*/
|
|
30610
|
+
function collectTrustAffectingSandboxPaths({ sandbox }) {
|
|
30611
|
+
const entries = [];
|
|
30612
|
+
for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
|
|
29522
30613
|
const leaf = path.at(-1);
|
|
29523
30614
|
if (leaf === void 0) continue;
|
|
29524
|
-
const
|
|
29525
|
-
|
|
29526
|
-
|
|
29527
|
-
|
|
29528
|
-
|
|
29529
|
-
|
|
29530
|
-
|
|
29531
|
-
|
|
29532
|
-
|
|
29533
|
-
|
|
29534
|
-
|
|
29535
|
-
|
|
29536
|
-
|
|
29537
|
-
|
|
29538
|
-
|
|
29539
|
-
|
|
29540
|
-
|
|
30615
|
+
const parent = resolveSandboxParent({
|
|
30616
|
+
root: sandbox,
|
|
30617
|
+
segments: path.slice(0, -1)
|
|
30618
|
+
});
|
|
30619
|
+
if (parent === void 0) continue;
|
|
30620
|
+
const value = parent[leaf];
|
|
30621
|
+
if (value === void 0 || !widens(value)) continue;
|
|
30622
|
+
entries.push({
|
|
30623
|
+
label: `sandbox.${path.join(".")}`,
|
|
30624
|
+
reason
|
|
30625
|
+
});
|
|
30626
|
+
}
|
|
30627
|
+
return entries;
|
|
30628
|
+
}
|
|
30629
|
+
/** Paths that name an executable Claude Code runs. Refused in both scopes. */
|
|
30630
|
+
const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
|
|
30631
|
+
paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
|
|
30632
|
+
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.`
|
|
30633
|
+
};
|
|
30634
|
+
/**
|
|
30635
|
+
* Paths Claude Code honors only from managed settings. Refused in both scopes,
|
|
30636
|
+
* like the command-executing paths, because managed settings are not a file
|
|
30637
|
+
* rulesync writes in either of them.
|
|
30638
|
+
*/
|
|
30639
|
+
const CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL = {
|
|
30640
|
+
paths: CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS,
|
|
30641
|
+
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.`
|
|
30642
|
+
};
|
|
30643
|
+
/** Paths Claude Code honors only above project scope. Refused at project scope. */
|
|
30644
|
+
const CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL = {
|
|
30645
|
+
paths: CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS,
|
|
30646
|
+
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.`
|
|
30647
|
+
};
|
|
30648
|
+
/**
|
|
30649
|
+
* Copy of the authored `sandbox` override with every path of every passed
|
|
30650
|
+
* refusal removed, warning once per dropped path. Only the override copy is
|
|
30651
|
+
* filtered — a value already hand-written in the target file is left untouched,
|
|
30652
|
+
* matching the `qwencode` `security.allowPrivateNetworkHooks` precedent.
|
|
30653
|
+
*/
|
|
30654
|
+
function stripSandboxPaths({ sandbox, refusals, relativeFilePath, logger }) {
|
|
30655
|
+
const filtered = structuredClone(sandbox);
|
|
30656
|
+
for (const { paths, warn } of refusals) for (const path of paths) {
|
|
30657
|
+
if (!deleteSandboxPath({
|
|
30658
|
+
target: filtered,
|
|
30659
|
+
path
|
|
30660
|
+
})) continue;
|
|
30661
|
+
logger?.warn(warn({
|
|
30662
|
+
label: `sandbox.${path.join(".")}`,
|
|
30663
|
+
relativeFilePath
|
|
30664
|
+
}));
|
|
29541
30665
|
}
|
|
29542
30666
|
return filtered;
|
|
29543
30667
|
}
|
|
@@ -29561,7 +30685,7 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
|
|
|
29561
30685
|
* such entries to `deny`), so the "reads as masked but isn't" state this guards
|
|
29562
30686
|
* against cannot slip through a differently-spelled value.
|
|
29563
30687
|
*
|
|
29564
|
-
* Like `
|
|
30688
|
+
* Like `stripSandboxPaths`, only the override copy is filtered — a
|
|
29565
30689
|
* value already in the target file is left untouched, which is why the warning
|
|
29566
30690
|
* points at it.
|
|
29567
30691
|
*
|
|
@@ -29592,6 +30716,271 @@ function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
|
|
|
29592
30716
|
else filtered.credentials = filteredCredentials;
|
|
29593
30717
|
return filtered;
|
|
29594
30718
|
}
|
|
30719
|
+
/**
|
|
30720
|
+
* Top-level `.claude/settings.json` keys another feature owns, derived from
|
|
30721
|
+
* {@link SHARED_CONFIG_OWNERSHIP} rather than restated here so a feature that
|
|
30722
|
+
* starts owning a new key is excluded from the passthrough automatically
|
|
30723
|
+
* (today: `hooks`, from the hooks feature). Only `replace-owned-keys` entries
|
|
30724
|
+
* name keys; the `custom` policies on this file (`ignore`, `permissions`) own
|
|
30725
|
+
* entries *inside* `permissions`, which the passthrough excludes wholesale.
|
|
30726
|
+
*/
|
|
30727
|
+
const CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS = Object.entries(SHARED_CONFIG_OWNERSHIP[".claude/settings.json"]?.features ?? {}).flatMap(([feature, policy]) => feature !== "permissions" && policy.kind === "replace-owned-keys" ? policy.ownedKeys : []);
|
|
30728
|
+
/**
|
|
30729
|
+
* Top-level `.claude/settings.json` keys the generic `claudecode` override
|
|
30730
|
+
* passthrough must not carry. `permissions` and `sandbox` have their own merge
|
|
30731
|
+
* branches (the managed `allow`/`ask`/`deny` arrays and the scope filtering
|
|
30732
|
+
* respectively), `permission` is rulesync's own canonical tool-scoped block
|
|
30733
|
+
* rather than a settings key, `$schema` is an editor pointer rather than a
|
|
30734
|
+
* Claude Code setting, and the rest belong to the other features writing this
|
|
30735
|
+
* shared file.
|
|
30736
|
+
*/
|
|
30737
|
+
const CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
|
|
30738
|
+
"permission",
|
|
30739
|
+
"permissions",
|
|
30740
|
+
"sandbox",
|
|
30741
|
+
"$schema",
|
|
30742
|
+
...CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS
|
|
30743
|
+
]);
|
|
30744
|
+
/**
|
|
30745
|
+
* Top-level settings keys Claude Code reads only from user settings, managed
|
|
30746
|
+
* settings and the `--settings` CLI flag — the same restriction
|
|
30747
|
+
* `CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS` records for the `sandbox` subtree, and
|
|
30748
|
+
* the reason the passthrough drops them at project scope instead of committing
|
|
30749
|
+
* a setting that never applies. `rulesync generate --global` writes the user
|
|
30750
|
+
* settings file, so they are emitted there.
|
|
30751
|
+
*
|
|
30752
|
+
* Derived from the per-key **Scope** column of the settings reference: every
|
|
30753
|
+
* top-level key documented as `User or managed` or `User, local, or managed`.
|
|
30754
|
+
*
|
|
30755
|
+
* @see https://code.claude.com/docs/en/settings-reference
|
|
30756
|
+
*/
|
|
30757
|
+
const CLAUDECODE_USER_SCOPE_ONLY_KEYS = /* @__PURE__ */ new Set([
|
|
30758
|
+
"askUserQuestionTimeout",
|
|
30759
|
+
"autoMode",
|
|
30760
|
+
"dialogExpiry",
|
|
30761
|
+
"enableArtifact",
|
|
30762
|
+
"footerLinksRegexes",
|
|
30763
|
+
"pluginConfigs",
|
|
30764
|
+
"skipAutoPermissionPrompt",
|
|
30765
|
+
"skipDangerousModePermissionPrompt",
|
|
30766
|
+
"spellcheck",
|
|
30767
|
+
"sshConfigs",
|
|
30768
|
+
"syncClaudeAiSkills",
|
|
30769
|
+
"useAutoModeDuringPlan",
|
|
30770
|
+
"vimInsertModeRemaps"
|
|
30771
|
+
]);
|
|
30772
|
+
/**
|
|
30773
|
+
* Top-level settings keys neither file rulesync writes can honor, with the file
|
|
30774
|
+
* that does. `Managed` keys are read only from the settings file an organization
|
|
30775
|
+
* deploys, and `Global config` keys only from `~/.claude.json` — rulesync writes
|
|
30776
|
+
* `.claude/settings.json` and `~/.claude/settings.json`, so authoring either
|
|
30777
|
+
* kind through the override would produce a policy that silently never applies.
|
|
30778
|
+
*
|
|
30779
|
+
* Derived from the per-key **Scope** column of the settings reference.
|
|
30780
|
+
*
|
|
30781
|
+
* @see https://code.claude.com/docs/en/settings-reference
|
|
30782
|
+
*/
|
|
30783
|
+
const CLAUDECODE_UNHONORED_KEY_SOURCES = {
|
|
30784
|
+
allowAllClaudeAiMcps: "managed settings",
|
|
30785
|
+
allowedChannelPlugins: "managed settings",
|
|
30786
|
+
allowManagedHooksOnly: "managed settings",
|
|
30787
|
+
allowManagedMcpServersOnly: "managed settings",
|
|
30788
|
+
allowManagedPermissionRulesOnly: "managed settings",
|
|
30789
|
+
autoConnectIde: "~/.claude.json",
|
|
30790
|
+
autoInstallIdeExtension: "~/.claude.json",
|
|
30791
|
+
blockedMarketplaces: "managed settings",
|
|
30792
|
+
browserExternalPageTools: "managed settings",
|
|
30793
|
+
channelsEnabled: "managed settings",
|
|
30794
|
+
claudeMd: "managed settings",
|
|
30795
|
+
diffTool: "~/.claude.json",
|
|
30796
|
+
disableBrowserExternalNavigation: "managed settings",
|
|
30797
|
+
disableCommandPluginSources: "managed settings",
|
|
30798
|
+
disableMobileSimulatorTools: "managed settings",
|
|
30799
|
+
disableSideloadFlags: "managed settings",
|
|
30800
|
+
externalEditorContext: "~/.claude.json",
|
|
30801
|
+
forceLoginGatewayUrl: "managed settings",
|
|
30802
|
+
forceRemoteSettingsRefresh: "managed settings",
|
|
30803
|
+
parentSettingsBehavior: "managed settings",
|
|
30804
|
+
permissionExplainerEnabled: "~/.claude.json",
|
|
30805
|
+
pluginSuggestionMarketplaces: "managed settings",
|
|
30806
|
+
pluginTrustMessage: "managed settings",
|
|
30807
|
+
requiredMaximumVersion: "managed settings",
|
|
30808
|
+
requiredMinimumVersion: "managed settings",
|
|
30809
|
+
sshHostAllowlist: "managed settings",
|
|
30810
|
+
strictKnownMarketplaces: "managed settings",
|
|
30811
|
+
strictPluginOnlyCustomization: "managed settings",
|
|
30812
|
+
teammateDefaultModel: "~/.claude.json",
|
|
30813
|
+
wslInheritsWindowsSettings: "managed settings"
|
|
30814
|
+
};
|
|
30815
|
+
/**
|
|
30816
|
+
* Top-level settings keys whose value Claude Code **executes**. The generic
|
|
30817
|
+
* passthrough refuses them outright rather than warning: `.rulesync/*` files
|
|
30818
|
+
* are shareable — `rulesync fetch` copies a third party's `permissions.jsonc`
|
|
30819
|
+
* straight into a project — and a file named for *restricting* what an agent
|
|
30820
|
+
* may do is not somewhere a reviewer looks for a command to run. Commands
|
|
30821
|
+
* belong in `.rulesync/hooks.jsonc` and `.rulesync/.mcp.json`, which are read
|
|
30822
|
+
* as executable by anyone reviewing them. Set these by hand in the settings
|
|
30823
|
+
* file if you need them.
|
|
30824
|
+
*
|
|
30825
|
+
* The value is the reason, spliced into the warning.
|
|
30826
|
+
*
|
|
30827
|
+
* @see https://code.claude.com/docs/en/settings-reference
|
|
30828
|
+
*/
|
|
30829
|
+
const CLAUDECODE_COMMAND_EXECUTING_KEYS = {
|
|
30830
|
+
apiKeyHelper: "runs the script it names to mint an API key",
|
|
30831
|
+
awsAuthRefresh: "runs the command it names to refresh AWS credentials",
|
|
30832
|
+
awsCredentialExport: "runs the command it names to export AWS credentials",
|
|
30833
|
+
fileSuggestion: "runs its `command` on every `@` file completion",
|
|
30834
|
+
gcpAuthRefresh: "runs the command it names to refresh Google Cloud credentials",
|
|
30835
|
+
otelHeadersHelper: "runs the script it names to build OpenTelemetry headers",
|
|
30836
|
+
policyHelper: "runs the executable it names to compute the managed settings",
|
|
30837
|
+
processWrapper: "wraps every process Claude Code spawns",
|
|
30838
|
+
statusLine: "runs its `command` on every status-line render",
|
|
30839
|
+
subagentStatusLine: "runs its `command` on every subagent status row"
|
|
30840
|
+
};
|
|
30841
|
+
/**
|
|
30842
|
+
* Top-level settings keys the passthrough does write, but never silently: each
|
|
30843
|
+
* one widens what Claude Code trusts or where it sends data, so a value that
|
|
30844
|
+
* arrived with a fetched `.rulesync/permissions.jsonc` should be looked at
|
|
30845
|
+
* deliberately. Warning on write follows the precedent set for Warp's
|
|
30846
|
+
* `command_denylist`, which also replaces a protection when rulesync writes it.
|
|
30847
|
+
*
|
|
30848
|
+
* The value is the reason, spliced into the warning.
|
|
30849
|
+
*/
|
|
30850
|
+
const CLAUDECODE_TRUST_AFFECTING_KEYS = {
|
|
30851
|
+
agent: "starts every session as the named subagent, with that subagent's prompt, tools and model",
|
|
30852
|
+
allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
|
|
30853
|
+
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",
|
|
30854
|
+
autoMode: "auto-approves shell commands with a classifier rather than with a prompt",
|
|
30855
|
+
claudeMdExcludes: "skips the CLAUDE.md files its patterns match, so the instructions a repository relies on can be dropped from every session",
|
|
30856
|
+
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",
|
|
30857
|
+
crossSessionInbound: "decides what a session does with messages arriving from your other Claude Code sessions, and `accept` delivers them straight to Claude",
|
|
30858
|
+
disableAllHooks: "controls whether hooks run at all",
|
|
30859
|
+
disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
|
|
30860
|
+
enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
|
|
30861
|
+
enabledMcpjsonServers: "auto-approves the named servers in the project `.mcp.json`",
|
|
30862
|
+
enabledPlugins: "enables plugins, which can ship their own hooks",
|
|
30863
|
+
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",
|
|
30864
|
+
extraKnownMarketplaces: "registers plugin marketplace sources",
|
|
30865
|
+
httpHookAllowedEnvVars: "controls which environment variables an HTTP hook may put in a request header, credentials included",
|
|
30866
|
+
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",
|
|
30867
|
+
outputStyle: "replaces the system prompt every session runs with",
|
|
30868
|
+
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",
|
|
30869
|
+
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",
|
|
30870
|
+
skipAutoPermissionPrompt: "removes the confirmation shown before auto-approval mode starts",
|
|
30871
|
+
skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts",
|
|
30872
|
+
skipWebFetchPreflight: "turns off the WebFetch domain safety check, so WebFetch retrieves any URL without consulting Anthropic's blocklist"
|
|
30873
|
+
};
|
|
30874
|
+
/**
|
|
30875
|
+
* The keys from the table above that only widen at one particular value.
|
|
30876
|
+
* `disableSkillShellExecution: true` turns inline shell execution off, which
|
|
30877
|
+
* restricts; anything else re-opens it, and that is what a fetched override
|
|
30878
|
+
* could use to undo a user setting. The rest default to off, so only a value
|
|
30879
|
+
* other than the one that leaves them off is worth a line — and for the
|
|
30880
|
+
* list-valued and map-valued keys, only a non-empty one, since an empty list or
|
|
30881
|
+
* map excludes, announces and overrides nothing.
|
|
30882
|
+
*/
|
|
30883
|
+
const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = {
|
|
30884
|
+
claudeMdExcludes: isNonEmptyList,
|
|
30885
|
+
companyAnnouncements: isNonEmptyList,
|
|
30886
|
+
crossSessionInbound: (value) => value !== "hold" && value !== "refuse",
|
|
30887
|
+
disableSkillShellExecution: isNotTrue,
|
|
30888
|
+
modelOverrides: isNonEmptyMap,
|
|
30889
|
+
remoteControlAtStartup: isNotFalse,
|
|
30890
|
+
skipWebFetchPreflight: isNotFalse
|
|
30891
|
+
};
|
|
30892
|
+
/**
|
|
30893
|
+
* Top-level keys a project-scoped `.claude/settings.json` honors at one value
|
|
30894
|
+
* but ignores at another — the value-level counterpart of
|
|
30895
|
+
* {@link CLAUDECODE_USER_SCOPE_ONLY_KEYS}, which is scoped per key. The ignored
|
|
30896
|
+
* value is dropped at project scope for the same reason a wholly unhonored key
|
|
30897
|
+
* is: committing it would read as a policy that never applies.
|
|
30898
|
+
*
|
|
30899
|
+
* `remoteControlAtStartup` is the only entry whose honored value can be decided
|
|
30900
|
+
* from the value alone. Claude Code honors a `false` from project or local
|
|
30901
|
+
* settings — a repository may turn auto-connect off for its own checkout — but
|
|
30902
|
+
* ignores a `true`, so that a checked-in file cannot turn Remote Control on for
|
|
30903
|
+
* everyone who opens the repository.
|
|
30904
|
+
*
|
|
30905
|
+
* `crossSessionInbound` is on the same documented list but deliberately absent
|
|
30906
|
+
* here: it is a ladder (`accept` < `hold` < `refuse`) whose project value is
|
|
30907
|
+
* honored only when it is stricter than the one above it, which no per-value
|
|
30908
|
+
* predicate can decide without reading the user's own settings. It is warned
|
|
30909
|
+
* about through {@link CLAUDECODE_TRUST_AFFECTING_KEYS} instead, since under
|
|
30910
|
+
* `--global` its loosening value is honored outright.
|
|
30911
|
+
*
|
|
30912
|
+
* @see https://code.claude.com/docs/en/settings#security-keys-where-the-stricter-value-applies
|
|
30913
|
+
*/
|
|
30914
|
+
const CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES = { remoteControlAtStartup: {
|
|
30915
|
+
ignored: isNotFalse,
|
|
30916
|
+
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"
|
|
30917
|
+
} };
|
|
30918
|
+
/**
|
|
30919
|
+
* A key name is authored data that ends up in a log line, so strip the control
|
|
30920
|
+
* characters that would let it forge a line or hide the warnings beside it, and
|
|
30921
|
+
* cap the length.
|
|
30922
|
+
*/
|
|
30923
|
+
function displayKey(key) {
|
|
30924
|
+
const stripped = stripControlCharacters(key);
|
|
30925
|
+
return stripped.length > 80 ? `${stripped.slice(0, 80)}…` : stripped;
|
|
30926
|
+
}
|
|
30927
|
+
/**
|
|
30928
|
+
* Alternate spellings Claude Code accepts for a top-level settings key, mapped
|
|
30929
|
+
* to the canonical key whose **Scope** the alias inherits: "In any settings
|
|
30930
|
+
* file that accepts the canonical key, Claude Code reads the alias exactly as
|
|
30931
|
+
* it reads the canonical key." Resolving through this map before the scope
|
|
30932
|
+
* check keeps an alias from slipping past a restriction its canonical spelling
|
|
30933
|
+
* is caught by — `allowedMarketplaces` is `Managed`, like
|
|
30934
|
+
* `strictKnownMarketplaces`. Both aliases require Claude Code v2.1.232+.
|
|
30935
|
+
*
|
|
30936
|
+
* @see https://code.claude.com/docs/en/settings-reference#marketplace-key-aliases
|
|
30937
|
+
*/
|
|
30938
|
+
const CLAUDECODE_SETTINGS_KEY_ALIASES = {
|
|
30939
|
+
additionalMarketplaces: "extraKnownMarketplaces",
|
|
30940
|
+
allowedMarketplaces: "strictKnownMarketplaces"
|
|
30941
|
+
};
|
|
30942
|
+
/**
|
|
30943
|
+
* Copy of the authored top-level passthrough with the keys the target file
|
|
30944
|
+
* cannot honor removed, warning once per dropped key. Like
|
|
30945
|
+
* `stripSandboxPaths`, only the override copy is filtered — a value
|
|
30946
|
+
* already hand-written in the target file is left untouched, which is why the
|
|
30947
|
+
* warning points at it.
|
|
30948
|
+
*/
|
|
30949
|
+
function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
|
|
30950
|
+
const filtered = {};
|
|
30951
|
+
const trustAffecting = [];
|
|
30952
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
30953
|
+
const shown = displayKey(key);
|
|
30954
|
+
const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
|
|
30955
|
+
if (Object.hasOwn(CLAUDECODE_COMMAND_EXECUTING_KEYS, canonicalKey)) {
|
|
30956
|
+
logger?.warn(`Claude Code permissions: '${shown}' ${CLAUDECODE_COMMAND_EXECUTING_KEYS[canonicalKey]}, 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; author commands in .rulesync/hooks.jsonc, or set this key in ${relativeFilePath} by hand.`);
|
|
30957
|
+
continue;
|
|
30958
|
+
}
|
|
30959
|
+
if (Object.hasOwn(CLAUDECODE_UNHONORED_KEY_SOURCES, canonicalKey)) {
|
|
30960
|
+
logger?.warn(`Claude Code permissions: '${shown}' is only honored in ${CLAUDECODE_UNHONORED_KEY_SOURCES[canonicalKey]}, which rulesync does not generate, so it is not written to ${relativeFilePath}. Set it in that file by hand, and check ${relativeFilePath} for a stale value an earlier generate may have left there.`);
|
|
30961
|
+
continue;
|
|
30962
|
+
}
|
|
30963
|
+
if (!global && CLAUDECODE_USER_SCOPE_ONLY_KEYS.has(canonicalKey)) {
|
|
30964
|
+
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.`);
|
|
30965
|
+
continue;
|
|
30966
|
+
}
|
|
30967
|
+
const projectIgnored = Object.hasOwn(CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES, canonicalKey) ? CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES[canonicalKey] : void 0;
|
|
30968
|
+
if (!global && projectIgnored !== void 0 && projectIgnored.ignored(value)) {
|
|
30969
|
+
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.`);
|
|
30970
|
+
continue;
|
|
30971
|
+
}
|
|
30972
|
+
const widensAtValue = Object.hasOwn(CLAUDECODE_TRUST_KEY_WIDENING_VALUES, canonicalKey) ? CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey] : void 0;
|
|
30973
|
+
if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) trustAffecting.push({
|
|
30974
|
+
label: shown,
|
|
30975
|
+
reason: CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]
|
|
30976
|
+
});
|
|
30977
|
+
filtered[key] = value;
|
|
30978
|
+
}
|
|
30979
|
+
return {
|
|
30980
|
+
filtered,
|
|
30981
|
+
trustAffecting
|
|
30982
|
+
};
|
|
30983
|
+
}
|
|
29595
30984
|
const CLAUDE_PATH_RULE_ALIASES = {
|
|
29596
30985
|
Write: "Edit",
|
|
29597
30986
|
NotebookEdit: "Edit",
|
|
@@ -29660,9 +31049,12 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
29660
31049
|
config,
|
|
29661
31050
|
logger
|
|
29662
31051
|
});
|
|
31052
|
+
const trustAffecting = [];
|
|
29663
31053
|
const overridePermissions = config.claudecode?.permissions;
|
|
29664
31054
|
if (overridePermissions && typeof overridePermissions === "object") {
|
|
29665
|
-
const { allow: _a, ask: _k, deny: _d, ...
|
|
31055
|
+
const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
|
|
31056
|
+
const nonListFields = Object.fromEntries(Object.entries(rest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
|
|
31057
|
+
trustAffecting.push(...collectWideningPermissionFields({ fields: nonListFields }));
|
|
29666
31058
|
settings.permissions = {
|
|
29667
31059
|
...settings.permissions,
|
|
29668
31060
|
...nonListFields
|
|
@@ -29670,17 +31062,44 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
29670
31062
|
}
|
|
29671
31063
|
const overrideSandbox = config.claudecode?.sandbox;
|
|
29672
31064
|
if (isPlainRecord(overrideSandbox)) {
|
|
29673
|
-
const
|
|
29674
|
-
sandbox:
|
|
29675
|
-
|
|
29676
|
-
|
|
29677
|
-
|
|
29678
|
-
|
|
31065
|
+
const honorableSandbox = stripSandboxPaths({
|
|
31066
|
+
sandbox: overrideSandbox,
|
|
31067
|
+
refusals: [
|
|
31068
|
+
CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL,
|
|
31069
|
+
CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL,
|
|
31070
|
+
...global ? [] : [CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL]
|
|
31071
|
+
],
|
|
29679
31072
|
relativeFilePath: paths.relativeFilePath,
|
|
29680
31073
|
logger
|
|
29681
31074
|
});
|
|
31075
|
+
const scopedSandbox = global ? honorableSandbox : stripProjectIgnoredMaskEntries({
|
|
31076
|
+
sandbox: honorableSandbox,
|
|
31077
|
+
relativeFilePath: paths.relativeFilePath,
|
|
31078
|
+
logger
|
|
31079
|
+
});
|
|
31080
|
+
trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
|
|
29682
31081
|
if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
|
|
29683
31082
|
}
|
|
31083
|
+
const overrideTopLevel = {};
|
|
31084
|
+
for (const [key, value] of Object.entries(config.claudecode ?? {})) {
|
|
31085
|
+
if (CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS.has(key)) continue;
|
|
31086
|
+
if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
|
|
31087
|
+
if (value === void 0) continue;
|
|
31088
|
+
overrideTopLevel[key] = value;
|
|
31089
|
+
}
|
|
31090
|
+
const { filtered: scopedTopLevel, trustAffecting: trustAffectingTopLevel } = stripUnhonoredTopLevelKeys({
|
|
31091
|
+
overrides: overrideTopLevel,
|
|
31092
|
+
global,
|
|
31093
|
+
relativeFilePath: paths.relativeFilePath,
|
|
31094
|
+
logger
|
|
31095
|
+
});
|
|
31096
|
+
trustAffecting.push(...trustAffectingTopLevel);
|
|
31097
|
+
if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
|
|
31098
|
+
warnOnTrustAffectingEntries({
|
|
31099
|
+
entries: trustAffecting,
|
|
31100
|
+
relativeFilePath: paths.relativeFilePath,
|
|
31101
|
+
logger
|
|
31102
|
+
});
|
|
29684
31103
|
const managedToolNames = managedClaudeToolNames(config);
|
|
29685
31104
|
const merged = applyPermissions({
|
|
29686
31105
|
settings,
|
|
@@ -29713,12 +31132,33 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
29713
31132
|
ask: permissions.ask ?? [],
|
|
29714
31133
|
deny: permissions.deny ?? []
|
|
29715
31134
|
});
|
|
29716
|
-
const { allow: _a, ask: _k, deny: _d, ...
|
|
31135
|
+
const { allow: _a, ask: _k, deny: _d, ...permissionsRest } = permissions;
|
|
31136
|
+
const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
|
|
29717
31137
|
if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
|
|
29718
31138
|
const { sandbox } = settings;
|
|
29719
|
-
if (isPlainRecord(sandbox)
|
|
31139
|
+
if (isPlainRecord(sandbox)) {
|
|
31140
|
+
const importedSandbox = structuredClone(sandbox);
|
|
31141
|
+
for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
|
|
31142
|
+
target: importedSandbox,
|
|
31143
|
+
path
|
|
31144
|
+
});
|
|
31145
|
+
if (Object.keys(importedSandbox).length > 0) config.claudecode = {
|
|
31146
|
+
...config.claudecode,
|
|
31147
|
+
sandbox: importedSandbox
|
|
31148
|
+
};
|
|
31149
|
+
}
|
|
31150
|
+
const topLevelPassthrough = {};
|
|
31151
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
31152
|
+
if (CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS.has(key)) continue;
|
|
31153
|
+
const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
|
|
31154
|
+
if (Object.hasOwn(CLAUDECODE_COMMAND_EXECUTING_KEYS, canonicalKey)) continue;
|
|
31155
|
+
if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
|
|
31156
|
+
if (value === void 0) continue;
|
|
31157
|
+
topLevelPassthrough[key] = value;
|
|
31158
|
+
}
|
|
31159
|
+
if (Object.keys(topLevelPassthrough).length > 0) config.claudecode = {
|
|
29720
31160
|
...config.claudecode,
|
|
29721
|
-
|
|
31161
|
+
...topLevelPassthrough
|
|
29722
31162
|
};
|
|
29723
31163
|
return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
|
|
29724
31164
|
}
|
|
@@ -34637,11 +36077,8 @@ const TOOL_KEY_TO_CATEGORY = {
|
|
|
34637
36077
|
updateConfluencePage: "edit"
|
|
34638
36078
|
};
|
|
34639
36079
|
const MANAGED_TOOL_KEYS = [.../* @__PURE__ */ new Set([...Object.values(CATEGORY_TO_TOOL_KEYS).flat(), ...Object.keys(TOOL_KEY_TO_CATEGORY)])];
|
|
34640
|
-
const OWNED_TOOL_PERMISSION_KEYS = [
|
|
34641
|
-
|
|
34642
|
-
"allowedExternalPaths",
|
|
34643
|
-
"default"
|
|
34644
|
-
];
|
|
36080
|
+
const OWNED_TOOL_PERMISSION_KEYS = ["allowedExternalPaths", "default"];
|
|
36081
|
+
const MANAGED_BASH_KEYS = ["default", "commands"];
|
|
34645
36082
|
/**
|
|
34646
36083
|
* Permissions adapter for Rovo Dev CLI.
|
|
34647
36084
|
*
|
|
@@ -34777,6 +36214,22 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
|
|
|
34777
36214
|
}
|
|
34778
36215
|
};
|
|
34779
36216
|
/**
|
|
36217
|
+
* Report a `toolPermissions.bash.runInSandbox: false` that this generate is
|
|
36218
|
+
* about to carry through.
|
|
36219
|
+
*
|
|
36220
|
+
* The unmanaged `bash` siblings survive untouched, which for this one means a
|
|
36221
|
+
* generate is not the reset a user might read it as: every command the agent
|
|
36222
|
+
* runs stays outside the sandbox. Rulesync never authors the key and will not
|
|
36223
|
+
* start owning it, but a setting that persists on a committed file and loosens
|
|
36224
|
+
* containment should not do so without saying anything — least of all on the
|
|
36225
|
+
* path where the user just tightened `.rulesync/permissions.*`.
|
|
36226
|
+
*/
|
|
36227
|
+
function warnAboutPreservedSandboxOptOut({ existingToolPermissions, filePath, logger }) {
|
|
36228
|
+
const existingBash = existingToolPermissions.bash;
|
|
36229
|
+
if (!isRecord$1(existingBash) || existingBash.runInSandbox !== false) return;
|
|
36230
|
+
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.`);
|
|
36231
|
+
}
|
|
36232
|
+
/**
|
|
34780
36233
|
* Resolve the `toolPermissions` block to write, merging the generated levels
|
|
34781
36234
|
* over the existing file. Every other top-level key of `config.yml` is the
|
|
34782
36235
|
* caller's to preserve; inside this block, keys rulesync manages are owned and
|
|
@@ -34784,6 +36237,11 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
|
|
|
34784
36237
|
*/
|
|
34785
36238
|
function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
|
|
34786
36239
|
const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
|
|
36240
|
+
warnAboutPreservedSandboxOptOut({
|
|
36241
|
+
existingToolPermissions,
|
|
36242
|
+
filePath,
|
|
36243
|
+
logger
|
|
36244
|
+
});
|
|
34787
36245
|
if (Object.keys(generated).length === 0 && sourceStatesRules) {
|
|
34788
36246
|
if (!isRecord$1(existing)) return;
|
|
34789
36247
|
const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
|
|
@@ -34792,9 +36250,12 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
|
|
|
34792
36250
|
}
|
|
34793
36251
|
const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
|
|
34794
36252
|
const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
|
|
36253
|
+
const hasExistingBashRecord = isRecord$1(existingToolPermissions.bash);
|
|
36254
|
+
const existingBash = hasExistingBashRecord ? { ...existingToolPermissions.bash } : {};
|
|
34795
36255
|
warnAboutDroppedOwnedKeys({
|
|
34796
36256
|
existingToolPermissions,
|
|
34797
36257
|
existingTools,
|
|
36258
|
+
existingBash,
|
|
34798
36259
|
generated,
|
|
34799
36260
|
filePath,
|
|
34800
36261
|
logger
|
|
@@ -34804,15 +36265,22 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
|
|
|
34804
36265
|
delete existingToolPermissions[toolKey];
|
|
34805
36266
|
delete existingTools[toolKey];
|
|
34806
36267
|
}
|
|
36268
|
+
for (const bashKey of MANAGED_BASH_KEYS) delete existingBash[bashKey];
|
|
34807
36269
|
const tools = {
|
|
34808
36270
|
...existingTools,
|
|
34809
36271
|
...generated.tools
|
|
34810
36272
|
};
|
|
36273
|
+
const bash = {
|
|
36274
|
+
...existingBash,
|
|
36275
|
+
...generated.bash
|
|
36276
|
+
};
|
|
34811
36277
|
if (hasExistingToolsRecord) delete existingToolPermissions.tools;
|
|
36278
|
+
if (hasExistingBashRecord) delete existingToolPermissions.bash;
|
|
34812
36279
|
return {
|
|
34813
36280
|
...existingToolPermissions,
|
|
34814
36281
|
...generated,
|
|
34815
|
-
...Object.keys(tools).length > 0 ? { tools } : {}
|
|
36282
|
+
...Object.keys(tools).length > 0 ? { tools } : {},
|
|
36283
|
+
...Object.keys(bash).length > 0 ? { bash } : {}
|
|
34816
36284
|
};
|
|
34817
36285
|
}
|
|
34818
36286
|
/**
|
|
@@ -34821,9 +36289,14 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
|
|
|
34821
36289
|
* `/directories` and by an "always allow" answer to a prompt — so their removal
|
|
34822
36290
|
* must not be silent.
|
|
34823
36291
|
*/
|
|
34824
|
-
function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, generated, filePath, logger }) {
|
|
36292
|
+
function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, existingBash, generated, filePath, logger }) {
|
|
34825
36293
|
const newTools = generated.tools ?? {};
|
|
34826
|
-
const
|
|
36294
|
+
const newBash = generated.bash ?? {};
|
|
36295
|
+
const droppedKeys = [
|
|
36296
|
+
...OWNED_TOOL_PERMISSION_KEYS.filter((ownedKey) => existingToolPermissions[ownedKey] !== void 0 && generated[ownedKey] === void 0),
|
|
36297
|
+
...MANAGED_TOOL_KEYS.filter((toolKey) => existingTools[toolKey] !== void 0 && newTools[toolKey] === void 0).map((toolKey) => `tools.${toolKey}`),
|
|
36298
|
+
...MANAGED_BASH_KEYS.filter((bashKey) => existingBash[bashKey] !== void 0 && newBash[bashKey] === void 0).map((bashKey) => `bash.${bashKey}`)
|
|
36299
|
+
];
|
|
34827
36300
|
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.`);
|
|
34828
36301
|
}
|
|
34829
36302
|
/**
|
|
@@ -36931,10 +38404,10 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
|
|
|
36931
38404
|
var PermissionsProcessor = class extends FeatureProcessor {
|
|
36932
38405
|
toolTarget;
|
|
36933
38406
|
global;
|
|
36934
|
-
constructor({ outputRoot = process.cwd(),
|
|
38407
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
|
|
36935
38408
|
super({
|
|
36936
38409
|
outputRoot,
|
|
36937
|
-
|
|
38410
|
+
inputRoots,
|
|
36938
38411
|
dryRun,
|
|
36939
38412
|
logger
|
|
36940
38413
|
});
|
|
@@ -36944,9 +38417,17 @@ var PermissionsProcessor = class extends FeatureProcessor {
|
|
|
36944
38417
|
this.global = global;
|
|
36945
38418
|
}
|
|
36946
38419
|
async loadRulesyncFiles() {
|
|
38420
|
+
const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncPermissions.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
|
|
38421
|
+
const sourceTree = await pickLastRootWithFile({
|
|
38422
|
+
inputRoots: this.inputRoots,
|
|
38423
|
+
relativePaths,
|
|
38424
|
+
logger: this.logger,
|
|
38425
|
+
artifactName: "The permissions file"
|
|
38426
|
+
}) ?? this.inputRoots[0];
|
|
36947
38427
|
try {
|
|
36948
38428
|
return [await RulesyncPermissions.fromFile({
|
|
36949
|
-
outputRoot:
|
|
38429
|
+
outputRoot: (0, node_path.dirname)(sourceTree),
|
|
38430
|
+
relativeDirPath: (0, node_path.basename)(sourceTree),
|
|
36950
38431
|
validate: true
|
|
36951
38432
|
})];
|
|
36952
38433
|
} catch (error) {
|
|
@@ -37370,13 +38851,26 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
|
|
|
37370
38851
|
//#region src/types/dir-feature-processor.ts
|
|
37371
38852
|
var DirFeatureProcessor = class {
|
|
37372
38853
|
outputRoot;
|
|
37373
|
-
|
|
38854
|
+
/**
|
|
38855
|
+
* Ordered, non-empty list of rulesync source-tree directories. Each entry
|
|
38856
|
+
* is a source tree itself — the directory that directly contains
|
|
38857
|
+
* feature subdirectories (`rules/`, `skills/`, …) and single-file
|
|
38858
|
+
* features (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence
|
|
38859
|
+
* when two trees supply the same relative path. Defaults to
|
|
38860
|
+
* `[join(process.cwd(), ".rulesync")]`.
|
|
38861
|
+
*
|
|
38862
|
+
* The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
|
|
38863
|
+
* `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
|
|
38864
|
+
* and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
|
|
38865
|
+
* reaches a processor.
|
|
38866
|
+
*/
|
|
38867
|
+
inputRoots;
|
|
37374
38868
|
dryRun;
|
|
37375
38869
|
avoidBlockScalars;
|
|
37376
38870
|
logger;
|
|
37377
|
-
constructor({ outputRoot = process.cwd(),
|
|
38871
|
+
constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, avoidBlockScalars = false, logger }) {
|
|
37378
38872
|
this.outputRoot = outputRoot;
|
|
37379
|
-
this.
|
|
38873
|
+
this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
|
|
37380
38874
|
this.dryRun = dryRun;
|
|
37381
38875
|
this.avoidBlockScalars = avoidBlockScalars;
|
|
37382
38876
|
this.logger = logger;
|
|
@@ -39635,9 +41129,9 @@ var DevinSkill = class DevinSkill extends ToolSkill {
|
|
|
39635
41129
|
* slug is owned by the commands feature: it must not be imported as a
|
|
39636
41130
|
* skill nor deleted as an orphan skill.
|
|
39637
41131
|
*/
|
|
39638
|
-
static async isDirOwned({ dirName,
|
|
41132
|
+
static async isDirOwned({ dirName, inputRoots }) {
|
|
39639
41133
|
return !await rulesyncCommandSlugExists({
|
|
39640
|
-
|
|
41134
|
+
inputRoots,
|
|
39641
41135
|
dirName
|
|
39642
41136
|
});
|
|
39643
41137
|
}
|
|
@@ -39687,7 +41181,11 @@ const FactorydroidSkillFrontmatterSchema = zod_mini.z.looseObject({
|
|
|
39687
41181
|
"user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
39688
41182
|
"disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
|
|
39689
41183
|
enabled: zod_mini.z.optional(zod_mini.z.boolean()),
|
|
39690
|
-
"allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
|
|
41184
|
+
"allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
41185
|
+
license: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
41186
|
+
compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
41187
|
+
metadata: zod_mini.z.optional(zod_mini.z.unknown()),
|
|
41188
|
+
version: zod_mini.z.optional(zod_mini.z.unknown())
|
|
39691
41189
|
});
|
|
39692
41190
|
/**
|
|
39693
41191
|
* Represents a Factory Droid skill directory.
|
|
@@ -39740,16 +41238,10 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
|
|
|
39740
41238
|
};
|
|
39741
41239
|
}
|
|
39742
41240
|
toRulesyncSkill() {
|
|
39743
|
-
const
|
|
39744
|
-
const factorydroidBlock = {
|
|
39745
|
-
...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
|
|
39746
|
-
...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
|
|
39747
|
-
...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
|
|
39748
|
-
...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
|
|
39749
|
-
};
|
|
41241
|
+
const { name, description, ...factorydroidBlock } = this.getFrontmatter();
|
|
39750
41242
|
const rulesyncFrontmatter = {
|
|
39751
|
-
name
|
|
39752
|
-
description
|
|
41243
|
+
name,
|
|
41244
|
+
description,
|
|
39753
41245
|
targets: ["*"],
|
|
39754
41246
|
...Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }
|
|
39755
41247
|
};
|
|
@@ -39776,13 +41268,13 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
|
|
|
39776
41268
|
rootFrontmatter: rulesyncFrontmatter,
|
|
39777
41269
|
section: factorydroidSection
|
|
39778
41270
|
});
|
|
41271
|
+
const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
|
|
39779
41272
|
const factorydroidFrontmatter = {
|
|
39780
41273
|
name: rulesyncFrontmatter.name,
|
|
39781
41274
|
description: rulesyncFrontmatter.description,
|
|
41275
|
+
...section,
|
|
39782
41276
|
...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
|
|
39783
|
-
...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
|
|
39784
|
-
...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
|
|
39785
|
-
...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
|
|
41277
|
+
...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
|
|
39786
41278
|
};
|
|
39787
41279
|
return new FactorydroidSkill({
|
|
39788
41280
|
outputRoot,
|
|
@@ -40194,7 +41686,10 @@ var JunieSkill = class JunieSkill extends ToolSkill {
|
|
|
40194
41686
|
}
|
|
40195
41687
|
}
|
|
40196
41688
|
static getSettablePaths(_options) {
|
|
40197
|
-
return {
|
|
41689
|
+
return {
|
|
41690
|
+
relativeDirPath: JUNIE_SKILLS_DIR_PATH,
|
|
41691
|
+
importOnlySkillRoots: [AGENTSMD_SKILLS_DIR_PATH]
|
|
41692
|
+
};
|
|
40198
41693
|
}
|
|
40199
41694
|
getFrontmatter() {
|
|
40200
41695
|
return JunieSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
|
|
@@ -42356,9 +43851,9 @@ var WarpSkill = class WarpSkill extends ToolSkill {
|
|
|
42356
43851
|
* slug is owned by the commands feature: it must not be imported as a
|
|
42357
43852
|
* skill nor deleted as an orphan skill.
|
|
42358
43853
|
*/
|
|
42359
|
-
static async isDirOwned({ dirName,
|
|
43854
|
+
static async isDirOwned({ dirName, inputRoots }) {
|
|
42360
43855
|
return !await rulesyncCommandSlugExists({
|
|
42361
|
-
|
|
43856
|
+
inputRoots,
|
|
42362
43857
|
dirName
|
|
42363
43858
|
});
|
|
42364
43859
|
}
|
|
@@ -42916,10 +44411,10 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
42916
44411
|
toolTarget;
|
|
42917
44412
|
global;
|
|
42918
44413
|
getFactory;
|
|
42919
|
-
constructor({ outputRoot = process.cwd(),
|
|
44414
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
|
|
42920
44415
|
super({
|
|
42921
44416
|
outputRoot,
|
|
42922
|
-
|
|
44417
|
+
inputRoots,
|
|
42923
44418
|
dryRun,
|
|
42924
44419
|
avoidBlockScalars: toolTarget === "cursor",
|
|
42925
44420
|
logger
|
|
@@ -42957,39 +44452,67 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
42957
44452
|
return rulesyncSkills;
|
|
42958
44453
|
}
|
|
42959
44454
|
/**
|
|
42960
|
-
*
|
|
42961
|
-
*
|
|
42962
|
-
*
|
|
42963
|
-
*
|
|
44455
|
+
* Load rulesync skill directories from a single source-tree's `skills/`
|
|
44456
|
+
* (and `skills/.curated/`) subtree. `sourceTree` is the source tree
|
|
44457
|
+
* itself (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`). Intra-tree:
|
|
44458
|
+
* local skills take precedence over curated skills with the same name.
|
|
42964
44459
|
*/
|
|
42965
|
-
async
|
|
42966
|
-
const
|
|
44460
|
+
async loadRulesyncDirsForRoot(sourceTree) {
|
|
44461
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
44462
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
44463
|
+
const treeSkillsDirPath = (0, node_path.join)(treeName, SKILLS_FEATURE_SUBDIR);
|
|
44464
|
+
const treeCuratedSkillsDirPath = (0, node_path.join)(treeName, CURATED_SKILLS_FEATURE_SUBDIR);
|
|
44465
|
+
const localDirNames = [...await getLocalSkillDirNames(sourceTree)];
|
|
42967
44466
|
const localSkills = await Promise.all(localDirNames.map((dirName) => RulesyncSkill.fromDir({
|
|
42968
|
-
outputRoot:
|
|
44467
|
+
outputRoot: treeParent,
|
|
44468
|
+
relativeDirPath: treeSkillsDirPath,
|
|
42969
44469
|
dirName,
|
|
42970
44470
|
global: this.global
|
|
42971
44471
|
})));
|
|
42972
|
-
const
|
|
42973
|
-
const curatedDirPath = (0, node_path.join)(
|
|
44472
|
+
const localSkillNamesByIdentity = groupSpellingsByCaseFoldedIdentity(localDirNames);
|
|
44473
|
+
const curatedDirPath = (0, node_path.join)(sourceTree, CURATED_SKILLS_FEATURE_SUBDIR);
|
|
42974
44474
|
let curatedSkills = [];
|
|
42975
44475
|
if (await directoryExists(curatedDirPath)) {
|
|
42976
44476
|
const nonConflicting = (await findFilesByGlobs((0, node_path.join)(curatedDirPath, "*"), { type: "dir" })).map((path) => (0, node_path.basename)(path)).filter((name) => {
|
|
42977
|
-
|
|
42978
|
-
|
|
42979
|
-
|
|
42980
|
-
|
|
42981
|
-
|
|
44477
|
+
const spellings = localSkillNamesByIdentity.get(caseFoldIdentity(name));
|
|
44478
|
+
if (spellings === void 0) return true;
|
|
44479
|
+
if (spellings.includes(name)) this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
|
|
44480
|
+
else this.logger.warn(formatCuratedCaseCollisionWarning({
|
|
44481
|
+
artifactKind: "skill",
|
|
44482
|
+
entryNoun: "skill",
|
|
44483
|
+
treeDirPath: treeSkillsDirPath,
|
|
44484
|
+
curatedSpelling: name,
|
|
44485
|
+
localSpellings: spellings
|
|
44486
|
+
}));
|
|
44487
|
+
return false;
|
|
42982
44488
|
});
|
|
42983
|
-
const curatedRelativeDirPath = RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH;
|
|
42984
44489
|
curatedSkills = await Promise.all(nonConflicting.map((dirName) => RulesyncSkill.fromDir({
|
|
42985
|
-
outputRoot:
|
|
42986
|
-
relativeDirPath:
|
|
44490
|
+
outputRoot: treeParent,
|
|
44491
|
+
relativeDirPath: treeCuratedSkillsDirPath,
|
|
42987
44492
|
dirName,
|
|
42988
44493
|
global: this.global
|
|
42989
44494
|
})));
|
|
42990
44495
|
}
|
|
42991
|
-
|
|
42992
|
-
|
|
44496
|
+
return [...localSkills, ...curatedSkills];
|
|
44497
|
+
}
|
|
44498
|
+
/**
|
|
44499
|
+
* Implementation of abstract method from DirFeatureProcessor.
|
|
44500
|
+
*
|
|
44501
|
+
* Load and parse rulesync skill directories from every configured input
|
|
44502
|
+
* root's `.rulesync/skills/` tree (each root also honours its own
|
|
44503
|
+
* `.curated/` subdirectory). When two roots supply a skill with the same
|
|
44504
|
+
* directory name, the later root's skill replaces the earlier root's copy
|
|
44505
|
+
* atomically (companion files included) — an overlay always ships a whole
|
|
44506
|
+
* skill directory, never a partial patch.
|
|
44507
|
+
*/
|
|
44508
|
+
async loadRulesyncDirs() {
|
|
44509
|
+
const allSkills = mergeByCaseInsensitiveIdentity({
|
|
44510
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncDirsForRoot(root))),
|
|
44511
|
+
identity: (skill) => skill.getDirName(),
|
|
44512
|
+
artifactName: "skill",
|
|
44513
|
+
logger: this.logger
|
|
44514
|
+
});
|
|
44515
|
+
this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills`);
|
|
42993
44516
|
return allSkills;
|
|
42994
44517
|
}
|
|
42995
44518
|
/**
|
|
@@ -43005,7 +44528,17 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43005
44528
|
}) : [];
|
|
43006
44529
|
const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
|
|
43007
44530
|
const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
|
|
43008
|
-
const
|
|
44531
|
+
const claimedSkillNames = new ClaimedIdentities();
|
|
44532
|
+
const claimSkillName = ({ skill, relativeDirPath, sourcePath }) => {
|
|
44533
|
+
const skillName = skill.getImportIdentity();
|
|
44534
|
+
const claimed = claimedSkillNames.claim({
|
|
44535
|
+
identity: skillName,
|
|
44536
|
+
source: relativeDirPath
|
|
44537
|
+
});
|
|
44538
|
+
if (claimed === null) return true;
|
|
44539
|
+
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.`);
|
|
44540
|
+
return false;
|
|
44541
|
+
};
|
|
43009
44542
|
const toolSkills = [];
|
|
43010
44543
|
for (const root of roots) {
|
|
43011
44544
|
const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
|
|
@@ -43021,54 +44554,60 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43021
44554
|
outputRoot: rootOutputRoot,
|
|
43022
44555
|
relativeDirPath,
|
|
43023
44556
|
dirName,
|
|
43024
|
-
|
|
44557
|
+
inputRoots: this.inputRoots
|
|
43025
44558
|
})) continue;
|
|
43026
44559
|
ownedDirNames.push(dirName);
|
|
43027
44560
|
}
|
|
43028
44561
|
const directorySkills = (await Promise.all(ownedDirNames.map(async (dirName) => {
|
|
44562
|
+
const sourcePath = (0, node_path.join)(relativeDirPath, dirName);
|
|
43029
44563
|
try {
|
|
43030
|
-
return
|
|
43031
|
-
|
|
43032
|
-
|
|
43033
|
-
|
|
43034
|
-
|
|
43035
|
-
|
|
44564
|
+
return {
|
|
44565
|
+
skill: await factory.class.fromDir({
|
|
44566
|
+
outputRoot: rootOutputRoot,
|
|
44567
|
+
relativeDirPath,
|
|
44568
|
+
dirName,
|
|
44569
|
+
global: this.global
|
|
44570
|
+
}),
|
|
44571
|
+
sourcePath
|
|
44572
|
+
};
|
|
43036
44573
|
} catch (error) {
|
|
43037
44574
|
if (!isLenientRoot) throw error;
|
|
43038
|
-
this.logger.warn(`Skipping ${
|
|
44575
|
+
this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
|
|
43039
44576
|
return null;
|
|
43040
44577
|
}
|
|
43041
|
-
}))).filter((
|
|
43042
|
-
for (const skill of directorySkills) {
|
|
43043
|
-
|
|
43044
|
-
|
|
43045
|
-
|
|
43046
|
-
|
|
43047
|
-
}
|
|
44578
|
+
}))).filter((loaded) => loaded !== null);
|
|
44579
|
+
for (const { skill, sourcePath } of directorySkills) if (claimSkillName({
|
|
44580
|
+
skill,
|
|
44581
|
+
relativeDirPath,
|
|
44582
|
+
sourcePath
|
|
44583
|
+
})) toolSkills.push(skill);
|
|
43048
44584
|
if (!factory.class.fromFlatFile) continue;
|
|
43049
44585
|
const fromFlatFile = factory.class.fromFlatFile;
|
|
43050
44586
|
const directoryStems = new Set(ownedDirNames);
|
|
43051
44587
|
const flatFilePaths = (await findFilesByGlobs((0, node_path.join)(skillsDirPath, "*.md"), { type: "file" })).filter((filePath) => !directoryStems.has((0, node_path.basename)(filePath, ".md")));
|
|
43052
44588
|
const flatSkills = (await Promise.all(flatFilePaths.map(async (filePath) => {
|
|
44589
|
+
const sourcePath = (0, node_path.join)(relativeDirPath, (0, node_path.basename)(filePath));
|
|
43053
44590
|
try {
|
|
43054
|
-
return
|
|
43055
|
-
|
|
43056
|
-
|
|
43057
|
-
|
|
43058
|
-
|
|
43059
|
-
|
|
44591
|
+
return {
|
|
44592
|
+
skill: await fromFlatFile({
|
|
44593
|
+
outputRoot: rootOutputRoot,
|
|
44594
|
+
relativeDirPath,
|
|
44595
|
+
relativeFilePath: (0, node_path.basename)(filePath),
|
|
44596
|
+
global: this.global
|
|
44597
|
+
}),
|
|
44598
|
+
sourcePath
|
|
44599
|
+
};
|
|
43060
44600
|
} catch (error) {
|
|
43061
44601
|
if (!isLenientRoot) throw error;
|
|
43062
|
-
this.logger.warn(`Skipping ${
|
|
44602
|
+
this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
|
|
43063
44603
|
return null;
|
|
43064
44604
|
}
|
|
43065
|
-
}))).filter((
|
|
43066
|
-
for (const skill of flatSkills) {
|
|
43067
|
-
|
|
43068
|
-
|
|
43069
|
-
|
|
43070
|
-
|
|
43071
|
-
}
|
|
44605
|
+
}))).filter((loaded) => loaded !== null);
|
|
44606
|
+
for (const { skill, sourcePath } of flatSkills) if (claimSkillName({
|
|
44607
|
+
skill,
|
|
44608
|
+
relativeDirPath,
|
|
44609
|
+
sourcePath
|
|
44610
|
+
})) toolSkills.push(skill);
|
|
43072
44611
|
}
|
|
43073
44612
|
this.logger.debug(`Successfully loaded ${toolSkills.length} skills from ${roots.length} root(s)`);
|
|
43074
44613
|
return toolSkills;
|
|
@@ -43098,7 +44637,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
43098
44637
|
outputRoot: this.outputRoot,
|
|
43099
44638
|
relativeDirPath: root,
|
|
43100
44639
|
dirName,
|
|
43101
|
-
|
|
44640
|
+
inputRoots: this.inputRoots
|
|
43102
44641
|
})) continue;
|
|
43103
44642
|
const toolSkill = factory.class.forDeletion({
|
|
43104
44643
|
outputRoot: this.outputRoot,
|
|
@@ -45856,6 +47395,7 @@ const JunieSubagentFrontmatterSchema = zod_mini.z.looseObject({
|
|
|
45856
47395
|
disallowedTools: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
45857
47396
|
mcpServers: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
45858
47397
|
model: zod_mini.z.optional(zod_mini.z.string()),
|
|
47398
|
+
permissionMode: zod_mini.z.optional(zod_mini.z.string()),
|
|
45859
47399
|
reasoningLevel: zod_mini.z.optional(zod_mini.z.string()),
|
|
45860
47400
|
maxTurns: zod_mini.z.optional(zod_mini.z.number()),
|
|
45861
47401
|
skills: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
|
|
@@ -47836,14 +49376,25 @@ const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((targe
|
|
|
47836
49376
|
const subagentsProcessorToolTargetsGlobal = allToolTargetKeys$1.filter((target) => {
|
|
47837
49377
|
return toolSubagentFactories.get(target)?.meta.supportsGlobal ?? false;
|
|
47838
49378
|
});
|
|
49379
|
+
/**
|
|
49380
|
+
* Stands in for a discovery root when a subagent came from a tool's own config
|
|
49381
|
+
* file rather than a directory (see `loadAdditionalImportFiles`). The angle
|
|
49382
|
+
* brackets keep it from ever matching a real relative directory path.
|
|
49383
|
+
*/
|
|
49384
|
+
const INLINE_SOURCE = "<inline>";
|
|
49385
|
+
/**
|
|
49386
|
+
* The single "root" of the post-conversion output guard, which de-duplicates
|
|
49387
|
+
* `.rulesync/subagents/` paths rather than discovery roots.
|
|
49388
|
+
*/
|
|
49389
|
+
const OUTPUT_SOURCE = "<output>";
|
|
47839
49390
|
var SubagentsProcessor = class extends FeatureProcessor {
|
|
47840
49391
|
toolTarget;
|
|
47841
49392
|
global;
|
|
47842
49393
|
getFactory;
|
|
47843
|
-
constructor({ outputRoot = process.cwd(),
|
|
49394
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
|
|
47844
49395
|
super({
|
|
47845
49396
|
outputRoot,
|
|
47846
|
-
|
|
49397
|
+
inputRoots,
|
|
47847
49398
|
dryRun,
|
|
47848
49399
|
logger
|
|
47849
49400
|
});
|
|
@@ -47889,24 +49440,31 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
47889
49440
|
rulesyncSubagents.push(toolSubagent.toRulesyncSubagent());
|
|
47890
49441
|
}
|
|
47891
49442
|
const uniqueRulesyncSubagents = [];
|
|
47892
|
-
const
|
|
49443
|
+
const claimedOutputPaths = new ClaimedIdentities();
|
|
47893
49444
|
for (const rulesyncSubagent of rulesyncSubagents) {
|
|
47894
49445
|
const outputPath = (0, node_path.join)(rulesyncSubagent.getRelativeDirPath(), rulesyncSubagent.getRelativeFilePath());
|
|
47895
|
-
|
|
47896
|
-
|
|
49446
|
+
const claimed = claimedOutputPaths.claim({
|
|
49447
|
+
identity: outputPath,
|
|
49448
|
+
source: OUTPUT_SOURCE
|
|
49449
|
+
});
|
|
49450
|
+
if (claimed !== null) {
|
|
49451
|
+
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.`);
|
|
47897
49452
|
continue;
|
|
47898
49453
|
}
|
|
47899
|
-
seenOutputPaths.add(outputPath);
|
|
47900
49454
|
uniqueRulesyncSubagents.push(rulesyncSubagent);
|
|
47901
49455
|
}
|
|
47902
49456
|
return uniqueRulesyncSubagents;
|
|
47903
49457
|
}
|
|
47904
49458
|
/**
|
|
47905
|
-
*
|
|
47906
|
-
*
|
|
49459
|
+
* Load subagent files from a single source-tree's `subagents/` subtree.
|
|
49460
|
+
* `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
|
|
49461
|
+
* `/repo/.rulesync.local`).
|
|
47907
49462
|
*/
|
|
47908
|
-
async
|
|
47909
|
-
const
|
|
49463
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
49464
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
49465
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
49466
|
+
const treeSubagentsDirPath = (0, node_path.join)(treeName, SUBAGENTS_FEATURE_SUBDIR);
|
|
49467
|
+
const subagentsDir = (0, node_path.join)(sourceTree, SUBAGENTS_FEATURE_SUBDIR);
|
|
47910
49468
|
if (!await directoryExists(subagentsDir)) {
|
|
47911
49469
|
this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
|
|
47912
49470
|
return [];
|
|
@@ -47922,7 +49480,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
47922
49480
|
const filepath = (0, node_path.join)(subagentsDir, mdFile);
|
|
47923
49481
|
try {
|
|
47924
49482
|
const rulesyncSubagent = await RulesyncSubagent.fromFile({
|
|
47925
|
-
outputRoot:
|
|
49483
|
+
outputRoot: treeParent,
|
|
49484
|
+
relativeDirPath: treeSubagentsDirPath,
|
|
47926
49485
|
relativeFilePath: mdFile,
|
|
47927
49486
|
validate: true
|
|
47928
49487
|
});
|
|
@@ -47933,8 +49492,24 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
47933
49492
|
continue;
|
|
47934
49493
|
}
|
|
47935
49494
|
}
|
|
49495
|
+
return rulesyncSubagents;
|
|
49496
|
+
}
|
|
49497
|
+
/**
|
|
49498
|
+
* Implementation of abstract method from Processor
|
|
49499
|
+
* Load and parse rulesync subagent files from every configured input root's
|
|
49500
|
+
* `.rulesync/subagents/` directory, merging by relative file path so a
|
|
49501
|
+
* subagent with the same target path from a later root replaces the
|
|
49502
|
+
* earlier root's copy.
|
|
49503
|
+
*/
|
|
49504
|
+
async loadRulesyncFiles() {
|
|
49505
|
+
const rulesyncSubagents = mergeByCaseInsensitiveIdentity({
|
|
49506
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
49507
|
+
identity: (subagent) => subagent.getRelativeFilePath(),
|
|
49508
|
+
artifactName: "subagent",
|
|
49509
|
+
logger: this.logger
|
|
49510
|
+
});
|
|
47936
49511
|
if (rulesyncSubagents.length === 0) {
|
|
47937
|
-
this.logger.debug(`No valid subagents found
|
|
49512
|
+
this.logger.debug(`No valid subagents found`);
|
|
47938
49513
|
return [];
|
|
47939
49514
|
}
|
|
47940
49515
|
this.logger.debug(`Successfully loaded ${rulesyncSubagents.length} rulesync subagents`);
|
|
@@ -47949,7 +49524,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
47949
49524
|
const paths = factory.class.getSettablePaths({ global: this.global });
|
|
47950
49525
|
const roots = forDeletion ? [paths.relativeDirPath] : [paths.relativeDirPath, ...paths.importDirPaths ?? []];
|
|
47951
49526
|
const toolSubagents = [];
|
|
47952
|
-
const
|
|
49527
|
+
const claimedRelativeFilePaths = new ClaimedIdentities();
|
|
47953
49528
|
for (const root of roots) {
|
|
47954
49529
|
const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
|
|
47955
49530
|
const dirPath = typeof root === "string" ? root : root.relativeDirPath;
|
|
@@ -47988,37 +49563,77 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
47988
49563
|
relativeFilePath: toRelativeFilePath(path),
|
|
47989
49564
|
global: this.global
|
|
47990
49565
|
})));
|
|
47991
|
-
|
|
47992
|
-
|
|
47993
|
-
|
|
47994
|
-
|
|
47995
|
-
|
|
47996
|
-
continue;
|
|
47997
|
-
}
|
|
47998
|
-
seenRelativeFilePaths.add(key);
|
|
47999
|
-
deduped.push(subagent);
|
|
48000
|
-
}
|
|
48001
|
-
toolSubagents.push(...deduped);
|
|
49566
|
+
toolSubagents.push(...this.claimStandaloneSubagents({
|
|
49567
|
+
loaded,
|
|
49568
|
+
dirPath,
|
|
49569
|
+
claimedRelativeFilePaths
|
|
49570
|
+
}));
|
|
48002
49571
|
}
|
|
48003
49572
|
if (!forDeletion && factory.class.loadAdditionalImportFiles) {
|
|
48004
49573
|
const additionalSubagents = await factory.class.loadAdditionalImportFiles({
|
|
48005
49574
|
outputRoot: this.outputRoot,
|
|
48006
49575
|
global: this.global
|
|
48007
49576
|
});
|
|
48008
|
-
|
|
48009
|
-
|
|
48010
|
-
|
|
48011
|
-
|
|
48012
|
-
continue;
|
|
48013
|
-
}
|
|
48014
|
-
seenRelativeFilePaths.add(key);
|
|
48015
|
-
toolSubagents.push(subagent);
|
|
48016
|
-
}
|
|
49577
|
+
toolSubagents.push(...this.claimInlineSubagents({
|
|
49578
|
+
additionalSubagents,
|
|
49579
|
+
claimedRelativeFilePaths
|
|
49580
|
+
}));
|
|
48017
49581
|
}
|
|
48018
49582
|
this.logger.debug(`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${roots.length} root(s)`);
|
|
48019
49583
|
return toolSubagents;
|
|
48020
49584
|
}
|
|
48021
49585
|
/**
|
|
49586
|
+
* Keeps the subagents from one discovery root whose import identity is still
|
|
49587
|
+
* unclaimed, warning about each copy that loses. Split out of
|
|
49588
|
+
* `loadToolFiles` so the two de-duplication passes stay readable side by
|
|
49589
|
+
* side (and so that method stays within the linter's complexity budget).
|
|
49590
|
+
*
|
|
49591
|
+
* When more than one discovery root is scanned (e.g. Junie's `.junie/agents/`
|
|
49592
|
+
* plus `.agents/`), two roots can hold a subagent with the same relative
|
|
49593
|
+
* path. Downstream conversion keys by that path, so a later one would
|
|
49594
|
+
* silently overwrite an earlier one. Warn instead of failing, keeping the
|
|
49595
|
+
* earlier (higher-precedence) root's file.
|
|
49596
|
+
*/
|
|
49597
|
+
claimStandaloneSubagents({ loaded, dirPath, claimedRelativeFilePaths }) {
|
|
49598
|
+
const deduped = [];
|
|
49599
|
+
for (const subagent of loaded) {
|
|
49600
|
+
const key = subagent.getImportIdentity();
|
|
49601
|
+
const claimed = claimedRelativeFilePaths.claim({
|
|
49602
|
+
identity: key,
|
|
49603
|
+
source: dirPath
|
|
49604
|
+
});
|
|
49605
|
+
if (claimed === null) {
|
|
49606
|
+
deduped.push(subagent);
|
|
49607
|
+
continue;
|
|
49608
|
+
}
|
|
49609
|
+
const keptFrom = claimed.source === dirPath ? `the earlier one in ${dirPath}` : `the one from the higher-precedence ${claimed.source}`;
|
|
49610
|
+
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.`);
|
|
49611
|
+
}
|
|
49612
|
+
return deduped;
|
|
49613
|
+
}
|
|
49614
|
+
/**
|
|
49615
|
+
* The same claim-or-warn pass for subagents defined inline in a tool's own
|
|
49616
|
+
* config file (see `loadAdditionalImportFiles`), which are scanned after
|
|
49617
|
+
* every standalone file so a Markdown file of the same name wins.
|
|
49618
|
+
*/
|
|
49619
|
+
claimInlineSubagents({ additionalSubagents, claimedRelativeFilePaths }) {
|
|
49620
|
+
const deduped = [];
|
|
49621
|
+
for (const subagent of additionalSubagents) {
|
|
49622
|
+
const key = subagent.getImportIdentity();
|
|
49623
|
+
const claimed = claimedRelativeFilePaths.claim({
|
|
49624
|
+
identity: key,
|
|
49625
|
+
source: INLINE_SOURCE
|
|
49626
|
+
});
|
|
49627
|
+
if (claimed === null) {
|
|
49628
|
+
deduped.push(subagent);
|
|
49629
|
+
continue;
|
|
49630
|
+
}
|
|
49631
|
+
const kept = claimed.source === INLINE_SOURCE ? "the earlier inline definition" : `the standalone file in ${claimed.source}`;
|
|
49632
|
+
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.`);
|
|
49633
|
+
}
|
|
49634
|
+
return deduped;
|
|
49635
|
+
}
|
|
49636
|
+
/**
|
|
48022
49637
|
* Implementation of abstract method from FeatureProcessor
|
|
48023
49638
|
* Return the tool targets that this processor supports
|
|
48024
49639
|
*/
|
|
@@ -50948,17 +52563,32 @@ var HermesagentRule = class HermesagentRule extends ToolRule {
|
|
|
50948
52563
|
* Rule generator for JetBrains Junie AI coding agent
|
|
50949
52564
|
*
|
|
50950
52565
|
* Generates `.junie/AGENTS.md` files based on rulesync rule content. Junie CLI
|
|
50951
|
-
* resolves project guidelines
|
|
50952
|
-
* `AGENTS.md`
|
|
50953
|
-
*
|
|
50954
|
-
*
|
|
50955
|
-
*
|
|
50956
|
-
*
|
|
50957
|
-
*
|
|
52566
|
+
* resolves project guidelines in this order: `.junie/AGENTS.md` → root
|
|
52567
|
+
* `AGENTS.md` **combined with `.junie/playbook.md` and every
|
|
52568
|
+
* `.junie/rules/*.md`** → legacy `.junie/guidelines.md` / `.junie/guidelines/`.
|
|
52569
|
+
* The multi-file branch exists, but it is unreachable while `.junie/AGENTS.md`
|
|
52570
|
+
* is present: that file "is used exclusively and no other guidelines files are
|
|
52571
|
+
* combined with it". So emitting `.junie/rules/*.md` next to the root file
|
|
52572
|
+
* rulesync writes would produce files Junie never reads, and moving the root
|
|
52573
|
+
* output to project-root `AGENTS.md` would both change every existing output
|
|
52574
|
+
* path and collide with the `agentsmd` target. Non-root rules therefore stay
|
|
52575
|
+
* folded into the single root `.junie/AGENTS.md` by the RulesProcessor
|
|
52576
|
+
* (`nonRoot` is `undefined`, mirroring the warp / deepagents targets) — the
|
|
52577
|
+
* fold is lossless, since Junie loads that one file in full. The original
|
|
52578
|
+
* rationale for this shape was recorded in issue #2211 and re-confirmed
|
|
52579
|
+
* against the 2026-08-21 docs revision in issue #2728. The legacy
|
|
50958
52580
|
* `.junie/guidelines.md` is still accepted as an import fallback, but
|
|
50959
52581
|
* generation always targets `.junie/AGENTS.md`. Junie uses plain markdown
|
|
50960
52582
|
* without frontmatter requirements.
|
|
50961
52583
|
*
|
|
52584
|
+
* The multi-file branch is still read on **import**, though: a repo that
|
|
52585
|
+
* authors `.junie/rules/*.md` or `.junie/playbook.md` by hand — the live
|
|
52586
|
+
* layout whenever `.junie/AGENTS.md` is absent, which is exactly the state a
|
|
52587
|
+
* first `rulesync import` finds — declares those paths as `importOnlyRoots`
|
|
52588
|
+
* and imports them as non-root rules. They are never written back there:
|
|
52589
|
+
* generating `.junie/AGENTS.md` moves Junie onto the first branch, where the
|
|
52590
|
+
* folded root file carries the same content.
|
|
52591
|
+
*
|
|
50962
52592
|
* Global (user) scope writes a single `~/.junie/AGENTS.md` file. Junie merges
|
|
50963
52593
|
* these user-scope guidelines with the project guidelines (both are included
|
|
50964
52594
|
* and marked clearly).
|
|
@@ -50980,6 +52610,14 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
50980
52610
|
alternativeRoots: [{
|
|
50981
52611
|
relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
|
|
50982
52612
|
relativeFilePath: JUNIE_LEGACY_RULE_FILE_NAME
|
|
52613
|
+
}],
|
|
52614
|
+
importOnlyRoots: [{
|
|
52615
|
+
relativeDirPath: buildToolPath(JUNIE_DIR, JUNIE_RULES_DIR_NAME, excludeToolDir),
|
|
52616
|
+
onlyWhenRootAbsent: true
|
|
52617
|
+
}, {
|
|
52618
|
+
relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
|
|
52619
|
+
relativeFilePath: JUNIE_PLAYBOOK_FILE_NAME,
|
|
52620
|
+
onlyWhenRootAbsent: true
|
|
50983
52621
|
}]
|
|
50984
52622
|
};
|
|
50985
52623
|
}
|
|
@@ -50990,7 +52628,7 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
50990
52628
|
static isRootRelativeFilePath(relativeFilePath) {
|
|
50991
52629
|
return relativeFilePath === "AGENTS.md" || relativeFilePath === "guidelines.md";
|
|
50992
52630
|
}
|
|
50993
|
-
static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
|
|
52631
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath: relativeDirPathParam, relativeFilePath, validate = true, global = false }) {
|
|
50994
52632
|
if (global) {
|
|
50995
52633
|
const paths = this.getSettablePaths({ global: true });
|
|
50996
52634
|
if (!("root" in paths) || !paths.root) throw new Error("JunieRule global settable paths must include a root path");
|
|
@@ -51004,7 +52642,8 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51004
52642
|
root: true
|
|
51005
52643
|
});
|
|
51006
52644
|
}
|
|
51007
|
-
const
|
|
52645
|
+
const settablePaths = this.getSettablePaths();
|
|
52646
|
+
const relativeDirPath = relativeDirPathParam ?? settablePaths.root.relativeDirPath;
|
|
51008
52647
|
const relativePath = (0, node_path.join)(relativeDirPath, relativeFilePath);
|
|
51009
52648
|
const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
|
|
51010
52649
|
return new JunieRule({
|
|
@@ -51013,7 +52652,7 @@ var JunieRule = class JunieRule extends ToolRule {
|
|
|
51013
52652
|
relativeFilePath,
|
|
51014
52653
|
fileContent,
|
|
51015
52654
|
validate,
|
|
51016
|
-
root: JunieRule.isRootRelativeFilePath(relativeFilePath)
|
|
52655
|
+
root: relativeDirPath === settablePaths.root.relativeDirPath && JunieRule.isRootRelativeFilePath(relativeFilePath)
|
|
51017
52656
|
});
|
|
51018
52657
|
}
|
|
51019
52658
|
static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
|
|
@@ -53365,10 +55004,23 @@ const defaultGetFactory = (target) => {
|
|
|
53365
55004
|
if (!factory) throw new Error(`Unsupported tool target: ${target}`);
|
|
53366
55005
|
return factory;
|
|
53367
55006
|
};
|
|
53368
|
-
|
|
53369
|
-
|
|
55007
|
+
/**
|
|
55008
|
+
* How many skipped import-only paths a single warning names before it
|
|
55009
|
+
* summarizes the rest. Keeps one line readable when a rules directory holds
|
|
55010
|
+
* dozens of files.
|
|
55011
|
+
*/
|
|
55012
|
+
const MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS = 10;
|
|
55013
|
+
/**
|
|
55014
|
+
* Fall back to a tool's legacy roots when its primary root file is absent.
|
|
55015
|
+
*
|
|
55016
|
+
* The primary hits are passed in rather than globbed here, so that callers
|
|
55017
|
+
* which need "the root file Rulesync generates" — rather than "whatever root
|
|
55018
|
+
* the tool will read" — can keep the two apart. A legacy root is a file
|
|
55019
|
+
* Rulesync reads but never writes, and the difference matters to them.
|
|
55020
|
+
*/
|
|
55021
|
+
const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob) => {
|
|
53370
55022
|
if (primaryFilePaths.length > 0) return primaryFilePaths;
|
|
53371
|
-
if (alternativeRoots) return findFilesByGlobs(alternativeRoots.map(buildAltGlob));
|
|
55023
|
+
if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
|
|
53372
55024
|
return [];
|
|
53373
55025
|
};
|
|
53374
55026
|
var RulesProcessor = class extends FeatureProcessor {
|
|
@@ -53380,10 +55032,10 @@ var RulesProcessor = class extends FeatureProcessor {
|
|
|
53380
55032
|
getFactory;
|
|
53381
55033
|
skills;
|
|
53382
55034
|
featureOptions;
|
|
53383
|
-
constructor({ outputRoot = process.cwd(),
|
|
55035
|
+
constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
|
|
53384
55036
|
super({
|
|
53385
55037
|
outputRoot,
|
|
53386
|
-
|
|
55038
|
+
inputRoots,
|
|
53387
55039
|
dryRun,
|
|
53388
55040
|
logger
|
|
53389
55041
|
});
|
|
@@ -53758,25 +55410,45 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
53758
55410
|
claimedBy.set(target.toLowerCase(), source);
|
|
53759
55411
|
continue;
|
|
53760
55412
|
}
|
|
53761
|
-
this.logger.warn(`Both ${previous} and ${source} import to ${(0, node_path.join)(RULESYNC_RULES_RELATIVE_DIR_PATH, target)} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
|
|
55413
|
+
this.logger.warn(`Both ${stripControlCharacters(previous)} and ${stripControlCharacters(source)} import to ${stripControlCharacters((0, node_path.join)(RULESYNC_RULES_RELATIVE_DIR_PATH, target))} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
|
|
53762
55414
|
}
|
|
53763
55415
|
return rulesyncRules;
|
|
53764
55416
|
}
|
|
53765
55417
|
/**
|
|
53766
|
-
*
|
|
53767
|
-
*
|
|
55418
|
+
* Load rulesync rule files from a single source-tree's `rules/` (and
|
|
55419
|
+
* `rules/.curated/`) subtree. `sourceTree` is the source tree itself
|
|
55420
|
+
* (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`), NOT its parent.
|
|
55421
|
+
*
|
|
55422
|
+
* Intra-tree behavior — the local-wins-over-curated rule and the
|
|
55423
|
+
* case-insensitive collision handling — is preserved from the previous
|
|
55424
|
+
* single-root implementation. See `loadRulesyncFiles` for how the
|
|
55425
|
+
* per-root results are combined into the effective set.
|
|
53768
55426
|
*/
|
|
53769
|
-
async
|
|
53770
|
-
const
|
|
53771
|
-
const
|
|
55427
|
+
async loadRulesyncFilesForRoot(sourceTree) {
|
|
55428
|
+
const treeParent = (0, node_path.dirname)(sourceTree);
|
|
55429
|
+
const treeName = (0, node_path.basename)(sourceTree);
|
|
55430
|
+
const treeRulesDirPath = (0, node_path.join)(treeName, RULES_FEATURE_SUBDIR);
|
|
55431
|
+
const rulesyncOutputRoot = (0, node_path.join)(sourceTree, RULES_FEATURE_SUBDIR);
|
|
55432
|
+
const curatedOutputRoot = (0, node_path.join)(sourceTree, CURATED_RULES_FEATURE_SUBDIR);
|
|
53772
55433
|
const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([findFilesByGlobs((0, node_path.join)(rulesyncOutputRoot, "**", "*.md")), findFilesByGlobs((0, node_path.join)(curatedOutputRoot, "**", "*.md"))]);
|
|
53773
55434
|
const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
|
|
53774
55435
|
const localFiles = files.filter((file) => !(0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`));
|
|
53775
|
-
const
|
|
55436
|
+
const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => (0, node_path.relative)(rulesyncOutputRoot, file)));
|
|
53776
55437
|
const curatedFiles = files.filter((file) => (0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`)).map((file) => ({
|
|
53777
55438
|
file,
|
|
53778
55439
|
relativeFilePath: (0, node_path.relative)(curatedOutputRoot, file)
|
|
53779
|
-
})).filter(({ relativeFilePath }) =>
|
|
55440
|
+
})).filter(({ relativeFilePath }) => {
|
|
55441
|
+
const spellings = localRelativePathsByIdentity.get(caseFoldIdentity(relativeFilePath));
|
|
55442
|
+
if (spellings === void 0) return true;
|
|
55443
|
+
if (!spellings.includes(relativeFilePath)) this.logger.warn(formatCuratedCaseCollisionWarning({
|
|
55444
|
+
artifactKind: "rule",
|
|
55445
|
+
entryNoun: "file",
|
|
55446
|
+
treeDirPath: treeRulesDirPath,
|
|
55447
|
+
curatedSpelling: (0, node_path.join)(".curated", relativeFilePath),
|
|
55448
|
+
localSpellings: spellings
|
|
55449
|
+
}));
|
|
55450
|
+
return false;
|
|
55451
|
+
});
|
|
53780
55452
|
const selectedFiles = [...localFiles.map((file) => ({
|
|
53781
55453
|
file,
|
|
53782
55454
|
sourceRelativeFilePath: (0, node_path.relative)(rulesyncOutputRoot, file),
|
|
@@ -53786,25 +55458,41 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
53786
55458
|
sourceRelativeFilePath: (0, node_path.join)(".curated", relativeFilePath),
|
|
53787
55459
|
relativeFilePath
|
|
53788
55460
|
}))];
|
|
53789
|
-
this.logger.debug(`Found ${selectedFiles.length} rulesync files`);
|
|
53790
|
-
|
|
55461
|
+
this.logger.debug(`Found ${selectedFiles.length} rulesync files under ${rulesyncOutputRoot}`);
|
|
55462
|
+
return await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
|
|
53791
55463
|
checkPathTraversal({
|
|
53792
55464
|
relativePath: sourceRelativeFilePath,
|
|
53793
55465
|
intendedRootDir: rulesyncOutputRoot
|
|
53794
55466
|
});
|
|
53795
55467
|
const rule = await RulesyncRule.fromFile({
|
|
53796
|
-
outputRoot:
|
|
55468
|
+
outputRoot: treeParent,
|
|
55469
|
+
relativeDirPath: treeRulesDirPath,
|
|
53797
55470
|
relativeFilePath: sourceRelativeFilePath
|
|
53798
55471
|
});
|
|
53799
55472
|
if (sourceRelativeFilePath === relativeFilePath) return rule;
|
|
53800
55473
|
return new RulesyncRule({
|
|
53801
|
-
outputRoot:
|
|
53802
|
-
relativeDirPath:
|
|
55474
|
+
outputRoot: treeParent,
|
|
55475
|
+
relativeDirPath: treeRulesDirPath,
|
|
53803
55476
|
relativeFilePath,
|
|
53804
55477
|
frontmatter: rule.getFrontmatter(),
|
|
53805
55478
|
body: rule.getBody()
|
|
53806
55479
|
});
|
|
53807
55480
|
}));
|
|
55481
|
+
}
|
|
55482
|
+
/**
|
|
55483
|
+
* Implementation of abstract method from FeatureProcessor
|
|
55484
|
+
* Load and parse rulesync rule files from every configured input root's
|
|
55485
|
+
* `.rulesync/rules/` directory, merging by relative path so that a rule
|
|
55486
|
+
* with the same target path from a later root replaces the earlier root's
|
|
55487
|
+
* copy (case-insensitive, matching the intra-root collision handling).
|
|
55488
|
+
*/
|
|
55489
|
+
async loadRulesyncFiles() {
|
|
55490
|
+
const rulesyncRules = mergeByCaseInsensitiveIdentity({
|
|
55491
|
+
perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
|
|
55492
|
+
identity: (rule) => rule.getRelativeFilePath(),
|
|
55493
|
+
artifactName: "rule",
|
|
55494
|
+
logger: this.logger
|
|
55495
|
+
});
|
|
53808
55496
|
const factory = this.getFactory(this.toolTarget);
|
|
53809
55497
|
const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
|
|
53810
55498
|
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}.`);
|
|
@@ -53898,23 +55586,42 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
53898
55586
|
});
|
|
53899
55587
|
}).filter((rule) => rule.isDeletable());
|
|
53900
55588
|
};
|
|
55589
|
+
/**
|
|
55590
|
+
* Import counterpart of {@link buildDeletionRulesFromPaths} for the
|
|
55591
|
+
* root-shaped scans (root, legacy roots, and read-only roots), whose
|
|
55592
|
+
* paths all sit at a directory Rulesync resolves from the file itself.
|
|
55593
|
+
*/
|
|
55594
|
+
const buildImportRulesFromPaths = (filePaths) => Promise.all(filePaths.map((filePath) => {
|
|
55595
|
+
const relativeDirPath = resolveRelativeDirPath(filePath);
|
|
55596
|
+
checkPathTraversal({
|
|
55597
|
+
relativePath: relativeDirPath,
|
|
55598
|
+
intendedRootDir: this.outputRoot
|
|
55599
|
+
});
|
|
55600
|
+
return factory.class.fromFile({
|
|
55601
|
+
outputRoot: this.outputRoot,
|
|
55602
|
+
relativeDirPath,
|
|
55603
|
+
relativeFilePath: (0, node_path.basename)(filePath),
|
|
55604
|
+
global: this.global
|
|
55605
|
+
});
|
|
55606
|
+
}));
|
|
55607
|
+
/**
|
|
55608
|
+
* The tool's own root file, as opposed to whichever root
|
|
55609
|
+
* `rootToolRules` ends up resolving. A legacy root reached through
|
|
55610
|
+
* `alternativeRoots` is deliberately not counted here: it is a
|
|
55611
|
+
* hand-authored file Rulesync never writes, so it has folded nothing in,
|
|
55612
|
+
* and in Junie's resolution order it ranks *below* the multi-file layout
|
|
55613
|
+
* that `importOnlyRoots` describes. Gating those roots on it would drop
|
|
55614
|
+
* exactly the files the tool is really reading.
|
|
55615
|
+
*
|
|
55616
|
+
* Resolved once, up front, so the two blocks that need it do not depend
|
|
55617
|
+
* on each other's evaluation order.
|
|
55618
|
+
*/
|
|
55619
|
+
const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath)) : [];
|
|
53901
55620
|
const rootToolRules = await (async () => {
|
|
53902
55621
|
if (!settablePaths.root) return [];
|
|
53903
|
-
const uniqueRootFilePaths = await findFilesWithFallback(
|
|
55622
|
+
const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
|
|
53904
55623
|
if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
|
|
53905
|
-
return await
|
|
53906
|
-
const relativeDirPath = resolveRelativeDirPath(filePath);
|
|
53907
|
-
checkPathTraversal({
|
|
53908
|
-
relativePath: relativeDirPath,
|
|
53909
|
-
intendedRootDir: this.outputRoot
|
|
53910
|
-
});
|
|
53911
|
-
return factory.class.fromFile({
|
|
53912
|
-
outputRoot: this.outputRoot,
|
|
53913
|
-
relativeFilePath: (0, node_path.basename)(filePath),
|
|
53914
|
-
relativeDirPath,
|
|
53915
|
-
global: this.global
|
|
53916
|
-
});
|
|
53917
|
-
}));
|
|
55624
|
+
return await buildImportRulesFromPaths(uniqueRootFilePaths);
|
|
53918
55625
|
})();
|
|
53919
55626
|
this.logger.debug(`Found ${rootToolRules.length} root tool rule files`);
|
|
53920
55627
|
const localRootToolRules = await (async () => {
|
|
@@ -53926,7 +55633,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
53926
55633
|
fileName
|
|
53927
55634
|
}));
|
|
53928
55635
|
if (!settablePaths.root) return [];
|
|
53929
|
-
return await findFilesWithFallback((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, fileName));
|
|
55636
|
+
return await findFilesWithFallback(await findFilesByGlobs((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName)), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, fileName));
|
|
53930
55637
|
})();
|
|
53931
55638
|
if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
|
|
53932
55639
|
return (await Promise.all(filePaths.map(async (filePath) => {
|
|
@@ -54002,6 +55709,29 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54002
55709
|
}));
|
|
54003
55710
|
})();
|
|
54004
55711
|
this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`);
|
|
55712
|
+
const importOnlyToolRules = await (async () => {
|
|
55713
|
+
const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
|
|
55714
|
+
if (forDeletion || !importOnlyRoots || importOnlyRoots.length === 0) return [];
|
|
55715
|
+
const rootFilePath = primaryRootFilePaths[0];
|
|
55716
|
+
const scannedPaths = [];
|
|
55717
|
+
const skippedPaths = [];
|
|
55718
|
+
for (const importOnlyRoot of importOnlyRoots) {
|
|
55719
|
+
const matchedPaths = await findFilesByGlobs((0, node_path.join)(this.outputRoot, importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), { type: "file" });
|
|
55720
|
+
if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
|
|
55721
|
+
skippedPaths.push(...matchedPaths);
|
|
55722
|
+
continue;
|
|
55723
|
+
}
|
|
55724
|
+
scannedPaths.push(...matchedPaths);
|
|
55725
|
+
}
|
|
55726
|
+
if (skippedPaths.length > 0 && rootFilePath !== void 0) {
|
|
55727
|
+
const skippedNames = skippedPaths.map((filePath) => stripControlCharacters((0, node_path.relative)(this.outputRoot, filePath)));
|
|
55728
|
+
const listedNames = skippedNames.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
|
|
55729
|
+
const remainingCount = skippedNames.length - listedNames.length;
|
|
55730
|
+
this.logger.warn(`Not importing ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} for ${this.toolTarget}: ${stripControlCharacters((0, node_path.relative)(this.outputRoot, rootFilePath))} exists, and the tool reads that file exclusively. Delete them once you have checked that content is in the root file, or move it into ${RULESYNC_RULES_RELATIVE_DIR_PATH} if it is not.`);
|
|
55731
|
+
}
|
|
55732
|
+
return await buildImportRulesFromPaths(scannedPaths);
|
|
55733
|
+
})();
|
|
55734
|
+
this.logger.debug(`Found ${importOnlyToolRules.length} import-only tool rule files`);
|
|
54005
55735
|
const nonRootToolRules = await (async () => {
|
|
54006
55736
|
if (!settablePaths.nonRoot) return [];
|
|
54007
55737
|
const nonRootOutputRoot = (0, node_path.join)(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
|
|
@@ -54034,6 +55764,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
|
|
|
54034
55764
|
})();
|
|
54035
55765
|
this.logger.debug(`Found ${nonRootToolRules.length} non-root tool rule files`);
|
|
54036
55766
|
return [
|
|
55767
|
+
...importOnlyToolRules,
|
|
54037
55768
|
...rootToolRules,
|
|
54038
55769
|
...localRootToolRules,
|
|
54039
55770
|
...rootMirrorDeletionRules,
|
|
@@ -54841,14 +56572,53 @@ function warnUnsupportedTargets(params) {
|
|
|
54841
56572
|
}
|
|
54842
56573
|
}
|
|
54843
56574
|
/**
|
|
54844
|
-
*
|
|
54845
|
-
*
|
|
54846
|
-
*
|
|
54847
|
-
*
|
|
54848
|
-
*
|
|
56575
|
+
* Inspect every configured input-root path. The first entry is the required
|
|
56576
|
+
* base source tree; later entries are optional overlays and may be absent.
|
|
56577
|
+
* Each existing entry is a rulesync source tree itself (the directory that
|
|
56578
|
+
* directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). Existing empty
|
|
56579
|
+
* directories are valid because delete and check workflows still need to
|
|
56580
|
+
* inspect generated outputs.
|
|
54849
56581
|
*/
|
|
54850
|
-
async function
|
|
54851
|
-
|
|
56582
|
+
async function inspectInputRoots(inputRoots) {
|
|
56583
|
+
const existing = [];
|
|
56584
|
+
const missing = [];
|
|
56585
|
+
const invalidOverlays = [];
|
|
56586
|
+
const nonDirectories = /* @__PURE__ */ new Set();
|
|
56587
|
+
for (const [index, root] of inputRoots.entries()) if (await directoryExists(root)) existing.push(root);
|
|
56588
|
+
else {
|
|
56589
|
+
missing.push(root);
|
|
56590
|
+
if (await fileExists(root)) {
|
|
56591
|
+
nonDirectories.add(root);
|
|
56592
|
+
if (index > 0) invalidOverlays.push(root);
|
|
56593
|
+
}
|
|
56594
|
+
}
|
|
56595
|
+
const primaryRoot = inputRoots[0];
|
|
56596
|
+
const displayPrimaryRoot = stripControlCharacters(primaryRoot ?? "");
|
|
56597
|
+
if (primaryRoot === void 0 || existing.includes(primaryRoot)) {
|
|
56598
|
+
const invalidOverlay = invalidOverlays[0];
|
|
56599
|
+
return {
|
|
56600
|
+
existing,
|
|
56601
|
+
missing,
|
|
56602
|
+
message: invalidOverlay === void 0 ? void 0 : `Configured optional input root '${stripControlCharacters(invalidOverlay)}' exists but is not a directory.`
|
|
56603
|
+
};
|
|
56604
|
+
}
|
|
56605
|
+
const defaultRoot = (0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH);
|
|
56606
|
+
if (primaryRoot === defaultRoot && !nonDirectories.has(primaryRoot)) return {
|
|
56607
|
+
existing,
|
|
56608
|
+
missing,
|
|
56609
|
+
message: `Rulesync source directory '${defaultRoot}' does not exist. Run 'rulesync init' first.`
|
|
56610
|
+
};
|
|
56611
|
+
const settingHint = `your input root setting ('inputRoots', or the deprecated 'inputRoot')`;
|
|
56612
|
+
if (nonDirectories.has(primaryRoot)) return {
|
|
56613
|
+
existing,
|
|
56614
|
+
missing,
|
|
56615
|
+
message: `Configured primary input root '${displayPrimaryRoot}' exists but is not a directory. Point ${settingHint} at a directory.`
|
|
56616
|
+
};
|
|
56617
|
+
return {
|
|
56618
|
+
existing,
|
|
56619
|
+
missing,
|
|
56620
|
+
message: `Configured primary input root '${displayPrimaryRoot}' does not exist. Create the directory or update ${settingHint}.`
|
|
56621
|
+
};
|
|
54852
56622
|
}
|
|
54853
56623
|
function dependsOnReachable(byId, from, target) {
|
|
54854
56624
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -54979,7 +56749,7 @@ async function warnSkillSubagentNameCollisions(params) {
|
|
|
54979
56749
|
const subagentsDirPath = subagentFactory.class.getSettablePaths({ global }).relativeDirPath;
|
|
54980
56750
|
if (subagentsDirPath !== skillFactory.class.getSettablePaths({ global }).relativeDirPath) continue;
|
|
54981
56751
|
const subagentsProcessor = new SubagentsProcessor({
|
|
54982
|
-
|
|
56752
|
+
inputRoots: config.getInputRoots(),
|
|
54983
56753
|
toolTarget,
|
|
54984
56754
|
global,
|
|
54985
56755
|
logger
|
|
@@ -54987,7 +56757,7 @@ async function warnSkillSubagentNameCollisions(params) {
|
|
|
54987
56757
|
const subagentNames = new Set((await subagentsProcessor.loadRulesyncFiles()).filter((file) => file instanceof RulesyncSubagent).filter((file) => subagentFactory.class.isTargetedByRulesyncSubagent(file)).map((file) => (0, node_path.basename)(file.getRelativeFilePath(), (0, node_path.extname)(file.getRelativeFilePath()))));
|
|
54988
56758
|
if (subagentNames.size === 0) continue;
|
|
54989
56759
|
const skillNames = (await new SkillsProcessor({
|
|
54990
|
-
|
|
56760
|
+
inputRoots: config.getInputRoots(),
|
|
54991
56761
|
toolTarget,
|
|
54992
56762
|
global,
|
|
54993
56763
|
logger
|
|
@@ -55036,6 +56806,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
|
|
|
55036
56806
|
*/
|
|
55037
56807
|
async function generate(params) {
|
|
55038
56808
|
const { config, logger } = params;
|
|
56809
|
+
resetRootShadowingWarnings({ logger });
|
|
55039
56810
|
for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
|
|
55040
56811
|
toolTarget,
|
|
55041
56812
|
outputRoot
|
|
@@ -55174,7 +56945,7 @@ async function generateRulesCore(params) {
|
|
|
55174
56945
|
toolTarget,
|
|
55175
56946
|
global: config.getGlobal()
|
|
55176
56947
|
}),
|
|
55177
|
-
|
|
56948
|
+
inputRoots: config.getInputRoots(),
|
|
55178
56949
|
toolTarget,
|
|
55179
56950
|
global: config.getGlobal(),
|
|
55180
56951
|
simulateCommands: config.getSimulateCommands(),
|
|
@@ -55224,7 +56995,7 @@ async function generateIgnoreCore(params) {
|
|
|
55224
56995
|
for (const outputRoot of config.getOutputRoots(toolTarget)) try {
|
|
55225
56996
|
const processor = new IgnoreProcessor({
|
|
55226
56997
|
outputRoot,
|
|
55227
|
-
|
|
56998
|
+
inputRoots: config.getInputRoots(),
|
|
55228
56999
|
toolTarget,
|
|
55229
57000
|
global,
|
|
55230
57001
|
dryRun: config.isPreviewMode(),
|
|
@@ -55271,7 +57042,7 @@ async function generateMcpCore(params) {
|
|
|
55271
57042
|
toolTarget,
|
|
55272
57043
|
global: config.getGlobal()
|
|
55273
57044
|
}),
|
|
55274
|
-
|
|
57045
|
+
inputRoots: config.getInputRoots(),
|
|
55275
57046
|
toolTarget,
|
|
55276
57047
|
global: config.getGlobal(),
|
|
55277
57048
|
dryRun: config.isPreviewMode(),
|
|
@@ -55317,7 +57088,7 @@ async function generateCommandsCore(params) {
|
|
|
55317
57088
|
toolTarget,
|
|
55318
57089
|
global: config.getGlobal()
|
|
55319
57090
|
}),
|
|
55320
|
-
|
|
57091
|
+
inputRoots: config.getInputRoots(),
|
|
55321
57092
|
toolTarget,
|
|
55322
57093
|
global: config.getGlobal(),
|
|
55323
57094
|
dryRun: config.isPreviewMode(),
|
|
@@ -55364,7 +57135,7 @@ async function generateSubagentsCore(params) {
|
|
|
55364
57135
|
toolTarget,
|
|
55365
57136
|
global: config.getGlobal()
|
|
55366
57137
|
}),
|
|
55367
|
-
|
|
57138
|
+
inputRoots: config.getInputRoots(),
|
|
55368
57139
|
toolTarget,
|
|
55369
57140
|
global: config.getGlobal(),
|
|
55370
57141
|
dryRun: config.isPreviewMode(),
|
|
@@ -55411,7 +57182,7 @@ async function generateSkillsCore(params) {
|
|
|
55411
57182
|
toolTarget,
|
|
55412
57183
|
global: config.getGlobal()
|
|
55413
57184
|
}),
|
|
55414
|
-
|
|
57185
|
+
inputRoots: config.getInputRoots(),
|
|
55415
57186
|
toolTarget,
|
|
55416
57187
|
global: config.getGlobal(),
|
|
55417
57188
|
dryRun: config.isPreviewMode(),
|
|
@@ -55456,7 +57227,7 @@ async function generateHooksCore(params) {
|
|
|
55456
57227
|
toolTarget,
|
|
55457
57228
|
global: config.getGlobal()
|
|
55458
57229
|
}),
|
|
55459
|
-
|
|
57230
|
+
inputRoots: config.getInputRoots(),
|
|
55460
57231
|
toolTarget,
|
|
55461
57232
|
global: config.getGlobal(),
|
|
55462
57233
|
dryRun: config.isPreviewMode(),
|
|
@@ -55498,7 +57269,7 @@ async function generatePermissionsCore(params) {
|
|
|
55498
57269
|
toolTarget,
|
|
55499
57270
|
global: config.getGlobal()
|
|
55500
57271
|
}),
|
|
55501
|
-
|
|
57272
|
+
inputRoots: config.getInputRoots(),
|
|
55502
57273
|
toolTarget,
|
|
55503
57274
|
global: config.getGlobal(),
|
|
55504
57275
|
dryRun: config.isPreviewMode(),
|
|
@@ -55544,7 +57315,7 @@ async function generateChecksCore(params) {
|
|
|
55544
57315
|
toolTarget,
|
|
55545
57316
|
global: config.getGlobal()
|
|
55546
57317
|
}),
|
|
55547
|
-
|
|
57318
|
+
inputRoots: config.getInputRoots(),
|
|
55548
57319
|
toolTarget,
|
|
55549
57320
|
global: config.getGlobal(),
|
|
55550
57321
|
dryRun: config.isPreviewMode(),
|
|
@@ -56408,12 +58179,6 @@ Object.defineProperty(exports, "checkPathTraversal", {
|
|
|
56408
58179
|
return checkPathTraversal;
|
|
56409
58180
|
}
|
|
56410
58181
|
});
|
|
56411
|
-
Object.defineProperty(exports, "checkRulesyncDirExists", {
|
|
56412
|
-
enumerable: true,
|
|
56413
|
-
get: function() {
|
|
56414
|
-
return checkRulesyncDirExists;
|
|
56415
|
-
}
|
|
56416
|
-
});
|
|
56417
58182
|
Object.defineProperty(exports, "convertFromTool", {
|
|
56418
58183
|
enumerable: true,
|
|
56419
58184
|
get: function() {
|
|
@@ -56510,6 +58275,12 @@ Object.defineProperty(exports, "importFromTool", {
|
|
|
56510
58275
|
return importFromTool;
|
|
56511
58276
|
}
|
|
56512
58277
|
});
|
|
58278
|
+
Object.defineProperty(exports, "inspectInputRoots", {
|
|
58279
|
+
enumerable: true,
|
|
58280
|
+
get: function() {
|
|
58281
|
+
return inspectInputRoots;
|
|
58282
|
+
}
|
|
58283
|
+
});
|
|
56513
58284
|
Object.defineProperty(exports, "isPackagingToolTarget", {
|
|
56514
58285
|
enumerable: true,
|
|
56515
58286
|
get: function() {
|
|
@@ -56534,6 +58305,12 @@ Object.defineProperty(exports, "loadYaml", {
|
|
|
56534
58305
|
return loadYaml;
|
|
56535
58306
|
}
|
|
56536
58307
|
});
|
|
58308
|
+
Object.defineProperty(exports, "mergeInputRootConfigs", {
|
|
58309
|
+
enumerable: true,
|
|
58310
|
+
get: function() {
|
|
58311
|
+
return mergeInputRootConfigs;
|
|
58312
|
+
}
|
|
58313
|
+
});
|
|
56537
58314
|
Object.defineProperty(exports, "parseJsonc", {
|
|
56538
58315
|
enumerable: true,
|
|
56539
58316
|
get: function() {
|
|
@@ -56582,6 +58359,12 @@ Object.defineProperty(exports, "removeTempDirectory", {
|
|
|
56582
58359
|
return removeTempDirectory;
|
|
56583
58360
|
}
|
|
56584
58361
|
});
|
|
58362
|
+
Object.defineProperty(exports, "resolveEffectiveInputRoots", {
|
|
58363
|
+
enumerable: true,
|
|
58364
|
+
get: function() {
|
|
58365
|
+
return resolveEffectiveInputRoots;
|
|
58366
|
+
}
|
|
58367
|
+
});
|
|
56585
58368
|
Object.defineProperty(exports, "resolvePath", {
|
|
56586
58369
|
enumerable: true,
|
|
56587
58370
|
get: function() {
|
|
@@ -56606,6 +58389,12 @@ Object.defineProperty(exports, "stringifyFrontmatter", {
|
|
|
56606
58389
|
return stringifyFrontmatter;
|
|
56607
58390
|
}
|
|
56608
58391
|
});
|
|
58392
|
+
Object.defineProperty(exports, "stripControlCharacters", {
|
|
58393
|
+
enumerable: true,
|
|
58394
|
+
get: function() {
|
|
58395
|
+
return stripControlCharacters;
|
|
58396
|
+
}
|
|
58397
|
+
});
|
|
56609
58398
|
Object.defineProperty(exports, "toPosixPath", {
|
|
56610
58399
|
enumerable: true,
|
|
56611
58400
|
get: function() {
|