rulesync 9.6.3 → 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
  });
@@ -12741,8 +13081,9 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
12741
13081
  }
12742
13082
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
12743
13083
  const paths = this.getSettablePaths({ global });
12744
- const configTomlFileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), smol_toml.stringify({}));
12745
- 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({}));
12746
13087
  const strippedMcpServers = rulesyncMcp.getMcpServers();
12747
13088
  const rawMcpServers = rulesyncMcp.getJson().mcpServers;
12748
13089
  const converted = convertToCodexFormat(Object.fromEntries(Object.entries(strippedMcpServers).map(([serverName, serverConfig]) => {
@@ -12755,7 +13096,7 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
12755
13096
  const filteredMcpServers = this.removeEmptyEntries(converted);
12756
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`);
12757
13098
  const existingMcpServers = isRecord(configToml["mcp_servers"]) ? configToml["mcp_servers"] : {};
12758
- configToml["mcp_servers"] = Object.fromEntries(Object.entries(filteredMcpServers).map(([name, serverConfig]) => {
13099
+ const mergedMcpServers = Object.fromEntries(Object.entries(filteredMcpServers).map(([name, serverConfig]) => {
12759
13100
  const existingServer = isRecord(existingMcpServers[name]) ? existingMcpServers[name] : void 0;
12760
13101
  const serverRecord = serverConfig;
12761
13102
  if (existingServer && isRecord(existingServer["tools"]) && !("tools" in serverRecord)) return [name, {
@@ -12768,7 +13109,13 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
12768
13109
  outputRoot,
12769
13110
  relativeDirPath: paths.relativeDirPath,
12770
13111
  relativeFilePath: paths.relativeFilePath,
12771
- 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
+ }),
12772
13119
  validate
12773
13120
  });
12774
13121
  }
@@ -13335,16 +13682,19 @@ var DevinMcp = class DevinMcp extends ToolMcp {
13335
13682
  }
13336
13683
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
13337
13684
  const paths = this.getSettablePaths({ global });
13338
- const fileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), JSON.stringify({ mcpServers: {} }, null, 2));
13339
- const devinConfig = {
13340
- ...this.parseJsonOrThrow(fileContent, paths.relativeDirPath, paths.relativeFilePath),
13341
- mcpServers: rulesyncMcp.getMcpServers()
13342
- };
13685
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
13686
+ const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({ mcpServers: {} }, null, 2));
13343
13687
  return new DevinMcp({
13344
13688
  outputRoot,
13345
13689
  relativeDirPath: paths.relativeDirPath,
13346
13690
  relativeFilePath: paths.relativeFilePath,
13347
- 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
+ }),
13348
13698
  validate,
13349
13699
  global
13350
13700
  });
@@ -13831,17 +14181,22 @@ var GrokcliMcp = class GrokcliMcp extends ToolMcp {
13831
14181
  }
13832
14182
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
13833
14183
  const paths = this.getSettablePaths({ global });
13834
- const configTomlFileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), smol_toml.stringify({}));
13835
- const configToml = smol_toml.parse(configTomlFileContent);
14184
+ const configTomlFilePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
14185
+ const configTomlFileContent = await readFileContentOrNull(configTomlFilePath) ?? "";
13836
14186
  const converted = convertToGrokFormat(rulesyncMcp.getMcpServers());
13837
14187
  const filteredMcpServers = this.removeEmptyEntries(converted);
13838
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`);
13839
- configToml["mcp_servers"] = filteredMcpServers;
13840
14189
  return new GrokcliMcp({
13841
14190
  outputRoot,
13842
14191
  relativeDirPath: paths.relativeDirPath,
13843
14192
  relativeFilePath: paths.relativeFilePath,
13844
- 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
+ }),
13845
14200
  validate
13846
14201
  });
13847
14202
  }
@@ -14374,20 +14729,21 @@ var KiloMcp = class KiloMcp extends ToolMcp {
14374
14729
  fileContent = await readFileContentOrNull(jsonPath);
14375
14730
  if (fileContent) relativeFilePath = KILO_JSON_FILE_NAME;
14376
14731
  }
