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.
@@ -2703,6 +2703,13 @@ const CODEXCLI_MCP_FILE_NAME = "config.toml";
2703
2703
  const CODEXCLI_RULE_FILE_NAME = "AGENTS.md";
2704
2704
  const CODEXCLI_BASH_RULES_FILE_NAME = "rulesync.rules";
2705
2705
  const CODEXCLI_OPENAI_YAML_RELATIVE_PATH = (0, node_path.join)("agents", "openai.yaml");
2706
+ const CODEXCLI_OVERRIDE_KEYS = [
2707
+ "approval_policy",
2708
+ "sandbox_mode",
2709
+ "sandbox_workspace_write",
2710
+ "apps",
2711
+ "approvals_reviewer"
2712
+ ];
2706
2713
  //#endregion
2707
2714
  //#region src/features/commands/codexcli-command.ts
2708
2715
  const CodexcliCommandFrontmatterSchema = zod_mini.z.looseObject({
@@ -4082,14 +4089,22 @@ function sanitizeSharedConfigValue(value) {
4082
4089
  * root follows `invalidRootPolicy`. Syntax errors are wrapped with the file
4083
4090
  * path when one is given.
4084
4091
  */
4085
- function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy = "coerce-empty" }) {
4092
+ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy = "coerce-empty", jsoncParseErrors = "tolerate" }) {
4086
4093
  if (fileContent.trim() === "") return {};
4087
4094
  const at = filePath === void 0 ? "" : ` at ${filePath}`;
4088
4095
  let parsed;
4089
4096
  try {
4090
4097
  if (format === "yaml") parsed = loadYaml(fileContent);
4098
+ else if (format === "toml") parsed = (0, smol_toml.parse)(fileContent);
4091
4099
  else if (format === "json") parsed = JSON.parse(fileContent);
4092
- else parsed = (0, jsonc_parser.parse)(fileContent);
4100
+ else if (jsoncParseErrors === "error") {
4101
+ const errors = [];
4102
+ parsed = (0, jsonc_parser.parse)(fileContent, errors, { allowTrailingComma: true });
4103
+ if (errors.length > 0) {
4104
+ const details = errors.map((error) => `${(0, jsonc_parser.printParseErrorCode)(error.error)} at offset ${error.offset}`).join(", ");
4105
+ throw new Error(details);
4106
+ }
4107
+ } else parsed = (0, jsonc_parser.parse)(fileContent);
4093
4108
  } catch (error) {
4094
4109
  throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
4095
4110
  }
@@ -4103,13 +4118,15 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
4103
4118
  /**
4104
4119
  * Serialize a shared config document. YAML output always ends with exactly one
4105
4120
  * newline; JSON output matches the 2-space `JSON.stringify` shape the JSON
4106
- * writers have always emitted (no trailing newline).
4121
+ * writers have always emitted (no trailing newline); TOML output matches the
4122
+ * `smol-toml` `stringify` shape the TOML writers have always emitted.
4107
4123
  */
4108
4124
  function stringifySharedConfig({ format, document }) {
4109
4125
  if (format === "yaml") return (0, js_yaml.dump)(document, {
4110
4126
  noRefs: true,
4111
4127
  sortKeys: false
4112
4128
  }).trimEnd() + "\n";
4129
+ if (format === "toml") return (0, smol_toml.stringify)(document);
4113
4130
  return JSON.stringify(document, null, 2);
4114
4131
  }
4115
4132
  /**
@@ -4148,6 +4165,25 @@ function mergeSharedConfigDeep({ base, patch }) {
4148
4165
  const CLAUDE_SETTINGS_SHARED_FILE_KEY = ".claude/settings.json";
4149
4166
  const HERMES_CONFIG_SHARED_FILE_KEY = ".hermes/config.yaml";
4150
4167
  const TAKT_CONFIG_SHARED_FILE_KEY = ".takt/config.yaml";
4168
+ const CODEXCLI_CONFIG_SHARED_FILE_KEY = ".codex/config.toml";
4169
+ const GROKCLI_CONFIG_SHARED_FILE_KEY = ".grok/config.toml";
4170
+ const VIBE_CONFIG_SHARED_FILE_KEY = ".vibe/config.toml";
4171
+ const REASONIX_PROJECT_CONFIG_SHARED_FILE_KEY = "reasonix.toml";
4172
+ const REASONIX_GLOBAL_CONFIG_SHARED_FILE_KEY = ".reasonix/config.toml";
4173
+ /**
4174
+ * Build the `SHARED_CONFIG_OWNERSHIP` lookup key from a tool's settable paths.
4175
+ * Mirrors `sharedFileKey` in `src/lib/shared-file-derive.ts` (kept separate so
4176
+ * feature classes don't pull the processor registry through this module and
4177
+ * create an import cycle); the ownership lock-step test keeps the two aligned.
4178
+ * Lets a tool whose file lives at a scope-dependent path (`.zed/settings.json`
4179
+ * vs `.config/zed/settings.json`) resolve its declaration from the settable
4180
+ * paths it already holds.
4181
+ */
4182
+ const sharedConfigFileKey = ({ relativeDirPath, relativeFilePath }) => {
4183
+ const dir = relativeDirPath.replace(/\\/g, "/").replace(/\/$/, "");
4184
+ const file = relativeFilePath.replace(/\\/g, "/");
4185
+ return dir === "" || dir === "." ? file : `${dir}/${file}`;
4186
+ };
4151
4187
  /**
4152
4188
  * Who owns what in each gateway-managed shared config file, and which policy
4153
4189
  * resolves conflicts. Keys are `dir/file` tokens matching
@@ -4204,6 +4240,298 @@ const SHARED_CONFIG_OWNERSHIP = {
4204
4240
  },
4205
4241
  permissions: { kind: "deep-merge" }
4206
4242
  }
4243
+ },
4244
+ ".zed/settings.json": {
4245
+ format: "json",
4246
+ features: {
4247
+ ignore: {
4248
+ kind: "replace-owned-keys",
4249
+ ownedKeys: ["private_files"]
4250
+ },
4251
+ mcp: {
4252
+ kind: "replace-owned-keys",
4253
+ ownedKeys: ["context_servers"]
4254
+ },
4255
+ permissions: {
4256
+ kind: "replace-owned-keys",
4257
+ ownedKeys: ["agent"]
4258
+ }
4259
+ }
4260
+ },
4261
+ ".config/zed/settings.json": {
4262
+ format: "json",
4263
+ features: {
4264
+ mcp: {
4265
+ kind: "replace-owned-keys",
4266
+ ownedKeys: ["context_servers"]
4267
+ },
4268
+ permissions: {
4269
+ kind: "replace-owned-keys",
4270
+ ownedKeys: ["agent"]
4271
+ }
4272
+ }
4273
+ },
4274
+ ".qwen/settings.json": {
4275
+ format: "json",
4276
+ features: {
4277
+ mcp: {
4278
+ kind: "replace-owned-keys",
4279
+ ownedKeys: ["mcpServers"]
4280
+ },
4281
+ hooks: {
4282
+ kind: "replace-owned-keys",
4283
+ ownedKeys: ["hooks", "disableAllHooks"]
4284
+ },
4285
+ permissions: {
4286
+ kind: "replace-owned-keys",
4287
+ ownedKeys: [
4288
+ "permissions",
4289
+ "tools",
4290
+ "security"
4291
+ ]
4292
+ }
4293
+ }
4294
+ },
4295
+ ".augment/settings.json": {
4296
+ format: "json",
4297
+ features: {
4298
+ mcp: {
4299
+ kind: "replace-owned-keys",
4300
+ ownedKeys: ["mcpServers"]
4301
+ },
4302
+ hooks: {
4303
+ kind: "replace-owned-keys",
4304
+ ownedKeys: ["hooks"]
4305
+ },
4306
+ permissions: {
4307
+ kind: "replace-owned-keys",
4308
+ ownedKeys: ["toolPermissions"]
4309
+ }
4310
+ }
4311
+ },
4312
+ ".devin/config.json": {
4313
+ format: "json",
4314
+ features: {
4315
+ mcp: {
4316
+ kind: "replace-owned-keys",
4317
+ ownedKeys: ["mcpServers"]
4318
+ },
4319
+ permissions: {
4320
+ kind: "replace-owned-keys",
4321
+ ownedKeys: ["permissions"]
4322
+ }
4323
+ }
4324
+ },
4325
+ ".config/devin/config.json": {
4326
+ format: "json",
4327
+ features: {
4328
+ mcp: {
4329
+ kind: "replace-owned-keys",
4330
+ ownedKeys: ["mcpServers"]
4331
+ },
4332
+ hooks: {
4333
+ kind: "replace-owned-keys",
4334
+ ownedKeys: ["hooks"]
4335
+ },
4336
+ permissions: {
4337
+ kind: "replace-owned-keys",
4338
+ ownedKeys: ["permissions"]
4339
+ }
4340
+ }
4341
+ },
4342
+ ".kiro/agents/default.json": {
4343
+ format: "json",
4344
+ features: {
4345
+ hooks: {
4346
+ kind: "replace-owned-keys",
4347
+ ownedKeys: ["hooks"]
4348
+ },
4349
+ permissions: {
4350
+ kind: "replace-owned-keys",
4351
+ ownedKeys: ["allowedTools", "toolsSettings"]
4352
+ }
4353
+ }
4354
+ },
4355
+ ".amp/settings.json": {
4356
+ format: "jsonc",
4357
+ invalidRootPolicy: "error",
4358
+ jsoncParseErrors: "error",
4359
+ features: {
4360
+ mcp: {
4361
+ kind: "replace-owned-keys",
4362
+ ownedKeys: ["amp.mcpServers"]
4363
+ },
4364
+ permissions: {
4365
+ kind: "replace-owned-keys",
4366
+ ownedKeys: [
4367
+ "amp.tools.disable",
4368
+ "amp.permissions",
4369
+ "amp.guardedFiles.allowlist",
4370
+ "amp.dangerouslyAllowAll",
4371
+ "amp.mcpPermissions"
4372
+ ]
4373
+ }
4374
+ }
4375
+ },
4376
+ ".config/amp/settings.json": {
4377
+ format: "jsonc",
4378
+ invalidRootPolicy: "error",
4379
+ jsoncParseErrors: "error",
4380
+ features: {
4381
+ mcp: {
4382
+ kind: "replace-owned-keys",
4383
+ ownedKeys: ["amp.mcpServers"]
4384
+ },
4385
+ permissions: {
4386
+ kind: "replace-owned-keys",
4387
+ ownedKeys: [
4388
+ "amp.tools.disable",
4389
+ "amp.permissions",
4390
+ "amp.guardedFiles.allowlist",
4391
+ "amp.dangerouslyAllowAll",
4392
+ "amp.mcpPermissions"
4393
+ ]
4394
+ }
4395
+ }
4396
+ },
4397
+ "opencode.json": {
4398
+ format: "jsonc",
4399
+ features: {
4400
+ mcp: {
4401
+ kind: "replace-owned-keys",
4402
+ ownedKeys: ["mcp", "tools"]
4403
+ },
4404
+ permissions: {
4405
+ kind: "replace-owned-keys",
4406
+ ownedKeys: ["permission"]
4407
+ },
4408
+ rules: {
4409
+ kind: "replace-owned-keys",
4410
+ ownedKeys: ["instructions"]
4411
+ }
4412
+ }
4413
+ },
4414
+ ".config/opencode/opencode.json": {
4415
+ format: "jsonc",
4416
+ features: {
4417
+ mcp: {
4418
+ kind: "replace-owned-keys",
4419
+ ownedKeys: ["mcp", "tools"]
4420
+ },
4421
+ permissions: {
4422
+ kind: "replace-owned-keys",
4423
+ ownedKeys: ["permission"]
4424
+ }
4425
+ }
4426
+ },
4427
+ "kilo.json": {
4428
+ format: "jsonc",
4429
+ features: {
4430
+ mcp: {
4431
+ kind: "replace-owned-keys",
4432
+ ownedKeys: ["mcp", "tools"]
4433
+ },
4434
+ rules: {
4435
+ kind: "replace-owned-keys",
4436
+ ownedKeys: ["instructions"]
4437
+ }
4438
+ }
4439
+ },
4440
+ ".config/kilo/kilo.json": {
4441
+ format: "jsonc",
4442
+ features: { mcp: {
4443
+ kind: "replace-owned-keys",
4444
+ ownedKeys: ["mcp", "tools"]
4445
+ } }
4446
+ },
4447
+ [CODEXCLI_CONFIG_SHARED_FILE_KEY]: {
4448
+ format: "toml",
4449
+ features: {
4450
+ hooks: {
4451
+ kind: "replace-owned-keys",
4452
+ ownedKeys: ["features"]
4453
+ },
4454
+ mcp: {
4455
+ kind: "replace-owned-keys",
4456
+ ownedKeys: ["mcp_servers"]
4457
+ },
4458
+ permissions: {
4459
+ kind: "replace-owned-keys",
4460
+ ownedKeys: [
4461
+ "permissions",
4462
+ "default_permissions",
4463
+ ...CODEXCLI_OVERRIDE_KEYS
4464
+ ]
4465
+ }
4466
+ }
4467
+ },
4468
+ [GROKCLI_CONFIG_SHARED_FILE_KEY]: {
4469
+ format: "toml",
4470
+ features: {
4471
+ mcp: {
4472
+ kind: "replace-owned-keys",
4473
+ ownedKeys: ["mcp_servers"]
4474
+ },
4475
+ permissions: {
4476
+ kind: "replace-owned-keys",
4477
+ ownedKeys: ["permission", "ui"]
4478
+ }
4479
+ }
4480
+ },
4481
+ [VIBE_CONFIG_SHARED_FILE_KEY]: {
4482
+ format: "toml",
4483
+ features: {
4484
+ hooks: {
4485
+ kind: "replace-owned-keys",
4486
+ ownedKeys: ["enable_experimental_hooks"]
4487
+ },
4488
+ mcp: {
4489
+ kind: "replace-owned-keys",
4490
+ ownedKeys: ["mcp_servers"]
4491
+ },
4492
+ permissions: {
4493
+ kind: "replace-owned-keys",
4494
+ ownedKeys: [
4495
+ "tools",
4496
+ "enabled_tools",
4497
+ "disabled_tools"
4498
+ ]
4499
+ }
4500
+ }
4501
+ },
4502
+ [REASONIX_PROJECT_CONFIG_SHARED_FILE_KEY]: {
4503
+ format: "toml",
4504
+ features: {
4505
+ mcp: {
4506
+ kind: "replace-owned-keys",
4507
+ ownedKeys: ["plugins"]
4508
+ },
4509
+ permissions: {
4510
+ kind: "replace-owned-keys",
4511
+ ownedKeys: [
4512
+ "permissions",
4513
+ "sandbox",
4514
+ "agent"
4515
+ ]
4516
+ }
4517
+ }
4518
+ },
4519
+ [REASONIX_GLOBAL_CONFIG_SHARED_FILE_KEY]: {
4520
+ format: "toml",
4521
+ features: {
4522
+ mcp: {
4523
+ kind: "replace-owned-keys",
4524
+ ownedKeys: ["plugins"]
4525
+ },
4526
+ permissions: {
4527
+ kind: "replace-owned-keys",
4528
+ ownedKeys: [
4529
+ "permissions",
4530
+ "sandbox",
4531
+ "agent"
4532
+ ]
4533
+ }
4534
+ }
4207
4535
  }
4208
4536
  };
4209
4537
  /**
@@ -4224,17 +4552,20 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
4224
4552
  format: declaration.format,
4225
4553
  fileContent: existingContent,
4226
4554
  filePath,
4227
- ...declaration.invalidRootPolicy !== void 0 && { invalidRootPolicy: declaration.invalidRootPolicy }
4555
+ ...declaration.invalidRootPolicy !== void 0 && { invalidRootPolicy: declaration.invalidRootPolicy },
4556
+ ...declaration.jsoncParseErrors !== void 0 && { jsoncParseErrors: declaration.jsoncParseErrors }
4228
4557
  });
4229
4558
  if (policy.kind === "replace-owned-keys") {
4230
4559
  const unowned = Object.keys(patch).filter((key) => !policy.ownedKeys.includes(key));
4231
4560
  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.`);
4561
+ const document = mergeSharedConfigShallow({
4562
+ base,
4563
+ patch
4564
+ });
4565
+ for (const [key, value] of Object.entries(patch)) if (value === void 0) delete document[key];
4232
4566
  return stringifySharedConfig({
4233
4567
  format: declaration.format,
4234
- document: mergeSharedConfigShallow({
4235
- base,
4236
- patch
4237
- })
4568
+ document
4238
4569
  });
4239
4570
  }
4240
4571
  const merged = mergeSharedConfigDeep({
@@ -7307,12 +7638,6 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
7307
7638
  const paths = AugmentcodeHooks.getSettablePaths({ global });
7308
7639
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
7309
7640
  const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
7310
- let settings;
7311
- try {
7312
- settings = JSON.parse(existingContent);
7313
- } catch (error) {
7314
- throw new Error(`Failed to parse existing AugmentCode settings at ${filePath}: ${formatError(error)}`, { cause: error });
7315
- }
7316
7641
  const config = rulesyncHooks.getJson();
7317
7642
  const augmentHooks = canonicalToToolHooks({
7318
7643
  config,
@@ -7320,11 +7645,13 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
7320
7645
  converterConfig: AUGMENTCODE_CONVERTER_CONFIG,
7321
7646
  logger
7322
7647
  });
7323
- const merged = {
7324
- ...settings,
7325
- hooks: augmentHooks
7326
- };
7327
- const fileContent = JSON.stringify(merged, null, 2);
7648
+ const fileContent = applySharedConfigPatch({
7649
+ fileKey: sharedConfigFileKey(paths),
7650
+ feature: "hooks",
7651
+ existingContent,
7652
+ patch: { hooks: augmentHooks },
7653
+ filePath
7654
+ });
7328
7655
  return new AugmentcodeHooks({
7329
7656
  outputRoot,
7330
7657
  relativeDirPath: paths.relativeDirPath,
@@ -7491,15 +7818,28 @@ const CODEXCLI_CONVERTER_CONFIG = {
7491
7818
  */
7492
7819
  async function buildCodexConfigTomlContent({ outputRoot }) {
7493
7820
  const configPath = (0, node_path.join)(outputRoot, CODEXCLI_DIR, CODEXCLI_MCP_FILE_NAME);
7494
- const existingContent = await readFileContentOrNull(configPath) ?? smol_toml.stringify({});
7821
+ const existingContent = await readFileContentOrNull(configPath) ?? "";
7495
7822
  let configToml;
7496
7823
  try {
7497
- configToml = smol_toml.parse(existingContent);
7824
+ configToml = smol_toml.parse(existingContent || smol_toml.stringify({}));
7498
7825
  } catch (error) {
7499
7826
  throw new Error(`Failed to parse existing Codex CLI config at ${configPath}: ${formatError(error)}`, { cause: error });
7500
7827
  }
7501
- if (typeof configToml.features === "object" && configToml.features !== null) delete configToml.features.codex_hooks;
7502
- return smol_toml.stringify(configToml);
7828
+ let features;
7829
+ if (typeof configToml.features === "object" && configToml.features !== null) {
7830
+ features = { ...configToml.features };
7831
+ delete features.codex_hooks;
7832
+ }
7833
+ return applySharedConfigPatch({
7834
+ fileKey: sharedConfigFileKey({
7835
+ relativeDirPath: CODEXCLI_DIR,
7836
+ relativeFilePath: CODEXCLI_MCP_FILE_NAME
7837
+ }),
7838
+ feature: "hooks",
7839
+ existingContent,
7840
+ patch: { features },
7841
+ filePath: configPath
7842
+ });
7503
7843
  }
7504
7844
  /**
7505
7845
  * Represents the `.codex/config.toml` file as a generated ToolFile,
@@ -8415,17 +8755,13 @@ var DevinHooks = class DevinHooks extends ToolHooks {
8415
8755
  let fileContent;
8416
8756
  if (global) {
8417
8757
  const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
8418
- let parsedSettings;
8419
- try {
8420
- parsedSettings = JSON.parse(existingContent);
8421
- } catch (error) {
8422
- throw new Error(`Failed to parse existing Devin config at ${filePath}: ${formatError(error)}`, { cause: error });
8423
- }
8424
- const merged = {
8425
- ...isRecord(parsedSettings) ? parsedSettings : {},
8426
- hooks: devinHooks
8427
- };
8428
- fileContent = JSON.stringify(merged, null, 2);
8758
+ fileContent = applySharedConfigPatch({
8759
+ fileKey: sharedConfigFileKey(paths),
8760
+ feature: "hooks",
8761
+ existingContent,
8762
+ patch: { hooks: devinHooks },
8763
+ filePath
8764
+ });
8429
8765
  } else {
8430
8766
  await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
8431
8767
  fileContent = JSON.stringify(devinHooks, null, 2);
@@ -9237,18 +9573,14 @@ var KiroHooks = class KiroHooks extends ToolHooks {
9237
9573
  const paths = KiroHooks.getSettablePaths({ global });
9238
9574
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
9239
9575
  const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
9240
- let agentConfig;
9241
- try {
9242
- agentConfig = JSON.parse(existingContent);
9243
- } catch (error) {
9244
- throw new Error(`Failed to parse existing Kiro agent config at ${filePath}: ${formatError(error)}`, { cause: error });
9245
- }
9246
9576
  const kiroHooks = canonicalToKiroHooks(rulesyncHooks.getJson(), this.getOverrideKey());
9247
- const merged = {
9248
- ...agentConfig,
9249
- hooks: kiroHooks
9250
- };
9251
- const fileContent = JSON.stringify(merged, null, 2);
9577
+ const fileContent = applySharedConfigPatch({
9578
+ fileKey: sharedConfigFileKey(paths),
9579
+ feature: "hooks",
9580
+ existingContent,
9581
+ patch: { hooks: kiroHooks },
9582
+ filePath
9583
+ });
9252
9584
  return new KiroHooks({
9253
9585
  outputRoot,
9254
9586
  relativeDirPath: paths.relativeDirPath,
@@ -9743,21 +10075,17 @@ var QwencodeHooks = class QwencodeHooks extends ToolHooks {
9743
10075
  const paths = QwencodeHooks.getSettablePaths({ global });
9744
10076
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
9745
10077
  const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
9746
- let settings;
9747
- try {
9748
- settings = JSON.parse(existingContent);
9749
- } catch (error) {
9750
- throw new Error(`Failed to parse existing Qwen Code settings at ${filePath}: ${formatError(error)}`, { cause: error });
9751
- }
9752
10078
  const config = rulesyncHooks.getJson();
9753
- const qwencodeHooks = canonicalToQwencodeHooks(config);
9754
- const merged = {
9755
- ...settings,
9756
- hooks: qwencodeHooks
9757
- };
10079
+ const patch = { hooks: canonicalToQwencodeHooks(config) };
9758
10080
  const disableAllHooks = config.qwencode?.disableAllHooks;
9759
- if (typeof disableAllHooks === "boolean") merged.disableAllHooks = disableAllHooks;
9760
- const fileContent = JSON.stringify(merged, null, 2);
10081
+ if (typeof disableAllHooks === "boolean") patch.disableAllHooks = disableAllHooks;
10082
+ const fileContent = applySharedConfigPatch({
10083
+ fileKey: sharedConfigFileKey(paths),
10084
+ feature: "hooks",
10085
+ existingContent,
10086
+ patch,
10087
+ filePath
10088
+ });
9761
10089
  return new QwencodeHooks({
9762
10090
  outputRoot,
9763
10091
  relativeDirPath: paths.relativeDirPath,
@@ -10016,10 +10344,6 @@ function canonicalToVibeHooks(config, toolOverride) {
10016
10344
  }
10017
10345
  return { hooks };
10018
10346
  }
10019
- /**
10020
- * Reverse {@link canonicalToVibeHooks}: parse the flat `[[hooks]]` array back
10021
- * into a canonical event → definition[] record.
10022
- */
10023
10347
  /** Convert one raw `[[hooks]]` entry to a canonical definition, or null to skip. */
10024
10348
  function vibeEntryToCanonicalDef(raw) {
10025
10349
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
@@ -10039,6 +10363,10 @@ function vibeEntryToCanonicalDef(raw) {
10039
10363
  def
10040
10364
  };
10041
10365
  }
10366
+ /**
10367
+ * Reverse {@link canonicalToVibeHooks}: parse the flat `[[hooks]]` array back
10368
+ * into a canonical event → definition[] record.
10369
+ */
10042
10370
  function vibeHooksToCanonical(parsed) {
10043
10371
  const canonical = {};
10044
10372
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return canonical;
@@ -10067,15 +10395,22 @@ function parseVibeToml(fileContent) {
10067
10395
  */
10068
10396
  async function buildVibeConfigTomlContent({ outputRoot }) {
10069
10397
  const configPath = (0, node_path.join)(outputRoot, VIBE_DIR, VIBE_CONFIG_FILE_NAME);
10070
- const existingContent = await readFileContentOrNull(configPath) ?? smol_toml.stringify({});
10071
- let config;
10398
+ const existingContent = await readFileContentOrNull(configPath) ?? "";
10072
10399
  try {
10073
- config = parseVibeToml(existingContent);
10400
+ parseVibeToml(existingContent);
10074
10401
  } catch (error) {
10075
10402
  throw new Error(`Failed to parse existing Vibe config at ${configPath}: ${formatError(error)}`, { cause: error });
10076
10403
  }
10077
- config.enable_experimental_hooks = true;
10078
- return smol_toml.stringify(config);
10404
+ return applySharedConfigPatch({
10405
+ fileKey: sharedConfigFileKey({
10406
+ relativeDirPath: VIBE_DIR,
10407
+ relativeFilePath: VIBE_CONFIG_FILE_NAME
10408
+ }),
10409
+ feature: "hooks",
10410
+ existingContent,
10411
+ patch: { enable_experimental_hooks: true },
10412
+ filePath: configPath
10413
+ });
10079
10414
  }
10080
10415
  /**
10081
10416
  * Represents the `.vibe/config.toml` file as a generated ToolFile so it goes
@@ -11575,17 +11910,18 @@ var ZedIgnore = class ZedIgnore extends ToolIgnore {
11575
11910
  const patterns = rulesyncIgnore.getFileContent().split(/\r?\n|\r/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
11576
11911
  const filePath = (0, node_path.join)(outputRoot, this.getSettablePaths().relativeDirPath, this.getSettablePaths().relativeFilePath);
11577
11912
  const existingFileContent = await fileExists(filePath) ? await readFileContent(filePath) : "{}";
11578
- const existingJsonValue = JSON.parse(existingFileContent);
11579
- const mergedPatterns = (0, es_toolkit.uniq)([...existingJsonValue.private_files ?? [], ...patterns].toSorted());
11580
- const jsonValue = {
11581
- ...existingJsonValue,
11582
- private_files: mergedPatterns
11583
- };
11913
+ const mergedPatterns = (0, es_toolkit.uniq)([...JSON.parse(existingFileContent).private_files ?? [], ...patterns].toSorted());
11584
11914
  return new ZedIgnore({
11585
11915
  outputRoot,
11586
11916
  relativeDirPath: this.getSettablePaths().relativeDirPath,
11587
11917
  relativeFilePath: this.getSettablePaths().relativeFilePath,
11588
- fileContent: JSON.stringify(jsonValue, null, 2),
11918
+ fileContent: applySharedConfigPatch({
11919
+ fileKey: sharedConfigFileKey(this.getSettablePaths()),
11920
+ feature: "ignore",
11921
+ existingContent: existingFileContent,
11922
+ patch: { private_files: mergedPatterns },
11923
+ filePath
11924
+ }),
11589
11925
  validate: true
11590
11926
  });
11591
11927
  }
@@ -12042,17 +12378,17 @@ var AmpMcp = class AmpMcp extends ToolMcp {
12042
12378
  const basePaths = this.getSettablePaths({ global });
12043
12379
  const jsonDir = (0, node_path.join)(outputRoot, basePaths.relativeDirPath);
12044
12380
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
12045
- const json = fileContent ? parseAmpSettingsJsonc(fileContent) : { [AMP_MCP_SERVERS_KEY]: {} };
12046
- const filteredMcpServers = filterMcpServers(rulesyncMcp.getMcpServers());
12047
- const newJson = {
12048
- ...json,
12049
- [AMP_MCP_SERVERS_KEY]: filteredMcpServers
12050
- };
12051
12381
  return new AmpMcp({
12052
12382
  outputRoot,
12053
12383
  relativeDirPath: basePaths.relativeDirPath,
12054
12384
  relativeFilePath,
12055
- fileContent: JSON.stringify(newJson, null, 2),
12385
+ fileContent: applySharedConfigPatch({
12386
+ fileKey: sharedConfigFileKey(basePaths),
12387
+ feature: "mcp",
12388
+ existingContent: fileContent ?? "",
12389
+ patch: { [AMP_MCP_SERVERS_KEY]: filterMcpServers(rulesyncMcp.getMcpServers()) },
12390
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath)
12391
+ }),
12056
12392
  validate,
12057
12393
  global
12058
12394
  });
@@ -12339,15 +12675,19 @@ var AugmentcodeMcp = class AugmentcodeMcp extends ToolMcp {
12339
12675
  }
12340
12676
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
12341
12677
  const paths = this.getSettablePaths({ global });
12342
- const merged = {
12343
- ...parseAugmentcodeSettings(await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), JSON.stringify({}, null, 2)), paths.relativeDirPath, paths.relativeFilePath),
12344
- mcpServers: rulesyncMcp.getMcpServers()
12345
- };
12678
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
12679
+ const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
12346
12680
  return new AugmentcodeMcp({
12347
12681
  outputRoot,
12348
12682
  relativeDirPath: paths.relativeDirPath,
12349
12683
  relativeFilePath: paths.relativeFilePath,
12350
- fileContent: JSON.stringify(merged, null, 2),
12684
+ fileContent: applySharedConfigPatch({
12685
+ fileKey: sharedConfigFileKey(paths),
12686
+ feature: "mcp",
12687
+ existingContent,
12688
+ patch: { mcpServers: rulesyncMcp.getMcpServers() },
12689
+ filePath
12690
+ }),
12351
12691
  validate,
12352
12692
  global
12353
12693
  });
@@ -12644,6 +12984,13 @@ function mapOauthFromCodex(oauth) {
12644
12984
  }
12645
12985
  return result;
12646
12986
  }
12987
+ const CODEX_MCP_SERVER_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
12988
+ function normalizeCodexMcpServerName(name) {
12989
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) return null;
12990
+ if (CODEX_MCP_SERVER_NAME_PATTERN.test(name)) return name;
12991
+ const normalizedName = name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
12992
+ return normalizedName && !PROTOTYPE_POLLUTION_KEYS.has(normalizedName) ? normalizedName : null;
12993
+ }
12647
12994
  function convertFromCodexFormat(codexMcp) {
12648
12995
  const result = {};
12649
12996
  for (const [name, config] of Object.entries(codexMcp)) {
@@ -12666,8 +13013,13 @@ function convertFromCodexFormat(codexMcp) {
12666
13013
  }
12667
13014
  function convertToCodexFormat(mcpServers) {
12668
13015
  const result = {};
13016
+ const originalNames = /* @__PURE__ */ new Map();
12669
13017
  for (const [name, config] of Object.entries(mcpServers)) {
12670
- if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
13018
+ const codexName = normalizeCodexMcpServerName(name);
13019
+ if (codexName === null) {
13020
+ warnWithFallback(void 0, `MCP server "${name}" could not be normalized to a valid Codex MCP server name and was skipped.`);
13021
+ continue;
13022
+ }
12671
13023
  if (!isRecord(config)) continue;
12672
13024
  const converted = {};
12673
13025
  for (const [key, value] of Object.entries(config)) {
@@ -12681,7 +13033,10 @@ function convertToCodexFormat(mcpServers) {
12681
13033
  else warnWithFallback(void 0, `[CodexCliMcp] Skipping invalid value type for mapped key '${key}': expected string array, got ${typeof value}`);
12682
13034
  } else converted[key] = value;
12683
13035
  }
12684
- result[name] = converted;
13036
+ const previousName = originalNames.get(codexName);
13037
+ 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.`);
13038
+ originalNames.set(codexName, name);
13039
+ result[codexName] = converted;
12685
13040
  }
12686
13041
  return result;
12687
13042
  }
@@ -12726,8 +13081,9 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
12726
13081
  }
12727
13082
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
12728
13083
  const paths = this.getSettablePaths({ global });
12729
- const configTomlFileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), smol_toml.stringify({}));
12730
- const configToml = smol_toml.parse(configTomlFileContent);
13084
+ const configTomlFilePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
13085
+ const configTomlFileContent = await readFileContentOrNull(configTomlFilePath) ?? "";
13086
+ const configToml = smol_toml.parse(configTomlFileContent || smol_toml.stringify({}));
12731
13087
  const strippedMcpServers = rulesyncMcp.getMcpServers();
12732
13088
  const rawMcpServers = rulesyncMcp.getJson().mcpServers;
12733
13089
  const converted = convertToCodexFormat(Object.fromEntries(Object.entries(strippedMcpServers).map(([serverName, serverConfig]) => {
@@ -12740,7 +13096,7 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
12740
13096
  const filteredMcpServers = this.removeEmptyEntries(converted);
12741
13097
  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`);
12742
13098
  const existingMcpServers = isRecord(configToml["mcp_servers"]) ? configToml["mcp_servers"] : {};
12743
- configToml["mcp_servers"] = Object.fromEntries(Object.entries(filteredMcpServers).map(([name, serverConfig]) => {
13099
+ const mergedMcpServers = Object.fromEntries(Object.entries(filteredMcpServers).map(([name, serverConfig]) => {
12744
13100
  const existingServer = isRecord(existingMcpServers[name]) ? existingMcpServers[name] : void 0;
12745
13101
  const serverRecord = serverConfig;
12746
13102
  if (existingServer && isRecord(existingServer["tools"]) && !("tools" in serverRecord)) return [name, {
@@ -12753,7 +13109,13 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
12753
13109
  outputRoot,
12754
13110
  relativeDirPath: paths.relativeDirPath,
12755
13111
  relativeFilePath: paths.relativeFilePath,
12756
- fileContent: smol_toml.stringify(configToml),
13112
+ fileContent: applySharedConfigPatch({
13113
+ fileKey: sharedConfigFileKey(paths),
13114
+ feature: "mcp",
13115
+ existingContent: configTomlFileContent,
13116
+ patch: { mcp_servers: mergedMcpServers },
13117
+ filePath: configTomlFilePath
13118
+ }),
12757
13119
  validate
12758
13120
  });
12759
13121
  }
@@ -13320,16 +13682,19 @@ var DevinMcp = class DevinMcp extends ToolMcp {
13320
13682
  }
13321
13683
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
13322
13684
  const paths = this.getSettablePaths({ global });
13323
- const fileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), JSON.stringify({ mcpServers: {} }, null, 2));
13324
- const devinConfig = {
13325
- ...this.parseJsonOrThrow(fileContent, paths.relativeDirPath, paths.relativeFilePath),
13326
- mcpServers: rulesyncMcp.getMcpServers()
13327
- };
13685
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
13686
+ const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({ mcpServers: {} }, null, 2));
13328
13687
  return new DevinMcp({
13329
13688
  outputRoot,
13330
13689
  relativeDirPath: paths.relativeDirPath,
13331
13690
  relativeFilePath: paths.relativeFilePath,
13332
- fileContent: JSON.stringify(devinConfig, null, 2),
13691
+ fileContent: applySharedConfigPatch({
13692
+ fileKey: sharedConfigFileKey(paths),
13693
+ feature: "mcp",
13694
+ existingContent,
13695
+ patch: { mcpServers: rulesyncMcp.getMcpServers() },
13696
+ filePath
13697
+ }),
13333
13698
  validate,
13334
13699
  global
13335
13700
  });
@@ -13816,17 +14181,22 @@ var GrokcliMcp = class GrokcliMcp extends ToolMcp {
13816
14181
  }
13817
14182
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
13818
14183
  const paths = this.getSettablePaths({ global });
13819
- const configTomlFileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), smol_toml.stringify({}));
13820
- const configToml = smol_toml.parse(configTomlFileContent);
14184
+ const configTomlFilePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
14185
+ const configTomlFileContent = await readFileContentOrNull(configTomlFilePath) ?? "";
13821
14186
  const converted = convertToGrokFormat(rulesyncMcp.getMcpServers());
13822
14187
  const filteredMcpServers = this.removeEmptyEntries(converted);
13823
14188
  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`);
13824
- configToml["mcp_servers"] = filteredMcpServers;
13825
14189
  return new GrokcliMcp({
13826
14190
  outputRoot,
13827
14191
  relativeDirPath: paths.relativeDirPath,
13828
14192
  relativeFilePath: paths.relativeFilePath,
13829
- fileContent: smol_toml.stringify(configToml),
14193
+ fileContent: applySharedConfigPatch({
14194
+ fileKey: sharedConfigFileKey(paths),
14195
+ feature: "mcp",
14196
+ existingContent: configTomlFileContent,
14197
+ patch: { mcp_servers: filteredMcpServers },
14198
+ filePath: configTomlFilePath
14199
+ }),
13830
14200
  validate
13831
14201
  });
13832
14202
  }
@@ -14359,20 +14729,21 @@ var KiloMcp = class KiloMcp extends ToolMcp {
14359
14729
  fileContent = await readFileContentOrNull(jsonPath);
14360
14730
  if (fileContent) relativeFilePath = KILO_JSON_FILE_NAME;
14361
14731
  }
14362
- if (!fileContent) fileContent = JSON.stringify({ mcp: {} }, null, 2);
14363
- const json = (0, jsonc_parser.parse)(fileContent);
14364
14732
  const { mcp: convertedMcp, tools: mcpTools } = convertToKiloFormat(rulesyncMcp.getMcpServers());
14365
- const { tools: _existingTools, ...jsonWithoutTools } = json;
14366
- const newJson = {
14367
- ...jsonWithoutTools,
14368
- mcp: convertedMcp,
14369
- ...Object.keys(mcpTools).length > 0 && { tools: mcpTools }
14370
- };
14371
14733
  return new KiloMcp({
14372
14734
  outputRoot,
14373
14735
  relativeDirPath: basePaths.relativeDirPath,
14374
14736
  relativeFilePath,
14375
- fileContent: JSON.stringify(newJson, null, 2),
14737
+ fileContent: applySharedConfigPatch({
14738
+ fileKey: sharedConfigFileKey(basePaths),
14739
+ feature: "mcp",
14740
+ existingContent: fileContent ?? "",
14741
+ patch: {
14742
+ mcp: convertedMcp,
14743
+ tools: Object.keys(mcpTools).length > 0 ? mcpTools : void 0
14744
+ },
14745
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath)
14746
+ }),
14376
14747
  validate
14377
14748
  });
14378
14749
  }
@@ -14401,15 +14772,17 @@ var KiloMcp = class KiloMcp extends ToolMcp {
14401
14772
  const json = fileContent ? (0, jsonc_parser.parse)(fileContent) : {};
14402
14773
  const existingInstructions = Array.isArray(json.instructions) ? json.instructions.filter((entry) => typeof entry === "string") : [];
14403
14774
  const mergedInstructions = Array.from(/* @__PURE__ */ new Set([...existingInstructions, ...instructions])).toSorted();
14404
- const newJson = {
14405
- ...json,
14406
- instructions: mergedInstructions
14407
- };
14408
14775
  return new KiloMcp({
14409
14776
  outputRoot,
14410
14777
  relativeDirPath: basePaths.relativeDirPath,
14411
14778
  relativeFilePath,
14412
- fileContent: JSON.stringify(newJson, null, 2),
14779
+ fileContent: applySharedConfigPatch({
14780
+ fileKey: sharedConfigFileKey(basePaths),
14781
+ feature: "rules",
14782
+ existingContent: fileContent ?? "",
14783
+ patch: { instructions: mergedInstructions },
14784
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath)
14785
+ }),
14413
14786
  validate
14414
14787
  });
14415
14788
  }
@@ -14681,23 +15054,24 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
14681
15054
  fileContent = await readFileContentOrNull(jsonPath);
14682
15055
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
14683
15056
  }
14684
- if (!fileContent) fileContent = JSON.stringify({ mcp: {} }, null, 2);
14685
- const json = (0, jsonc_parser.parse)(fileContent);
14686
15057
  const { mcp: convertedMcp, tools: mcpTools } = convertToOpencodeFormat(convertEnvVarRefsToToolFormat({
14687
15058
  mcpServers: rulesyncMcp.getMcpServers(),
14688
15059
  replacement: "{env:$1}"
14689
15060
  }));
14690
- const { tools: _existingTools, ...jsonWithoutTools } = json;
14691
- const newJson = {
14692
- ...jsonWithoutTools,
14693
- mcp: convertedMcp,
14694
- ...Object.keys(mcpTools).length > 0 && { tools: mcpTools }
14695
- };
14696
15061
  return new OpencodeMcp({
14697
15062
  outputRoot,
14698
15063
  relativeDirPath: basePaths.relativeDirPath,
14699
15064
  relativeFilePath,
14700
- fileContent: JSON.stringify(newJson, null, 2),
15065
+ fileContent: applySharedConfigPatch({
15066
+ fileKey: sharedConfigFileKey(basePaths),
15067
+ feature: "mcp",
15068
+ existingContent: fileContent ?? "",
15069
+ patch: {
15070
+ mcp: convertedMcp,
15071
+ tools: Object.keys(mcpTools).length > 0 ? mcpTools : void 0
15072
+ },
15073
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath)
15074
+ }),
14701
15075
  validate
14702
15076
  });
14703
15077
  }
@@ -14732,15 +15106,17 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
14732
15106
  const json = fileContent ? (0, jsonc_parser.parse)(fileContent) : {};
14733
15107
  const existingInstructions = Array.isArray(json.instructions) ? json.instructions.filter((entry) => typeof entry === "string") : [];
14734
15108
  const mergedInstructions = Array.from(/* @__PURE__ */ new Set([...existingInstructions, ...instructions])).toSorted();
14735
- const newJson = {
14736
- ...json,
14737
- instructions: mergedInstructions
14738
- };
14739
15109
  return new OpencodeMcp({
14740
15110
  outputRoot,
14741
15111
  relativeDirPath: basePaths.relativeDirPath,
14742
15112
  relativeFilePath,
14743
- fileContent: JSON.stringify(newJson, null, 2),
15113
+ fileContent: applySharedConfigPatch({
15114
+ fileKey: sharedConfigFileKey(basePaths),
15115
+ feature: "rules",
15116
+ existingContent: fileContent ?? "",
15117
+ patch: { instructions: mergedInstructions },
15118
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath)
15119
+ }),
14744
15120
  validate
14745
15121
  });
14746
15122
  }
@@ -14850,16 +15226,19 @@ var QwencodeMcp = class QwencodeMcp extends ToolMcp {
14850
15226
  }
14851
15227
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
14852
15228
  const paths = this.getSettablePaths({ global });
14853
- const fileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), JSON.stringify({ mcpServers: {} }, null, 2));
14854
- const newJson = {
14855
- ...JSON.parse(fileContent),
14856
- mcpServers: convertToQwencodeFormat(rulesyncMcp.getMcpServers())
14857
- };
15229
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
15230
+ const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({ mcpServers: {} }, null, 2));
14858
15231
  return new QwencodeMcp({
14859
15232
  outputRoot,
14860
15233
  relativeDirPath: paths.relativeDirPath,
14861
15234
  relativeFilePath: paths.relativeFilePath,
14862
- fileContent: JSON.stringify(newJson, null, 2),
15235
+ fileContent: applySharedConfigPatch({
15236
+ fileKey: sharedConfigFileKey(paths),
15237
+ feature: "mcp",
15238
+ existingContent,
15239
+ patch: { mcpServers: convertToQwencodeFormat(rulesyncMcp.getMcpServers()) },
15240
+ filePath
15241
+ }),
14863
15242
  validate
14864
15243
  });
14865
15244
  }
@@ -14944,13 +15323,20 @@ var ReasonixMcp = class ReasonixMcp extends ToolMcp {
14944
15323
  }
14945
15324
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
14946
15325
  const paths = this.getSettablePaths({ global });
14947
- const config = parseReasonixConfig$1(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smol_toml.stringify({}));
14948
- config.plugins = Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => rulesyncMcpServerToReasonix(name, server));
15326
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
15327
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
15328
+ const plugins = Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => rulesyncMcpServerToReasonix(name, server));
14949
15329
  return new ReasonixMcp({
14950
15330
  outputRoot,
14951
15331
  relativeDirPath: paths.relativeDirPath,
14952
15332
  relativeFilePath: paths.relativeFilePath,
14953
- fileContent: smol_toml.stringify(config),
15333
+ fileContent: applySharedConfigPatch({
15334
+ fileKey: sharedConfigFileKey(paths),
15335
+ feature: "mcp",
15336
+ existingContent,
15337
+ patch: { plugins },
15338
+ filePath
15339
+ }),
14954
15340
  validate,
14955
15341
  global
14956
15342
  });
@@ -15425,13 +15811,20 @@ var VibeMcp = class VibeMcp extends ToolMcp {
15425
15811
  }
15426
15812
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
15427
15813
  const paths = this.getSettablePaths({ global });
15428
- const config = parseVibeConfig$1(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smol_toml.stringify({}));
15429
- config.mcp_servers = Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => rulesyncMcpServerToVibe(name, server));
15814
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
15815
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
15816
+ const mcpServers = Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => rulesyncMcpServerToVibe(name, server));
15430
15817
  return new VibeMcp({
15431
15818
  outputRoot,
15432
15819
  relativeDirPath: paths.relativeDirPath,
15433
15820
  relativeFilePath: paths.relativeFilePath,
15434
- fileContent: smol_toml.stringify(config),
15821
+ fileContent: applySharedConfigPatch({
15822
+ fileKey: sharedConfigFileKey(paths),
15823
+ feature: "mcp",
15824
+ existingContent,
15825
+ patch: { mcp_servers: mcpServers },
15826
+ filePath
15827
+ }),
15435
15828
  validate,
15436
15829
  global
15437
15830
  });
@@ -15662,16 +16055,19 @@ var ZedMcp = class ZedMcp extends ToolMcp {
15662
16055
  }
15663
16056
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
15664
16057
  const paths = this.getSettablePaths({ global });
15665
- const fileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), "{}");
15666
- const newJson = {
15667
- ...JSON.parse(fileContent),
15668
- context_servers: rulesyncMcp.getMcpServers()
15669
- };
16058
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
16059
+ const existingContent = await readOrInitializeFileContent(filePath, "{}");
15670
16060
  return new ZedMcp({
15671
16061
  outputRoot,
15672
16062
  relativeDirPath: paths.relativeDirPath,
15673
16063
  relativeFilePath: paths.relativeFilePath,
15674
- fileContent: JSON.stringify(newJson, null, 2),
16064
+ fileContent: applySharedConfigPatch({
16065
+ fileKey: sharedConfigFileKey(paths),
16066
+ feature: "mcp",
16067
+ existingContent,
16068
+ patch: { context_servers: rulesyncMcp.getMcpServers() },
16069
+ filePath
16070
+ }),
15675
16071
  validate
15676
16072
  });
15677
16073
  }
@@ -16840,25 +17236,29 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
16840
17236
  const override = config.amp;
16841
17237
  const authoredExtras = override?.permissions ? toPermissionsList(override.permissions) : [];
16842
17238
  const preservedDelegates = override?.permissions ? [] : toPermissionsList(json[AMP_PERMISSIONS_KEY]).filter((entry) => entry.action === "delegate");
16843
- const newJson = {
16844
- ...json,
16845
- [AMP_TOOLS_DISABLE_KEY]: disable
16846
- };
16847
17239
  const mergedPermissions = mergeAmpPermissions([
16848
17240
  ...permissions,
16849
17241
  ...authoredExtras,
16850
17242
  ...preservedDelegates
16851
17243
  ]);
16852
- if (mergedPermissions.length > 0) newJson[AMP_PERMISSIONS_KEY] = mergedPermissions;
16853
- else delete newJson[AMP_PERMISSIONS_KEY];
16854
- if (override?.guardedFiles?.allowlist !== void 0) newJson[AMP_GUARDED_FILES_ALLOWLIST_KEY] = override.guardedFiles.allowlist;
16855
- if (override?.dangerouslyAllowAll !== void 0) newJson[AMP_DANGEROUSLY_ALLOW_ALL_KEY] = override.dangerouslyAllowAll;
16856
- if (override?.mcpPermissions !== void 0) newJson[AMP_MCP_PERMISSIONS_KEY] = override.mcpPermissions;
17244
+ const patch = {
17245
+ [AMP_TOOLS_DISABLE_KEY]: disable,
17246
+ [AMP_PERMISSIONS_KEY]: mergedPermissions.length > 0 ? mergedPermissions : void 0
17247
+ };
17248
+ if (override?.guardedFiles?.allowlist !== void 0) patch[AMP_GUARDED_FILES_ALLOWLIST_KEY] = override.guardedFiles.allowlist;
17249
+ if (override?.dangerouslyAllowAll !== void 0) patch[AMP_DANGEROUSLY_ALLOW_ALL_KEY] = override.dangerouslyAllowAll;
17250
+ if (override?.mcpPermissions !== void 0) patch[AMP_MCP_PERMISSIONS_KEY] = override.mcpPermissions;
16857
17251
  return new AmpPermissions({
16858
17252
  outputRoot,
16859
17253
  relativeDirPath: basePaths.relativeDirPath,
16860
17254
  relativeFilePath,
16861
- fileContent: JSON.stringify(newJson, null, 2),
17255
+ fileContent: applySharedConfigPatch({
17256
+ fileKey: sharedConfigFileKey(basePaths),
17257
+ feature: "permissions",
17258
+ existingContent: fileContent ?? "",
17259
+ patch,
17260
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath)
17261
+ }),
16862
17262
  validate: true
16863
17263
  });
16864
17264
  }
@@ -17800,11 +18200,13 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
17800
18200
  ...preservedBasicEntries,
17801
18201
  ...authoredBasics
17802
18202
  ]);
17803
- const merged = {
17804
- ...settings,
17805
- toolPermissions: [...specialEntries, ...sortedBasic]
17806
- };
17807
- const fileContent = JSON.stringify(merged, null, 2);
18203
+ const fileContent = applySharedConfigPatch({
18204
+ fileKey: sharedConfigFileKey(paths),
18205
+ feature: "permissions",
18206
+ existingContent,
18207
+ patch: { toolPermissions: [...specialEntries, ...sortedBasic] },
18208
+ filePath
18209
+ });
17808
18210
  return new AugmentcodePermissions({
17809
18211
  outputRoot,
17810
18212
  relativeDirPath: paths.relativeDirPath,
@@ -18396,13 +18798,6 @@ const WORKSPACE_WIDE_WRITE_PATTERNS = /* @__PURE__ */ new Set([
18396
18798
  ]);
18397
18799
  const CODEX_MINIMAL_KEY = ":minimal";
18398
18800
  const GLOBAL_WILDCARD_DOMAIN = "*";
18399
- const CODEXCLI_OVERRIDE_KEYS = [
18400
- "approval_policy",
18401
- "sandbox_mode",
18402
- "sandbox_workspace_write",
18403
- "apps",
18404
- "approvals_reviewer"
18405
- ];
18406
18801
  var CodexcliPermissions = class CodexcliPermissions extends ToolPermissions {
18407
18802
  static getSettablePaths(_options = {}) {
18408
18803
  return {
@@ -18426,26 +18821,27 @@ var CodexcliPermissions = class CodexcliPermissions extends ToolPermissions {
18426
18821
  }
18427
18822
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
18428
18823
  const paths = this.getSettablePaths({ global });
18429
- const existingContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smol_toml.stringify({});
18430
- const parsed = toMutableTable(smol_toml.parse(existingContent));
18824
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
18825
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
18826
+ const existing = toMutableTable(smol_toml.parse(existingContent || smol_toml.stringify({})));
18431
18827
  const newProfile = convertRulesyncToCodexProfile({
18432
18828
  config: rulesyncPermissions.getJson(),
18433
18829
  logger
18434
18830
  });
18435
- const permissionsTable = toMutableTable(parsed.permissions);
18831
+ const permissionsTable = toMutableTable(existing.permissions);
18436
18832
  const { profile: existingProfile, domainsHadUnknown: existingDomainsHadUnknown } = toCodexProfile(permissionsTable[RULESYNC_PROFILE_NAME]);
18437
- 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)"}".`);
18438
- 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.`);
18439
- 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.`);
18440
- if (existingDomainsHadUnknown) logger?.warn(`Existing "network.domains" contained unrecognized values. These entries were skipped and will not be imported.`);
18833
+ warnAboutPreservedProfileState({
18834
+ existingProfile,
18835
+ newProfile,
18836
+ existingDomainsHadUnknown,
18837
+ logger
18838
+ });
18441
18839
  permissionsTable[RULESYNC_PROFILE_NAME] = mergeWithExistingProfile({
18442
18840
  newProfile,
18443
18841
  existingProfile
18444
18842
  });
18445
- parsed.permissions = permissionsTable;
18446
- parsed.default_permissions = RULESYNC_PROFILE_NAME;
18447
- applyCodexcliOverride({
18448
- parsed,
18843
+ const overridePatch = computeCodexcliOverridePatch({
18844
+ existing,
18449
18845
  override: rulesyncPermissions.getJson().codexcli,
18450
18846
  logger
18451
18847
  });
@@ -18453,7 +18849,17 @@ var CodexcliPermissions = class CodexcliPermissions extends ToolPermissions {
18453
18849
  outputRoot,
18454
18850
  relativeDirPath: paths.relativeDirPath,
18455
18851
  relativeFilePath: paths.relativeFilePath,
18456
- fileContent: smol_toml.stringify(parsed),
18852
+ fileContent: applySharedConfigPatch({
18853
+ fileKey: sharedConfigFileKey(paths),
18854
+ feature: "permissions",
18855
+ existingContent,
18856
+ patch: {
18857
+ permissions: permissionsTable,
18858
+ default_permissions: RULESYNC_PROFILE_NAME,
18859
+ ...overridePatch
18860
+ },
18861
+ filePath
18862
+ }),
18457
18863
  validate
18458
18864
  });
18459
18865
  }
@@ -18623,6 +19029,12 @@ function toCodexProfile(value) {
18623
19029
  domainsHadUnknown
18624
19030
  };
18625
19031
  }
