rulesync 9.6.2 → 9.7.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.
@@ -8,6 +8,7 @@ import { intersection, kebabCase, uniq } from "es-toolkit";
8
8
  import { globbySync } from "globby";
9
9
  import { isDeepStrictEqual } from "node:util";
10
10
  import * as smolToml from "smol-toml";
11
+ import { parse as parse$1, stringify } from "smol-toml";
11
12
  import matter from "gray-matter";
12
13
  import { YAMLException, dump, load } from "js-yaml";
13
14
  import { omit } from "es-toolkit/object";
@@ -2677,6 +2678,13 @@ const CODEXCLI_MCP_FILE_NAME = "config.toml";
2677
2678
  const CODEXCLI_RULE_FILE_NAME = "AGENTS.md";
2678
2679
  const CODEXCLI_BASH_RULES_FILE_NAME = "rulesync.rules";
2679
2680
  const CODEXCLI_OPENAI_YAML_RELATIVE_PATH = join("agents", "openai.yaml");
2681
+ const CODEXCLI_OVERRIDE_KEYS = [
2682
+ "approval_policy",
2683
+ "sandbox_mode",
2684
+ "sandbox_workspace_write",
2685
+ "apps",
2686
+ "approvals_reviewer"
2687
+ ];
2680
2688
  //#endregion
2681
2689
  //#region src/features/commands/codexcli-command.ts