14377
- if (!fileContent) fileContent = JSON.stringify({ mcp: {} }, null, 2);
14378
- const json = (0, jsonc_parser.parse)(fileContent);
14379
14732
  const { mcp: convertedMcp, tools: mcpTools } = convertToKiloFormat(rulesyncMcp.getMcpServers());
14380
- const { tools: _existingTools, ...jsonWithoutTools } = json;
14381
- const newJson = {
14382
- ...jsonWithoutTools,
14383
- mcp: convertedMcp,
14384
- ...Object.keys(mcpTools).length > 0 && { tools: mcpTools }
14385
- };
14386
14733
  return new KiloMcp({
14387
14734
  outputRoot,
14388
14735
  relativeDirPath: basePaths.relativeDirPath,
14389
14736
  relativeFilePath,
14390
- 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
+ }),
14391
14747
  validate
14392
14748
  });
14393
14749
  }
@@ -14416,15 +14772,17 @@ var KiloMcp = class KiloMcp extends ToolMcp {
14416
14772
  const json = fileContent ? (0, jsonc_parser.parse)(fileContent) : {};
14417
14773
  const existingInstructions = Array.isArray(json.instructions) ? json.instructions.filter((entry) => typeof entry === "string") : [];
14418
14774
  const mergedInstructions = Array.from(/* @__PURE__ */ new Set([...existingInstructions, ...instructions])).toSorted();
14419
- const newJson = {
14420
- ...json,
14421
- instructions: mergedInstructions
14422
- };
14423
14775
  return new KiloMcp({
14424
14776
  outputRoot,
14425
14777
  relativeDirPath: basePaths.relativeDirPath,
14426
14778
  relativeFilePath,
14427
- 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
+ }),
14428
14786
  validate
14429
14787
  });
14430
14788
  }
@@ -14696,23 +15054,24 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
14696
15054
  fileContent = await readFileContentOrNull(jsonPath);
14697
15055
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
14698
15056
  }
14699
- if (!fileContent) fileContent = JSON.stringify({ mcp: {} }, null, 2);
14700
- const json = (0, jsonc_parser.parse)(fileContent);
14701
15057
  const { mcp: convertedMcp, tools: mcpTools } = convertToOpencodeFormat(convertEnvVarRefsToToolFormat({
14702
15058
  mcpServers: rulesyncMcp.getMcpServers(),
14703
15059
  replacement: "{env:$1}"
14704
15060
  }));
14705
- const { tools: _existingTools, ...jsonWithoutTools } = json;
14706
- const newJson = {
14707
- ...jsonWithoutTools,
14708
- mcp: convertedMcp,
14709
- ...Object.keys(mcpTools).length > 0 && { tools: mcpTools }
14710
- };
14711
15061
  return new OpencodeMcp({
14712
15062
  outputRoot,
14713
15063
  relativeDirPath: basePaths.relativeDirPath,
14714
15064
  relativeFilePath,
14715
- 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
+ }),
14716
15075
  validate
14717
15076
  });
14718
15077
  }
@@ -14747,15 +15106,17 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
14747
15106
  const json = fileContent ? (0, jsonc_parser.parse)(fileContent) : {};
14748
15107
  const existingInstructions = Array.isArray(json.instructions) ? json.instructions.filter((entry) => typeof entry === "string") : [];
14749
15108
  const mergedInstructions = Array.from(/* @__PURE__ */ new Set([...existingInstructions, ...instructions])).toSorted();
14750
- const newJson = {
14751
- ...json,
14752
- instructions: mergedInstructions
14753
- };
14754
15109
  return new OpencodeMcp({
14755
15110
  outputRoot,
14756
15111
  relativeDirPath: basePaths.relativeDirPath,
14757
15112
  relativeFilePath,
14758
- 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
+ }),
14759
15120
  validate
14760
15121
  });