19032
+ function warnAboutPreservedProfileState({ existingProfile, newProfile, existingDomainsHadUnknown, logger }) {
19033
+ 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)"}".`);
19034
+ 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.`);
19035
+ 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.`);
19036
+ if (existingDomainsHadUnknown) logger?.warn(`Existing "network.domains" contained unrecognized values. These entries were skipped and will not be imported.`);
19037
+ }
18626
19038
  function mergeWithExistingProfile({ newProfile, existingProfile }) {
18627
19039
  if (!existingProfile) return newProfile;
18628
19040
  const mergedNetwork = { ...newProfile.network };
@@ -18673,8 +19085,9 @@ function toMutableTable(value) {
18673
19085
  function isPlainObject(value) {
18674
19086
  return value !== null && typeof value === "object" && !Array.isArray(value);
18675
19087
  }
18676
- function applyCodexcliOverride({ parsed, override, logger }) {
18677
- if (!override) return;
19088
+ function computeCodexcliOverridePatch({ existing, override, logger }) {
19089
+ const patch = {};
19090
+ if (!override) return patch;
18678
19091
  const allowed = new Set(CODEXCLI_OVERRIDE_KEYS);
18679
19092
  for (const [key, value] of Object.entries(override)) {
18680
19093
  if (!allowed.has(key)) {
@@ -18682,12 +19095,13 @@ function applyCodexcliOverride({ parsed, override, logger }) {
18682
19095
  continue;
18683
19096
  }
18684
19097
  if (value === void 0) continue;
18685
- const existing = parsed[key];
18686
- parsed[key] = isPlainObject(existing) && isPlainObject(value) ? {
18687
- ...existing,
19098
+ const existingValue = existing[key];
19099
+ patch[key] = isPlainObject(existingValue) && isPlainObject(value) ? {
19100
+ ...existingValue,
18688
19101
  ...value
18689
19102
  } : value;
18690
19103
  }
19104
+ return patch;
18691
19105
  }
18692
19106
  function extractCodexcliOverride(table) {
18693
19107
  const override = {};
@@ -19248,15 +19662,17 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
19248
19662
  else delete mergedPermissions.ask;
19249
19663
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
19250
19664
  else delete mergedPermissions.deny;
19251
- const merged = {
19252
- ...settings,
19253
- permissions: mergedPermissions
19254
- };
19255
19665
  return new DevinPermissions({
19256
19666
  outputRoot,
19257
19667
  relativeDirPath: paths.relativeDirPath,
19258
19668
  relativeFilePath: paths.relativeFilePath,
19259
- fileContent: JSON.stringify(merged, null, 2),
19669
+ fileContent: applySharedConfigPatch({
19670
+ fileKey: sharedConfigFileKey(paths),
19671
+ feature: "permissions",
19672
+ existingContent,
19673
+ patch: { permissions: mergedPermissions },
19674
+ filePath
19675
+ }),
19260
19676
  validate
19261
19677
  });
19262
19678
  }
@@ -19865,23 +20281,31 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
19865
20281
  const config = rulesyncPermissions.getJson();
19866
20282
  const existingPermission = isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {};
19867
20283
  const buckets = buildGrokPermissionArrays(config, existingPermission, logger);
19868
- parsed[GROKCLI_PERMISSION_KEY] = {
20284
+ const permission = {
19869
20285
  ...existingPermission,
19870
20286
  allow: buckets.allow,
19871
20287
  deny: buckets.deny,
19872
20288
  ask: buckets.ask
19873
20289
  };
19874
20290
  const mode = deriveGrokPermissionMode(config);
19875
- const existingUi = isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {};
19876
- parsed[GROKCLI_UI_KEY] = {
19877
- ...existingUi,
20291
+ const ui = {
20292
+ ...isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {},
19878
20293
  [GROKCLI_PERMISSION_MODE_KEY]: mode
19879
20294
  };
19880
20295
  return new GrokcliPermissions({
19881
20296
  outputRoot,
19882
20297
  relativeDirPath: paths.relativeDirPath,
19883
20298
  relativeFilePath: paths.relativeFilePath,
19884
- fileContent: smol_toml.stringify(parsed),
20299
+ fileContent: applySharedConfigPatch({
20300
+ fileKey: sharedConfigFileKey(paths),
20301
+ feature: "permissions",
20302
+ existingContent,
20303
+ patch: {
20304
+ [GROKCLI_PERMISSION_KEY]: permission,
20305
+ [GROKCLI_UI_KEY]: ui
20306
+ },
20307
+ filePath
20308
+ }),
19885
20309
  validate: true,
19886
20310
  global: true
19887
20311
  });
@@ -20584,7 +21008,16 @@ var KiroPermissions = class KiroPermissions extends ToolPermissions {
20584
21008
  outputRoot,
20585
21009
  relativeDirPath: paths.relativeDirPath,
20586
21010
  relativeFilePath: paths.relativeFilePath,
20587
- fileContent: JSON.stringify(next, null, 2),
21011
+ fileContent: applySharedConfigPatch({
21012
+ fileKey: sharedConfigFileKey(paths),
21013
+ feature: "permissions",
21014
+ existingContent,
21015
+ patch: {
21016
+ allowedTools: next.allowedTools,
21017
+ toolsSettings: next.toolsSettings
21018
+ },
21019
+ filePath
21020
+ }),
20588
21021
  validate
20589
21022
  });
20590
21023
  }
@@ -20881,21 +21314,22 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
20881
21314
  fileContent = await readFileContentOrNull(jsonPath);
20882
21315
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
20883
21316
  }
20884
- const parsed = (0, jsonc_parser.parse)(fileContent ?? "{}");
20885
21317
  const rulesyncJson = rulesyncPermissions.getJson();
20886
21318
  const overridePermission = rulesyncJson.opencode?.permission ?? {};
20887
- const nextJson = {
20888
- ...parsed,
20889
- permission: {
20890
- ...rulesyncJson.permission,
20891
- ...overridePermission
20892
- }
20893
- };
20894
21319
  return new OpencodePermissions({
20895
21320
  outputRoot,
20896
21321
  relativeDirPath: basePaths.relativeDirPath,
20897
21322
  relativeFilePath,
20898
- fileContent: JSON.stringify(nextJson, null, 2),
21323
+ fileContent: applySharedConfigPatch({
21324
+ fileKey: sharedConfigFileKey(basePaths),
21325
+ feature: "permissions",
21326
+ existingContent: fileContent ?? "",
21327
+ patch: { permission: {
21328
+ ...rulesyncJson.permission,
21329
+ ...overridePermission
21330
+ } },
21331
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath)
21332
+ }),
20899
21333
  validate: true
20900
21334
  });
20901
21335
  }
@@ -21104,20 +21538,23 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
21104
21538
  else delete mergedPermissions.ask;
21105
21539
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
21106
21540
  else delete mergedPermissions.deny;
21107
- const merged = {
21108
- ...settings,
21109
- permissions: mergedPermissions
21110
- };
21541
+ const patch = { permissions: mergedPermissions };
21111
21542
  const override = config.qwencode;
21112
- if (override?.tools !== void 0) merged.tools = {
21543
+ if (override?.tools !== void 0) patch.tools = {
21113
21544
  ...asPlainRecord(settings.tools),
21114
21545
  ...asPlainRecord(override.tools)
21115
21546
  };
21116
- if (override?.security !== void 0) merged.security = {
21547
+ if (override?.security !== void 0) patch.security = {
21117
21548
  ...asPlainRecord(settings.security),
21118
21549
  ...asPlainRecord(override.security)
21119
21550
  };
21120
- const fileContent = JSON.stringify(merged, null, 2);
21551
+ const fileContent = applySharedConfigPatch({
21552
+ fileKey: sharedConfigFileKey(paths),
21553
+ feature: "permissions",
21554
+ existingContent,
21555
+ patch,
21556
+ filePath
21557
+ });
21121
21558
  return new QwencodePermissions({
21122
21559
  outputRoot,
21123
21560
  relativeDirPath: paths.relativeDirPath,
@@ -21352,7 +21789,9 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
21352
21789
  }
21353
21790
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
21354
21791
  const paths = this.getSettablePaths({ global });
21355
- const parsed = parseReasonixConfig(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smol_toml.stringify({}));
21792
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
21793
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
21794
+ const parsed = parseReasonixConfig(existingContent);
21356
21795
  const config = rulesyncPermissions.getJson();
21357
21796
  const { allow, ask, deny } = convertRulesyncToReasonixPermissions(config);
21358
21797
  const managedToolNames = new Set(Object.keys(config.permission).map((category) => toReasonixToolName(category)));
@@ -21377,25 +21816,27 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
21377
21816
  else delete mergedPermissions.ask;
21378
21817
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
21379
21818
  else delete mergedPermissions.deny;
21380
- const merged = {
21381
- ...parsed,
21382
- permissions: mergedPermissions
21383
- };
21819
+ const patch = { permissions: mergedPermissions };
21384
21820
  const override = config.reasonix;
21385
- if (override?.sandbox !== void 0) merged.sandbox = {
21821
+ if (override?.sandbox !== void 0) patch.sandbox = {
21386
21822
  ...asReasonixRecord(parsed.sandbox),
21387
21823
  ...asReasonixRecord(override.sandbox)
21388
21824
  };
21389
- if (override?.agent !== void 0) merged.agent = {
21825
+ if (override?.agent !== void 0) patch.agent = {
21390
21826
  ...asReasonixRecord(parsed.agent),
21391
21827
  ...asReasonixRecord(override.agent)
21392
21828
  };
21393
- const fileContent = smol_toml.stringify(merged);
21394
21829
  return new ReasonixPermissions({
21395
21830
  outputRoot,
21396
21831
  relativeDirPath: paths.relativeDirPath,
21397
21832
  relativeFilePath: paths.relativeFilePath,
21398
- fileContent,
21833
+ fileContent: applySharedConfigPatch({
21834
+ fileKey: sharedConfigFileKey(paths),
21835
+ feature: "permissions",
21836
+ existingContent,
21837
+ patch,
21838
+ filePath
21839
+ }),
21399
21840
  validate
21400
21841
  });
21401
21842
  }
@@ -21966,7 +22407,9 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
21966
22407
  }
21967
22408
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
21968
22409
  const paths = this.getSettablePaths({ global });
21969
- const config = parseVibeConfig(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smol_toml.stringify({}));
22410
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22411
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
22412
+ const config = parseVibeConfig(existingContent);
21970
22413
  const permission = rulesyncPermissions.getJson().permission;
21971
22414
  const tools = toVibeToolsRecord(config.tools);
21972
22415
  const enabledTools = new Set(toStringArray(config.enabled_tools));
@@ -22011,16 +22454,21 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
22011
22454
  tools[vibeToolName] = nextTool;
22012
22455
  }
22013
22456
  applyVibeSensitivePatterns(tools, rulesyncPermissions.getJson().vibe);
22014
- config.tools = tools;
22015
- if (enabledTools.size > 0) config.enabled_tools = [...enabledTools].toSorted();
22016
- else delete config.enabled_tools;
22017
- if (disabledTools.size > 0) config.disabled_tools = [...disabledTools].toSorted();
22018
- else delete config.disabled_tools;
22019
22457
  return new VibePermissions({
22020
22458
  outputRoot,
22021
22459
  relativeDirPath: paths.relativeDirPath,
22022
22460
  relativeFilePath: paths.relativeFilePath,
22023
- fileContent: smol_toml.stringify(config),
22461
+ fileContent: applySharedConfigPatch({
22462
+ fileKey: sharedConfigFileKey(paths),
22463
+ feature: "permissions",
22464
+ existingContent,
22465
+ patch: {
22466
+ tools,
22467
+ enabled_tools: enabledTools.size > 0 ? [...enabledTools].toSorted() : void 0,
22468
+ disabled_tools: disabledTools.size > 0 ? [...disabledTools].toSorted() : void 0
22469
+ },
22470
+ filePath
22471
+ }),
22024
22472
  validate,
22025
22473
  global
22026
22474
  });
@@ -22483,24 +22931,26 @@ var ZedPermissions = class ZedPermissions extends ToolPermissions {
22483
22931
  }
22484
22932
  const managedToolNames = new Set(Object.keys(managedTools));
22485
22933
  const preservedTools = Object.fromEntries(Object.entries(existingTools).filter(([toolName]) => !managedToolNames.has(toolName)));
22486
- const mergedSettings = {
22487
- ...settings,
22488
- agent: {
22489
- ...agent,
22490
- tool_permissions: {
22491
- ...toolPermissions,
22492
- tools: {
22493
- ...preservedTools,
22494
- ...managedTools
22495
- }
22496
- }
22497
- }
22498
- };
22499
22934
  return new ZedPermissions({
22500
22935
  outputRoot,
22501
22936
  relativeDirPath: paths.relativeDirPath,
22502
22937
  relativeFilePath: paths.relativeFilePath,
22503
- fileContent: JSON.stringify(mergedSettings, null, 2),
22938
+ fileContent: applySharedConfigPatch({
22939
+ fileKey: sharedConfigFileKey(paths),
22940
+ feature: "permissions",
22941
+ existingContent,
22942
+ patch: { agent: {
22943
+ ...agent,
22944
+ tool_permissions: {
22945
+ ...toolPermissions,
22946
+ tools: {
22947
+ ...preservedTools,
22948
+ ...managedTools
22949
+ }
22950
+ }
22951
+ } },
22952
+ filePath
22953
+ }),
22504
22954
  validate: true
22505
22955
  });
22506
22956
  }
@@ -38190,8 +38640,13 @@ const collectFactoryPaths = (factory) => [...settablePathsForScope({
38190
38640
  * Derive, from the processor registry, every on-disk file that two or more
38191
38641
  * features read-modify-write. Source of truth the generation step graph's
38192
38642
  * `writesSharedFile` declarations must match.
38643
+ *
38644
+ * `minWriters: 1` widens the result to single-feature files as well — used to
38645
+ * validate `SHARED_CONFIG_OWNERSHIP` declarations for files that route through
38646
+ * the gateway without being cross-feature shared (e.g. a global-scope twin
38647
+ * only one feature writes).
38193
38648
  */
38194
- const deriveSharedFileWriters = () => {
38649
+ const deriveSharedFileWriters = ({ minWriters = 2 } = {}) => {
38195
38650
  const byKey = /* @__PURE__ */ new Map();
38196
38651
  const pathByKey = /* @__PURE__ */ new Map();
38197
38652
  for (const entry of PROCESSOR_REGISTRY) {
@@ -38217,7 +38672,7 @@ const deriveSharedFileWriters = () => {
38217
38672
  }
38218
38673
  const writers = [];
38219
38674
  for (const [key, features] of byKey) {
38220
- if (features.size < 2) continue;
38675
+ if (features.size < minWriters) continue;
38221
38676
  const path = pathByKey.get(key);
38222
38677
  const toolsByFeature = /* @__PURE__ */ new Map();
38223
38678
  for (const [feature, tools] of features) toolsByFeature.set(feature, [...tools].toSorted());