2682
2690
  const CodexcliCommandFrontmatterSchema = z.looseObject({
@@ -4055,14 +4063,22 @@ function sanitizeSharedConfigValue(value) {
4055
4063
  * root follows `invalidRootPolicy`. Syntax errors are wrapped with the file
4056
4064
  * path when one is given.
4057
4065
  */
4058
- function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy = "coerce-empty" }) {
4066
+ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy = "coerce-empty", jsoncParseErrors = "tolerate" }) {
4059
4067
  if (fileContent.trim() === "") return {};
4060
4068
  const at = filePath === void 0 ? "" : ` at ${filePath}`;
4061
4069
  let parsed;
4062
4070
  try {
4063
4071
  if (format === "yaml") parsed = loadYaml(fileContent);
4072
+ else if (format === "toml") parsed = parse$1(fileContent);
4064
4073
  else if (format === "json") parsed = JSON.parse(fileContent);
4065
- else parsed = parse(fileContent);
4074
+ else if (jsoncParseErrors === "error") {
4075
+ const errors = [];
4076
+ parsed = parse(fileContent, errors, { allowTrailingComma: true });
4077
+ if (errors.length > 0) {
4078
+ const details = errors.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`).join(", ");
4079
+ throw new Error(details);
4080
+ }
4081
+ } else parsed = parse(fileContent);
4066
4082
  } catch (error) {
4067
4083
  throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
4068
4084
  }
@@ -4076,13 +4092,15 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
4076
4092
  /**
4077
4093
  * Serialize a shared config document. YAML output always ends with exactly one
4078
4094
  * newline; JSON output matches the 2-space `JSON.stringify` shape the JSON
4079
- * writers have always emitted (no trailing newline).
4095
+ * writers have always emitted (no trailing newline); TOML output matches the
4096
+ * `smol-toml` `stringify` shape the TOML writers have always emitted.
4080
4097
  */
4081
4098
  function stringifySharedConfig({ format, document }) {
4082
4099
  if (format === "yaml") return dump(document, {
4083
4100
  noRefs: true,
4084
4101
  sortKeys: false
4085
4102
  }).trimEnd() + "\n";
4103
+ if (format === "toml") return stringify(document);
4086
4104
  return JSON.stringify(document, null, 2);
4087
4105
  }
4088
4106
  /**
@@ -4121,6 +4139,25 @@ function mergeSharedConfigDeep({ base, patch }) {
4121
4139
  const CLAUDE_SETTINGS_SHARED_FILE_KEY = ".claude/settings.json";
4122
4140
  const HERMES_CONFIG_SHARED_FILE_KEY = ".hermes/config.yaml";
4123
4141
  const TAKT_CONFIG_SHARED_FILE_KEY = ".takt/config.yaml";
4142
+ const CODEXCLI_CONFIG_SHARED_FILE_KEY = ".codex/config.toml";
4143
+ const GROKCLI_CONFIG_SHARED_FILE_KEY = ".grok/config.toml";
4144
+ const VIBE_CONFIG_SHARED_FILE_KEY = ".vibe/config.toml";
4145
+ const REASONIX_PROJECT_CONFIG_SHARED_FILE_KEY = "reasonix.toml";
4146
+ const REASONIX_GLOBAL_CONFIG_SHARED_FILE_KEY = ".reasonix/config.toml";
4147
+ /**
4148
+ * Build the `SHARED_CONFIG_OWNERSHIP` lookup key from a tool's settable paths.
4149
+ * Mirrors `sharedFileKey` in `src/lib/shared-file-derive.ts` (kept separate so
4150
+ * feature classes don't pull the processor registry through this module and
4151
+ * create an import cycle); the ownership lock-step test keeps the two aligned.
4152
+ * Lets a tool whose file lives at a scope-dependent path (`.zed/settings.json`
4153
+ * vs `.config/zed/settings.json`) resolve its declaration from the settable
4154
+ * paths it already holds.
4155
+ */
4156
+ const sharedConfigFileKey = ({ relativeDirPath, relativeFilePath }) => {
4157
+ const dir = relativeDirPath.replace(/\\/g, "/").replace(/\/$/, "");
4158
+ const file = relativeFilePath.replace(/\\/g, "/");
4159
+ return dir === "" || dir === "." ? file : `${dir}/${file}`;
4160
+ };
4124
4161
  /**
4125
4162
  * Who owns what in each gateway-managed shared config file, and which policy
4126
4163
  * resolves conflicts. Keys are `dir/file` tokens matching
@@ -4177,6 +4214,298 @@ const SHARED_CONFIG_OWNERSHIP = {
4177
4214
  },
4178
4215
  permissions: { kind: "deep-merge" }
4179
4216
  }
4217
+ },
4218
+ ".zed/settings.json": {
4219
+ format: "json",
4220
+ features: {
4221
+ ignore: {
4222
+ kind: "replace-owned-keys",
4223
+ ownedKeys: ["private_files"]
4224
+ },
4225
+ mcp: {
4226
+ kind: "replace-owned-keys",
4227
+ ownedKeys: ["context_servers"]
4228
+ },
4229
+ permissions: {
4230
+ kind: "replace-owned-keys",
4231
+ ownedKeys: ["agent"]
4232
+ }
4233
+ }
4234
+ },
4235
+ ".config/zed/settings.json": {
4236
+ format: "json",
4237
+ features: {
4238
+ mcp: {
4239
+ kind: "replace-owned-keys",
4240
+ ownedKeys: ["context_servers"]
4241
+ },
4242
+ permissions: {
4243
+ kind: "replace-owned-keys",
4244
+ ownedKeys: ["agent"]
4245
+ }
4246
+ }
4247
+ },
4248
+ ".qwen/settings.json": {
4249
+ format: "json",
4250
+ features: {
4251
+ mcp: {
4252
+ kind: "replace-owned-keys",
4253
+ ownedKeys: ["mcpServers"]
4254
+ },
4255
+ hooks: {
4256
+ kind: "replace-owned-keys",
4257
+ ownedKeys: ["hooks", "disableAllHooks"]
4258
+ },
4259
+ permissions: {
4260
+ kind: "replace-owned-keys",
4261
+ ownedKeys: [
4262
+ "permissions",
4263
+ "tools",
4264
+ "security"
4265
+ ]
4266
+ }
4267
+ }
4268
+ },
4269
+ ".augment/settings.json": {
4270
+ format: "json",
4271
+ features: {
4272
+ mcp: {
4273
+ kind: "replace-owned-keys",
4274
+ ownedKeys: ["mcpServers"]
4275
+ },
4276
+ hooks: {
4277
+ kind: "replace-owned-keys",
4278
+ ownedKeys: ["hooks"]
4279
+ },
4280
+ permissions: {
4281
+ kind: "replace-owned-keys",
4282
+ ownedKeys: ["toolPermissions"]
4283
+ }
4284
+ }
4285
+ },
4286
+ ".devin/config.json": {
4287
+ format: "json",
4288
+ features: {
4289
+ mcp: {
4290
+ kind: "replace-owned-keys",
4291
+ ownedKeys: ["mcpServers"]
4292
+ },
4293
+ permissions: {
4294
+ kind: "replace-owned-keys",
4295
+ ownedKeys: ["permissions"]
4296
+ }
4297
+ }
4298
+ },
4299
+ ".config/devin/config.json": {
4300
+ format: "json",
4301
+ features: {
4302
+ mcp: {
4303
+ kind: "replace-owned-keys",
4304
+ ownedKeys: ["mcpServers"]
4305
+ },
4306
+ hooks: {
4307
+ kind: "replace-owned-keys",
4308
+ ownedKeys: ["hooks"]
4309
+ },
4310
+ permissions: {
4311
+ kind: "replace-owned-keys",
4312
+ ownedKeys: ["permissions"]
4313
+ }
4314
+ }
4315
+ },
4316
+ ".kiro/agents/default.json": {
4317
+ format: "json",
4318
+ features: {
4319
+ hooks: {
4320
+ kind: "replace-owned-keys",
4321
+ ownedKeys: ["hooks"]
4322
+ },
4323
+ permissions: {
4324
+ kind: "replace-owned-keys",
4325
+ ownedKeys: ["allowedTools", "toolsSettings"]
4326
+ }
4327
+ }
4328
+ },
4329
+ ".amp/settings.json": {
4330
+ format: "jsonc",
4331
+ invalidRootPolicy: "error",
4332
+ jsoncParseErrors: "error",
4333
+ features: {
4334
+ mcp: {
4335
+ kind: "replace-owned-keys",
4336
+ ownedKeys: ["amp.mcpServers"]
4337
+ },
4338
+ permissions: {
4339
+ kind: "replace-owned-keys",
4340
+ ownedKeys: [
4341
+ "amp.tools.disable",
4342
+ "amp.permissions",
4343
+ "amp.guardedFiles.allowlist",
4344
+ "amp.dangerouslyAllowAll",
4345
+ "amp.mcpPermissions"
4346
+ ]
4347
+ }
4348
+ }
4349
+ },
4350
+ ".config/amp/settings.json": {
4351
+ format: "jsonc",
4352
+ invalidRootPolicy: "error",
4353
+ jsoncParseErrors: "error",
4354
+ features: {
4355
+ mcp: {
4356
+ kind: "replace-owned-keys",
4357
+ ownedKeys: ["amp.mcpServers"]
4358
+ },
4359
+ permissions: {
4360
+ kind: "replace-owned-keys",
4361
+ ownedKeys: [
4362
+ "amp.tools.disable",
4363
+ "amp.permissions",
4364
+ "amp.guardedFiles.allowlist",
4365
+ "amp.dangerouslyAllowAll",
4366
+ "amp.mcpPermissions"
4367
+ ]
4368
+ }
4369
+ }
4370
+ },
4371
+ "opencode.json": {
4372
+ format: "jsonc",
4373
+ features: {
4374
+ mcp: {
4375
+ kind: "replace-owned-keys",
4376
+ ownedKeys: ["mcp", "tools"]
4377
+ },
4378
+ permissions: {
4379
+ kind: "replace-owned-keys",
4380
+ ownedKeys: ["permission"]
4381
+ },
4382
+ rules: {
4383
+ kind: "replace-owned-keys",
4384
+ ownedKeys: ["instructions"]
4385
+ }
4386
+ }
4387
+ },
4388
+ ".config/opencode/opencode.json": {
4389
+ format: "jsonc",
4390
+ features: {
4391
+ mcp: {
4392
+ kind: "replace-owned-keys",
4393
+ ownedKeys: ["mcp", "tools"]
4394
+ },
4395
+ permissions: {
4396
+ kind: "replace-owned-keys",
4397
+ ownedKeys: ["permission"]
4398
+ }
4399
+ }
4400
+ },
4401
+ "kilo.json": {
4402
+ format: "jsonc",
4403
+ features: {
4404
+ mcp: {
4405
+ kind: "replace-owned-keys",
4406
+ ownedKeys: ["mcp", "tools"]
4407
+ },
4408
+ rules: {
4409
+ kind: "replace-owned-keys",
4410
+ ownedKeys: ["instructions"]
4411
+ }
4412
+ }
4413
+ },
4414
+ ".config/kilo/kilo.json": {
4415
+ format: "jsonc",
4416
+ features: { mcp: {
4417
+ kind: "replace-owned-keys",
4418
+ ownedKeys: ["mcp", "tools"]
4419
+ } }
4420
+ },
4421
+ [CODEXCLI_CONFIG_SHARED_FILE_KEY]: {
4422
+ format: "toml",
4423
+ features: {
4424
+ hooks: {
4425
+ kind: "replace-owned-keys",
4426
+ ownedKeys: ["features"]
4427
+ },
4428
+ mcp: {
4429
+ kind: "replace-owned-keys",
4430
+ ownedKeys: ["mcp_servers"]
4431
+ },
4432
+ permissions: {
4433
+ kind: "replace-owned-keys",
4434
+ ownedKeys: [
4435
+ "permissions",
4436
+ "default_permissions",
4437
+ ...CODEXCLI_OVERRIDE_KEYS
4438
+ ]
4439
+ }
4440
+ }
4441
+ },
4442
+ [GROKCLI_CONFIG_SHARED_FILE_KEY]: {
4443
+ format: "toml",
4444
+ features: {
4445
+ mcp: {
4446
+ kind: "replace-owned-keys",
4447
+ ownedKeys: ["mcp_servers"]
4448
+ },
4449
+ permissions: {
4450
+ kind: "replace-owned-keys",
4451
+ ownedKeys: ["permission", "ui"]
4452
+ }
4453
+ }
4454
+ },
4455
+ [VIBE_CONFIG_SHARED_FILE_KEY]: {
4456
+ format: "toml",
4457
+ features: {
4458
+ hooks: {
4459
+ kind: "replace-owned-keys",
4460
+ ownedKeys: ["enable_experimental_hooks"]
4461
+ },
4462
+ mcp: {
4463
+ kind: "replace-owned-keys",
4464
+ ownedKeys: ["mcp_servers"]
4465
+ },
4466
+ permissions: {
4467
+ kind: "replace-owned-keys",
4468
+ ownedKeys: [
4469
+ "tools",
4470
+ "enabled_tools",
4471
+ "disabled_tools"
4472
+ ]
4473
+ }
4474
+ }
4475
+ },
4476
+ [REASONIX_PROJECT_CONFIG_SHARED_FILE_KEY]: {
4477
+ format: "toml",
4478
+ features: {
4479
+ mcp: {
4480
+ kind: "replace-owned-keys",
4481
+ ownedKeys: ["plugins"]
4482
+ },
4483
+ permissions: {
4484
+ kind: "replace-owned-keys",
4485
+ ownedKeys: [
4486
+ "permissions",
4487
+ "sandbox",
4488
+ "agent"
4489
+ ]
4490
+ }
4491
+ }
4492
+ },
4493
+ [REASONIX_GLOBAL_CONFIG_SHARED_FILE_KEY]: {
4494
+ format: "toml",
4495
+ features: {
4496
+ mcp: {
4497
+ kind: "replace-owned-keys",
4498
+ ownedKeys: ["plugins"]
4499
+ },
4500
+ permissions: {
4501
+ kind: "replace-owned-keys",
4502
+ ownedKeys: [
4503
+ "permissions",
4504
+ "sandbox",
4505
+ "agent"
4506
+ ]
4507
+ }
4508
+ }
4180
4509
  }
4181
4510
  };
4182
4511
  /**
@@ -4197,17 +4526,20 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
4197
4526
  format: declaration.format,
4198
4527
  fileContent: existingContent,
4199
4528
  filePath,
4200
- ...declaration.invalidRootPolicy !== void 0 && { invalidRootPolicy: declaration.invalidRootPolicy }
4529
+ ...declaration.invalidRootPolicy !== void 0 && { invalidRootPolicy: declaration.invalidRootPolicy },
4530
+ ...declaration.jsoncParseErrors !== void 0 && { jsoncParseErrors: declaration.jsoncParseErrors }
4201
4531
  });
4202
4532
  if (policy.kind === "replace-owned-keys") {
4203
4533
  const unowned = Object.keys(patch).filter((key) => !policy.ownedKeys.includes(key));
4204
4534
  if (unowned.length > 0) throw new Error(`Feature '${feature}' tried to write undeclared keys [${unowned.join(", ")}] to '${fileKey}'; extend its ownedKeys declaration if that ownership is intended.`);
4535
+ const document = mergeSharedConfigShallow({
4536
+ base,
4537
+ patch
4538
+ });
4539
+ for (const [key, value] of Object.entries(patch)) if (value === void 0) delete document[key];
4205
4540
  return stringifySharedConfig({
4206
4541
  format: declaration.format,
4207
- document: mergeSharedConfigShallow({
4208
- base,
4209
- patch
4210
- })
4542
+ document
4211
4543
  });
4212
4544
  }
4213
4545
  const merged = mergeSharedConfigDeep({
@@ -7280,12 +7612,6 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
7280
7612
  const paths = AugmentcodeHooks.getSettablePaths({ global });
7281
7613
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
7282
7614
  const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
7283
- let settings;
7284
- try {
7285
- settings = JSON.parse(existingContent);
7286
- } catch (error) {
7287
- throw new Error(`Failed to parse existing AugmentCode settings at ${filePath}: ${formatError(error)}`, { cause: error });
7288
- }
7289
7615
  const config = rulesyncHooks.getJson();
7290
7616
  const augmentHooks = canonicalToToolHooks({
7291
7617
  config,
@@ -7293,11 +7619,13 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
7293
7619
  converterConfig: AUGMENTCODE_CONVERTER_CONFIG,
7294
7620
  logger
7295
7621
  });
7296
- const merged = {
7297
- ...settings,
7298
- hooks: augmentHooks
7299
- };
7300
- const fileContent = JSON.stringify(merged, null, 2);
7622
+ const fileContent = applySharedConfigPatch({
7623
+ fileKey: sharedConfigFileKey(paths),
7624
+ feature: "hooks",
7625
+ existingContent,
7626
+ patch: { hooks: augmentHooks },
7627
+ filePath
7628
+ });
7301
7629
  return new AugmentcodeHooks({
7302
7630
  outputRoot,
7303
7631
  relativeDirPath: paths.relativeDirPath,
@@ -7464,15 +7792,28 @@ const CODEXCLI_CONVERTER_CONFIG = {
7464
7792
  */
7465
7793
  async function buildCodexConfigTomlContent({ outputRoot }) {
7466
7794
  const configPath = join(outputRoot, CODEXCLI_DIR, CODEXCLI_MCP_FILE_NAME);
7467
- const existingContent = await readFileContentOrNull(configPath) ?? smolToml.stringify({});
7795
+ const existingContent = await readFileContentOrNull(configPath) ?? "";
7468
7796
  let configToml;
7469
7797
  try {
7470
- configToml = smolToml.parse(existingContent);
7798
+ configToml = smolToml.parse(existingContent || smolToml.stringify({}));
7471
7799
  } catch (error) {
7472
7800
  throw new Error(`Failed to parse existing Codex CLI config at ${configPath}: ${formatError(error)}`, { cause: error });
7473
7801
  }
7474
- if (typeof configToml.features === "object" && configToml.features !== null) delete configToml.features.codex_hooks;
7475
- return smolToml.stringify(configToml);
7802
+ let features;
7803
+ if (typeof configToml.features === "object" && configToml.features !== null) {
7804
+ features = { ...configToml.features };
7805
+ delete features.codex_hooks;
7806
+ }
7807
+ return applySharedConfigPatch({
7808
+ fileKey: sharedConfigFileKey({
7809
+ relativeDirPath: CODEXCLI_DIR,
7810
+ relativeFilePath: CODEXCLI_MCP_FILE_NAME
7811
+ }),
7812
+ feature: "hooks",
7813
+ existingContent,
7814
+ patch: { features },
7815
+ filePath: configPath
7816
+ });
7476
7817
  }
7477
7818
  /**
7478
7819
  * Represents the `.codex/config.toml` file as a generated ToolFile,
@@ -8388,17 +8729,13 @@ var DevinHooks = class DevinHooks extends ToolHooks {
8388
8729
  let fileContent;
8389
8730
  if (global) {
8390
8731
  const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
8391
- let parsedSettings;
8392
- try {
8393
- parsedSettings = JSON.parse(existingContent);
8394
- } catch (error) {
8395
- throw new Error(`Failed to parse existing Devin config at ${filePath}: ${formatError(error)}`, { cause: error });
8396
- }
8397
- const merged = {
8398
- ...isRecord(parsedSettings) ? parsedSettings : {},
8399
- hooks: devinHooks
8400
- };
8401
- fileContent = JSON.stringify(merged, null, 2);
8732
+ fileContent = applySharedConfigPatch({
8733
+ fileKey: sharedConfigFileKey(paths),
8734
+ feature: "hooks",
8735
+ existingContent,
8736
+ patch: { hooks: devinHooks },
8737
+ filePath
8738
+ });
8402
8739
  } else {
8403
8740
  await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
8404
8741
  fileContent = JSON.stringify(devinHooks, null, 2);
@@ -9210,18 +9547,14 @@ var KiroHooks = class KiroHooks extends ToolHooks {
9210
9547
  const paths = KiroHooks.getSettablePaths({ global });
9211
9548
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
9212
9549
  const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
9213
- let agentConfig;
9214
- try {
9215
- agentConfig = JSON.parse(existingContent);
9216
- } catch (error) {
9217
- throw new Error(`Failed to parse existing Kiro agent config at ${filePath}: ${formatError(error)}`, { cause: error });
9218
- }
9219
9550
  const kiroHooks = canonicalToKiroHooks(rulesyncHooks.getJson(), this.getOverrideKey());
9220
- const merged = {
9221
- ...agentConfig,
9222
- hooks: kiroHooks
9223
- };
9224
- const fileContent = JSON.stringify(merged, null, 2);
9551
+ const fileContent = applySharedConfigPatch({
9552
+ fileKey: sharedConfigFileKey(paths),
9553
+ feature: "hooks",
9554
+ existingContent,
9555
+ patch: { hooks: kiroHooks },
9556
+ filePath
9557
+ });
9225
9558
  return new KiroHooks({
9226
9559
  outputRoot,
9227
9560
  relativeDirPath: paths.relativeDirPath,
@@ -9716,21 +10049,17 @@ var QwencodeHooks = class QwencodeHooks extends ToolHooks {
9716
10049
  const paths = QwencodeHooks.getSettablePaths({ global });
9717
10050
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
9718
10051
  const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
9719
- let settings;
9720
- try {
9721
- settings = JSON.parse(existingContent);
9722
- } catch (error) {
9723
- throw new Error(`Failed to parse existing Qwen Code settings at ${filePath}: ${formatError(error)}`, { cause: error });
9724
- }
9725
10052
  const config = rulesyncHooks.getJson();
9726
- const qwencodeHooks = canonicalToQwencodeHooks(config);
9727
- const merged = {
9728
- ...settings,
9729
- hooks: qwencodeHooks
9730
- };
10053
+ const patch = { hooks: canonicalToQwencodeHooks(config) };
9731
10054
  const disableAllHooks = config.qwencode?.disableAllHooks;
9732
- if (typeof disableAllHooks === "boolean") merged.disableAllHooks = disableAllHooks;
9733
- const fileContent = JSON.stringify(merged, null, 2);
10055
+ if (typeof disableAllHooks === "boolean") patch.disableAllHooks = disableAllHooks;
10056
+ const fileContent = applySharedConfigPatch({
10057
+ fileKey: sharedConfigFileKey(paths),
10058
+ feature: "hooks",
10059
+ existingContent,
10060
+ patch,
10061
+ filePath
10062
+ });
9734
10063
  return new QwencodeHooks({
9735
10064
  outputRoot,
9736
10065
  relativeDirPath: paths.relativeDirPath,
@@ -9989,10 +10318,6 @@ function canonicalToVibeHooks(config, toolOverride) {
9989
10318
  }
9990
10319
  return { hooks };
9991
10320
  }
9992
- /**
9993
- * Reverse {@link canonicalToVibeHooks}: parse the flat `[[hooks]]` array back
9994
- * into a canonical event → definition[] record.
9995
- */
9996
10321
  /** Convert one raw `[[hooks]]` entry to a canonical definition, or null to skip. */
9997
10322
  function vibeEntryToCanonicalDef(raw) {
9998
10323
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
@@ -10012,6 +10337,10 @@ function vibeEntryToCanonicalDef(raw) {
10012
10337
  def
10013
10338
  };
10014
10339
  }
10340
+ /**
10341
+ * Reverse {@link canonicalToVibeHooks}: parse the flat `[[hooks]]` array back
10342
+ * into a canonical event → definition[] record.
10343
+ */
10015
10344
  function vibeHooksToCanonical(parsed) {
10016
10345
  const canonical = {};
10017
10346
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return canonical;
@@ -10040,15 +10369,22 @@ function parseVibeToml(fileContent) {
10040
10369
  */
10041
10370
  async function buildVibeConfigTomlContent({ outputRoot }) {
10042
10371
  const configPath = join(outputRoot, VIBE_DIR, VIBE_CONFIG_FILE_NAME);
10043
- const existingContent = await readFileContentOrNull(configPath) ?? smolToml.stringify({});
10044
- let config;
10372
+ const existingContent = await readFileContentOrNull(configPath) ?? "";
10045
10373
  try {
10046
- config = parseVibeToml(existingContent);
10374
+ parseVibeToml(existingContent);
10047
10375
  } catch (error) {
10048
10376
  throw new Error(`Failed to parse existing Vibe config at ${configPath}: ${formatError(error)}`, { cause: error });
10049
10377
  }
10050
- config.enable_experimental_hooks = true;
10051
- return smolToml.stringify(config);
10378
+ return applySharedConfigPatch({
10379
+ fileKey: sharedConfigFileKey({
10380
+ relativeDirPath: VIBE_DIR,
10381
+ relativeFilePath: VIBE_CONFIG_FILE_NAME
10382
+ }),
10383
+ feature: "hooks",
10384
+ existingContent,
10385
+ patch: { enable_experimental_hooks: true },
10386
+ filePath: configPath
10387
+ });
10052
10388
  }
10053
10389
  /**
10054
10390
  * Represents the `.vibe/config.toml` file as a generated ToolFile so it goes
@@ -11548,17 +11884,18 @@ var ZedIgnore = class ZedIgnore extends ToolIgnore {
11548
11884
  const patterns = rulesyncIgnore.getFileContent().split(/\r?\n|\r/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
11549
11885
  const filePath = join(outputRoot, this.getSettablePaths().relativeDirPath, this.getSettablePaths().relativeFilePath);
11550
11886
  const existingFileContent = await fileExists(filePath) ? await readFileContent(filePath) : "{}";
11551
- const existingJsonValue = JSON.parse(existingFileContent);
11552
- const mergedPatterns = uniq([...existingJsonValue.private_files ?? [], ...patterns].toSorted());
11553
- const jsonValue = {
11554
- ...existingJsonValue,
11555
- private_files: mergedPatterns
11556
- };
11887
+ const mergedPatterns = uniq([...JSON.parse(existingFileContent).private_files ?? [], ...patterns].toSorted());
11557
11888
  return new ZedIgnore({
11558
11889
  outputRoot,
11559
11890
  relativeDirPath: this.getSettablePaths().relativeDirPath,
11560
11891
  relativeFilePath: this.getSettablePaths().relativeFilePath,
11561
- fileContent: JSON.stringify(jsonValue, null, 2),
11892
+ fileContent: applySharedConfigPatch({
11893
+ fileKey: sharedConfigFileKey(this.getSettablePaths()),
11894
+ feature: "ignore",
11895
+ existingContent: existingFileContent,
11896
+ patch: { private_files: mergedPatterns },
11897
+ filePath
11898
+ }),
11562
11899
  validate: true
11563
11900
  });
11564
11901
  }
@@ -12015,17 +12352,17 @@ var AmpMcp = class AmpMcp extends ToolMcp {
12015
12352
  const basePaths = this.getSettablePaths({ global });
12016
12353
  const jsonDir = join(outputRoot, basePaths.relativeDirPath);
12017
12354
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
12018
- const json = fileContent ? parseAmpSettingsJsonc(fileContent) : { [AMP_MCP_SERVERS_KEY]: {} };
12019
- const filteredMcpServers = filterMcpServers(rulesyncMcp.getMcpServers());
12020
- const newJson = {
12021
- ...json,
12022
- [AMP_MCP_SERVERS_KEY]: filteredMcpServers
12023
- };
12024
12355
  return new AmpMcp({
12025
12356
  outputRoot,
12026
12357
  relativeDirPath: basePaths.relativeDirPath,
12027
12358
  relativeFilePath,
12028
- fileContent: JSON.stringify(newJson, null, 2),
12359
+ fileContent: applySharedConfigPatch({
12360
+ fileKey: sharedConfigFileKey(basePaths),
12361
+ feature: "mcp",
12362
+ existingContent: fileContent ?? "",
12363
+ patch: { [AMP_MCP_SERVERS_KEY]: filterMcpServers(rulesyncMcp.getMcpServers()) },
12364
+ filePath: join(jsonDir, relativeFilePath)
12365
+ }),
12029
12366
  validate,
12030
12367
  global
12031
12368
  });
@@ -12312,15 +12649,19 @@ var AugmentcodeMcp = class AugmentcodeMcp extends ToolMcp {
12312
12649
  }
12313
12650
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
12314
12651
  const paths = this.getSettablePaths({ global });
12315
- const merged = {
12316
- ...parseAugmentcodeSettings(await readOrInitializeFileContent(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), JSON.stringify({}, null, 2)), paths.relativeDirPath, paths.relativeFilePath),
12317
- mcpServers: rulesyncMcp.getMcpServers()
12318
- };
12652
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
12653
+ const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
12319
12654
  return new AugmentcodeMcp({
12320
12655
  outputRoot,
12321
12656
  relativeDirPath: paths.relativeDirPath,
12322
12657
  relativeFilePath: paths.relativeFilePath,
12323
- fileContent: JSON.stringify(merged, null, 2),
12658
+ fileContent: applySharedConfigPatch({
12659
+ fileKey: sharedConfigFileKey(paths),
12660
+ feature: "mcp",
12661
+ existingContent,
12662
+ patch: { mcpServers: rulesyncMcp.getMcpServers() },
12663
+ filePath
12664
+ }),
12324
12665
  validate,
12325
12666
  global
12326
12667
  });
@@ -12617,6 +12958,13 @@ function mapOauthFromCodex(oauth) {
12617
12958
  }
12618
12959
  return result;
12619
12960
  }
12961
+ const CODEX_MCP_SERVER_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
12962
+ function normalizeCodexMcpServerName(name) {
12963
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) return null;
12964
+ if (CODEX_MCP_SERVER_NAME_PATTERN.test(name)) return name;
12965
+ const normalizedName = name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
12966
+ return normalizedName && !PROTOTYPE_POLLUTION_KEYS.has(normalizedName) ? normalizedName : null;
12967
+ }
12620
12968
  function convertFromCodexFormat(codexMcp) {
12621
12969
  const result = {};
12622
12970
  for (const [name, config] of Object.entries(codexMcp)) {
@@ -12639,8 +12987,13 @@ function convertFromCodexFormat(codexMcp) {
12639
12987
  }
12640
12988
  function convertToCodexFormat(mcpServers) {
12641
12989
  const result = {};
12990
+ const originalNames = /* @__PURE__ */ new Map();
12642
12991
  for (const [name, config] of Object.entries(mcpServers)) {
12643
- if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
12992
+ const codexName = normalizeCodexMcpServerName(name);
12993
+ if (codexName === null) {
12994
+ warnWithFallback(void 0, `MCP server "${name}" could not be normalized to a valid Codex MCP server name and was skipped.`);
12995
+ continue;
12996
+ }
12644
12997
  if (!isRecord(config)) continue;
12645
12998
  const converted = {};
12646
12999
  for (const [key, value] of Object.entries(config)) {
@@ -12654,7 +13007,10 @@ function convertToCodexFormat(mcpServers) {
12654
13007
  else warnWithFallback(void 0, `[CodexCliMcp] Skipping invalid value type for mapped key '${key}': expected string array, got ${typeof value}`);
12655
13008
  } else converted[key] = value;
12656
13009
  }
12657
- result[name] = converted;
13010
+ const previousName = originalNames.get(codexName);
13011
+ if (previousName !== void 0) warnWithFallback(void 0, `Codex MCP server name collision: "${previousName}" and "${name}" both normalize to "${codexName}". Only the last processed server will be used.`);
13012
+ originalNames.set(codexName, name);
13013
+ result[codexName] = converted;
12658
13014
  }
12659
13015
  return result;
12660
13016
  }
@@ -12699,8 +13055,9 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
12699
13055
  }
12700
13056
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
12701
13057
  const paths = this.getSettablePaths({ global });
12702
- const configTomlFileContent = await readOrInitializeFileContent(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), smolToml.stringify({}));
12703
- const configToml = smolToml.parse(configTomlFileContent);
13058
+ const configTomlFilePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
13059
+ const configTomlFileContent = await readFileContentOrNull(configTomlFilePath) ?? "";
13060
+ const configToml = smolToml.parse(configTomlFileContent || smolToml.stringify({}));
12704
13061
  const strippedMcpServers = rulesyncMcp.getMcpServers();
12705
13062
  const rawMcpServers = rulesyncMcp.getJson().mcpServers;
12706
13063
  const converted = convertToCodexFormat(Object.fromEntries(Object.entries(strippedMcpServers).map(([serverName, serverConfig]) => {
@@ -12713,7 +13070,7 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
12713
13070
  const filteredMcpServers = this.removeEmptyEntries(converted);
12714
13071
  for (const name of Object.keys(converted)) if (!Object.hasOwn(filteredMcpServers, name)) warnWithFallback(void 0, `MCP server "${name}" had no non-empty configuration and was dropped from the codex CLI config`);
12715
13072
  const existingMcpServers = isRecord(configToml["mcp_servers"]) ? configToml["mcp_servers"] : {};
12716
- configToml["mcp_servers"] = Object.fromEntries(Object.entries(filteredMcpServers).map(([name, serverConfig]) => {
13073
+ const mergedMcpServers = Object.fromEntries(Object.entries(filteredMcpServers).map(([name, serverConfig]) => {
12717
13074
  const existingServer = isRecord(existingMcpServers[name]) ? existingMcpServers[name] : void 0;
12718
13075
  const serverRecord = serverConfig;
12719
13076
  if (existingServer && isRecord(existingServer["tools"]) && !("tools" in serverRecord)) return [name, {
@@ -12726,7 +13083,13 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
12726
13083
  outputRoot,
12727
13084
  relativeDirPath: paths.relativeDirPath,
12728
13085
  relativeFilePath: paths.relativeFilePath,
12729
- fileContent: smolToml.stringify(configToml),
13086
+ fileContent: applySharedConfigPatch({
13087
+ fileKey: sharedConfigFileKey(paths),
13088
+ feature: "mcp",
13089
+ existingContent: configTomlFileContent,
13090
+ patch: { mcp_servers: mergedMcpServers },
13091
+ filePath: configTomlFilePath
13092
+ }),
12730
13093
  validate
12731
13094
  });
12732
13095
  }
@@ -13293,16 +13656,19 @@ var DevinMcp = class DevinMcp extends ToolMcp {
13293
13656
  }
13294
13657
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
13295
13658
  const paths = this.getSettablePaths({ global });
13296
- const fileContent = await readOrInitializeFileContent(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), JSON.stringify({ mcpServers: {} }, null, 2));
13297
- const devinConfig = {
13298
- ...this.parseJsonOrThrow(fileContent, paths.relativeDirPath, paths.relativeFilePath),
13299
- mcpServers: rulesyncMcp.getMcpServers()
13300
- };
13659
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
13660
+ const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({ mcpServers: {} }, null, 2));
13301
13661
  return new DevinMcp({
13302
13662
  outputRoot,
13303
13663
  relativeDirPath: paths.relativeDirPath,
13304
13664
  relativeFilePath: paths.relativeFilePath,
13305
- fileContent: JSON.stringify(devinConfig, null, 2),
13665
+ fileContent: applySharedConfigPatch({
13666
+ fileKey: sharedConfigFileKey(paths),
13667
+ feature: "mcp",
13668
+ existingContent,
13669
+ patch: { mcpServers: rulesyncMcp.getMcpServers() },
13670
+ filePath
13671
+ }),
13306
13672
  validate,
13307
13673
  global
13308
13674
  });
@@ -13789,17 +14155,22 @@ var GrokcliMcp = class GrokcliMcp extends ToolMcp {
13789
14155
  }
13790
14156
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
13791
14157
  const paths = this.getSettablePaths({ global });
13792
- const configTomlFileContent = await readOrInitializeFileContent(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), smolToml.stringify({}));
13793
- const configToml = smolToml.parse(configTomlFileContent);
14158
+ const configTomlFilePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
14159
+ const configTomlFileContent = await readFileContentOrNull(configTomlFilePath) ?? "";
13794
14160
  const converted = convertToGrokFormat(rulesyncMcp.getMcpServers());
13795
14161
  const filteredMcpServers = this.removeEmptyEntries(converted);
13796
14162
  for (const name of Object.keys(converted)) if (!Object.hasOwn(filteredMcpServers, name)) warnWithFallback(void 0, `MCP server "${name}" had no non-empty configuration and was dropped from the grok CLI config`);
13797
- configToml["mcp_servers"] = filteredMcpServers;
13798
14163
  return new GrokcliMcp({
13799
14164
  outputRoot,
13800
14165
  relativeDirPath: paths.relativeDirPath,
13801
14166
  relativeFilePath: paths.relativeFilePath,
13802
- fileContent: smolToml.stringify(configToml),
14167
+ fileContent: applySharedConfigPatch({
14168
+ fileKey: sharedConfigFileKey(paths),
14169
+ feature: "mcp",
14170
+ existingContent: configTomlFileContent,
14171
+ patch: { mcp_servers: filteredMcpServers },
14172
+ filePath: configTomlFilePath
14173
+ }),
13803
14174
  validate
13804
14175
  });
13805
14176
  }
@@ -14332,20 +14703,21 @@ var KiloMcp = class KiloMcp extends ToolMcp {
14332
14703
  fileContent = await readFileContentOrNull(jsonPath);
14333
14704
  if (fileContent) relativeFilePath = KILO_JSON_FILE_NAME;
14334
14705
  }
14335
- if (!fileContent) fileContent = JSON.stringify({ mcp: {} }, null, 2);
14336
- const json = parse(fileContent);
14337
14706
  const { mcp: convertedMcp, tools: mcpTools } = convertToKiloFormat(rulesyncMcp.getMcpServers());
14338
- const { tools: _existingTools, ...jsonWithoutTools } = json;
14339
- const newJson = {
14340
- ...jsonWithoutTools,
14341
- mcp: convertedMcp,
14342
- ...Object.keys(mcpTools).length > 0 && { tools: mcpTools }
14343
- };
14344
14707
  return new KiloMcp({
14345
14708
  outputRoot,
14346
14709
  relativeDirPath: basePaths.relativeDirPath,
14347
14710
  relativeFilePath,
14348
- fileContent: JSON.stringify(newJson, null, 2),
14711
+ fileContent: applySharedConfigPatch({
14712
+ fileKey: sharedConfigFileKey(basePaths),
14713
+ feature: "mcp",
14714
+ existingContent: fileContent ?? "",
14715
+ patch: {
14716
+ mcp: convertedMcp,
14717
+ tools: Object.keys(mcpTools).length > 0 ? mcpTools : void 0
14718
+ },
14719
+ filePath: join(jsonDir, relativeFilePath)
14720
+ }),
14349
14721
  validate
14350
14722
  });
14351
14723
  }
@@ -14374,15 +14746,17 @@ var KiloMcp = class KiloMcp extends ToolMcp {
14374
14746
  const json = fileContent ? parse(fileContent) : {};
14375
14747
  const existingInstructions = Array.isArray(json.instructions) ? json.instructions.filter((entry) => typeof entry === "string") : [];
14376
14748
  const mergedInstructions = Array.from(/* @__PURE__ */ new Set([...existingInstructions, ...instructions])).toSorted();
14377
- const newJson = {
14378
- ...json,
14379
- instructions: mergedInstructions
14380
- };
14381
14749
  return new KiloMcp({
14382
14750
  outputRoot,
14383
14751
  relativeDirPath: basePaths.relativeDirPath,
14384
14752
  relativeFilePath,
14385
- fileContent: JSON.stringify(newJson, null, 2),
14753
+ fileContent: applySharedConfigPatch({
14754
+ fileKey: sharedConfigFileKey(basePaths),
14755
+ feature: "rules",
14756
+ existingContent: fileContent ?? "",
14757
+ patch: { instructions: mergedInstructions },
14758
+ filePath: join(jsonDir, relativeFilePath)
14759
+ }),
14386
14760
  validate
14387
14761
  });
14388
14762
  }
@@ -14654,23 +15028,24 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
14654
15028
  fileContent = await readFileContentOrNull(jsonPath);
14655
15029
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
14656
15030
  }
14657
- if (!fileContent) fileContent = JSON.stringify({ mcp: {} }, null, 2);
14658
- const json = parse(fileContent);
14659
15031
  const { mcp: convertedMcp, tools: mcpTools } = convertToOpencodeFormat(convertEnvVarRefsToToolFormat({
14660
15032
  mcpServers: rulesyncMcp.getMcpServers(),
14661
15033
  replacement: "{env:$1}"
14662
15034
  }));
14663
- const { tools: _existingTools, ...jsonWithoutTools } = json;
14664
- const newJson = {
14665
- ...jsonWithoutTools,
14666
- mcp: convertedMcp,
14667
- ...Object.keys(mcpTools).length > 0 && { tools: mcpTools }
14668
- };
14669
15035
  return new OpencodeMcp({
14670
15036
  outputRoot,
14671
15037
  relativeDirPath: basePaths.relativeDirPath,
14672
15038
  relativeFilePath,
14673
- fileContent: JSON.stringify(newJson, null, 2),
15039
+ fileContent: applySharedConfigPatch({
15040
+ fileKey: sharedConfigFileKey(basePaths),
15041
+ feature: "mcp",
15042
+ existingContent: fileContent ?? "",
15043
+ patch: {
15044
+ mcp: convertedMcp,
15045
+ tools: Object.keys(mcpTools).length > 0 ? mcpTools : void 0
15046
+ },
15047
+ filePath: join(jsonDir, relativeFilePath)
15048
+ }),
14674
15049
  validate
14675
15050
  });
14676
15051
  }
@@ -14705,15 +15080,17 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
14705
15080
  const json = fileContent ? parse(fileContent) : {};
14706
15081
  const existingInstructions = Array.isArray(json.instructions) ? json.instructions.filter((entry) => typeof entry === "string") : [];
14707
15082
  const mergedInstructions = Array.from(/* @__PURE__ */ new Set([...existingInstructions, ...instructions])).toSorted();
14708
- const newJson = {
14709
- ...json,
14710
- instructions: mergedInstructions
14711
- };
14712
15083
  return new OpencodeMcp({
14713
15084
  outputRoot,
14714
15085
  relativeDirPath: basePaths.relativeDirPath,
14715
15086
  relativeFilePath,
14716
- fileContent: JSON.stringify(newJson, null, 2),
15087
+ fileContent: applySharedConfigPatch({
15088
+ fileKey: sharedConfigFileKey(basePaths),
15089
+ feature: "rules",
15090
+ existingContent: fileContent ?? "",
15091
+ patch: { instructions: mergedInstructions },
15092
+ filePath: join(jsonDir, relativeFilePath)
15093
+ }),
14717
15094
  validate
14718
15095
  });
14719
15096
  }
@@ -14823,16 +15200,19 @@ var QwencodeMcp = class QwencodeMcp extends ToolMcp {
14823
15200
  }
14824
15201
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
14825
15202
  const paths = this.getSettablePaths({ global });
14826
- const fileContent = await readOrInitializeFileContent(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), JSON.stringify({ mcpServers: {} }, null, 2));
14827
- const newJson = {
14828
- ...JSON.parse(fileContent),
14829
- mcpServers: convertToQwencodeFormat(rulesyncMcp.getMcpServers())
14830
- };
15203
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
15204
+ const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({ mcpServers: {} }, null, 2));
14831
15205
  return new QwencodeMcp({
14832
15206
  outputRoot,
14833
15207
  relativeDirPath: paths.relativeDirPath,
14834
15208
  relativeFilePath: paths.relativeFilePath,
14835
- fileContent: JSON.stringify(newJson, null, 2),
15209
+ fileContent: applySharedConfigPatch({
15210
+ fileKey: sharedConfigFileKey(paths),
15211
+ feature: "mcp",
15212
+ existingContent,
15213
+ patch: { mcpServers: convertToQwencodeFormat(rulesyncMcp.getMcpServers()) },
15214
+ filePath
15215
+ }),
14836
15216
  validate
14837
15217
  });
14838
15218
  }
@@ -14917,13 +15297,20 @@ var ReasonixMcp = class ReasonixMcp extends ToolMcp {
14917
15297
  }
14918
15298
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
14919
15299
  const paths = this.getSettablePaths({ global });
14920
- const config = parseReasonixConfig$1(await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smolToml.stringify({}));
14921
- config.plugins = Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => rulesyncMcpServerToReasonix(name, server));
15300
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
15301
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
15302
+ const plugins = Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => rulesyncMcpServerToReasonix(name, server));
14922
15303
  return new ReasonixMcp({
14923
15304
  outputRoot,
14924
15305
  relativeDirPath: paths.relativeDirPath,
14925
15306
  relativeFilePath: paths.relativeFilePath,
14926
- fileContent: smolToml.stringify(config),
15307
+ fileContent: applySharedConfigPatch({
15308
+ fileKey: sharedConfigFileKey(paths),
15309
+ feature: "mcp",
15310
+ existingContent,
15311
+ patch: { plugins },
15312
+ filePath
15313
+ }),
14927
15314
  validate,
14928
15315
  global
14929
15316
  });
@@ -15398,13 +15785,20 @@ var VibeMcp = class VibeMcp extends ToolMcp {
15398
15785
  }
15399
15786
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
15400
15787
  const paths = this.getSettablePaths({ global });
15401
- const config = parseVibeConfig$1(await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smolToml.stringify({}));
15402
- config.mcp_servers = Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => rulesyncMcpServerToVibe(name, server));
15788
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
15789
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
15790
+ const mcpServers = Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => rulesyncMcpServerToVibe(name, server));
15403
15791
  return new VibeMcp({
15404
15792
  outputRoot,
15405
15793
  relativeDirPath: paths.relativeDirPath,
15406
15794
  relativeFilePath: paths.relativeFilePath,
15407
- fileContent: smolToml.stringify(config),
15795
+ fileContent: applySharedConfigPatch({
15796
+ fileKey: sharedConfigFileKey(paths),
15797
+ feature: "mcp",
15798
+ existingContent,
15799
+ patch: { mcp_servers: mcpServers },
15800
+ filePath
15801
+ }),
15408
15802
  validate,
15409
15803
  global
15410
15804
  });
@@ -15635,16 +16029,19 @@ var ZedMcp = class ZedMcp extends ToolMcp {
15635
16029
  }
15636
16030
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
15637
16031
  const paths = this.getSettablePaths({ global });
15638
- const fileContent = await readOrInitializeFileContent(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), "{}");
15639
- const newJson = {
15640
- ...JSON.parse(fileContent),
15641
- context_servers: rulesyncMcp.getMcpServers()
15642
- };
16032
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
16033
+ const existingContent = await readOrInitializeFileContent(filePath, "{}");
15643
16034
  return new ZedMcp({
15644
16035
  outputRoot,
15645
16036
  relativeDirPath: paths.relativeDirPath,
15646
16037
  relativeFilePath: paths.relativeFilePath,
15647
- fileContent: JSON.stringify(newJson, null, 2),
16038
+ fileContent: applySharedConfigPatch({
16039
+ fileKey: sharedConfigFileKey(paths),
16040
+ feature: "mcp",
16041
+ existingContent,
16042
+ patch: { context_servers: rulesyncMcp.getMcpServers() },
16043
+ filePath
16044
+ }),
15648
16045
  validate
15649
16046
  });
15650
16047
  }
@@ -16813,25 +17210,29 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
16813
17210
  const override = config.amp;
16814
17211
  const authoredExtras = override?.permissions ? toPermissionsList(override.permissions) : [];
16815
17212
  const preservedDelegates = override?.permissions ? [] : toPermissionsList(json[AMP_PERMISSIONS_KEY]).filter((entry) => entry.action === "delegate");
16816
- const newJson = {
16817
- ...json,
16818
- [AMP_TOOLS_DISABLE_KEY]: disable
16819
- };
16820
17213
  const mergedPermissions = mergeAmpPermissions([
16821
17214
  ...permissions,
16822
17215
  ...authoredExtras,
16823
17216
  ...preservedDelegates
16824
17217
  ]);
16825
- if (mergedPermissions.length > 0) newJson[AMP_PERMISSIONS_KEY] = mergedPermissions;
16826
- else delete newJson[AMP_PERMISSIONS_KEY];
16827
- if (override?.guardedFiles?.allowlist !== void 0) newJson[AMP_GUARDED_FILES_ALLOWLIST_KEY] = override.guardedFiles.allowlist;
16828
- if (override?.dangerouslyAllowAll !== void 0) newJson[AMP_DANGEROUSLY_ALLOW_ALL_KEY] = override.dangerouslyAllowAll;
16829
- if (override?.mcpPermissions !== void 0) newJson[AMP_MCP_PERMISSIONS_KEY] = override.mcpPermissions;
17218
+ const patch = {
17219
+ [AMP_TOOLS_DISABLE_KEY]: disable,
17220
+ [AMP_PERMISSIONS_KEY]: mergedPermissions.length > 0 ? mergedPermissions : void 0
17221
+ };
17222
+ if (override?.guardedFiles?.allowlist !== void 0) patch[AMP_GUARDED_FILES_ALLOWLIST_KEY] = override.guardedFiles.allowlist;
17223
+ if (override?.dangerouslyAllowAll !== void 0) patch[AMP_DANGEROUSLY_ALLOW_ALL_KEY] = override.dangerouslyAllowAll;
17224
+ if (override?.mcpPermissions !== void 0) patch[AMP_MCP_PERMISSIONS_KEY] = override.mcpPermissions;
16830
17225
  return new AmpPermissions({
16831
17226
  outputRoot,
16832
17227
  relativeDirPath: basePaths.relativeDirPath,
16833
17228
  relativeFilePath,
16834
- fileContent: JSON.stringify(newJson, null, 2),
17229
+ fileContent: applySharedConfigPatch({
17230
+ fileKey: sharedConfigFileKey(basePaths),
17231
+ feature: "permissions",
17232
+ existingContent: fileContent ?? "",
17233
+ patch,
17234
+ filePath: join(jsonDir, relativeFilePath)
17235
+ }),
16835
17236
  validate: true
16836
17237
  });
16837
17238
  }
@@ -17773,11 +18174,13 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
17773
18174
  ...preservedBasicEntries,
17774
18175
  ...authoredBasics
17775
18176
  ]);
17776
- const merged = {
17777
- ...settings,
17778
- toolPermissions: [...specialEntries, ...sortedBasic]
17779
- };
17780
- const fileContent = JSON.stringify(merged, null, 2);
18177
+ const fileContent = applySharedConfigPatch({
18178
+ fileKey: sharedConfigFileKey(paths),
18179
+ feature: "permissions",
18180
+ existingContent,
18181
+ patch: { toolPermissions: [...specialEntries, ...sortedBasic] },
18182
+ filePath
18183
+ });
17781
18184
  return new AugmentcodePermissions({
17782
18185
  outputRoot,
17783
18186
  relativeDirPath: paths.relativeDirPath,
@@ -18369,13 +18772,6 @@ const WORKSPACE_WIDE_WRITE_PATTERNS = /* @__PURE__ */ new Set([
18369
18772
  ]);
18370
18773
  const CODEX_MINIMAL_KEY = ":minimal";
18371
18774
  const GLOBAL_WILDCARD_DOMAIN = "*";
18372
- const CODEXCLI_OVERRIDE_KEYS = [
18373
- "approval_policy",
18374
- "sandbox_mode",
18375
- "sandbox_workspace_write",
18376
- "apps",
18377
- "approvals_reviewer"
18378
- ];
18379
18775
  var CodexcliPermissions = class CodexcliPermissions extends ToolPermissions {
18380
18776
  static getSettablePaths(_options = {}) {
18381
18777
  return {
@@ -18399,26 +18795,27 @@ var CodexcliPermissions = class CodexcliPermissions extends ToolPermissions {
18399
18795
  }
18400
18796
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
18401
18797
  const paths = this.getSettablePaths({ global });
18402
- const existingContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smolToml.stringify({});
18403
- const parsed = toMutableTable(smolToml.parse(existingContent));
18798
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
18799
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
18800
+ const existing = toMutableTable(smolToml.parse(existingContent || smolToml.stringify({})));
18404
18801
  const newProfile = convertRulesyncToCodexProfile({
18405
18802
  config: rulesyncPermissions.getJson(),
18406
18803
  logger
18407
18804
  });
18408
- const permissionsTable = toMutableTable(parsed.permissions);
18805
+ const permissionsTable = toMutableTable(existing.permissions);
18409
18806
  const { profile: existingProfile, domainsHadUnknown: existingDomainsHadUnknown } = toCodexProfile(permissionsTable[RULESYNC_PROFILE_NAME]);
18410
- if (existingProfile?.extends !== void 0 && existingProfile.extends !== newProfile.extends) logger?.warn(`Existing "extends" value "${existingProfile.extends}" will be replaced by Rulesync-managed "${newProfile.extends ?? "(none)"}".`);
18411
- if (existingProfile?.network?.unix_sockets !== void 0) logger?.warn(`Preserving existing "network.unix_sockets" from config. Review these entries manually as they may grant broad system access.`);
18412
- if (existingProfile?.network?.mode !== void 0) logger?.warn(`Preserving existing "network.mode" from config. Review this value manually as it may grant broader network access than the Rulesync-managed domain rules.`);
18413
- if (existingDomainsHadUnknown) logger?.warn(`Existing "network.domains" contained unrecognized values. These entries were skipped and will not be imported.`);
18807
+ warnAboutPreservedProfileState({
18808
+ existingProfile,
18809
+ newProfile,
18810
+ existingDomainsHadUnknown,
18811
+ logger
18812
+ });
18414
18813
  permissionsTable[RULESYNC_PROFILE_NAME] = mergeWithExistingProfile({
18415
18814
  newProfile,
18416
18815
  existingProfile
18417
18816
  });
18418
- parsed.permissions = permissionsTable;
18419
- parsed.default_permissions = RULESYNC_PROFILE_NAME;
18420
- applyCodexcliOverride({
18421
- parsed,
18817
+ const overridePatch = computeCodexcliOverridePatch({
18818
+ existing,
18422
18819
  override: rulesyncPermissions.getJson().codexcli,
18423
18820
  logger
18424
18821
  });
@@ -18426,7 +18823,17 @@ var CodexcliPermissions = class CodexcliPermissions extends ToolPermissions {
18426
18823
  outputRoot,
18427
18824
  relativeDirPath: paths.relativeDirPath,
18428
18825
  relativeFilePath: paths.relativeFilePath,
18429
- fileContent: smolToml.stringify(parsed),
18826
+ fileContent: applySharedConfigPatch({
18827
+ fileKey: sharedConfigFileKey(paths),
18828
+ feature: "permissions",
18829
+ existingContent,
18830
+ patch: {
18831
+ permissions: permissionsTable,
18832
+ default_permissions: RULESYNC_PROFILE_NAME,
18833
+ ...overridePatch
18834
+ },
18835
+ filePath
18836
+ }),
18430
18837
  validate
18431
18838
  });
18432
18839
  }
@@ -18596,6 +19003,12 @@ function toCodexProfile(value) {
18596
19003
  domainsHadUnknown
18597
19004
  };
18598
19005
  }
19006
+ function warnAboutPreservedProfileState({ existingProfile, newProfile, existingDomainsHadUnknown, logger }) {
19007
+ if (existingProfile?.extends !== void 0 && existingProfile.extends !== newProfile.extends) logger?.warn(`Existing "extends" value "${existingProfile.extends}" will be replaced by Rulesync-managed "${newProfile.extends ?? "(none)"}".`);
19008
+ if (existingProfile?.network?.unix_sockets !== void 0) logger?.warn(`Preserving existing "network.unix_sockets" from config. Review these entries manually as they may grant broad system access.`);
19009
+ if (existingProfile?.network?.mode !== void 0) logger?.warn(`Preserving existing "network.mode" from config. Review this value manually as it may grant broader network access than the Rulesync-managed domain rules.`);
19010
+ if (existingDomainsHadUnknown) logger?.warn(`Existing "network.domains" contained unrecognized values. These entries were skipped and will not be imported.`);
19011
+ }
18599
19012
  function mergeWithExistingProfile({ newProfile, existingProfile }) {
18600
19013
  if (!existingProfile) return newProfile;
18601
19014
  const mergedNetwork = { ...newProfile.network };
@@ -18646,8 +19059,9 @@ function toMutableTable(value) {
18646
19059
  function isPlainObject(value) {
18647
19060
  return value !== null && typeof value === "object" && !Array.isArray(value);
18648
19061
  }
18649
- function applyCodexcliOverride({ parsed, override, logger }) {
18650
- if (!override) return;
19062
+ function computeCodexcliOverridePatch({ existing, override, logger }) {
19063
+ const patch = {};
19064
+ if (!override) return patch;
18651
19065
  const allowed = new Set(CODEXCLI_OVERRIDE_KEYS);
18652
19066
  for (const [key, value] of Object.entries(override)) {
18653
19067
  if (!allowed.has(key)) {
@@ -18655,12 +19069,13 @@ function applyCodexcliOverride({ parsed, override, logger }) {
18655
19069
  continue;
18656
19070
  }
18657
19071
  if (value === void 0) continue;
18658
- const existing = parsed[key];
18659
- parsed[key] = isPlainObject(existing) && isPlainObject(value) ? {
18660
- ...existing,
19072
+ const existingValue = existing[key];
19073
+ patch[key] = isPlainObject(existingValue) && isPlainObject(value) ? {
19074
+ ...existingValue,
18661
19075
  ...value
18662
19076
  } : value;
18663
19077
  }
19078
+ return patch;
18664
19079
  }
18665
19080
  function extractCodexcliOverride(table) {
18666
19081
  const override = {};
@@ -19221,15 +19636,17 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
19221
19636
  else delete mergedPermissions.ask;
19222
19637
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
19223
19638
  else delete mergedPermissions.deny;
19224
- const merged = {
19225
- ...settings,
19226
- permissions: mergedPermissions
19227
- };
19228
19639
  return new DevinPermissions({
19229
19640
  outputRoot,
19230
19641
  relativeDirPath: paths.relativeDirPath,
19231
19642
  relativeFilePath: paths.relativeFilePath,
19232
- fileContent: JSON.stringify(merged, null, 2),
19643
+ fileContent: applySharedConfigPatch({
19644
+ fileKey: sharedConfigFileKey(paths),
19645
+ feature: "permissions",
19646
+ existingContent,
19647
+ patch: { permissions: mergedPermissions },
19648
+ filePath
19649
+ }),
19233
19650
  validate
19234
19651
  });
19235
19652
  }
@@ -19838,23 +20255,31 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
19838
20255
  const config = rulesyncPermissions.getJson();
19839
20256
  const existingPermission = isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {};
19840
20257
  const buckets = buildGrokPermissionArrays(config, existingPermission, logger);
19841
- parsed[GROKCLI_PERMISSION_KEY] = {
20258
+ const permission = {
19842
20259
  ...existingPermission,
19843
20260
  allow: buckets.allow,
19844
20261
  deny: buckets.deny,
19845
20262
  ask: buckets.ask
19846
20263
  };
19847
20264
  const mode = deriveGrokPermissionMode(config);
19848
- const existingUi = isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {};
19849
- parsed[GROKCLI_UI_KEY] = {
19850
- ...existingUi,
20265
+ const ui = {
20266
+ ...isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {},
19851
20267
  [GROKCLI_PERMISSION_MODE_KEY]: mode
19852
20268
  };
19853
20269
  return new GrokcliPermissions({
19854
20270
  outputRoot,
19855
20271
  relativeDirPath: paths.relativeDirPath,
19856
20272
  relativeFilePath: paths.relativeFilePath,
19857
- fileContent: smolToml.stringify(parsed),
20273
+ fileContent: applySharedConfigPatch({
20274
+ fileKey: sharedConfigFileKey(paths),
20275
+ feature: "permissions",
20276
+ existingContent,
20277
+ patch: {
20278
+ [GROKCLI_PERMISSION_KEY]: permission,
20279
+ [GROKCLI_UI_KEY]: ui
20280
+ },
20281
+ filePath
20282
+ }),
19858
20283
  validate: true,
19859
20284
  global: true
19860
20285
  });
@@ -20557,7 +20982,16 @@ var KiroPermissions = class KiroPermissions extends ToolPermissions {
20557
20982
  outputRoot,
20558
20983
  relativeDirPath: paths.relativeDirPath,
20559
20984
  relativeFilePath: paths.relativeFilePath,
20560
- fileContent: JSON.stringify(next, null, 2),
20985
+ fileContent: applySharedConfigPatch({
20986
+ fileKey: sharedConfigFileKey(paths),
20987
+ feature: "permissions",
20988
+ existingContent,
20989
+ patch: {
20990
+ allowedTools: next.allowedTools,
20991
+ toolsSettings: next.toolsSettings
20992
+ },
20993
+ filePath
20994
+ }),
20561
20995
  validate
20562
20996
  });
20563
20997
  }
@@ -20854,21 +21288,22 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
20854
21288
  fileContent = await readFileContentOrNull(jsonPath);
20855
21289
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
20856
21290
  }
20857
- const parsed = parse(fileContent ?? "{}");
20858
21291
  const rulesyncJson = rulesyncPermissions.getJson();
20859
21292
  const overridePermission = rulesyncJson.opencode?.permission ?? {};
20860
- const nextJson = {
20861
- ...parsed,
20862
- permission: {
20863
- ...rulesyncJson.permission,
20864
- ...overridePermission
20865
- }
20866
- };
20867
21293
  return new OpencodePermissions({
20868
21294
  outputRoot,
20869
21295
  relativeDirPath: basePaths.relativeDirPath,
20870
21296
  relativeFilePath,
20871
- fileContent: JSON.stringify(nextJson, null, 2),
21297
+ fileContent: applySharedConfigPatch({
21298
+ fileKey: sharedConfigFileKey(basePaths),
21299
+ feature: "permissions",
21300
+ existingContent: fileContent ?? "",
21301
+ patch: { permission: {
21302
+ ...rulesyncJson.permission,
21303
+ ...overridePermission
21304
+ } },
21305
+ filePath: join(jsonDir, relativeFilePath)
21306
+ }),
20872
21307
  validate: true
20873
21308
  });
20874
21309
  }
@@ -21077,20 +21512,23 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
21077
21512
  else delete mergedPermissions.ask;
21078
21513
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
21079
21514
  else delete mergedPermissions.deny;
21080
- const merged = {
21081
- ...settings,
21082
- permissions: mergedPermissions
21083
- };
21515
+ const patch = { permissions: mergedPermissions };
21084
21516
  const override = config.qwencode;
21085
- if (override?.tools !== void 0) merged.tools = {
21517
+ if (override?.tools !== void 0) patch.tools = {
21086
21518
  ...asPlainRecord(settings.tools),
21087
21519
  ...asPlainRecord(override.tools)
21088
21520
  };
21089
- if (override?.security !== void 0) merged.security = {
21521
+ if (override?.security !== void 0) patch.security = {
21090
21522
  ...asPlainRecord(settings.security),
21091
21523
  ...asPlainRecord(override.security)
21092
21524
  };
21093
- const fileContent = JSON.stringify(merged, null, 2);
21525
+ const fileContent = applySharedConfigPatch({
21526
+ fileKey: sharedConfigFileKey(paths),
21527
+ feature: "permissions",
21528
+ existingContent,
21529
+ patch,
21530
+ filePath
21531
+ });
21094
21532
  return new QwencodePermissions({
21095
21533
  outputRoot,
21096
21534
  relativeDirPath: paths.relativeDirPath,
@@ -21325,7 +21763,9 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
21325
21763
  }
21326
21764
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
21327
21765
  const paths = this.getSettablePaths({ global });
21328
- const parsed = parseReasonixConfig(await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smolToml.stringify({}));
21766
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
21767
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
21768
+ const parsed = parseReasonixConfig(existingContent);
21329
21769
  const config = rulesyncPermissions.getJson();
21330
21770
  const { allow, ask, deny } = convertRulesyncToReasonixPermissions(config);
21331
21771
  const managedToolNames = new Set(Object.keys(config.permission).map((category) => toReasonixToolName(category)));
@@ -21350,25 +21790,27 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
21350
21790
  else delete mergedPermissions.ask;
21351
21791
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
21352
21792
  else delete mergedPermissions.deny;
21353
- const merged = {
21354
- ...parsed,
21355
- permissions: mergedPermissions
21356
- };
21793
+ const patch = { permissions: mergedPermissions };
21357
21794
  const override = config.reasonix;
21358
- if (override?.sandbox !== void 0) merged.sandbox = {
21795
+ if (override?.sandbox !== void 0) patch.sandbox = {
21359
21796
  ...asReasonixRecord(parsed.sandbox),
21360
21797
  ...asReasonixRecord(override.sandbox)
21361
21798
  };
21362
- if (override?.agent !== void 0) merged.agent = {
21799
+ if (override?.agent !== void 0) patch.agent = {
21363
21800
  ...asReasonixRecord(parsed.agent),
21364
21801
  ...asReasonixRecord(override.agent)
21365
21802
  };
21366
- const fileContent = smolToml.stringify(merged);
21367
21803
  return new ReasonixPermissions({
21368
21804
  outputRoot,
21369
21805
  relativeDirPath: paths.relativeDirPath,
21370
21806
  relativeFilePath: paths.relativeFilePath,
21371
- fileContent,
21807
+ fileContent: applySharedConfigPatch({
21808
+ fileKey: sharedConfigFileKey(paths),
21809
+ feature: "permissions",
21810
+ existingContent,
21811
+ patch,
21812
+ filePath
21813
+ }),
21372
21814
  validate
21373
21815
  });
21374
21816
  }
@@ -21939,7 +22381,9 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
21939
22381
  }
21940
22382
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
21941
22383
  const paths = this.getSettablePaths({ global });
21942
- const config = parseVibeConfig(await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smolToml.stringify({}));
22384
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22385
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
22386
+ const config = parseVibeConfig(existingContent);
21943
22387
  const permission = rulesyncPermissions.getJson().permission;
21944
22388
  const tools = toVibeToolsRecord(config.tools);
21945
22389
  const enabledTools = new Set(toStringArray(config.enabled_tools));
@@ -21984,16 +22428,21 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
21984
22428
  tools[vibeToolName] = nextTool;
21985
22429
  }
21986
22430
  applyVibeSensitivePatterns(tools, rulesyncPermissions.getJson().vibe);
21987
- config.tools = tools;
21988
- if (enabledTools.size > 0) config.enabled_tools = [...enabledTools].toSorted();
21989
- else delete config.enabled_tools;
21990
- if (disabledTools.size > 0) config.disabled_tools = [...disabledTools].toSorted();
21991
- else delete config.disabled_tools;
21992
22431
  return new VibePermissions({
21993
22432
  outputRoot,
21994
22433
  relativeDirPath: paths.relativeDirPath,
21995
22434
  relativeFilePath: paths.relativeFilePath,
21996
- fileContent: smolToml.stringify(config),
22435
+ fileContent: applySharedConfigPatch({
22436
+ fileKey: sharedConfigFileKey(paths),
22437
+ feature: "permissions",
22438
+ existingContent,
22439
+ patch: {
22440
+ tools,
22441
+ enabled_tools: enabledTools.size > 0 ? [...enabledTools].toSorted() : void 0,
22442
+ disabled_tools: disabledTools.size > 0 ? [...disabledTools].toSorted() : void 0
22443
+ },
22444
+ filePath
22445
+ }),
21997
22446
  validate,
21998
22447
  global
21999
22448
  });
@@ -22456,24 +22905,26 @@ var ZedPermissions = class ZedPermissions extends ToolPermissions {
22456
22905
  }
22457
22906
  const managedToolNames = new Set(Object.keys(managedTools));
22458
22907
  const preservedTools = Object.fromEntries(Object.entries(existingTools).filter(([toolName]) => !managedToolNames.has(toolName)));
22459
- const mergedSettings = {
22460
- ...settings,
22461
- agent: {
22462
- ...agent,
22463
- tool_permissions: {
22464
- ...toolPermissions,
22465
- tools: {
22466
- ...preservedTools,
22467
- ...managedTools
22468
- }
22469
- }
22470
- }
22471
- };
22472
22908
  return new ZedPermissions({
22473
22909
  outputRoot,
22474
22910
  relativeDirPath: paths.relativeDirPath,
22475
22911
  relativeFilePath: paths.relativeFilePath,
22476
- fileContent: JSON.stringify(mergedSettings, null, 2),
22912
+ fileContent: applySharedConfigPatch({
22913
+ fileKey: sharedConfigFileKey(paths),
22914
+ feature: "permissions",
22915
+ existingContent,
22916
+ patch: { agent: {
22917
+ ...agent,
22918
+ tool_permissions: {
22919
+ ...toolPermissions,
22920
+ tools: {
22921
+ ...preservedTools,
22922
+ ...managedTools
22923
+ }
22924
+ }
22925
+ } },
22926
+ filePath
22927
+ }),
22477
22928
  validate: true
22478
22929
  });
22479
22930
  }
@@ -38163,8 +38614,13 @@ const collectFactoryPaths = (factory) => [...settablePathsForScope({
38163
38614
  * Derive, from the processor registry, every on-disk file that two or more
38164
38615
  * features read-modify-write. Source of truth the generation step graph's
38165
38616
  * `writesSharedFile` declarations must match.
38617
+ *
38618
+ * `minWriters: 1` widens the result to single-feature files as well — used to
38619
+ * validate `SHARED_CONFIG_OWNERSHIP` declarations for files that route through
38620
+ * the gateway without being cross-feature shared (e.g. a global-scope twin
38621
+ * only one feature writes).
38166
38622
  */
38167
- const deriveSharedFileWriters = () => {
38623
+ const deriveSharedFileWriters = ({ minWriters = 2 } = {}) => {
38168
38624
  const byKey = /* @__PURE__ */ new Map();
38169
38625
  const pathByKey = /* @__PURE__ */ new Map();
38170
38626
  for (const entry of PROCESSOR_REGISTRY) {
@@ -38190,7 +38646,7 @@ const deriveSharedFileWriters = () => {
38190
38646
  }
38191
38647
  const writers = [];
38192
38648
  for (const [key, features] of byKey) {
38193
- if (features.size < 2) continue;
38649
+ if (features.size < minWriters) continue;
38194
38650
  const path = pathByKey.get(key);
38195
38651
  const toolsByFeature = /* @__PURE__ */ new Map();
38196
38652
  for (const [feature, tools] of features) toolsByFeature.set(feature, [...tools].toSorted());
@@ -39135,4 +39591,4 @@ async function importPermissionsCore(params) {
39135
39591
  //#endregion
39136
39592
  export { removeTempDirectory as $, RulesyncCommand as A, checkPathTraversal as B, RulesyncHooks as C, RULESYNC_RULES_RELATIVE_DIR_PATH as Ct, CLAUDECODE_MEMORIES_DIR_NAME as D, formatError as Dt, CLAUDECODE_LOCAL_RULE_FILE_NAME as E, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as Et, findControlCharacter as F, findFilesByGlobs as G, directoryExists as H, ConsoleLogger as I, isSymlink as J, getFileSize as K, JsonLogger as L, stringifyFrontmatter as M, loadYaml as N, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as O, ALL_FEATURES as Ot, ConfigResolver as P, removeFile as Q, CLIError as R, HooksProcessor as S, RULESYNC_RELATIVE_DIR_PATH as St, CLAUDECODE_DIR as T, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Tt, ensureDir as U, createTempDirectory as V, fileExists as W, readFileContent as X, listDirectoryFiles as Y, removeDirectory as Z, RulesyncPermissions as _, RULESYNC_MCP_RELATIVE_FILE_PATH as _t, convertFromTool as a, MAX_FILE_SIZE as at, IgnoreProcessor as b, RULESYNC_PERMISSIONS_FILE_NAME as bt, RulesyncRuleFrontmatterSchema as c, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as ct, RulesyncSubagentFrontmatterSchema as d, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as dt, toPosixPath as et, SkillsProcessor as f, RULESYNC_HOOKS_FILE_NAME as ft, SKILL_FILE_NAME as g, RULESYNC_MCP_FILE_NAME as gt, RulesyncSkillFrontmatterSchema as h, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as ht, getProcessorRegistryEntry as i, ToolTargetSchema as it, RulesyncCommandFrontmatterSchema as j, CLAUDECODE_SKILLS_DIR_PATH as k, ALL_FEATURES_WITH_WILDCARD as kt, SubagentsProcessor as l, RULESYNC_CONFIG_RELATIVE_FILE_PATH as lt, RulesyncSkill as m, RULESYNC_IGNORE_RELATIVE_FILE_PATH as mt, checkRulesyncDirExists as n, ALL_TOOL_TARGETS as nt, RulesProcessor as o, RULESYNC_AIIGNORE_FILE_NAME as ot, getLocalSkillDirNames as p, RULESYNC_HOOKS_RELATIVE_FILE_PATH as pt, getHomeDirectory as q, generate as r, ALL_TOOL_TARGETS_WITH_WILDCARD as rt, RulesyncRule as s, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as st, importFromTool as t, writeFileContent as tt, RulesyncSubagent as u, RULESYNC_CONFIG_SCHEMA_URL as ut, McpProcessor as v, RULESYNC_MCP_SCHEMA_URL as vt, CommandsProcessor as w, RULESYNC_SKILLS_RELATIVE_DIR_PATH as wt, RulesyncIgnore as x, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as xt, RulesyncMcp as y, RULESYNC_OVERVIEW_FILE_NAME as yt, ErrorCodes as z };
39137
39593
 
39138
- //# sourceMappingURL=import-CUHHL2_P.js.map
39594
+ //# sourceMappingURL=import-DC9T1JlQ.js.map