14761
15122
  }
@@ -14865,16 +15226,19 @@ var QwencodeMcp = class QwencodeMcp extends ToolMcp {
14865
15226
  }
14866
15227
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
14867
15228
  const paths = this.getSettablePaths({ global });
14868
- const fileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), JSON.stringify({ mcpServers: {} }, null, 2));
14869
- const newJson = {
14870
- ...JSON.parse(fileContent),
14871
- mcpServers: convertToQwencodeFormat(rulesyncMcp.getMcpServers())
14872
- };
15229
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
15230
+ const existingContent = await readOrInitializeFileContent(filePath, JSON.stringify({ mcpServers: {} }, null, 2));
14873
15231
  return new QwencodeMcp({
14874
15232
  outputRoot,
14875
15233
  relativeDirPath: paths.relativeDirPath,
14876
15234
  relativeFilePath: paths.relativeFilePath,
14877
- 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
+ }),
14878
15242
  validate
14879
15243
  });
14880
15244
  }
@@ -14959,13 +15323,20 @@ var ReasonixMcp = class ReasonixMcp extends ToolMcp {
14959
15323
  }
14960
15324
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
14961
15325
  const paths = this.getSettablePaths({ global });
14962
- const config = parseReasonixConfig$1(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smol_toml.stringify({}));
14963
- 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));
14964
15329
  return new ReasonixMcp({
14965
15330
  outputRoot,
14966
15331
  relativeDirPath: paths.relativeDirPath,
14967
15332
  relativeFilePath: paths.relativeFilePath,
14968
- fileContent: smol_toml.stringify(config),
15333
+ fileContent: applySharedConfigPatch({
15334
+ fileKey: sharedConfigFileKey(paths),
15335
+ feature: "mcp",
15336
+ existingContent,
15337
+ patch: { plugins },
15338
+ filePath
15339
+ }),
14969
15340
  validate,
14970
15341
  global
14971
15342
  });
@@ -15440,13 +15811,20 @@ var VibeMcp = class VibeMcp extends ToolMcp {
15440
15811
  }
15441
15812
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
15442
15813
  const paths = this.getSettablePaths({ global });
15443
- const config = parseVibeConfig$1(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smol_toml.stringify({}));
15444
- 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));
15445
15817
  return new VibeMcp({
15446
15818
  outputRoot,
15447
15819
  relativeDirPath: paths.relativeDirPath,
15448
15820
  relativeFilePath: paths.relativeFilePath,
15449
- 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
+ }),
15450
15828
  validate,
15451
15829
  global
15452
15830
  });
@@ -15677,16 +16055,19 @@ var ZedMcp = class ZedMcp extends ToolMcp {
15677
16055
  }
15678
16056
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
15679
16057
  const paths = this.getSettablePaths({ global });
15680
- const fileContent = await readOrInitializeFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath), "{}");
15681
- const newJson = {
15682
- ...JSON.parse(fileContent),
15683
- context_servers: rulesyncMcp.getMcpServers()
15684
- };
16058
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
16059
+ const existingContent = await readOrInitializeFileContent(filePath, "{}");
15685
16060
  return new ZedMcp({
15686
16061
  outputRoot,
15687
16062
  relativeDirPath: paths.relativeDirPath,
15688
16063
  relativeFilePath: paths.relativeFilePath,
15689
- 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
+ }),
15690
16071
  validate
15691
16072
  });
15692
16073
  }
@@ -16855,25 +17236,29 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
16855
17236
  const override = config.amp;
16856
17237
  const authoredExtras = override?.permissions ? toPermissionsList(override.permissions) : [];
16857
17238
  const preservedDelegates = override?.permissions ? [] : toPermissionsList(json[AMP_PERMISSIONS_KEY]).filter((entry) => entry.action === "delegate");
16858
- const newJson = {
16859
- ...json,
16860
- [AMP_TOOLS_DISABLE_KEY]: disable
16861
- };
16862
17239
  const mergedPermissions = mergeAmpPermissions([
16863
17240
  ...permissions,
16864
17241
  ...authoredExtras,
16865
17242
  ...preservedDelegates
16866
17243
  ]);
16867
- if (mergedPermissions.length > 0) newJson[AMP_PERMISSIONS_KEY] = mergedPermissions;
16868
- else delete newJson[AMP_PERMISSIONS_KEY];
16869
- if (override?.guardedFiles?.allowlist !== void 0) newJson[AMP_GUARDED_FILES_ALLOWLIST_KEY] = override.guardedFiles.allowlist;
16870
- if (override?.dangerouslyAllowAll !== void 0) newJson[AMP_DANGEROUSLY_ALLOW_ALL_KEY] = override.dangerouslyAllowAll;
16871
- 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;
16872
17251
  return new AmpPermissions({
16873
17252
  outputRoot,
16874
17253
  relativeDirPath: basePaths.relativeDirPath,
16875
17254
  relativeFilePath,
16876
- 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
+ }),
16877
17262
  validate: true
16878
17263
  });
16879
17264
  }
@@ -17815,11 +18200,13 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
17815
18200
  ...preservedBasicEntries,
17816
18201
  ...authoredBasics
17817
18202
  ]);
17818
- const merged = {
17819
- ...settings,
17820
- toolPermissions: [...specialEntries, ...sortedBasic]
17821
- };
17822
- 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
+ });
17823
18210
  return new AugmentcodePermissions({
17824
18211
  outputRoot,
17825
18212
  relativeDirPath: paths.relativeDirPath,
@@ -18411,13 +18798,6 @@ const WORKSPACE_WIDE_WRITE_PATTERNS = /* @__PURE__ */ new Set([
18411
18798
  ]);
18412
18799
  const CODEX_MINIMAL_KEY = ":minimal";
18413
18800
  const GLOBAL_WILDCARD_DOMAIN = "*";
18414
- const CODEXCLI_OVERRIDE_KEYS = [
18415
- "approval_policy",
18416
- "sandbox_mode",
18417
- "sandbox_workspace_write",
18418
- "apps",
18419
- "approvals_reviewer"
18420
- ];
18421
18801
  var CodexcliPermissions = class CodexcliPermissions extends ToolPermissions {
18422
18802
  static getSettablePaths(_options = {}) {
18423
18803
  return {
@@ -18441,26 +18821,27 @@ var CodexcliPermissions = class CodexcliPermissions extends ToolPermissions {
18441
18821
  }
18442
18822
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
18443
18823
  const paths = this.getSettablePaths({ global });
18444
- const existingContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? smol_toml.stringify({});
18445
- 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({})));
18446
18827
  const newProfile = convertRulesyncToCodexProfile({
18447
18828
  config: rulesyncPermissions.getJson(),
18448
18829
  logger
18449
18830
  });
18450
- const permissionsTable = toMutableTable(parsed.permissions);
18831
+ const permissionsTable = toMutableTable(existing.permissions);
18451
18832
  const { profile: existingProfile, domainsHadUnknown: existingDomainsHadUnknown } = toCodexProfile(permissionsTable[RULESYNC_PROFILE_NAME]);
18452
- 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)"}".`);
18453
- 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.`);
18454
- 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.`);
18455
- 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
+ });
18456
18839
  permissionsTable[RULESYNC_PROFILE_NAME] = mergeWithExistingProfile({
18457
18840
  newProfile,
18458
18841
  existingProfile
18459
18842
  });
18460
- parsed.permissions = permissionsTable;
18461
- parsed.default_permissions = RULESYNC_PROFILE_NAME;
18462
- applyCodexcliOverride({
18463
- parsed,
18843
+ const overridePatch = computeCodexcliOverridePatch({
18844
+ existing,
18464
18845
  override: rulesyncPermissions.getJson().codexcli,
18465
18846
  logger
18466
18847
  });
@@ -18468,7 +18849,17 @@ var CodexcliPermissions = class CodexcliPermissions extends ToolPermissions {
18468
18849
  outputRoot,
18469
18850
  relativeDirPath: paths.relativeDirPath,
18470
18851
  relativeFilePath: paths.relativeFilePath,
18471
- 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
+ }),
18472
18863
  validate
18473
18864
  });
18474
18865
  }
@@ -18638,6 +19029,12 @@ function toCodexProfile(value) {
18638
19029
  domainsHadUnknown
18639
19030
  };
18640
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
+ }
18641
19038
  function mergeWithExistingProfile({ newProfile, existingProfile }) {
18642
19039
  if (!existingProfile) return newProfile;
18643
19040
  const mergedNetwork = { ...newProfile.network };
@@ -18688,8 +19085,9 @@ function toMutableTable(value) {
18688
19085
  function isPlainObject(value) {
18689
19086
  return value !== null && typeof value === "object" && !Array.isArray(value);
18690
19087
  }
18691
- function applyCodexcliOverride({ parsed, override, logger }) {
18692
- if (!override) return;
19088
+ function computeCodexcliOverridePatch({ existing, override, logger }) {
19089
+ const patch = {};
19090
+ if (!override) return patch;
18693
19091
  const allowed = new Set(CODEXCLI_OVERRIDE_KEYS);
18694
19092
  for (const [key, value] of Object.entries(override)) {
18695
19093
  if (!allowed.has(key)) {
@@ -18697,12 +19095,13 @@ function applyCodexcliOverride({ parsed, override, logger }) {
18697
19095
  continue;
18698
19096
  }
18699
19097
  if (value === void 0) continue;
18700
- const existing = parsed[key];
18701
- parsed[key] = isPlainObject(existing) && isPlainObject(value) ? {
18702
- ...existing,
19098
+ const existingValue = existing[key];
19099
+ patch[key] = isPlainObject(existingValue) && isPlainObject(value) ? {
19100
+ ...existingValue,
18703
19101
  ...value
18704
19102
  } : value;
18705
19103
  }
19104
+ return patch;
18706
19105
  }
18707
19106
  function extractCodexcliOverride(table) {
18708
19107
  const override = {};
@@ -19263,15 +19662,17 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
19263
19662
  else delete mergedPermissions.ask;
19264
19663
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
19265
19664
  else delete mergedPermissions.deny;
19266
- const merged = {
19267
- ...settings,
19268
- permissions: mergedPermissions
19269
- };
19270
19665
  return new DevinPermissions({
19271
19666
  outputRoot,
19272
19667
  relativeDirPath: paths.relativeDirPath,
19273
19668
  relativeFilePath: paths.relativeFilePath,
19274
- 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
+ }),
19275
19676
  validate
19276
19677
  });
19277
19678
  }
@@ -19880,23 +20281,31 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
19880
20281
  const config = rulesyncPermissions.getJson();
19881
20282
  const existingPermission = isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {};
19882
20283
  const buckets = buildGrokPermissionArrays(config, existingPermission, logger);
19883
- parsed[GROKCLI_PERMISSION_KEY] = {
20284
+ const permission = {
19884
20285
  ...existingPermission,
19885
20286
  allow: buckets.allow,
19886
20287
  deny: buckets.deny,
19887
20288
  ask: buckets.ask
19888
20289
  };
19889
20290
  const mode = deriveGrokPermissionMode(config);
19890
- const existingUi = isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {};
19891
- parsed[GROKCLI_UI_KEY] = {
19892
- ...existingUi,
20291
+ const ui = {
20292
+ ...isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {},
19893
20293
  [GROKCLI_PERMISSION_MODE_KEY]: mode
19894
20294
  };
19895
20295
  return new GrokcliPermissions({
19896
20296
  outputRoot,
19897
20297
  relativeDirPath: paths.relativeDirPath,
19898
20298
  relativeFilePath: paths.relativeFilePath,
19899
- 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
+ }),
19900
20309
  validate: true,
19901
20310
  global: true
19902
20311
  });
@@ -20599,7 +21008,16 @@ var KiroPermissions = class KiroPermissions extends ToolPermissions {
20599
21008
  outputRoot,
20600
21009
  relativeDirPath: paths.relativeDirPath,
20601
21010
  relativeFilePath: paths.relativeFilePath,
20602
- 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
+ }),
20603
21021
  validate
20604
21022
  });
20605
21023
  }
@@ -20896,21 +21314,22 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
20896
21314
  fileContent = await readFileContentOrNull(jsonPath);
20897
21315
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
20898
21316
  }
20899
- const parsed = (0, jsonc_parser.parse)(fileContent ?? "{}");
20900
21317
  const rulesyncJson = rulesyncPermissions.getJson();
20901
21318
  const overridePermission = rulesyncJson.opencode?.permission ?? {};
20902
- const nextJson = {
20903
- ...parsed,
20904
- permission: {
20905
- ...rulesyncJson.permission,
20906
- ...overridePermission
20907
- }
20908
- };
20909
21319
  return new OpencodePermissions({
20910
21320
  outputRoot,
20911
21321
  relativeDirPath: basePaths.relativeDirPath,
20912
21322
  relativeFilePath,
20913
- 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
+ }),
20914
21333
  validate: true
20915
21334
  });
20916
21335
  }
@@ -21119,20 +21538,23 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
21119
21538
  else delete mergedPermissions.ask;
21120
21539
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
21121
21540
  else delete mergedPermissions.deny;
21122
- const merged = {
21123
- ...settings,
21124
- permissions: mergedPermissions
21125
- };
21541
+ const patch = { permissions: mergedPermissions };
21126
21542
  const override = config.qwencode;
21127
- if (override?.tools !== void 0) merged.tools = {
21543
+ if (override?.tools !== void 0) patch.tools = {
21128
21544
  ...asPlainRecord(settings.tools),
21129
21545
  ...asPlainRecord(override.tools)
21130
21546
  };
21131
- if (override?.security !== void 0) merged.security = {
21547
+ if (override?.security !== void 0) patch.security = {
21132
21548
  ...asPlainRecord(settings.security),
21133
21549
  ...asPlainRecord(override.security)
21134
21550
  };
21135
- 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
+ });
21136
21558
  return new QwencodePermissions({
21137
21559
  outputRoot,
21138
21560
  relativeDirPath: paths.relativeDirPath,
@@ -21367,7 +21789,9 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
21367
21789
  }
21368
21790
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
21369
21791
  const paths = this.getSettablePaths({ global });
21370
- 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);
21371
21795
  const config = rulesyncPermissions.getJson();
21372
21796
  const { allow, ask, deny } = convertRulesyncToReasonixPermissions(config);
21373
21797
  const managedToolNames = new Set(Object.keys(config.permission).map((category) => toReasonixToolName(category)));
@@ -21392,25 +21816,27 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
21392
21816
  else delete mergedPermissions.ask;
21393
21817
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
21394
21818
  else delete mergedPermissions.deny;
21395
- const merged = {
21396
- ...parsed,
21397
- permissions: mergedPermissions
21398
- };
21819
+ const patch = { permissions: mergedPermissions };
21399
21820
  const override = config.reasonix;
21400
- if (override?.sandbox !== void 0) merged.sandbox = {
21821
+ if (override?.sandbox !== void 0) patch.sandbox = {
21401
21822
  ...asReasonixRecord(parsed.sandbox),
21402
21823
  ...asReasonixRecord(override.sandbox)
21403
21824
  };
21404
- if (override?.agent !== void 0) merged.agent = {
21825
+ if (override?.agent !== void 0) patch.agent = {
21405
21826
  ...asReasonixRecord(parsed.agent),
21406
21827
  ...asReasonixRecord(override.agent)
21407
21828
  };
21408
- const fileContent = smol_toml.stringify(merged);
21409
21829
  return new ReasonixPermissions({
21410
21830
  outputRoot,
21411
21831
  relativeDirPath: paths.relativeDirPath,
21412
21832
  relativeFilePath: paths.relativeFilePath,
21413
- fileContent,
21833
+ fileContent: applySharedConfigPatch({
21834
+ fileKey: sharedConfigFileKey(paths),
21835
+ feature: "permissions",
21836
+ existingContent,
21837
+ patch,
21838
+ filePath
21839
+ }),
21414
21840
  validate
21415
21841
  });
21416
21842
  }
@@ -21981,7 +22407,9 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
21981
22407
  }
21982
22408
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
21983
22409
  const paths = this.getSettablePaths({ global });
21984
- 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);
21985
22413
  const permission = rulesyncPermissions.getJson().permission;
21986
22414
  const tools = toVibeToolsRecord(config.tools);
21987
22415
  const enabledTools = new Set(toStringArray(config.enabled_tools));
@@ -22026,16 +22454,21 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
22026
22454
  tools[vibeToolName] = nextTool;
22027
22455
  }
22028
22456
  applyVibeSensitivePatterns(tools, rulesyncPermissions.getJson().vibe);
22029
- config.tools = tools;
22030
- if (enabledTools.size > 0) config.enabled_tools = [...enabledTools].toSorted();
22031
- else delete config.enabled_tools;
22032
- if (disabledTools.size > 0) config.disabled_tools = [...disabledTools].toSorted();
22033
- else delete config.disabled_tools;
22034
22457
  return new VibePermissions({
22035
22458
  outputRoot,
22036
22459
  relativeDirPath: paths.relativeDirPath,
22037
22460
  relativeFilePath: paths.relativeFilePath,
22038
- 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
+ }),
22039
22472
  validate,
22040
22473
  global
22041
22474
  });
@@ -22498,24 +22931,26 @@ var ZedPermissions = class ZedPermissions extends ToolPermissions {
22498
22931
  }
22499
22932
  const managedToolNames = new Set(Object.keys(managedTools));
22500
22933
  const preservedTools = Object.fromEntries(Object.entries(existingTools).filter(([toolName]) => !managedToolNames.has(toolName)));
22501
- const mergedSettings = {
22502
- ...settings,
22503
- agent: {
22504
- ...agent,
22505
- tool_permissions: {
22506
- ...toolPermissions,
22507
- tools: {
22508
- ...preservedTools,
22509
- ...managedTools
22510
- }
22511
- }
22512
- }
22513
- };
22514
22934
  return new ZedPermissions({
22515
22935
  outputRoot,
22516
22936
  relativeDirPath: paths.relativeDirPath,
22517
22937
  relativeFilePath: paths.relativeFilePath,
22518
- 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
+ }),
22519
22954
  validate: true
22520
22955
  });
22521
22956
  }
@@ -38205,8 +38640,13 @@ const collectFactoryPaths = (factory) => [...settablePathsForScope({
38205
38640
  * Derive, from the processor registry, every on-disk file that two or more
38206
38641
  * features read-modify-write. Source of truth the generation step graph's
38207
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).
38208
38648
  */
38209
- const deriveSharedFileWriters = () => {
38649
+ const deriveSharedFileWriters = ({ minWriters = 2 } = {}) => {
38210
38650
  const byKey = /* @__PURE__ */ new Map();
38211
38651
  const pathByKey = /* @__PURE__ */ new Map();
38212
38652
  for (const entry of PROCESSOR_REGISTRY) {
@@ -38232,7 +38672,7 @@ const deriveSharedFileWriters = () => {
38232
38672
  }
38233
38673
  const writers = [];
38234
38674
  for (const [key, features] of byKey) {
38235
- if (features.size < 2) continue;
38675
+ if (features.size < minWriters) continue;
38236
38676
  const path = pathByKey.get(key);
38237
38677
  const toolsByFeature = /* @__PURE__ */ new Map();
38238
38678
  for (const [feature, tools] of features) toolsByFeature.set(feature, [...tools].toSorted());