rulesync 29.0.0 → 31.0.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.
@@ -453,36 +453,36 @@ const isFeatureValueEnabled = (value) => {
453
453
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
454
454
  //#endregion
455
455
  //#region src/constants/rulesync-paths.ts
456
- const { join: join$413 } = node_path.posix;
456
+ const { join: join$416 } = node_path.posix;
457
457
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
458
458
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
459
459
  const RULESYNC_USER_CONFIG_DIR_NAME = "rulesync";
460
460
  const RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME = ".config";
461
461
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
462
462
  const RULES_FEATURE_SUBDIR = "rules";
463
- const CURATED_RULES_FEATURE_SUBDIR = join$413(RULES_FEATURE_SUBDIR, ".curated");
463
+ const CURATED_RULES_FEATURE_SUBDIR = join$416(RULES_FEATURE_SUBDIR, ".curated");
464
464
  const COMMANDS_FEATURE_SUBDIR = "commands";
465
465
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
466
466
  const CHECKS_FEATURE_SUBDIR = "checks";
467
467
  const SKILLS_FEATURE_SUBDIR = "skills";
468
- const CURATED_SKILLS_FEATURE_SUBDIR = join$413(SKILLS_FEATURE_SUBDIR, ".curated");
469
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
470
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
471
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
472
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
473
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
474
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
475
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
476
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
477
- join$413(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
478
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
479
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
468
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$416(SKILLS_FEATURE_SUBDIR, ".curated");
469
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
470
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
471
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
472
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
473
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
474
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
475
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
476
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
477
+ join$416(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
478
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
479
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
480
480
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
481
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
481
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
482
482
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
483
483
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
484
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
485
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$413(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
484
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
485
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$416(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
486
486
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
487
487
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
488
488
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -935,6 +935,7 @@ const checksProcessorToolTargetTuple = [
935
935
  "cursor",
936
936
  "factorydroid",
937
937
  "gitlabduo",
938
+ "goose",
938
939
  "hermesagent",
939
940
  "kilo",
940
941
  "qwencode",
@@ -7883,6 +7884,26 @@ const CopilotPermissionsOverrideSchema = zod_mini.z.looseObject({
7883
7884
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
7884
7885
  defaultPermissionMode: zod_mini.z.optional(zod_mini.z.enum(COPILOT_DEFAULT_PERMISSION_MODES))
7885
7886
  });
7887
+ /**
7888
+ * oh-my-pi's `tools.approvalMode` values: the tool tiers it auto-approves
7889
+ * before prompting. `always-ask` auto-approves only read-only tools, `write`
7890
+ * also workspace writes, and `yolo` (oh-my-pi's default) every tier.
7891
+ *
7892
+ * @see https://github.com/can1357/oh-my-pi/blob/main/docs/approval-mode.md
7893
+ */
7894
+ const OMP_APPROVAL_MODES = [
7895
+ "always-ask",
7896
+ "write",
7897
+ "yolo"
7898
+ ];
7899
+ /**
7900
+ * Tool-scoped override block for oh-my-pi (`omp`). `approvalMode` writes the
7901
+ * `tools.approvalMode` key of `config.yml`, which has no canonical category.
7902
+ */
7903
+ const OmpPermissionsOverrideSchema = zod_mini.z.looseObject({
7904
+ permission: zod_mini.z.optional(ToolScopedPermissionSchema),
7905
+ approvalMode: zod_mini.z.optional(zod_mini.z.enum(OMP_APPROVAL_MODES))
7906
+ });
7886
7907
  const KimiCodePermissionsOverrideSchema = zod_mini.z.looseObject({
7887
7908
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
7888
7909
  defaultPermissionMode: zod_mini.z.optional(zod_mini.z.enum([
@@ -7890,6 +7911,14 @@ const KimiCodePermissionsOverrideSchema = zod_mini.z.looseObject({
7890
7911
  "yolo",
7891
7912
  "auto"
7892
7913
  ])),
7914
+ /**
7915
+ * Kimi's `[permission] dangerous_command_guard` switch (Kimi Code 0.40.0,
7916
+ * default `true`). `false` turns off the built-in dangerous-command policy
7917
+ * (no confirmation for `rm -rf`, `shutdown` and the like).
7918
+ *
7919
+ * @see https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#permission
7920
+ */
7921
+ dangerousCommandGuard: zod_mini.z.optional(zod_mini.z.boolean()),
7893
7922
  rules: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.looseObject({
7894
7923
  decision: PermissionActionSchema,
7895
7924
  pattern: zod_mini.z.string(),
@@ -8105,7 +8134,10 @@ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
8105
8134
  * `sandbox`, `sandboxImage`, `disabled`, `visible`, `eager`, `listDirectory`,
8106
8135
  * `todoWrite`, `workflowsEnabled`, `workflowNameOnly`, `executionSandbox`) and
8107
8136
  * `security` (`folderTrust`, `allowedHttpHookUrls`, `allowPrivateNetworkHooks`,
8108
- * `allowedInsecureVoiceBaseUrls`). Qwen Code strips `tools.workflowsEnabled`,
8137
+ * `allowedInsecureVoiceBaseUrls`) and `skills` (the skill kill-switches
8138
+ * `disabled`, `defaultDisabled`, `enabled`, `disabledLevels`, each a list Qwen
8139
+ * Code unions across scopes) and `general` (`outputStyle`, the output style
8140
+ * that reshapes how responses are written). Qwen Code strips `tools.workflowsEnabled`,
8109
8141
  * `tools.executionSandbox`, `security.allowPrivateNetworkHooks` and
8110
8142
  * `security.allowedInsecureVoiceBaseUrls` out of workspace settings, so generate
8111
8143
  * skips those four in project scope and announces a granting value in global
@@ -8122,7 +8154,8 @@ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
8122
8154
  * registry controls (`disabled`, `visible`, `listDirectory`, `todoWrite`, and `eager`, which
8123
8155
  * demotes an omitted tool to deferred rather than removing it, replacing rather
8124
8156
  * than merging with the list a higher scope set), the Auto Mode
8125
- * classifier config, and, because these groups are loose objects, any key
8157
+ * classifier config, the skill kill-switches, and, because these groups are
8158
+ * loose objects, any key
8126
8159
  * rulesync does not model. Import flags a scope-dependent key only when it read
8127
8160
  * the project file, since that is the value a `--global` regenerate would
8128
8161
  * promote. It also
@@ -8133,11 +8166,13 @@ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
8133
8166
  * matching `settings.json` group and emitted only for Qwen, while the shared
8134
8167
  * `permission` block continues to drive the `permissions.allow`/`ask`/`deny`
8135
8168
  * arrays. Kept `looseObject` (verbatim passthrough) so any current or future
8136
- * `tools`/`security`/`autoMode` key can be authored.
8169
+ * `tools`/`security`/`skills`/`general`/`autoMode` key can be authored.
8137
8170
  *
8138
8171
  * @example
8139
8172
  * {
8140
8173
  * "tools": { "approvalMode": "auto-edit" },
8174
+ * "skills": { "disabled": ["pdf"], "disabledLevels": ["bundled"] },
8175
+ * "general": { "outputStyle": "Concise" },
8141
8176
  * "security": { "folderTrust": { "enabled": true } },
8142
8177
  * "autoMode": { "hints": { "allow": ["Running tests"] }, "classifyAllShell": true }
8143
8178
  * }
@@ -8146,6 +8181,8 @@ const QwencodePermissionsOverrideSchema = zod_mini.z.looseObject({
8146
8181
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
8147
8182
  tools: zod_mini.z.optional(zod_mini.z.looseObject({})),
8148
8183
  security: zod_mini.z.optional(zod_mini.z.looseObject({})),
8184
+ skills: zod_mini.z.optional(zod_mini.z.looseObject({})),
8185
+ general: zod_mini.z.optional(zod_mini.z.looseObject({})),
8149
8186
  autoMode: zod_mini.z.optional(zod_mini.z.looseObject({}))
8150
8187
  });
8151
8188
  /**
@@ -8231,6 +8268,8 @@ const TabninePermissionsOverrideSchema = zod_mini.z.looseObject({
8231
8268
  * { "sandbox": { "bash": "enforce", "network": false }, "agent": { "plan_mode_read_only_commands": ["gh pr diff"] } }
8232
8269
  * @example
8233
8270
  * { "allowDynamicBash": true, "rawAllow": ["Bash=pnpm test"] }
8271
+ * @example
8272
+ * { "mode": "deny" }
8234
8273
  */
8235
8274
  const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
8236
8275
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
@@ -8239,7 +8278,12 @@ const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
8239
8278
  rawAllow: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8240
8279
  rawAsk: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8241
8280
  rawDeny: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8242
- allowDynamicBash: zod_mini.z.optional(zod_mini.z.boolean())
8281
+ allowDynamicBash: zod_mini.z.optional(zod_mini.z.boolean()),
8282
+ mode: zod_mini.z.optional(zod_mini.z.enum([
8283
+ "ask",
8284
+ "allow",
8285
+ "deny"
8286
+ ]))
8243
8287
  });
8244
8288
  /**
8245
8289
  * Tool-scoped override block for Factory Droid. Factory Droid's `settings.json`
@@ -8282,7 +8326,8 @@ const FactorydroidPermissionsOverrideSchema = zod_mini.z.looseObject({
8282
8326
  * {
8283
8327
  * "agent_mode_coding_permissions": "always_allow_reading",
8284
8328
  * "agent_mode_execute_readonly_commands": true,
8285
- * "execution_profile": { "read_files": "always_allow", "mcp_denylist": ["untrusted-server"] }
8329
+ * "execution_profile": { "read_files": "always_allow", "mcp_denylist": ["untrusted-server"] },
8330
+ * "auto_approve_bypasses_command_denylist": false
8286
8331
  * }
8287
8332
  */
8288
8333
  /**
@@ -8334,6 +8379,16 @@ const WarpExecutionProfileOverrideSchema = zod_mini.z.looseObject({
8334
8379
  mcp_allowlist: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8335
8380
  mcp_denylist: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
8336
8381
  });
8382
+ /**
8383
+ * Whether auto-approve may run commands that match the command denylist
8384
+ * (Warp defaults to `true`). It is not an execution-profile field: it lives in
8385
+ * the sibling `[agents.warp_agent.other]` table of the same `settings.toml`,
8386
+ * shared by the Warp app and the Warp Agent CLI.
8387
+ *
8388
+ * @see https://docs.warp.dev/agents/cli/permissions-and-profiles/
8389
+ * @see https://github.com/warpdotdev/warp/blob/main/app/src/settings/ai.rs (`AutoApproveBypassesCommandDenylist`)
8390
+ */
8391
+ const WarpAutoApproveBypassSchema = zod_mini.z.optional(zod_mini.z.boolean());
8337
8392
  const WarpPermissionsOverrideSchema = zod_mini.z.looseObject({
8338
8393
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
8339
8394
  agent_mode_coding_permissions: zod_mini.z.optional(zod_mini.z.enum([
@@ -8343,7 +8398,8 @@ const WarpPermissionsOverrideSchema = zod_mini.z.looseObject({
8343
8398
  ])),
8344
8399
  agent_mode_coding_file_read_allowlist: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8345
8400
  agent_mode_execute_readonly_commands: zod_mini.z.optional(zod_mini.z.boolean()),
8346
- execution_profile: zod_mini.z.optional(WarpExecutionProfileOverrideSchema)
8401
+ execution_profile: zod_mini.z.optional(WarpExecutionProfileOverrideSchema),
8402
+ auto_approve_bypasses_command_denylist: WarpAutoApproveBypassSchema
8347
8403
  });
8348
8404
  /**
8349
8405
  * Tool-scoped override block for the standalone Warp Agent CLI. The CLI reads
@@ -8352,16 +8408,21 @@ const WarpPermissionsOverrideSchema = zod_mini.z.looseObject({
8352
8408
  * the app's legacy `[agents.profiles]` keys), so only the nested
8353
8409
  * `execution_profile` block is offered; it is merged into that record, and the
8354
8410
  * shared `permission.bash` block still drives `command_allowlist` /
8355
- * `command_denylist`.
8411
+ * `command_denylist`. `auto_approve_bypasses_command_denylist` is written to
8412
+ * the sibling `[agents.warp_agent.other]` table.
8356
8413
  *
8357
8414
  * @example
8358
- * { "execution_profile": { "apply_code_diffs": "always_ask", "run_agents": "never_allow" } }
8415
+ * {
8416
+ * "execution_profile": { "apply_code_diffs": "always_ask", "run_agents": "never_allow" },
8417
+ * "auto_approve_bypasses_command_denylist": false
8418
+ * }
8359
8419
  *
8360
8420
  * @see https://docs.warp.dev/agents/cli/permissions-and-profiles/
8361
8421
  */
8362
8422
  const WarpcliPermissionsOverrideSchema = zod_mini.z.looseObject({
8363
8423
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
8364
- execution_profile: zod_mini.z.optional(WarpExecutionProfileOverrideSchema)
8424
+ execution_profile: zod_mini.z.optional(WarpExecutionProfileOverrideSchema),
8425
+ auto_approve_bypasses_command_denylist: WarpAutoApproveBypassSchema
8365
8426
  });
8366
8427
  /**
8367
8428
  * deepagents-cli's approval-mode knobs under `[startup]` in
@@ -9057,7 +9118,7 @@ const PermissionsConfigSchema = zod_mini.z.looseObject({
9057
9118
  copilotcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
9058
9119
  crush: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
9059
9120
  goose: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
9060
- omp: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
9121
+ omp: zod_mini.z.optional(OmpPermissionsOverrideSchema),
9061
9122
  pool: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
9062
9123
  qoder: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
9063
9124
  grokcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
@@ -12767,7 +12828,9 @@ const SHARED_CONFIG_OWNERSHIP = {
12767
12828
  ownedKeys: [
12768
12829
  "permissions",
12769
12830
  "tools",
12770
- "security"
12831
+ "security",
12832
+ "skills",
12833
+ "general"
12771
12834
  ]
12772
12835
  }
12773
12836
  }
@@ -13137,7 +13200,7 @@ const SHARED_CONFIG_OWNERSHIP = {
13137
13200
  features: {
13138
13201
  mcp: {
13139
13202
  kind: "replace-owned-keys",
13140
- ownedKeys: ["mcp_servers"]
13203
+ ownedKeys: ["mcp_servers", "disabled_mcp_tools"]
13141
13204
  },
13142
13205
  permissions: {
13143
13206
  kind: "replace-owned-keys",
@@ -13272,6 +13335,14 @@ function serializeSharedConfigFile({ fileKey, document, existingContent, filePat
13272
13335
  });
13273
13336
  }
13274
13337
  /**
13338
+ * A patch value for an owned key that holds a map of managed entries: the map
13339
+ * itself, or `undefined` when it is empty, which retracts the key instead of
13340
+ * leaving an empty object behind (`applySharedConfigPatch` deletes a key whose patch value is `undefined`).
13341
+ */
13342
+ function undefinedWhenEmpty(entries) {
13343
+ return Object.keys(entries).length > 0 ? entries : void 0;
13344
+ }
13345
+ /**
13275
13346
  * Execute a feature's declared write to a gateway-managed shared file: parse
13276
13347
  * the existing content, merge the patch under the feature's declared policy,
13277
13348
  * and serialize it back over the existing content (see
@@ -13758,14 +13829,23 @@ var FeatureProcessor = class extends RulesyncSourceConsumer {
13758
13829
  async removeOrphanAiFiles(existingFiles, generatedFiles) {
13759
13830
  const generatedPaths = new Set(generatedFiles.map((f) => caseFoldIdentity(f.getFilePath())));
13760
13831
  const orphanFiles = existingFiles.filter((f) => !generatedPaths.has(caseFoldIdentity(f.getFilePath())));
13832
+ const refusedDirs = /* @__PURE__ */ new Map();
13761
13833
  let removedCount = 0;
13762
13834
  for (const aiFile of orphanFiles) {
13763
13835
  const filePath = aiFile.getFilePath();
13764
- if (await refusesDeleteOutsideRoot({
13765
- logger: this.logger,
13766
- rootPath: aiFile.getOutputRoot(),
13767
- filePath
13768
- })) continue;
13836
+ const rootPath = aiFile.getOutputRoot();
13837
+ const dirPath = (0, node_path.dirname)(filePath);
13838
+ const cacheKey = JSON.stringify([rootPath, dirPath]);
13839
+ let refused = refusedDirs.get(cacheKey);
13840
+ if (refused === void 0) {
13841
+ refused = await refusesDeleteOutsideRoot({
13842
+ logger: this.logger,
13843
+ rootPath,
13844
+ dirPath
13845
+ });
13846
+ refusedDirs.set(cacheKey, refused);
13847
+ }
13848
+ if (refused) continue;
13769
13849
  const loggedPath = stripControlCharacters(filePath);
13770
13850
  if (this.dryRun) this.logger.info(`[DRY RUN] Would delete: ${loggedPath}`);
13771
13851
  else {
@@ -13886,22 +13966,24 @@ async function refusesWriteOutsideRoot({ logger, rootPath, targetPath }) {
13886
13966
  return true;
13887
13967
  }
13888
13968
  /**
13889
- * Whether deleting `filePath` has to be refused because a link on the way to
13890
- * the directory holding it leads out of `rootPath`, warning about the refusal.
13969
+ * Whether deleting the files in `dirPath` has to be refused because a link on
13970
+ * the way to that directory leads out of `rootPath`, warning about the refusal
13971
+ * once for the whole directory.
13891
13972
  *
13892
13973
  * Deletion candidates are enumerated without following links *inside* the
13893
13974
  * scanned directory, but the scanned directory itself is reached through
13894
13975
  * whatever links its path contains: a checked-out repository can carry
13895
13976
  * `.cursor/commands -> ~/notes`, and every file in `~/notes` would then read as
13896
- * an orphan. Only the parent is judged, because removing a file that is itself
13897
- * a link unlinks the link where it is spelled and never touches its target.
13977
+ * an orphan. Only the directory is judged, never the file in it, because
13978
+ * removing a file that is itself a link unlinks the link where it is spelled
13979
+ * and never touches its target.
13898
13980
  */
13899
- async function refusesDeleteOutsideRoot({ logger, rootPath, filePath }) {
13981
+ async function refusesDeleteOutsideRoot({ logger, rootPath, dirPath }) {
13900
13982
  if (!await writablePathEscapesRoot({
13901
13983
  rootPath,
13902
- targetPath: (0, node_path.dirname)(filePath)
13984
+ targetPath: dirPath
13903
13985
  })) return false;
13904
- logger.warn(`Refusing to delete ${quoteForLog(filePath)}: it resolves outside ${quoteForLog(rootPath)} through a symbolic link`);
13986
+ logger.warn(`Refusing to delete files in ${quoteForLog(dirPath)}: it resolves outside ${quoteForLog(rootPath)} through a symbolic link`);
13905
13987
  return true;
13906
13988
  }
13907
13989
  /**
@@ -14140,12 +14222,12 @@ const AmpCheckFrontmatterSchema = zod_mini.z.looseObject({
14140
14222
  * this format continue to apply.
14141
14223
  * @see https://ampcode.com/docs/review
14142
14224
  */
14143
- var AmpCheck = class AmpCheck extends ToolCheck {
14225
+ var AmpCheck = class extends ToolCheck {
14144
14226
  frontmatter;
14145
14227
  body;
14146
14228
  constructor({ frontmatter, body, fileContent, ...rest }) {
14147
14229
  if (rest.validate !== false) {
14148
- const result = AmpCheckFrontmatterSchema.safeParse(frontmatter);
14230
+ const result = new.target.getFrontmatterSchema().safeParse(frontmatter);
14149
14231
  if (!result.success) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
14150
14232
  }
14151
14233
  super({
@@ -14155,6 +14237,26 @@ var AmpCheck = class AmpCheck extends ToolCheck {
14155
14237
  this.frontmatter = frontmatter;
14156
14238
  this.body = body;
14157
14239
  }
14240
+ /**
14241
+ * The tool target this check format belongs to, which is also the name of
14242
+ * its tool-specific frontmatter section. Goose reads the same per-file
14243
+ * format with a few extra keys, so `GooseCheck` reuses this class through
14244
+ * these hooks.
14245
+ */
14246
+ static getCheckToolTarget() {
14247
+ return "amp";
14248
+ }
14249
+ /** The frontmatter schema a check file of this tool is validated against. */
14250
+ static getFrontmatterSchema() {
14251
+ return AmpCheckFrontmatterSchema;
14252
+ }
14253
+ /**
14254
+ * Frontmatter keys only this tool reads. Import moves them into the tool's
14255
+ * own section so they are not passed on to every other checks target.
14256
+ */
14257
+ static getToolOnlyKeys() {
14258
+ return [];
14259
+ }
14158
14260
  static getSettablePaths({ global = false } = {}) {
14159
14261
  return { relativeDirPath: global ? AMP_CHECKS_GLOBAL_DIR : AMP_CHECKS_PROJECT_DIR };
14160
14262
  }
@@ -14166,6 +14268,10 @@ var AmpCheck = class AmpCheck extends ToolCheck {
14166
14268
  }
14167
14269
  toRulesyncCheck() {
14168
14270
  const { name: _name, description, "severity-default": severityDefault, tools, ...restFields } = this.frontmatter;
14271
+ const checkClass = this.constructor;
14272
+ const toolOnlyKeys = new Set(checkClass.getToolOnlyKeys());
14273
+ const sharedFields = Object.fromEntries(Object.entries(restFields).filter(([key]) => !toolOnlyKeys.has(key)));
14274
+ const toolSection = Object.fromEntries(Object.entries(restFields).filter(([key]) => toolOnlyKeys.has(key)));
14169
14275
  return new RulesyncCheck({
14170
14276
  outputRoot: ".",
14171
14277
  frontmatter: {
@@ -14173,7 +14279,8 @@ var AmpCheck = class AmpCheck extends ToolCheck {
14173
14279
  ...description !== void 0 && { description },
14174
14280
  ...severityDefault !== void 0 && { severity: severityDefault },
14175
14281
  ...tools !== void 0 && { tools },
14176
- ...restFields
14282
+ ...sharedFields,
14283
+ ...Object.keys(toolSection).length > 0 && { [checkClass.getCheckToolTarget()]: toolSection }
14177
14284
  },
14178
14285
  body: this.body,
14179
14286
  relativeDirPath: RULESYNC_CHECKS_RELATIVE_DIR_PATH,
@@ -14188,22 +14295,24 @@ var AmpCheck = class AmpCheck extends ToolCheck {
14188
14295
  const { targets: _targets, description, severity, tools, ...restFields } = rulesyncFrontmatter;
14189
14296
  const toolTargetKeys = new Set(ALL_TOOL_TARGETS);
14190
14297
  const passthroughFields = Object.fromEntries(Object.entries(restFields).filter(([key]) => !toolTargetKeys.has(key) && key !== "name"));
14191
- const ampSection = this.filterToolSpecificSection(rulesyncFrontmatter.amp ?? {}, ["name"]);
14298
+ const toolTarget = this.getCheckToolTarget();
14299
+ const rawSection = rulesyncFrontmatter[toolTarget];
14300
+ const toolSection = this.filterToolSpecificSection(rawSection !== null && typeof rawSection === "object" && !Array.isArray(rawSection) ? rawSection : {}, ["name"]);
14192
14301
  const rawAmpFrontmatter = {
14193
14302
  name,
14194
14303
  ...description !== void 0 && { description },
14195
14304
  ...severity !== void 0 && { "severity-default": severity },
14196
14305
  ...tools !== void 0 && { tools },
14197
14306
  ...passthroughFields,
14198
- ...ampSection
14307
+ ...toolSection
14199
14308
  };
14200
- const result = AmpCheckFrontmatterSchema.safeParse(rawAmpFrontmatter);
14201
- if (!result.success) throw new Error(`Invalid amp check frontmatter in ${relativeFilePath}: ${formatError(result.error)}`);
14309
+ const result = this.getFrontmatterSchema().safeParse(rawAmpFrontmatter);
14310
+ if (!result.success) throw new Error(`Invalid ${toolTarget} check frontmatter in ${relativeFilePath}: ${formatError(result.error)}`);
14202
14311
  const ampFrontmatter = result.data;
14203
14312
  const body = rulesyncCheck.getBody();
14204
14313
  const fileContent = stringifyFrontmatter(body, ampFrontmatter);
14205
14314
  const paths = this.getSettablePaths({ global });
14206
- return new AmpCheck({
14315
+ return new this({
14207
14316
  outputRoot,
14208
14317
  frontmatter: ampFrontmatter,
14209
14318
  body,
@@ -14218,7 +14327,7 @@ var AmpCheck = class AmpCheck extends ToolCheck {
14218
14327
  success: true,
14219
14328
  error: null
14220
14329
  };
14221
- const result = AmpCheckFrontmatterSchema.safeParse(this.frontmatter);
14330
+ const result = this.constructor.getFrontmatterSchema().safeParse(this.frontmatter);
14222
14331
  if (result.success) return {
14223
14332
  success: true,
14224
14333
  error: null
@@ -14231,7 +14340,7 @@ var AmpCheck = class AmpCheck extends ToolCheck {
14231
14340
  static isTargetedByRulesyncCheck(rulesyncCheck) {
14232
14341
  return this.isTargetedByRulesyncCheckDefault({
14233
14342
  rulesyncCheck,
14234
- toolTarget: "amp"
14343
+ toolTarget: this.getCheckToolTarget()
14235
14344
  });
14236
14345
  }
14237
14346
  static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
@@ -14239,9 +14348,9 @@ var AmpCheck = class AmpCheck extends ToolCheck {
14239
14348
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
14240
14349
  const fileContent = await readFileContent(filePath);
14241
14350
  const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
14242
- const result = AmpCheckFrontmatterSchema.safeParse(frontmatter);
14351
+ const result = this.getFrontmatterSchema().safeParse(frontmatter);
14243
14352
  if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
14244
- return new AmpCheck({
14353
+ return new this({
14245
14354
  outputRoot,
14246
14355
  relativeDirPath: paths.relativeDirPath,
14247
14356
  relativeFilePath,
@@ -14252,7 +14361,7 @@ var AmpCheck = class AmpCheck extends ToolCheck {
14252
14361
  });
14253
14362
  }
14254
14363
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
14255
- return new AmpCheck({
14364
+ return new this({
14256
14365
  outputRoot,
14257
14366
  relativeDirPath,
14258
14367
  relativeFilePath,
@@ -15352,6 +15461,60 @@ var GitlabduoCheck = class GitlabduoCheck extends ToolCheck {
15352
15461
  }
15353
15462
  };
15354
15463
  //#endregion
15464
+ //#region src/constants/goose-paths.ts
15465
+ const GOOSE_DIR = ".goose";
15466
+ const GOOSE_GLOBAL_DIR = (0, node_path.join)(".config", "goose");
15467
+ const GOOSE_RULE_FILE_NAME = ".goosehints";
15468
+ const GOOSE_MCP_FILE_NAME = "config.yaml";
15469
+ const GOOSE_PERMISSIONS_FILE_NAME = "permission.yaml";
15470
+ const GOOSE_HOOKS_DIR_PATH = (0, node_path.join)(".agents", "plugins", "rulesync", "hooks");
15471
+ const GOOSE_HOOKS_FILE_NAME = "hooks.json";
15472
+ const GOOSE_PLUGIN_MCP_DIR_PATH = (0, node_path.join)(".agents", "plugins", "rulesync");
15473
+ const GOOSE_PLUGIN_MCP_FILE_NAME = ".mcp.json";
15474
+ const GOOSE_SKILLS_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "skills");
15475
+ const GOOSE_GLOBAL_SKILLS_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "skills");
15476
+ const GOOSE_RECIPES_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "recipes");
15477
+ const GOOSE_GLOBAL_RECIPES_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "recipes");
15478
+ const GOOSE_AGENTS_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "agents");
15479
+ const GOOSE_GLOBAL_AGENTS_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "agents");
15480
+ const GOOSE_CHECKS_PROJECT_DIR = (0, node_path.join)(".agents", "checks");
15481
+ const GOOSE_CHECKS_GLOBAL_DIR = (0, node_path.join)(GOOSE_GLOBAL_DIR, "checks");
15482
+ //#endregion
15483
+ //#region src/features/checks/goose-check.ts
15484
+ const GooseCheckFrontmatterSchema = zod_mini.z.extend(AmpCheckFrontmatterSchema, {
15485
+ model: zod_mini.z.optional(zod_mini.z.string()),
15486
+ "turn-limit": zod_mini.z.optional(zod_mini.z.int().check(zod_mini.z.nonnegative()))
15487
+ });
15488
+ /**
15489
+ * Represents a Goose review check (`goose review`), written to the user-wide
15490
+ * `~/.config/goose/checks/` (global scope only).
15491
+ *
15492
+ * Goose discovers repository checks in `.agents/checks/*.md`, the same
15493
+ * cross-tool directory the `amp` checks target already writes (Goose's
15494
+ * `tools` and `severity-default` fields exist for parity with Amp's), so the
15495
+ * project scope is left to `amp` rather than having two targets own the same
15496
+ * files. Its global discovery reads `~/.config/goose/checks/` first, then
15497
+ * `~/.config/agents/checks/`. The file format is Amp's, so this class reuses
15498
+ * `AmpCheck` and adds Goose's `model` and `turn-limit`, which live in the
15499
+ * `goose:` section of a rulesync check; `name` is derived from the file name.
15500
+ *
15501
+ * @see https://github.com/aaif-goose/goose/blob/main/crates/goose/src/checks/mod.rs
15502
+ */
15503
+ var GooseCheck = class extends AmpCheck {
15504
+ static getCheckToolTarget() {
15505
+ return "goose";
15506
+ }
15507
+ static getFrontmatterSchema() {
15508
+ return GooseCheckFrontmatterSchema;
15509
+ }
15510
+ static getToolOnlyKeys() {
15511
+ return ["model", "turn-limit"];
15512
+ }
15513
+ static getSettablePaths({ global = false } = {}) {
15514
+ return { relativeDirPath: global ? GOOSE_CHECKS_GLOBAL_DIR : GOOSE_CHECKS_PROJECT_DIR };
15515
+ }
15516
+ };
15517
+ //#endregion
15355
15518
  //#region src/constants/hermesagent-paths.ts
15356
15519
  /**
15357
15520
  * Hermes Agent configuration-layout conventions.
@@ -16080,6 +16243,14 @@ const toolCheckFactories = /* @__PURE__ */ new Map([
16080
16243
  committedOutput: true
16081
16244
  }
16082
16245
  }],
16246
+ ["goose", {
16247
+ class: GooseCheck,
16248
+ meta: {
16249
+ supportsGlobal: true,
16250
+ supportsProject: false,
16251
+ filePattern: "*.md"
16252
+ }
16253
+ }],
16083
16254
  ["hermesagent", {
16084
16255
  class: HermesagentCheck,
16085
16256
  meta: {
@@ -16125,7 +16296,9 @@ const defaultGetFactory$6 = (target) => {
16125
16296
  return factory;
16126
16297
  };
16127
16298
  const allToolTargetKeys$5 = [...toolCheckFactories.keys()];
16128
- const checksProcessorToolTargets = allToolTargetKeys$5;
16299
+ const checksProcessorToolTargets = allToolTargetKeys$5.filter((target) => {
16300
+ return toolCheckFactories.get(target)?.meta.supportsProject !== false;
16301
+ });
16129
16302
  const checksProcessorToolTargetsGlobal = allToolTargetKeys$5.filter((target) => {
16130
16303
  return toolCheckFactories.get(target)?.meta.supportsGlobal ?? false;
16131
16304
  });
@@ -16296,23 +16469,6 @@ var ChecksProcessor = class extends FeatureProcessor {
16296
16469
  }
16297
16470
  };
16298
16471
  //#endregion
16299
- //#region src/constants/goose-paths.ts
16300
- const GOOSE_DIR = ".goose";
16301
- const GOOSE_GLOBAL_DIR = (0, node_path.join)(".config", "goose");
16302
- const GOOSE_RULE_FILE_NAME = ".goosehints";
16303
- const GOOSE_MCP_FILE_NAME = "config.yaml";
16304
- const GOOSE_PERMISSIONS_FILE_NAME = "permission.yaml";
16305
- const GOOSE_HOOKS_DIR_PATH = (0, node_path.join)(".agents", "plugins", "rulesync", "hooks");
16306
- const GOOSE_HOOKS_FILE_NAME = "hooks.json";
16307
- const GOOSE_PLUGIN_MCP_DIR_PATH = (0, node_path.join)(".agents", "plugins", "rulesync");
16308
- const GOOSE_PLUGIN_MCP_FILE_NAME = ".mcp.json";
16309
- const GOOSE_SKILLS_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "skills");
16310
- const GOOSE_GLOBAL_SKILLS_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "skills");
16311
- const GOOSE_RECIPES_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "recipes");
16312
- const GOOSE_GLOBAL_RECIPES_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "recipes");
16313
- const GOOSE_AGENTS_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "agents");
16314
- const GOOSE_GLOBAL_AGENTS_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "agents");
16315
- //#endregion
16316
16472
  //#region src/utils/tool-home.ts
16317
16473
  /**
16318
16474
  * Where the rulesync-side source files of a tool with a home override belong.
@@ -17224,7 +17380,7 @@ var AntigravitySharedCommand = class extends ToolCommand {
17224
17380
  static getProjectRelativeDirPath() {
17225
17381
  throw new Error("Please implement this method in the subclass.");
17226
17382
  }
17227
- /** Global-scope workflows directory under `~/.gemini/` (e.g. `.gemini/antigravity/global_workflows`). */
17383
+ /** Global-scope workflows directory under `~/.gemini/` (e.g. `.gemini/config/workflows`). */
17228
17384
  static getGlobalRelativeDirPath() {
17229
17385
  throw new Error("Please implement this method in the subclass.");
17230
17386
  }
@@ -17387,7 +17543,8 @@ const ANTIGRAVITY_IDE_GEMINI_DIR = ".gemini";
17387
17543
  const ANTIGRAVITY_IDE_GLOBAL_RULE_FILE_NAME = "GEMINI.md";
17388
17544
  const ANTIGRAVITY_IDE_GLOBAL_CONFIG_SUBDIR = "config";
17389
17545
  const ANTIGRAVITY_IDE_GLOBAL_RULES_SUBDIR = (0, node_path.join)(ANTIGRAVITY_IDE_GLOBAL_CONFIG_SUBDIR, "rules");
17390
- const ANTIGRAVITY_IDE_GLOBAL_WORKFLOWS_DIR_PATH = (0, node_path.join)(ANTIGRAVITY_IDE_GEMINI_DIR, "antigravity", "global_workflows");
17546
+ const ANTIGRAVITY_IDE_GLOBAL_WORKFLOWS_DIR_PATH = (0, node_path.join)(ANTIGRAVITY_IDE_GEMINI_DIR, ANTIGRAVITY_IDE_GLOBAL_CONFIG_SUBDIR, "workflows");
17547
+ const ANTIGRAVITY_IDE_PRE_V2_GLOBAL_WORKFLOWS_DIR_PATH = (0, node_path.join)(ANTIGRAVITY_IDE_GEMINI_DIR, "antigravity", "global_workflows");
17391
17548
  const ANTIGRAVITY_IDE_PERMISSIONS_DIR = ".antigravity";
17392
17549
  const ANTIGRAVITY_IDE_PERMISSIONS_FILE_NAME = "settings.json";
17393
17550
  //#endregion
@@ -17417,10 +17574,12 @@ var AntigravityIdeSkill = class extends AntigravitySharedSkill {
17417
17574
  * Emits each command as a skill in `.agents/skills/<name>/SKILL.md` (project
17418
17575
  * scope) and `~/.gemini/config/skills/<name>/SKILL.md` (global scope), the
17419
17576
  * IDE's skills directories. Legacy workflows are still imported from
17420
- * `.agents/workflows/` and `~/.gemini/antigravity/global_workflows/`. All
17421
- * body and frontmatter handling is shared with {@link AntigravitySharedCommand}.
17577
+ * `.agents/workflows/` and `~/.gemini/config/workflows/` (the global workflows
17578
+ * directory of Antigravity 2.0), and, in global scope only, from the pre-2.0
17579
+ * `~/.gemini/antigravity/global_workflows/`. All body and frontmatter handling
17580
+ * is shared with {@link AntigravitySharedCommand}.
17422
17581
  */
17423
- var AntigravityIdeCommand = class extends AntigravitySharedCommand {
17582
+ var AntigravityIdeCommand = class AntigravityIdeCommand extends AntigravitySharedCommand {
17424
17583
  static getProjectRelativeDirPath() {
17425
17584
  return ANTIGRAVITY_IDE_COMMANDS_DIR_PATH;
17426
17585
  }
@@ -17430,6 +17589,37 @@ var AntigravityIdeCommand = class extends AntigravitySharedCommand {
17430
17589
  static getSkillsRelativeDirPath({ global }) {
17431
17590
  return AntigravityIdeSkill.getSettablePaths({ global }).relativeDirPath;
17432
17591
  }
17592
+ /**
17593
+ * Import-only, global scope: the workflows left in the pre-2.0
17594
+ * `~/.gemini/antigravity/global_workflows/` directory, where rulesync also
17595
+ * wrote IDE global commands before they were emitted as skills. The
17596
+ * directory is never swept by `generate --delete`; a workflow with the same
17597
+ * file name in `~/.gemini/config/workflows/` takes precedence.
17598
+ *
17599
+ * @see https://antigravity.google/docs/migration/workflows-to-skills
17600
+ */
17601
+ static async loadAdditionalImportFiles({ outputRoot = process.cwd(), global = false, logger } = {}) {
17602
+ if (!global) return [];
17603
+ const filePaths = await findFilesByGlobs("*.md", { cwd: (0, node_path.join)(outputRoot, ANTIGRAVITY_IDE_PRE_V2_GLOBAL_WORKFLOWS_DIR_PATH) });
17604
+ return (await Promise.all(filePaths.map(async (filePath) => {
17605
+ const fileContent = await readFileContent(filePath);
17606
+ const { frontmatter, body } = parseFrontmatter(fileContent, filePath);
17607
+ const result = AntigravityCommandFrontmatterSchema.safeParse(frontmatter);
17608
+ if (!result.success) {
17609
+ logger?.warn(`Skipping ${filePath}: invalid frontmatter (${formatError(result.error)}).`);
17610
+ return;
17611
+ }
17612
+ return new AntigravityIdeCommand({
17613
+ outputRoot,
17614
+ relativeDirPath: ANTIGRAVITY_IDE_PRE_V2_GLOBAL_WORKFLOWS_DIR_PATH,
17615
+ relativeFilePath: (0, node_path.basename)(filePath),
17616
+ frontmatter: result.data,
17617
+ body: body.trim(),
17618
+ fileContent,
17619
+ global
17620
+ });
17621
+ }))).filter((command) => command !== void 0);
17622
+ }
17433
17623
  getToolTargetName() {
17434
17624
  return "antigravity-ide";
17435
17625
  }
@@ -18000,27 +18190,71 @@ const CLINE_HOOKS_GLOBAL_DIR_PATH = (0, node_path.join)("Documents", "Cline", "H
18000
18190
  const CLINE_HOOKS_MANIFEST_FILE_NAME = "rulesync-hooks.json";
18001
18191
  //#endregion
18002
18192
  //#region src/features/commands/cline-command.ts
18193
+ const CLINE_WORKFLOW_FIELD_TYPES = {
18194
+ name: "string",
18195
+ disabled: "boolean",
18196
+ enabled: "boolean"
18197
+ };
18198
+ function sanitizeClineWorkflowFrontmatter({ cline, relativeFilePath, logger }) {
18199
+ if (!isRecord$1(cline)) return {};
18200
+ return Object.fromEntries(Object.entries(cline).filter(([key, value]) => {
18201
+ const expected = Object.hasOwn(CLINE_WORKFLOW_FIELD_TYPES, key) ? CLINE_WORKFLOW_FIELD_TYPES[key] : void 0;
18202
+ if (expected === void 0 || typeof value === expected) return true;
18203
+ warnWithFallback(logger, `Cline commands: dropping cline.${key} from ${relativeFilePath}: Cline rejects the whole workflow unless it is a ${expected}.`);
18204
+ return false;
18205
+ }));
18206
+ }
18207
+ /**
18208
+ * Cline workflows (commands). A workflow may carry YAML frontmatter that Cline
18209
+ * reads: `name` (the slash-command name) and a `disabled` / `enabled` switch
18210
+ * (`parseWorkflowConfigFromMarkdown` in Cline's
18211
+ * `user-instruction-config-loader.ts`). The `cline:` section of a
18212
+ * `.rulesync/commands/*.md` file is written as that frontmatter, and every key
18213
+ * of an imported workflow's frontmatter is lifted back into `cline:`, so
18214
+ * hand-written workflow frontmatter survives an import → generate round trip.
18215
+ * Cline's own UI toggle is kept in Cline's state, not in the file. A workflow
18216
+ * without a `cline:` section is written as a bare body, as before.
18217
+ *
18218
+ * @see https://github.com/cline/cline/blob/main/sdk/packages/core/src/extensions/config/user-instruction-config-loader.ts
18219
+ */
18003
18220
  var ClineCommand = class ClineCommand extends ToolCommand {
18004
18221
  static getSettablePaths({ global } = {}) {
18005
18222
  if (global) return { relativeDirPath: CLINE_COMMANDS_GLOBAL_DIR_PATH };
18006
18223
  return { relativeDirPath: CLINE_COMMANDS_DIR_PATH };
18007
18224
  }
18225
+ /** The workflow's own frontmatter, or an empty object for a bare body. */
18226
+ getClineFrontmatter() {
18227
+ const { frontmatter } = parseFrontmatter(this.getFileContent(), this.getFilePath());
18228
+ return isRecord$1(frontmatter) ? frontmatter : {};
18229
+ }
18008
18230
  toRulesyncCommand() {
18231
+ const cline = this.getClineFrontmatter();
18232
+ const rulesyncFrontmatter = {
18233
+ targets: ["*"],
18234
+ ...Object.keys(cline).length > 0 && { cline }
18235
+ };
18236
+ const body = this.getBody();
18009
18237
  return new RulesyncCommand({
18010
18238
  outputRoot: process.cwd(),
18011
- frontmatter: { targets: ["*"] },
18012
- body: this.getFileContent(),
18239
+ frontmatter: rulesyncFrontmatter,
18240
+ body,
18013
18241
  relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath,
18014
18242
  relativeFilePath: this.relativeFilePath,
18015
- fileContent: this.getFileContent(),
18243
+ fileContent: stringifyFrontmatter(body, rulesyncFrontmatter),
18016
18244
  validate: true
18017
18245
  });
18018
18246
  }
18019
- static fromRulesyncCommand({ outputRoot = process.cwd(), rulesyncCommand, validate = true, global = false }) {
18247
+ static fromRulesyncCommand({ outputRoot = process.cwd(), rulesyncCommand, validate = true, global = false, logger }) {
18020
18248
  const paths = this.getSettablePaths({ global });
18249
+ const cline = sanitizeClineWorkflowFrontmatter({
18250
+ cline: rulesyncCommand.getFrontmatter().cline,
18251
+ relativeFilePath: rulesyncCommand.getRelativeFilePath(),
18252
+ logger
18253
+ });
18254
+ const body = rulesyncCommand.getBody();
18021
18255
  return new ClineCommand({
18022
18256
  outputRoot,
18023
- fileContent: rulesyncCommand.getBody(),
18257
+ fileContent: Object.keys(cline).length > 0 ? stringifyFrontmatter(body, cline) : body,
18024
18258
  relativeDirPath: paths.relativeDirPath,
18025
18259
  relativeFilePath: rulesyncCommand.getRelativeFilePath(),
18026
18260
  validate
@@ -18033,7 +18267,7 @@ var ClineCommand = class ClineCommand extends ToolCommand {
18033
18267
  };
18034
18268
  }
18035
18269
  getBody() {
18036
- return this.getFileContent();
18270
+ return parseFrontmatter(this.getFileContent(), this.getFilePath()).body.trim();
18037
18271
  }
18038
18272
  static isTargetedByRulesyncCommand(rulesyncCommand) {
18039
18273
  return this.isTargetedByRulesyncCommandDefault({
@@ -18044,12 +18278,13 @@ var ClineCommand = class ClineCommand extends ToolCommand {
18044
18278
  static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
18045
18279
  const paths = this.getSettablePaths({ global });
18046
18280
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
18047
- const { body: content } = parseFrontmatter(await readFileContent(filePath), filePath);
18281
+ const { frontmatter, body } = parseFrontmatter(await readFileContent(filePath), filePath);
18282
+ const cline = isRecord$1(frontmatter) ? frontmatter : {};
18048
18283
  return new ClineCommand({
18049
18284
  outputRoot,
18050
18285
  relativeDirPath: paths.relativeDirPath,
18051
18286
  relativeFilePath,
18052
- fileContent: content.trim(),
18287
+ fileContent: Object.keys(cline).length > 0 ? stringifyFrontmatter(body.trim(), cline) : body.trim(),
18053
18288
  validate
18054
18289
  });
18055
18290
  }
@@ -25082,7 +25317,7 @@ function findCommandPaths({ command, scan }) {
25082
25317
  } else {
25083
25318
  if (path !== void 0) toAnchor.add(path);
25084
25319
  state.owner = text.slice(text.lastIndexOf("/") + 1);
25085
- state.expecting = afterCommandWord(state.owner);
25320
+ state.expecting = afterCommandWord$1(state.owner);
25086
25321
  state.prefix = void 0;
25087
25322
  state.shellCode = false;
25088
25323
  }
@@ -25148,7 +25383,7 @@ function readOption({ state, text }) {
25148
25383
  if (SHELL_NAME_PATTERN.test(state.owner) && SHELL_CODE_OPTION.test(text)) state.shellCode = true;
25149
25384
  }
25150
25385
  /** What follows a command word whose basename is `name`. */
25151
- function afterCommandWord(name) {
25386
+ function afterCommandWord$1(name) {
25152
25387
  if (Object.hasOwn(RUN_SUBCOMMANDS, name)) return "subcommand";
25153
25388
  if (PACKAGE_RUNNERS.has(name)) return "wrapped";
25154
25389
  return SCRIPT_RUNNER_PATTERN.test(name) ? "script" : "none";
@@ -33491,6 +33726,19 @@ function isQwencodeLegacyMillisecondTimeout(timeout) {
33491
33726
  return typeof timeout === "number" && Number.isFinite(timeout) && timeout >= QWENCODE_LEGACY_MILLISECOND_TIMEOUT_THRESHOLD;
33492
33727
  }
33493
33728
  /**
33729
+ * Read a `timeout` the way Qwen Code does. Settings files are not type-checked
33730
+ * and Qwen Code coerces a numeric string such as `"60000"` with `Number()`, so
33731
+ * import accepts one too; a value that is not a finite number is dropped, since
33732
+ * Qwen Code falls back to the hook type's default for it.
33733
+ * https://github.com/QwenLM/qwen-code/blob/main/packages/core/src/hooks/hook-timeout.ts
33734
+ */
33735
+ function readQwencodeTimeout(timeout) {
33736
+ if (timeout === void 0) return void 0;
33737
+ if (typeof timeout === "string" && timeout.trim() === "") return void 0;
33738
+ const value = Number(timeout);
33739
+ return Number.isFinite(value) ? value : void 0;
33740
+ }
33741
+ /**
33494
33742
  * Canonical `timeout` is seconds. A command hook value below the threshold is
33495
33743
  * already read as seconds; a larger one can only be expressed in milliseconds.
33496
33744
  */
@@ -33601,7 +33849,7 @@ const QwencodeHookEntrySchema = zod_mini.z.looseObject({
33601
33849
  type: zod_mini.z.optional(zod_mini.z.string()),
33602
33850
  command: zod_mini.z.optional(zod_mini.z.string()),
33603
33851
  url: zod_mini.z.optional(zod_mini.z.string()),
33604
- timeout: zod_mini.z.optional(zod_mini.z.number()),
33852
+ timeout: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.number(), zod_mini.z.string()])),
33605
33853
  name: zod_mini.z.optional(zod_mini.z.string()),
33606
33854
  description: zod_mini.z.optional(zod_mini.z.string()),
33607
33855
  statusMessage: zod_mini.z.optional(zod_mini.z.string()),
@@ -33634,12 +33882,13 @@ function qwencodeHookEntryToCanonical({ hook, sequential, matcher }) {
33634
33882
  const isCommand = hookType === "command";
33635
33883
  const isPrompt = hookType === "prompt";
33636
33884
  const shell = h.shell === "bash" || h.shell === "powershell" ? h.shell : void 0;
33885
+ const timeout = readQwencodeTimeout(h.timeout);
33637
33886
  return {
33638
33887
  type: hookType,
33639
33888
  ...compact({
33640
33889
  command: h.command,
33641
33890
  url: h.url,
33642
- timeout: isCommand ? qwencodeCommandTimeoutToCanonicalTimeout(h.timeout) : h.timeout,
33891
+ timeout: isCommand ? qwencodeCommandTimeoutToCanonicalTimeout(timeout) : timeout,
33643
33892
  name: h.name,
33644
33893
  description: h.description,
33645
33894
  statusMessage: h.statusMessage,
@@ -38141,7 +38390,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
38141
38390
  fileKey: sharedConfigFileKey(basePaths),
38142
38391
  feature: "mcp",
38143
38392
  existingContent: fileContent ?? "",
38144
- patch: { [AMP_MCP_SERVERS_KEY]: filterMcpServers(rulesyncMcp.getMcpServers()) },
38393
+ patch: { [AMP_MCP_SERVERS_KEY]: undefinedWhenEmpty(filterMcpServers(rulesyncMcp.getMcpServers())) },
38145
38394
  filePath: (0, node_path.join)(jsonDir, relativeFilePath),
38146
38395
  logger
38147
38396
  }),
@@ -38439,6 +38688,7 @@ var AugmentcodeMcp = class AugmentcodeMcp extends ToolMcp {
38439
38688
  const paths = this.getSettablePaths({ global });
38440
38689
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
38441
38690
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
38691
+ const mcpServers = rulesyncMcp.getMcpServers();
38442
38692
  return new AugmentcodeMcp({
38443
38693
  outputRoot,
38444
38694
  relativeDirPath: paths.relativeDirPath,
@@ -38447,7 +38697,7 @@ var AugmentcodeMcp = class AugmentcodeMcp extends ToolMcp {
38447
38697
  fileKey: sharedConfigFileKey(paths),
38448
38698
  feature: "mcp",
38449
38699
  existingContent,
38450
- patch: { mcpServers: rulesyncMcp.getMcpServers() },
38700
+ patch: { mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : void 0 },
38451
38701
  filePath,
38452
38702
  logger
38453
38703
  }),
@@ -39003,6 +39253,229 @@ var ClaudecodeMcp = class ClaudecodeMcp extends ToolMcp {
39003
39253
  }
39004
39254
  };
39005
39255
  //#endregion
39256
+ //#region src/features/mcp/claudecode-plugin-mcp.ts
39257
+ /**
39258
+ * The placeholder Claude Code substitutes with the installed plugin's own
39259
+ * directory in an MCP server's `command`, `args` and `env`.
39260
+ * @see https://code.claude.com/docs/en/plugins-reference
39261
+ */
39262
+ const PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
39263
+ const PLUGIN_ROOT_PREFIX = /^(?:\$\{CLAUDE_PLUGIN_ROOT\}|"\$CLAUDE_PLUGIN_ROOT"|\$CLAUDE_PLUGIN_ROOT)\//;
39264
+ /**
39265
+ * Whether a server `command` names a file inside the plugin rather than an
39266
+ * executable found on `PATH`. The command is spawned without a shell, so a
39267
+ * name with a slash is a path, and one without (`npx`, `node`, `uvx`) is a
39268
+ * `PATH` lookup. An absolute path, a home path, one that leaves the plugin
39269
+ * (`../`), and one that already starts with a variable are left as written.
39270
+ */
39271
+ function isPluginRelativeCommand(command) {
39272
+ return /[\\/]/.test(command) && !command.startsWith("$") && !command.startsWith("%") && !command.startsWith("~") && !/^\.\.[\\/]/.test(command) && !node_path.posix.isAbsolute(command) && !node_path.win32.isAbsolute(command);
39273
+ }
39274
+ function anchorToPluginRoot(path) {
39275
+ return `${PLUGIN_ROOT_PLACEHOLDER}/${path.replace(/^\.[\\/]/, "")}`;
39276
+ }
39277
+ /** The runner name of a command word: its basename without a `.exe` suffix. */
39278
+ function runnerName(command) {
39279
+ return (command.split(/[\\/]/).pop() ?? command).replace(/\.exe$/i, "");
39280
+ }
39281
+ /** What follows a command word, as plugin hook commands read it. */
39282
+ function afterCommandWord(name) {
39283
+ if (Object.hasOwn(RUN_SUBCOMMANDS, name)) return "subcommand";
39284
+ if (PACKAGE_RUNNERS.has(name)) return "command";
39285
+ return SCRIPT_RUNNER_PATTERN.test(name) ? "script" : "none";
39286
+ }
39287
+ /**
39288
+ * The positions in `args` that name a file the server runs, read with the
39289
+ * rules plugin hook commands follow (`hook-command-paths.ts`): the script of a
39290
+ * script runner (`node ./dist/server.js`), the command after a run subcommand
39291
+ * or package runner (`uv run ./server.py`, `npx tsx ./x.ts`), and the value of
39292
+ * an option that loads a file (`node -r ./register.js`). Only a position whose
39293
+ * value `isPath` accepts is returned. Everything else is data the server reads
39294
+ * relative to the consumer's project (`server-filesystem ./docs`), and the
39295
+ * scan stops at an option that runs inline code or standard input
39296
+ * (`node -e`, `python3 -m`, `sh -c`, `bash -s`).
39297
+ */
39298
+ function fileArgumentIndices({ command, args, isPath }) {
39299
+ const indices = /* @__PURE__ */ new Set();
39300
+ let owner = runnerName(command);
39301
+ let expecting = afterCommandWord(owner);
39302
+ for (let index = 0; index < args.length && expecting !== "none"; index++) {
39303
+ const arg = args[index];
39304
+ if (typeof arg !== "string") break;
39305
+ if (arg.startsWith("-") && arg !== "-") {
39306
+ if (optionsFor({
39307
+ table: CODE_OPTIONS,
39308
+ name: owner
39309
+ })?.has(arg) || SHELL_NAME_PATTERN.test(owner) && SHELL_CODE_OPTION.test(arg)) break;
39310
+ if (optionsFor({
39311
+ table: VALUE_OPTIONS,
39312
+ name: owner
39313
+ })?.has(arg)) {
39314
+ const value = args[index + 1];
39315
+ if (typeof value === "string" && isPath(value)) indices.add(index + 1);
39316
+ index++;
39317
+ }
39318
+ continue;
39319
+ }
39320
+ if (expecting === "subcommand") {
39321
+ if (RUN_SUBCOMMANDS[owner]?.has(arg)) {
39322
+ expecting = "command";
39323
+ continue;
39324
+ }
39325
+ if (!SCRIPT_RUNNER_PATTERN.test(owner)) break;
39326
+ expecting = "script";
39327
+ }
39328
+ if (isPath(arg)) indices.add(index);
39329
+ if (expecting === "script") break;
39330
+ owner = runnerName(arg);
39331
+ expecting = afterCommandWord(owner);
39332
+ }
39333
+ return indices;
39334
+ }
39335
+ function isDotRelative(value) {
39336
+ return /^\.[\\/]/.test(value);
39337
+ }
39338
+ /** Turn a plugin-root path back into its `./` form, or return it unchanged. */
39339
+ function relativizeFromPluginRoot(value) {
39340
+ return PLUGIN_ROOT_PREFIX.test(value) ? value.replace(PLUGIN_ROOT_PREFIX, "./") : value;
39341
+ }
39342
+ /** The command and arguments of a stdio server, with inline array arguments split out. */
39343
+ function splitCommand(server) {
39344
+ if (Array.isArray(server.command)) {
39345
+ const [first, ...rest] = server.command;
39346
+ return {
39347
+ command: typeof first === "string" ? first : void 0,
39348
+ args: [...rest, ...Array.isArray(server.args) ? server.args : []]
39349
+ };
39350
+ }
39351
+ return {
39352
+ command: typeof server.command === "string" ? server.command : void 0,
39353
+ args: Array.isArray(server.args) ? server.args : []
39354
+ };
39355
+ }
39356
+ /**
39357
+ * Rewrite a server's command and the argument positions that name a file it
39358
+ * runs (see `fileArgumentIndices`), leaving every other argument as written.
39359
+ * Generate and import share this, so a position generate never anchors is
39360
+ * never relativized on import either, and a hand-written
39361
+ * `${CLAUDE_PLUGIN_ROOT}/…` data argument keeps its meaning.
39362
+ */
39363
+ function rewriteServer({ server, rewriteCommand, isPath, rewritePath }) {
39364
+ const { command, args } = splitCommand(server);
39365
+ if (command === void 0) return server;
39366
+ const indices = fileArgumentIndices({
39367
+ command,
39368
+ args,
39369
+ isPath
39370
+ });
39371
+ const rewriteArgs = (values, offset) => values.map((value, index) => indices.has(index + offset) && typeof value === "string" ? rewritePath(value) : value);
39372
+ const next = { ...server };
39373
+ if (Array.isArray(server.command)) {
39374
+ const inlineArgs = server.command.slice(1);
39375
+ next.command = [rewriteCommand(command), ...rewriteArgs(inlineArgs, 0)];
39376
+ if (Array.isArray(server.args)) next.args = rewriteArgs(server.args, inlineArgs.length);
39377
+ } else {
39378
+ next.command = rewriteCommand(command);
39379
+ if (Array.isArray(server.args)) next.args = rewriteArgs(server.args, 0);
39380
+ }
39381
+ return next;
39382
+ }
39383
+ /** Anchor a server's plugin-relative command and the files its runner executes. */
39384
+ function anchorServer(server) {
39385
+ return rewriteServer({
39386
+ server,
39387
+ rewriteCommand: (command) => isPluginRelativeCommand(command) ? anchorToPluginRoot(command) : command,
39388
+ isPath: isDotRelative,
39389
+ rewritePath: anchorToPluginRoot
39390
+ });
39391
+ }
39392
+ /** Turn the plugin-root forms generate writes back into `./`. */
39393
+ function relativizeServer(server) {
39394
+ return rewriteServer({
39395
+ server,
39396
+ rewriteCommand: relativizeFromPluginRoot,
39397
+ isPath: (value) => PLUGIN_ROOT_PREFIX.test(value),
39398
+ rewritePath: relativizeFromPluginRoot
39399
+ });
39400
+ }
39401
+ /** Apply a per-server transform to every server entry that is an object. */
39402
+ function mapServers({ mcpServers, transform }) {
39403
+ if (mcpServers === null || typeof mcpServers !== "object" || Array.isArray(mcpServers)) return mcpServers;
39404
+ return Object.fromEntries(Object.entries(mcpServers).map(([name, server]) => server === null || typeof server !== "object" || Array.isArray(server) ? [name, server] : [name, transform(server)]));
39405
+ }
39406
+ /**
39407
+ * MCP servers bundled in a Claude Code plugin (`.mcp.json` at the plugin
39408
+ * root).
39409
+ *
39410
+ * Claude Code loads a plugin's `.mcp.json` with the consumer's project as the
39411
+ * working directory, so a relative `command` such as `./servers/db-server`
39412
+ * would be looked up in the consumer's repository, where the bundled binary
39413
+ * does not exist. Generate therefore rewrites a relative `command`, and the
39414
+ * `./` script a script runner executes (`node ./dist/server.js`,
39415
+ * `uv run ./server.py`), to `${CLAUDE_PLUGIN_ROOT}/…`, which Claude Code
39416
+ * substitutes with the installed plugin's directory. Other `./` arguments are
39417
+ * data the server reads relative to the consumer's project
39418
+ * (`server-filesystem ./docs`), so they are left as written, the same rule
39419
+ * plugin hook commands follow. Bare executables (`npx`, `node`), absolute paths
39420
+ * and values that already start with a variable pass through. `env` values and
39421
+ * the remote transports' `url` / `headers` are left as written, because
39422
+ * rulesync cannot tell a path from an opaque string there. Import turns the
39423
+ * plugin-root forms in `command` and `args` back into `./`, including the
39424
+ * shell-variable spellings a hand-written file may have copied from a hook
39425
+ * command (which Claude Code does not substitute in MCP config).
39426
+ *
39427
+ * @see https://code.claude.com/docs/en/plugins-reference
39428
+ */
39429
+ var ClaudecodePluginMcp = class ClaudecodePluginMcp extends ClaudecodeMcp {
39430
+ static async fromRulesyncMcp(params) {
39431
+ const base = await super.fromRulesyncMcp(params);
39432
+ const json = base.getJson();
39433
+ const mcpServers = mapServers({
39434
+ mcpServers: json.mcpServers,
39435
+ transform: anchorServer
39436
+ });
39437
+ return new ClaudecodePluginMcp({
39438
+ outputRoot: params.outputRoot ?? process.cwd(),
39439
+ relativeDirPath: base.getRelativeDirPath(),
39440
+ relativeFilePath: base.getRelativeFilePath(),
39441
+ fileContent: JSON.stringify({
39442
+ ...json,
39443
+ mcpServers
39444
+ }, null, 2),
39445
+ validate: params.validate ?? true,
39446
+ global: params.global ?? false
39447
+ });
39448
+ }
39449
+ static async fromFile(params) {
39450
+ const base = await super.fromFile(params);
39451
+ return new ClaudecodePluginMcp({
39452
+ outputRoot: params.outputRoot ?? process.cwd(),
39453
+ relativeDirPath: base.getRelativeDirPath(),
39454
+ relativeFilePath: base.getRelativeFilePath(),
39455
+ fileContent: base.getFileContent(),
39456
+ validate: params.validate ?? true,
39457
+ global: params.global ?? false
39458
+ });
39459
+ }
39460
+ toRulesyncMcp() {
39461
+ const mcpServers = mapServers({
39462
+ mcpServers: this.getJson().mcpServers ?? {},
39463
+ transform: relativizeServer
39464
+ });
39465
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers }, null, 2) });
39466
+ }
39467
+ static forDeletion(params) {
39468
+ return new ClaudecodePluginMcp({
39469
+ outputRoot: params.outputRoot ?? process.cwd(),
39470
+ relativeDirPath: params.relativeDirPath,
39471
+ relativeFilePath: params.relativeFilePath,
39472
+ fileContent: "{}",
39473
+ validate: false,
39474
+ global: params.global ?? false
39475
+ });
39476
+ }
39477
+ };
39478
+ //#endregion
39006
39479
  //#region src/features/mcp/cline-mcp.ts
39007
39480
  const CLINE_GLOBAL_ONLY_MESSAGE = "Cline MCP is global-only; use --global to sync ~/.cline/data/settings/cline_mcp_settings.json";
39008
39481
  function parseClineSettings(fileContent, relativeDirPath, relativeFilePath) {
@@ -40191,6 +40664,35 @@ function convertFromCodexFormat(codexMcp) {
40191
40664
  }
40192
40665
  return result;
40193
40666
  }
40667
+ /**
40668
+ * Keys Codex refuses on each transport. Its `McpServerConfig` deserializer
40669
+ * calls `throw_if_set` for these, and the error fails the whole `config.toml`,
40670
+ * not just the one server, so they are dropped with a warning instead of
40671
+ * written. Checked against `codex-rs/config/src/mcp_types.rs` at rust-v0.162.1.
40672
+ */
40673
+ const CODEX_STDIO_REJECTED_KEYS = [
40674
+ "url",
40675
+ "bearer_token_env_var",
40676
+ "bearer_token",
40677
+ "http_headers_helper",
40678
+ "http_headers",
40679
+ "env_http_headers",
40680
+ "oauth",
40681
+ "oauth_resource",
40682
+ "auth"
40683
+ ];
40684
+ const CODEX_HTTP_REJECTED_KEYS = [
40685
+ "args",
40686
+ "env",
40687
+ "env_vars",
40688
+ "cwd",
40689
+ "bearer_token"
40690
+ ];
40691
+ function dropTransportRejectedKeys({ converted, isStdio, serverName }) {
40692
+ const dropped = (isStdio ? CODEX_STDIO_REJECTED_KEYS : CODEX_HTTP_REJECTED_KEYS).filter((key) => converted[key] !== void 0);
40693
+ for (const key of dropped) delete converted[key];
40694
+ if (dropped.length > 0) warnWithFallback(void 0, `[CodexCliMcp] Dropping ${dropped.map((key) => `'${key}'`).join(", ")} from ${isStdio ? "stdio" : "url-based"} MCP server "${serverName}": Codex rejects ${dropped.length === 1 ? "this key" : "these keys"} on a ${isStdio ? "stdio" : "url-based"} server and would fail to load the whole config.toml.`);
40695
+ }
40194
40696
  function convertToCodexFormat(mcpServers) {
40195
40697
  const result = {};
40196
40698
  const originalNames = /* @__PURE__ */ new Map();
@@ -40233,6 +40735,11 @@ function convertToCodexFormat(mcpServers) {
40233
40735
  converted,
40234
40736
  serverName: name
40235
40737
  });
40738
+ dropTransportRejectedKeys({
40739
+ converted,
40740
+ isStdio,
40741
+ serverName: name
40742
+ });
40236
40743
  const previousName = originalNames.get(codexName);
40237
40744
  if (previousName !== void 0) warnWithFallback(void 0, `Codex MCP server name collision: "${previousName}" and "${name}" both normalize to "${codexName}"; "${name}" (processed last) overwrites "${previousName}".`);
40238
40745
  originalNames.set(codexName, name);
@@ -41712,7 +42219,7 @@ var CrushMcp = class CrushMcp extends ToolMcp {
41712
42219
  fileKey: sharedConfigFileKey(this.getSettablePaths({ global })),
41713
42220
  feature: "mcp",
41714
42221
  existingContent,
41715
- patch: { ["mcp"]: converted },
42222
+ patch: { ["mcp"]: undefinedWhenEmpty(converted) },
41716
42223
  filePath: location.filePath,
41717
42224
  logger
41718
42225
  }),
@@ -43292,13 +43799,23 @@ var GooseMcp = class GooseMcp extends ToolMcp {
43292
43799
  //#region src/features/mcp/grokcli-mcp.ts
43293
43800
  const MAX_REMOVE_EMPTY_ENTRIES_DEPTH = 32;
43294
43801
  /**
43802
+ * Grok keeps per-server MCP tool deny lists in a top-level
43803
+ * `[disabled_mcp_tools]` table (`map<server, string[]>` of unqualified tool
43804
+ * names), not inside `[mcp_servers.<name>]`. Grok reads it from the user
43805
+ * `~/.grok/config.toml` only: a project `.grok/config.toml` contributes just
43806
+ * `[mcp_servers]`, `[plugins]`, `[permission]` and `[mcp] max_output_bytes`.
43807
+ * https://github.com/xai-org/grok-build/blob/2bdd1d6a/crates/codegen/xai-grok-pager/docs/user-guide/26-config-reference.md
43808
+ */
43809
+ const GROK_DISABLED_MCP_TOOLS_KEY = "disabled_mcp_tools";
43810
+ /**
43295
43811
  * Grok Build stores MCP servers in `config.toml` under a `[mcp_servers.<name>]`
43296
43812
  * table. Verified against `grok mcp add` (grok 0.2.54): a stdio server emits
43297
43813
  * `command`, `args`, `enabled = true`, and an `[mcp_servers.<name>.env]` table;
43298
43814
  * a remote server emits `url` and `enabled`. Unlike Codex CLI, Grok uses a
43299
- * literal `env` table (not the `env_vars` passthrough list) and has no
43300
- * per-server tool allow/deny lists, so the only field rename is
43301
- * `disabled` (rulesync) ↔ `enabled = false` (grok).
43815
+ * literal `env` table (not the `env_vars` passthrough list), so the only field
43816
+ * rename is `disabled` (rulesync) ↔ `enabled = false` (grok). `disabledTools`
43817
+ * never lands in the server table: it is lifted into the top-level
43818
+ * `[disabled_mcp_tools]` table by `buildDisabledMcpTools`.
43302
43819
  */
43303
43820
  function convertToGrokFormat(mcpServers) {
43304
43821
  const result = {};
@@ -43307,7 +43824,7 @@ function convertToGrokFormat(mcpServers) {
43307
43824
  if (!isRecord$1(config)) continue;
43308
43825
  const converted = {};
43309
43826
  for (const [key, value] of Object.entries(config)) {
43310
- if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
43827
+ if (PROTOTYPE_POLLUTION_KEYS.has(key) || key === "disabledTools") continue;
43311
43828
  if (key === "disabled") {
43312
43829
  if (value === true) converted["enabled"] = false;
43313
43830
  } else converted[key] = value;
@@ -43331,6 +43848,38 @@ function convertFromGrokFormat(grokMcp) {
43331
43848
  }
43332
43849
  return result;
43333
43850
  }
43851
+ function toToolNameList(value) {
43852
+ if (!Array.isArray(value)) return [];
43853
+ return value.filter((tool) => typeof tool === "string");
43854
+ }
43855
+ /**
43856
+ * Recompute the global `[disabled_mcp_tools]` table: every server Rulesync
43857
+ * declares gets its `disabledTools` (or loses a stale entry when it has none —
43858
+ * Grok itself removes an entry whose list empties), while entries for other
43859
+ * servers — ones toggled in Grok's `/mcps` UI, plugin or compat servers, and
43860
+ * the `__managed_gateway_connectors` sentinel — are carried over untouched.
43861
+ * Returns `undefined` when nothing is left, so the key is retracted.
43862
+ */
43863
+ function buildDisabledMcpTools({ mcpServers, existingContent, filePath }) {
43864
+ const existing = parseSharedConfig({
43865
+ format: "toml",
43866
+ fileContent: existingContent,
43867
+ filePath
43868
+ })[GROK_DISABLED_MCP_TOOLS_KEY];
43869
+ const result = isRecord$1(existing) ? { ...existing } : {};
43870
+ for (const [name, config] of Object.entries(mcpServers)) {
43871
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
43872
+ const tools = toToolNameList(config.disabledTools);
43873
+ if (tools.length > 0) result[name] = tools;
43874
+ else delete result[name];
43875
+ }
43876
+ return Object.keys(result).length > 0 ? result : void 0;
43877
+ }
43878
+ function warnProjectDisabledTools(mcpServers, logger) {
43879
+ const serverNames = Object.entries(mcpServers).filter(([, config]) => isRecord$1(config) && config.disabledTools !== void 0).map(([name]) => name);
43880
+ if (serverNames.length === 0) return;
43881
+ warnWithFallback(logger, `grokcli reads per-server \`disabledTools\` only from the user ~/.grok/config.toml ([disabled_mcp_tools]); dropping it from ${serverNames.join(", ")} in project mode. Generate with --global to apply it.`);
43882
+ }
43334
43883
  var GrokcliMcp = class GrokcliMcp extends ToolMcp {
43335
43884
  toml;
43336
43885
  constructor({ ...rest }) {
@@ -43367,16 +43916,19 @@ var GrokcliMcp = class GrokcliMcp extends ToolMcp {
43367
43916
  relativeDirPath: paths.relativeDirPath,
43368
43917
  relativeFilePath: paths.relativeFilePath,
43369
43918
  fileContent,
43370
- validate
43919
+ validate,
43920
+ global
43371
43921
  });
43372
43922
  }
43373
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
43923
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
43374
43924
  const paths = this.getSettablePaths({ global });
43375
43925
  const configTomlFilePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43376
43926
  const configTomlFileContent = await readFileContentOrNull(configTomlFilePath) ?? "";
43377
- const converted = convertToGrokFormat(rulesyncMcp.getMcpServers());
43927
+ const strippedMcpServers = rulesyncMcp.getMcpServers();
43928
+ const converted = convertToGrokFormat(strippedMcpServers);
43378
43929
  const filteredMcpServers = this.removeEmptyEntries(converted);
43379
- 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`);
43930
+ if (!global) warnProjectDisabledTools(strippedMcpServers, logger);
43931
+ for (const name of Object.keys(converted)) if (!Object.hasOwn(filteredMcpServers, name)) warnWithFallback(logger, `MCP server "${name}" had no non-empty configuration and was dropped from the grok CLI config`);
43380
43932
  return new GrokcliMcp({
43381
43933
  outputRoot,
43382
43934
  relativeDirPath: paths.relativeDirPath,
@@ -43385,14 +43937,31 @@ var GrokcliMcp = class GrokcliMcp extends ToolMcp {
43385
43937
  fileKey: sharedConfigFileKey(paths),
43386
43938
  feature: "mcp",
43387
43939
  existingContent: configTomlFileContent,
43388
- patch: { mcp_servers: filteredMcpServers },
43940
+ patch: {
43941
+ mcp_servers: Object.keys(filteredMcpServers).length > 0 ? filteredMcpServers : void 0,
43942
+ ...global && { [GROK_DISABLED_MCP_TOOLS_KEY]: buildDisabledMcpTools({
43943
+ mcpServers: strippedMcpServers,
43944
+ existingContent: configTomlFileContent,
43945
+ filePath: configTomlFilePath
43946
+ }) }
43947
+ },
43389
43948
  filePath: configTomlFilePath
43390
43949
  }),
43391
- validate
43950
+ validate,
43951
+ global
43392
43952
  });
43393
43953
  }
43394
43954
  toRulesyncMcp() {
43395
43955
  const converted = convertFromGrokFormat(this.toml.mcp_servers ?? {});
43956
+ const disabledMcpTools = this.toml[GROK_DISABLED_MCP_TOOLS_KEY];
43957
+ if (this.global && isRecord$1(disabledMcpTools)) for (const [name, config] of Object.entries(converted)) {
43958
+ if (!Object.hasOwn(disabledMcpTools, name)) continue;
43959
+ const tools = toToolNameList(disabledMcpTools[name]);
43960
+ if (tools.length > 0) converted[name] = {
43961
+ ...config,
43962
+ disabledTools: tools
43963
+ };
43964
+ }
43396
43965
  return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers: converted }, null, 2) });
43397
43966
  }
43398
43967
  validate() {
@@ -46456,7 +47025,7 @@ var PoolMcp = class PoolMcp extends ToolMcp {
46456
47025
  fileKey: sharedConfigFileKey(paths),
46457
47026
  feature: "mcp",
46458
47027
  existingContent,
46459
- patch: { [POOL_MCP_SERVERS_KEY]: converted },
47028
+ patch: { [POOL_MCP_SERVERS_KEY]: undefinedWhenEmpty(converted) },
46460
47029
  filePath
46461
47030
  }),
46462
47031
  validate,
@@ -46586,7 +47155,7 @@ var QoderMcp = class QoderMcp extends ToolMcp {
46586
47155
  fileKey: sharedConfigFileKey(paths),
46587
47156
  feature: "mcp",
46588
47157
  existingContent,
46589
- patch: { mcpServers },
47158
+ patch: { mcpServers: undefinedWhenEmpty(mcpServers) },
46590
47159
  filePath
46591
47160
  }) : JSON.stringify({
46592
47161
  ...parseQoderMcpConfig({
@@ -46965,18 +47534,28 @@ function warnAboutRetiredFields({ name, serverRecord, logger }) {
46965
47534
  * `" Serial "` counts too and is written as authored.
46966
47535
  */
46967
47536
  const REASONIX_CONCURRENCY_VALUES = /* @__PURE__ */ new Set(["serial", "parallel"]);
46968
- /**
46969
- * The type Reasonix decodes each fixed-type scalar passthrough field into
46970
- * (`PluginEntry` in `internal/config/plugin_entry.go`, and
46971
- * `internal/contract/config/plugin_entry.go` on the CLI v2 line).
46972
- */
46973
47537
  const REASONIX_TYPED_PLUGIN_FIELDS = {
47538
+ startup_timeout_seconds: "integer",
47539
+ call_timeout_seconds: "integer",
47540
+ tool_timeout_seconds: "integer table",
46974
47541
  concurrency: "string",
46975
47542
  auto_start: "boolean",
46976
47543
  load: "string",
46977
47544
  oauth_allow_missing_pkce_metadata: "boolean"
46978
47545
  };
46979
47546
  /**
47547
+ * Whether `value` is written as a TOML integer. smol-toml writes a JS number
47548
+ * outside the safe-integer range, or with a fraction, as a TOML float.
47549
+ */
47550
+ function isTomlInteger(value) {
47551
+ return typeof value === "number" && Number.isSafeInteger(value);
47552
+ }
47553
+ function hasExpectedType(value, expected) {
47554
+ if (expected === "integer") return isTomlInteger(value);
47555
+ if (expected === "integer table") return isRecord$1(value) && Object.values(value).every(isTomlInteger);
47556
+ return typeof value === expected;
47557
+ }
47558
+ /**
46980
47559
  * Why a passthrough value cannot be written, or `undefined` when it can (and
46981
47560
  * for every field without a fixed type). A wrong type is the case that matters:
46982
47561
  * a TOML type mismatch makes Reasonix refuse the whole config file. An unknown
@@ -46988,7 +47567,7 @@ const REASONIX_TYPED_PLUGIN_FIELDS = {
46988
47567
  */
46989
47568
  function invalidPassthroughFieldReason(field, value) {
46990
47569
  const expected = REASONIX_TYPED_PLUGIN_FIELDS[field];
46991
- if (expected !== void 0 && typeof value !== expected) return `Reasonix expects a ${expected} and fails to load a config file holding any other type.`;
47570
+ if (expected !== void 0 && !hasExpectedType(value, expected)) return `Reasonix expects ${expected === "integer" ? "an" : "a"} ${expected === "integer table" ? "table of integers" : expected} and fails to load a config file holding any other type.`;
46992
47571
  if (field === "concurrency" && typeof value === "string" && !REASONIX_CONCURRENCY_VALUES.has(value.trim().toLowerCase())) return `Reasonix accepts only "serial" or "parallel" and ignores ${JSON.stringify(value)}, falling back to the server-name default.`;
46993
47572
  }
46994
47573
  /**
@@ -48010,7 +48589,7 @@ var TabnineMcp = class TabnineMcp extends ToolMcp {
48010
48589
  fileKey: sharedConfigFileKey(paths),
48011
48590
  feature: "mcp",
48012
48591
  existingContent,
48013
- patch: { mcpServers: convertToTabnineFormat(rulesyncMcp.getMcpServers(), logger) },
48592
+ patch: { mcpServers: undefinedWhenEmpty(convertToTabnineFormat(rulesyncMcp.getMcpServers(), logger)) },
48014
48593
  filePath
48015
48594
  }),
48016
48595
  validate,
@@ -49331,6 +49910,7 @@ var ZedMcp = class ZedMcp extends ToolMcp {
49331
49910
  const paths = this.getSettablePaths({ global });
49332
49911
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
49333
49912
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
49913
+ const contextServers = convertServersToZedFormat(rulesyncMcp.getMcpServers(), logger);
49334
49914
  return new ZedMcp({
49335
49915
  outputRoot,
49336
49916
  relativeDirPath: paths.relativeDirPath,
@@ -49339,7 +49919,7 @@ var ZedMcp = class ZedMcp extends ToolMcp {
49339
49919
  fileKey: sharedConfigFileKey(paths),
49340
49920
  feature: "mcp",
49341
49921
  existingContent,
49342
- patch: { context_servers: convertServersToZedFormat(rulesyncMcp.getMcpServers(), logger) },
49922
+ patch: { context_servers: Object.keys(contextServers).length > 0 ? contextServers : void 0 },
49343
49923
  filePath
49344
49924
  }),
49345
49925
  validate
@@ -49472,7 +50052,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
49472
50052
  }
49473
50053
  }],
49474
50054
  ["claudecode-plugin", {
49475
- class: ClaudecodeMcp,
50055
+ class: ClaudecodePluginMcp,
49476
50056
  meta: {
49477
50057
  supportsProject: true,
49478
50058
  supportsGlobal: false,
@@ -49648,7 +50228,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
49648
50228
  supportsProject: true,
49649
50229
  supportsGlobal: true,
49650
50230
  supportsEnabledTools: false,
49651
- supportsDisabledTools: false
50231
+ supportsDisabledTools: true
49652
50232
  }
49653
50233
  }],
49654
50234
  ["hermesagent", {
@@ -50814,6 +51394,8 @@ function parsedGlobsIntersect(left, right, budget) {
50814
51394
  * @see https://antigravity.google/docs/permissions?tab=cli
50815
51395
  */
50816
51396
  const REGEX_PREFIX = "regex:";
51397
+ const TRAILING_ANCHOR = /(?<!\\)\$$/;
51398
+ const PLAIN_TARGET_GLOB_CHARACTERS = /[*?[{\\]/;
50817
51399
  const REGEX_METACHARACTERS$1 = /[\\^$.|?*+()[\]{}]/;
50818
51400
  const WHITESPACE_CODES = [
50819
51401
  9,
@@ -50954,6 +51536,7 @@ function replacePosixClasses(source) {
50954
51536
  function isInvalidRegex(source) {
50955
51537
  const unquoted = source.replace(RE2_QUOTED, (match) => match === "\\\\" ? match : "q");
50956
51538
  if (LOOKAROUND.test(unquoted) || hasRefusedEscape(unquoted)) return true;
51539
+ if (hasClassEscapeRangeEnd(unquoted)) return true;
50957
51540
  const withoutPosixClasses = replacePosixClasses(unquoted);
50958
51541
  if (withoutPosixClasses === void 0 || hasRefusedRepeat(withoutPosixClasses)) return true;
50959
51542
  const asJavaScript = withoutPosixClasses.replace(/\(\?P</g, "(?<").replace(/\(\?[imsU]*-?[imsU]*\)/g, "").replace(/\(\?[imsU]*-?[imsU]*:/g, "(?:").replace(/\\A/g, "^").replace(/\\z/g, "$");
@@ -50978,6 +51561,74 @@ function hasRefusedEscape(source) {
50978
51561
  }
50979
51562
  return false;
50980
51563
  }
51564
+ const CLASS_ESCAPE_LETTERS = /[dDsSwWpP]/;
51565
+ /** The length of the escape at `index`, braces (`\x{41}`, `\p{Greek}`) included. */
51566
+ function escapeLength(source, index) {
51567
+ const escaped = source[index + 1] ?? "";
51568
+ if (/[xpP]/.test(escaped) && source[index + 2] === "{") {
51569
+ const close = source.indexOf("}", index + 3);
51570
+ return close === -1 ? source.length - index : close - index + 1;
51571
+ }
51572
+ if (escaped === "p" || escaped === "P") return 3;
51573
+ return escaped === "x" ? 4 : 2;
51574
+ }
51575
+ /** Step past `^` and a leading `]` (a member) after the `[` that ends at `index`. */
51576
+ function openClass(source, start) {
51577
+ const index = source[start] === "^" ? start + 1 : start;
51578
+ return source[index] === "]" ? {
51579
+ index: index + 1,
51580
+ afterCharacter: true
51581
+ } : {
51582
+ index,
51583
+ afterCharacter: false
51584
+ };
51585
+ }
51586
+ /**
51587
+ * Whether a bracket range ends in a class escape (`[a-\d]`, `[--\s]`). Go
51588
+ * reads the end of a range as one character and refuses such a regex, while
51589
+ * JavaScript without the `u` flag reads it as three members. A class escape
51590
+ * that starts the range (`[\d-z]`) is a member followed by `-`, which both
51591
+ * accept. The `u` flag cannot be used instead: it also refuses RE2 spellings
51592
+ * such as `\x{41}` and `\-` outside a class.
51593
+ */
51594
+ function hasClassEscapeRangeEnd(source) {
51595
+ let inClass = false;
51596
+ let afterCharacter = false;
51597
+ let index = 0;
51598
+ while (index < source.length) {
51599
+ const character = source[index] ?? "";
51600
+ if (character === "\\") {
51601
+ afterCharacter = inClass && !CLASS_ESCAPE_LETTERS.test(source[index + 1] ?? "");
51602
+ index += escapeLength(source, index);
51603
+ continue;
51604
+ }
51605
+ if (!inClass) {
51606
+ inClass = character === "[";
51607
+ ({index, afterCharacter} = inClass ? openClass(source, index + 1) : {
51608
+ index: index + 1,
51609
+ afterCharacter: false
51610
+ });
51611
+ continue;
51612
+ }
51613
+ const posixEnd = source.startsWith("[:", index) ? source.indexOf(":]", index + 2) : -1;
51614
+ if (character === "]" || posixEnd !== -1) {
51615
+ inClass = character !== "]";
51616
+ afterCharacter = false;
51617
+ index = posixEnd === -1 ? index + 1 : posixEnd + 2;
51618
+ continue;
51619
+ }
51620
+ if (character === "-" && afterCharacter && source[index + 1] !== "]") {
51621
+ const end = index + 1;
51622
+ if (source[end] === "\\" && CLASS_ESCAPE_LETTERS.test(source[end + 1] ?? "")) return true;
51623
+ afterCharacter = false;
51624
+ index = end + (source[end] === "\\" ? escapeLength(source, end) : 1);
51625
+ continue;
51626
+ }
51627
+ afterCharacter = true;
51628
+ index += 1;
51629
+ }
51630
+ return false;
51631
+ }
50981
51632
  /**
50982
51633
  * Whether a repeat count is over Go's limit. Braces inside a bracket class
50983
51634
  * (`[a{1001}]`) and in `\x{2003}` or `\p{Greek}` are not repeats, so those
@@ -51125,7 +51776,7 @@ function toAntigravityCommandLiteralPrefix(pattern) {
51125
51776
  * the rule a regex.
51126
51777
  */
51127
51778
  function regexWordToGlob(word) {
51128
- const body = word.replace(/^\^/, "").replace(/(?<!\\)\$$/, "");
51779
+ const body = word.replace(/^\^/, "").replace(TRAILING_ANCHOR, "");
51129
51780
  const characters = Array.from(body);
51130
51781
  if (characters.length === 0) return;
51131
51782
  let glob = "";
@@ -51166,12 +51817,61 @@ function regexToGlob(source) {
51166
51817
  function fromAntigravityCommandTarget(target) {
51167
51818
  if (target.startsWith(REGEX_PREFIX)) return regexToGlob(target.slice(6)) ?? target;
51168
51819
  if (target === "*") return target;
51169
- if (/[*?[{\\]/.test(target)) {
51820
+ if (PLAIN_TARGET_GLOB_CHARACTERS.test(target)) {
51170
51821
  const words = splitWords(target);
51171
51822
  return `${REGEX_PREFIX}${words.map((word) => `^${[...word].map(escapeRegexCharacter).join("")}$`).join(" ")}`;
51172
51823
  }
51173
51824
  return `${target} *`;
51174
51825
  }
51826
+ /**
51827
+ * Whether a regex word means the same anchored or not: each end is either
51828
+ * anchored (`^`, an unescaped `$`) or open-ended (`.*`).
51829
+ */
51830
+ function isAnchoredWord(word) {
51831
+ const startAnchored = word.startsWith("^") || word.startsWith(".*");
51832
+ const endAnchored = TRAILING_ANCHOR.test(word) || word.endsWith(".*");
51833
+ return startAnchored && endAnchored;
51834
+ }
51835
+ /**
51836
+ * Explain what importing an Antigravity `command` target may lose, or return
51837
+ * `undefined` when the import is faithful.
51838
+ *
51839
+ * - A `regex:` target with a word that is not anchored is imported as a glob
51840
+ * that assumes anchoring. If Antigravity matches such a word unanchored,
51841
+ * the imported rule is narrower (`regex:rm` also matches `rmdir`).
51842
+ * - A plain target holding characters that are special in a glob is most
51843
+ * likely the verbatim output of an older rulesync (`command(rm -rf *)`).
51844
+ * Antigravity matches it literally, so it imports as a regex that matches a
51845
+ * literal `*` word and is inert in the other tools it is exported to.
51846
+ */
51847
+ function describeAntigravityCommandImportLoss(target) {
51848
+ if (target.startsWith(REGEX_PREFIX)) {
51849
+ const source = target.slice(6);
51850
+ if (splitWords(source).some((word) => !isAnchoredWord(word)) && regexToGlob(source) !== void 0) return {
51851
+ reason: "it has a regex word without `^...$` anchors and is imported as a glob that assumes Antigravity anchors every word, so the imported rule may be narrower than the original",
51852
+ denyConsequence: "The imported deny may block less than it does in Antigravity."
51853
+ };
51854
+ return;
51855
+ }
51856
+ if (target !== "*" && PLAIN_TARGET_GLOB_CHARACTERS.test(target)) return {
51857
+ reason: "it holds characters that are special in a glob (`*`, `?`, `[`, `{` or `\\`), which Antigravity matches literally (likely output of an older rulesync), so it is imported as a literal-match regex that no other tool enforces",
51858
+ denyConsequence: "This deny is NOT enforced by the other tools it is generated for."
51859
+ };
51860
+ }
51861
+ /**
51862
+ * Import one Antigravity `command` target as a canonical bash pattern, warning
51863
+ * when the result may differ from what Antigravity enforces. Shared by the
51864
+ * Antigravity CLI and IDE importers.
51865
+ */
51866
+ function importAntigravityCommandTarget({ target, entry, decision, logger, toolLabel }) {
51867
+ const pattern = fromAntigravityCommandTarget(target);
51868
+ const loss = describeAntigravityCommandImportLoss(target);
51869
+ if (loss !== void 0) {
51870
+ const consequence = decision === "deny" ? ` ${loss.denyConsequence}` : "";
51871
+ logger.warn(`${toolLabel} permissions: importing ${entry} as "${pattern}": ${loss.reason}.${consequence}`);
51872
+ }
51873
+ return pattern;
51874
+ }
51175
51875
  //#endregion
51176
51876
  //#region src/features/permissions/antigravity-targets.ts
51177
51877
  /**
@@ -51202,6 +51902,7 @@ const MATCH_ALL_PATH_GLOBS = /* @__PURE__ */ new Set([
51202
51902
  "**/*"
51203
51903
  ]);
51204
51904
  const DIRECTORY_CONTENTS_SUFFIX = /\/\*\*(?:\/\*)?$/;
51905
+ const HOME_OR_VARIABLE_PATH = /^~(?:\/|$)|^\$[A-Za-z_]|\$\{[A-Za-z_]/;
51205
51906
  const HOSTNAME = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/;
51206
51907
  const DENY_HINTS = {
51207
51908
  read_file: "deny a directory or file path instead",
@@ -51284,6 +51985,10 @@ function buildAntigravityPermissionEntry({ action, category, pattern, decision,
51284
51985
  return;
51285
51986
  }
51286
51987
  if (result.note !== void 0 && decision !== "deny") logger.warn(`${toolLabel} permissions: ${rule}: ${result.note}.`);
51988
+ if (PATH_ACTIONS.has(action) && HOME_OR_VARIABLE_PATH.test(result.target)) {
51989
+ const consequence = decision === "deny" ? "this deny may block nothing" : `${toolLabel} may fall back to its default`;
51990
+ logger.warn(`${toolLabel} permissions: ${rule}: \`~\` and environment variables may not be expanded in Antigravity path targets, so ${action}(${result.target}) may match nothing and ${consequence}; use an absolute path instead.`);
51991
+ }
51287
51992
  return result.target === "*" ? action : `${action}(${result.target})`;
51288
51993
  }
51289
51994
  //#endregion
@@ -51850,9 +52555,9 @@ function convertRulesyncToAntigravityCliPermissions({ config, logger }) {
51850
52555
  }
51851
52556
  }
51852
52557
  return {
51853
- allow,
51854
- ask,
51855
- deny
52558
+ allow: [...new Set(allow)],
52559
+ ask: [...new Set(ask)],
52560
+ deny: [...new Set(deny)]
51856
52561
  };
51857
52562
  }
51858
52563
  /**
@@ -51864,7 +52569,13 @@ function convertAntigravityCliToRulesyncPermissions(params) {
51864
52569
  for (const entry of entries) {
51865
52570
  const { toolName, pattern: target } = parsePermissionEntry$1(entry);
51866
52571
  const canonical = toCanonicalToolName$9(toolName);
51867
- const pattern = toolName === "command" && target !== "*" ? fromAntigravityCommandTarget(target) : target;
52572
+ const pattern = toolName === "command" ? importAntigravityCommandTarget({
52573
+ target,
52574
+ entry,
52575
+ decision: action,
52576
+ logger: moduleLogger$4,
52577
+ toolLabel: "Antigravity CLI"
52578
+ }) : target;
51868
52579
  if (!permission[canonical]) permission[canonical] = {};
51869
52580
  permission[canonical][pattern] = action;
51870
52581
  }
@@ -52068,9 +52779,9 @@ function convertRulesyncToAntigravityIdePermissions({ config, logger }) {
52068
52779
  }
52069
52780
  }
52070
52781
  return {
52071
- allow,
52072
- ask,
52073
- deny
52782
+ allow: [...new Set(allow)],
52783
+ ask: [...new Set(ask)],
52784
+ deny: [...new Set(deny)]
52074
52785
  };
52075
52786
  }
52076
52787
  /**
@@ -52082,7 +52793,13 @@ function convertAntigravityIdeToRulesyncPermissions(params) {
52082
52793
  for (const entry of entries) {
52083
52794
  const { action: ideAction, pattern: target } = parsePermissionEntry(entry);
52084
52795
  const canonical = toCanonicalCategory$2(ideAction);
52085
- const pattern = ideAction === "command" && target !== "*" ? fromAntigravityCommandTarget(target) : target;
52796
+ const pattern = ideAction === "command" ? importAntigravityCommandTarget({
52797
+ target,
52798
+ entry,
52799
+ decision: action,
52800
+ logger: fallbackLogger,
52801
+ toolLabel: "Antigravity IDE"
52802
+ }) : target;
52086
52803
  if (!permission[canonical]) permission[canonical] = {};
52087
52804
  permission[canonical][pattern] = action;
52088
52805
  }
@@ -54064,7 +54781,6 @@ const CLAUDECODE_UNHONORED_KEY_SOURCES = {
54064
54781
  managedSourcesBehavior: "managed settings",
54065
54782
  modelPricing: "managed settings",
54066
54783
  parentSettingsBehavior: "managed settings",
54067
- permissionExplainerEnabled: "~/.claude.json",
54068
54784
  pluginSuggestionMarketplaces: "managed settings",
54069
54785
  pluginTrustMessage: "managed settings",
54070
54786
  prStatusFooterEnabled: "~/.claude.json",
@@ -54073,10 +54789,24 @@ const CLAUDECODE_UNHONORED_KEY_SOURCES = {
54073
54789
  sshHostAllowlist: "managed settings",
54074
54790
  strictKnownMarketplaces: "managed settings",
54075
54791
  strictPluginOnlyCustomization: "managed settings",
54076
- teammateDefaultModel: "~/.claude.json",
54077
54792
  wslInheritsWindowsSettings: "managed settings"
54078
54793
  };
54079
54794
  /**
54795
+ * Top-level settings keys Claude Code has removed, with the version that
54796
+ * removed each. Current versions ignore them wherever they are set, so they
54797
+ * are dropped in **both** scopes, and the warning names the version so the
54798
+ * author knows to delete the key rather than move it to another file.
54799
+ *
54800
+ * Derived from the "Removed in" notes of the settings reference.
54801
+ *
54802
+ * @see https://code.claude.com/docs/en/settings-reference
54803
+ */
54804
+ const CLAUDECODE_REMOVED_KEYS = {
54805
+ permissionExplainerEnabled: "v2.1.257",
54806
+ taskOutputMaxChars: "v2.1.277",
54807
+ teammateDefaultModel: "v2.1.234"
54808
+ };
54809
+ /**
54080
54810
  * Top-level settings keys whose value Claude Code **executes**. The generic
54081
54811
  * passthrough refuses them outright rather than warning: `.rulesync/*` files
54082
54812
  * are shareable — `rulesync fetch` copies a third party's `permissions.jsonc`
@@ -54356,6 +55086,15 @@ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
54356
55086
  allowedMarketplaces: "strictKnownMarketplaces"
54357
55087
  };
54358
55088
  /**
55089
+ * The warning for a key that neither scope can honor — one Claude Code has
55090
+ * removed, or one read only from a file rulesync does not generate — or
55091
+ * `undefined` when the key may be written in at least one scope.
55092
+ */
55093
+ function neverHonoredKeyWarning({ canonicalKey, shown, relativeFilePath }) {
55094
+ if (Object.hasOwn(CLAUDECODE_REMOVED_KEYS, canonicalKey)) return `Claude Code permissions: '${shown}' was removed in Claude Code ${CLAUDECODE_REMOVED_KEYS[canonicalKey]} and has no effect on current versions, so it is not written to ${relativeFilePath}. Delete it from .rulesync/permissions.jsonc, and check ${relativeFilePath} for a stale value an earlier generate may have left there.`;
55095
+ if (Object.hasOwn(CLAUDECODE_UNHONORED_KEY_SOURCES, canonicalKey)) return `Claude Code permissions: '${shown}' is only honored in ${CLAUDECODE_UNHONORED_KEY_SOURCES[canonicalKey]}, which rulesync does not generate, so it is not written to ${relativeFilePath}. Set it in that file by hand, and check ${relativeFilePath} for a stale value an earlier generate may have left there.`;
55096
+ }
55097
+ /**
54359
55098
  * Copy of the authored top-level passthrough with the keys the target file
54360
55099
  * cannot honor removed, warning once per dropped key. Like
54361
55100
  * `stripSandboxPaths`, only the override copy is filtered — a value
@@ -54372,8 +55111,13 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
54372
55111
  logger?.warn(`Claude Code permissions: '${shown}' ${CLAUDECODE_COMMAND_EXECUTING_KEYS[canonicalKey]}, so rulesync does not write it to ${relativeFilePath}. A permissions file is shareable — 'rulesync fetch' copies one into a project — and is not where a reviewer looks for a command to run; author commands in .rulesync/hooks.jsonc, or set this key in ${relativeFilePath} by hand.`);
54373
55112
  continue;
54374
55113
  }
54375
- if (Object.hasOwn(CLAUDECODE_UNHONORED_KEY_SOURCES, canonicalKey)) {
54376
- logger?.warn(`Claude Code permissions: '${shown}' is only honored in ${CLAUDECODE_UNHONORED_KEY_SOURCES[canonicalKey]}, which rulesync does not generate, so it is not written to ${relativeFilePath}. Set it in that file by hand, and check ${relativeFilePath} for a stale value an earlier generate may have left there.`);
55114
+ const neverHonored = neverHonoredKeyWarning({
55115
+ canonicalKey,
55116
+ shown,
55117
+ relativeFilePath
55118
+ });
55119
+ if (neverHonored !== void 0) {
55120
+ logger?.warn(neverHonored);
54377
55121
  continue;
54378
55122
  }
54379
55123
  if (!global && CLAUDECODE_USER_SCOPE_ONLY_KEYS.has(canonicalKey)) {
@@ -62064,8 +62808,10 @@ function mergeKimiCodeToolsSection({ existingContent, patch }) {
62064
62808
  * table. The gateway replaces the owned `permission` key wholesale and rulesync
62065
62809
  * only ever authors `rules`, so a hand-written sibling such as
62066
62810
  * `dangerous_command_guard = false` (Kimi Code 0.40.0) would otherwise be
62067
- * deleted on every generate. Rulesync does not model these keys; they pass
62068
- * through verbatim and `rules` always comes from the patch.
62811
+ * deleted on every generate. Unmodeled keys pass through verbatim; `rules`
62812
+ * always comes from the patch, and so does `dangerous_command_guard` when the
62813
+ * `kimi-code.dangerousCommandGuard` override sets it. Leaving the override out
62814
+ * keeps whatever the file already has.
62069
62815
  *
62070
62816
  * @see https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#permission
62071
62817
  */
@@ -62308,14 +63054,18 @@ var KimiCodePermissions = class KimiCodePermissions extends ToolPermissions {
62308
63054
  static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, logger }) {
62309
63055
  const config = rulesyncPermissions.getJson();
62310
63056
  const defaultPermissionMode = config["kimi-code"]?.defaultPermissionMode;
63057
+ const dangerousCommandGuard = config["kimi-code"]?.dangerousCommandGuard;
62311
63058
  const tools = buildKimiCodeToolsSection(config["kimi-code"]?.tools);
62312
63059
  const document = {
62313
63060
  ...defaultPermissionMode && { default_permission_mode: defaultPermissionMode },
62314
63061
  ...tools && { tools },
62315
- permission: { rules: canonicalToKimiCodeRules({
62316
- config,
62317
- logger
62318
- }) }
63062
+ permission: {
63063
+ ...typeof dangerousCommandGuard === "boolean" && { dangerous_command_guard: dangerousCommandGuard },
63064
+ rules: canonicalToKimiCodeRules({
63065
+ config,
63066
+ logger
63067
+ })
63068
+ }
62319
63069
  };
62320
63070
  let fileContent;
62321
63071
  try {
@@ -62337,11 +63087,14 @@ var KimiCodePermissions = class KimiCodePermissions extends ToolPermissions {
62337
63087
  format: "toml",
62338
63088
  fileContent: this.getFileContent()
62339
63089
  });
62340
- const { permission, nativeRules } = preserveKimiCodeRules((isRecord$1(config.permission) ? config.permission : {}).rules);
63090
+ const permissionConfig = isRecord$1(config.permission) ? config.permission : {};
63091
+ const { permission, nativeRules } = preserveKimiCodeRules(permissionConfig.rules);
62341
63092
  const defaultPermissionMode = config.default_permission_mode;
63093
+ const dangerousCommandGuard = permissionConfig.dangerous_command_guard;
62342
63094
  const tools = buildKimiCodeToolsSection(config.tools);
62343
63095
  const toolOverride = {
62344
63096
  ...defaultPermissionMode === "manual" || defaultPermissionMode === "yolo" || defaultPermissionMode === "auto" ? { defaultPermissionMode } : {},
63097
+ ...typeof dangerousCommandGuard === "boolean" && { dangerousCommandGuard },
62345
63098
  ...nativeRules.length > 0 && { rules: nativeRules },
62346
63099
  ...tools && { tools }
62347
63100
  };
@@ -63223,6 +63976,7 @@ var MimocodePermissions = class extends OpencodePermissions {
63223
63976
  //#endregion
63224
63977
  //#region src/features/permissions/omp-permissions.ts
63225
63978
  const CATCH_ALL_PATTERN$4 = "*";
63979
+ const APPROVAL_MODE_KEY = "approvalMode";
63226
63980
  const ACTION_TO_OMP_APPROVAL = {
63227
63981
  allow: "allow",
63228
63982
  ask: "prompt",
@@ -63278,10 +64032,14 @@ const MANAGED_OMP_TOOLS = /* @__PURE__ */ new Set([
63278
64032
  * commands, and its deny / ask also restrict every other tool's policy (a
63279
64033
  * catch-all one at its own strictness, a pattern-specific one as `prompt`).
63280
64034
  *
64035
+ * The `omp.approvalMode` override writes `tools.approvalMode` (`always-ask`,
64036
+ * `write` or `yolo`); without it the key is left as it is in the file, and on
64037
+ * import a recognized value is read back into the override.
64038
+ *
63281
64039
  * `config.yml` carries every other oh-my-pi setting, so only `tools.approval`
63282
- * (the keys rulesync manages) and `bash.patterns` are rewritten; sibling keys
63283
- * such as `tools.approvalMode` and `bash.enabled` are kept, and the file is
63284
- * never deleted.
64040
+ * (the keys rulesync manages), `bash.patterns` and an authored
64041
+ * `tools.approvalMode` are rewritten; sibling keys such as `bash.enabled` are
64042
+ * kept, and the file is never deleted.
63285
64043
  *
63286
64044
  * @see https://github.com/can1357/oh-my-pi/blob/main/docs/settings.md
63287
64045
  * @see https://github.com/can1357/oh-my-pi/blob/main/docs/approval-mode.md
@@ -63324,16 +64082,19 @@ var OmpPermissions = class OmpPermissions extends ToolPermissions {
63324
64082
  fileContent: existingContent,
63325
64083
  filePath
63326
64084
  });
64085
+ const config = rulesyncPermissions.getJson();
63327
64086
  const { approval, patterns, floor } = convertRulesyncToOmp({
63328
- config: rulesyncPermissions.getJson(),
64087
+ config,
63329
64088
  logger
63330
64089
  });
64090
+ const approvalMode = config.omp?.approvalMode;
63331
64091
  const existingTools = isRecord$1(existing.tools) ? existing.tools : {};
63332
64092
  const nextApproval = {
63333
64093
  ...isRecord$1(existingTools.approval) ? Object.fromEntries(Object.entries(existingTools.approval).filter(([tool]) => !MANAGED_OMP_TOOLS.has(tool) && !Object.hasOwn(approval, tool)).map(([tool, value]) => [tool, raisedApproval(value, floor)])) : {},
63334
64094
  ...approval
63335
64095
  };
63336
64096
  const toolsSiblings = withoutKey(existingTools, "approval");
64097
+ if (approvalMode !== void 0) toolsSiblings[APPROVAL_MODE_KEY] = approvalMode;
63337
64098
  const nextTools = Object.keys(nextApproval).length > 0 ? {
63338
64099
  ...toolsSiblings,
63339
64100
  approval: nextApproval
@@ -63589,7 +64350,14 @@ function convertOmpToRulesync(config) {
63589
64350
  permission[category] = { [CATCH_ALL_PATTERN$4]: stricter(permission[category]?.[CATCH_ALL_PATTERN$4], action) };
63590
64351
  }
63591
64352
  if (Object.keys(bashRules).length > 0) permission.bash = bashRules;
63592
- return { permission };
64353
+ const approvalMode = tools[APPROVAL_MODE_KEY];
64354
+ return {
64355
+ permission,
64356
+ ...isOmpApprovalMode(approvalMode) && { omp: { approvalMode } }
64357
+ };
64358
+ }
64359
+ function isOmpApprovalMode(value) {
64360
+ return OMP_APPROVAL_MODES.some((mode) => mode === value);
63593
64361
  }
63594
64362
  //#endregion
63595
64363
  //#region src/features/permissions/pi-permissions.ts
@@ -64629,6 +65397,13 @@ const QWEN_OVERRIDE_SECURITY_KEYS = [
64629
65397
  "allowPrivateNetworkHooks",
64630
65398
  "allowedInsecureVoiceBaseUrls"
64631
65399
  ];
65400
+ const QWEN_OVERRIDE_SKILLS_KEYS = [
65401
+ "disabled",
65402
+ "defaultDisabled",
65403
+ "enabled",
65404
+ "disabledLevels"
65405
+ ];
65406
+ const QWEN_OVERRIDE_GENERAL_KEYS = ["outputStyle"];
64632
65407
  /**
64633
65408
  * What each rule means for the two directions, so a key's behavior is declared
64634
65409
  * once beside its rule rather than spelled out at every branch.
@@ -64685,77 +65460,125 @@ const QWEN_SCOPE_RULES = {
64685
65460
  globalNote: "This is not a key rulesync models, so it cannot say what Qwen Code does with it — and some settings in these groups are potent (`tools.discoveryCommand` and `tools.callCommand` are spawned as commands when Qwen Code starts). Check what it now says for every project on this machine."
64686
65461
  }
64687
65462
  };
64688
- const QWEN_OVERRIDE_GROUPS = [{
64689
- groupName: "tools",
64690
- overrideKeys: QWEN_OVERRIDE_TOOLS_KEYS,
64691
- scopedKeys: {
64692
- workflowsEnabled: { rule: "workspace-stripped" },
64693
- executionSandbox: {
64694
- rule: "workspace-stripped",
64695
- grants: () => true,
64696
- globalNote: "Qwen Code ignores this key in workspace settings so a repository cannot choose its own sandbox; in the global scope it sets the filesystem and network policy every tool command runs under for every project on this machine (a System value still replaces it, and Qwen Code needs a restart to pick it up)."
64697
- },
64698
- workflowNameOnly: {
64699
- rule: "global-machine-wide",
64700
- projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}. Qwen Code lets a workspace only turn this key on: \`true\` restricts the model to named workflows in this repository, while any other value cannot lift a restriction set in user or system settings.`,
64701
- globalNote: "Qwen Code honors this key in the global scope for every project on this machine, and a repository can only add the restriction, never lift it, so this decides whether the model may run inline or path-based workflow scripts anywhere a workspace does not turn the restriction on itself."
64702
- },
64703
- approvalMode: {
64704
- rule: "global-machine-wide",
64705
- projectNote: autonomyProjectNote
64706
- },
64707
- autoAccept: {
64708
- rule: "global-machine-wide",
64709
- projectNote: autonomyProjectNote
64710
- },
64711
- sandbox: {
64712
- rule: "global-machine-wide",
64713
- projectNote: autonomyProjectNote
64714
- },
64715
- sandboxImage: {
64716
- rule: "global-machine-wide",
64717
- projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so a sandboxed run in this repository executes inside the named image — check that it is one you trust to run your code.`,
64718
- globalNote: "Qwen Code honors this key wherever it is written, so in the global scope every sandboxed run on this machine executes inside the named image — check that it is one you trust to run your code."
64719
- },
64720
- disabled: {
64721
- rule: "global-machine-wide",
64722
- projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, replacing the list in that file rather than adding to it, so a name that is gone is a tool the model can call again in this repository unless another scope still disables it.`,
64723
- globalNote: "Qwen Code honors this key wherever it is written, and the override replaces the list in this file rather than adding to it, so in the global scope this decides which tools stay out of the registry for every project on this machine — dropping a name hands that tool back to the model unless another scope still disables it."
64724
- },
64725
- visible: {
64726
- rule: "global-machine-wide",
64727
- projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so it decides which deferred tools are visible at startup in this repository.`,
64728
- globalNote: "Qwen Code honors this key wherever it is written, so in the global scope this decides which deferred tools are visible at startup for every project on this machine."
64729
- },
64730
- eager: {
64731
- rule: "global-machine-wide",
64732
- projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, replacing the list in that file rather than adding to it, so in this repository every non-exempt eager-by-default tool it does not name is demoted to deferred — still callable, but only after \`tool_search\` loads it, and out of reach for the session if \`tool_search\` is not registered. A name added back is a tool whose schema is in the initial request again.`,
64733
- globalNote: "Qwen Code honors this key wherever it is written, and the override replaces the list in this file rather than adding to it, so in the global scope it decides which eager-by-default tools keep their schemas in the initial request for every project on this machine — the ones it does not name are demoted to deferred, reachable only through `tool_search`, and out of reach entirely for a session where `tool_search` is not registered, while a name it adds back is a tool declared up front again."
64734
- },
64735
- listDirectory: {
64736
- rule: "global-machine-wide",
64737
- projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so it decides whether the built-in \`list_directory\` tool is registered in this repository.`,
64738
- globalNote: "Qwen Code honors this key wherever it is written, so in the global scope this decides whether the built-in `list_directory` tool is registered for every project on this machine."
64739
- },
64740
- todoWrite: {
65463
+ const QWEN_SCOPED_TOOLS_KEYS = {
65464
+ workflowsEnabled: { rule: "workspace-stripped" },
65465
+ executionSandbox: {
65466
+ rule: "workspace-stripped",
65467
+ grants: () => true,
65468
+ globalNote: "Qwen Code ignores this key in workspace settings so a repository cannot choose its own sandbox; in the global scope it sets the filesystem and network policy every tool command runs under for every project on this machine (a System value still replaces it, and Qwen Code needs a restart to pick it up)."
65469
+ },
65470
+ workflowNameOnly: {
65471
+ rule: "global-machine-wide",
65472
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}. Qwen Code lets a workspace only turn this key on: \`true\` restricts the model to named workflows in this repository, while any other value cannot lift a restriction set in user or system settings.`,
65473
+ globalNote: "Qwen Code honors this key in the global scope for every project on this machine, and a repository can only add the restriction, never lift it, so this decides whether the model may run inline or path-based workflow scripts anywhere a workspace does not turn the restriction on itself."
65474
+ },
65475
+ approvalMode: {
65476
+ rule: "global-machine-wide",
65477
+ projectNote: autonomyProjectNote
65478
+ },
65479
+ autoAccept: {
65480
+ rule: "global-machine-wide",
65481
+ projectNote: autonomyProjectNote
65482
+ },
65483
+ sandbox: {
65484
+ rule: "global-machine-wide",
65485
+ projectNote: autonomyProjectNote
65486
+ },
65487
+ sandboxImage: {
65488
+ rule: "global-machine-wide",
65489
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so a sandboxed run in this repository executes inside the named image — check that it is one you trust to run your code.`,
65490
+ globalNote: "Qwen Code honors this key wherever it is written, so in the global scope every sandboxed run on this machine executes inside the named image — check that it is one you trust to run your code."
65491
+ },
65492
+ disabled: {
65493
+ rule: "global-machine-wide",
65494
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, replacing the list in that file rather than adding to it, so a name that is gone is a tool the model can call again in this repository unless another scope still disables it.`,
65495
+ globalNote: "Qwen Code honors this key wherever it is written, and the override replaces the list in this file rather than adding to it, so in the global scope this decides which tools stay out of the registry for every project on this machine — dropping a name hands that tool back to the model unless another scope still disables it."
65496
+ },
65497
+ visible: {
65498
+ rule: "global-machine-wide",
65499
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so it decides which deferred tools are visible at startup in this repository.`,
65500
+ globalNote: "Qwen Code honors this key wherever it is written, so in the global scope this decides which deferred tools are visible at startup for every project on this machine."
65501
+ },
65502
+ eager: {
65503
+ rule: "global-machine-wide",
65504
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, replacing the list in that file rather than adding to it, so in this repository every non-exempt eager-by-default tool it does not name is demoted to deferred — still callable, but only after \`tool_search\` loads it, and out of reach for the session if \`tool_search\` is not registered. A name added back is a tool whose schema is in the initial request again.`,
65505
+ globalNote: "Qwen Code honors this key wherever it is written, and the override replaces the list in this file rather than adding to it, so in the global scope it decides which eager-by-default tools keep their schemas in the initial request for every project on this machine — the ones it does not name are demoted to deferred, reachable only through `tool_search`, and out of reach entirely for a session where `tool_search` is not registered, while a name it adds back is a tool declared up front again."
65506
+ },
65507
+ listDirectory: {
65508
+ rule: "global-machine-wide",
65509
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so it decides whether the built-in \`list_directory\` tool is registered in this repository.`,
65510
+ globalNote: "Qwen Code honors this key wherever it is written, so in the global scope this decides whether the built-in `list_directory` tool is registered for every project on this machine."
65511
+ },
65512
+ todoWrite: {
65513
+ rule: "global-machine-wide",
65514
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so it decides whether the built-in \`todo_write\` tool is registered in this repository.`,
65515
+ globalNote: "Qwen Code honors this key wherever it is written, so in the global scope this decides whether the built-in `todo_write` tool is registered for every project on this machine."
65516
+ }
65517
+ };
65518
+ /**
65519
+ * The notes for a skills list that hides something. Qwen Code unions the list
65520
+ * across scopes, but the override replaces the one in the file it writes, so
65521
+ * the consequence worth naming is what dropping an entry hands back.
65522
+ */
65523
+ function skillsHidingListNotes(hides) {
65524
+ return {
65525
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, replacing the list in that file rather than adding to it, so it decides which ${hides} in this repository — an entry that is gone is one the model can load again unless another scope still names it.`,
65526
+ globalNote: `Qwen Code honors this key wherever it is written and unions it across scopes, but the override replaces the list in this file rather than adding to it, so in the global scope this decides which ${hides} for every project on this machine — an entry that is gone is one the model can load again unless another scope still names it.`
65527
+ };
65528
+ }
65529
+ const QWEN_SCOPED_SKILLS_KEYS = {
65530
+ disabled: {
65531
+ rule: "global-machine-wide",
65532
+ ...skillsHidingListNotes("skills are hidden")
65533
+ },
65534
+ defaultDisabled: {
65535
+ rule: "global-machine-wide",
65536
+ ...skillsHidingListNotes("skills start disabled until `skills.enabled` opts into them")
65537
+ },
65538
+ disabledLevels: {
65539
+ rule: "global-machine-wide",
65540
+ ...skillsHidingListNotes("skill discovery levels (`project`, `user`, `extension`, `bundled`) are skipped")
65541
+ },
65542
+ enabled: {
65543
+ rule: "global-machine-wide",
65544
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so in this repository it opts the model into the skills it names that \`skills.defaultDisabled\` would otherwise keep off in any scope (it cannot override \`skills.disabled\` or a skipped \`skills.disabledLevels\` level). Check it if the override arrived with a repository you cloned.`,
65545
+ globalNote: "Qwen Code honors this key wherever it is written and unions it across scopes, so in the global scope it opts the model into the skills it names that `skills.defaultDisabled` would otherwise keep off, for every project on this machine (it cannot override `skills.disabled` or a skipped `skills.disabledLevels` level)."
65546
+ }
65547
+ };
65548
+ const QWEN_OVERRIDE_GROUPS = [
65549
+ {
65550
+ groupName: "tools",
65551
+ overrideKeys: QWEN_OVERRIDE_TOOLS_KEYS,
65552
+ scopedKeys: QWEN_SCOPED_TOOLS_KEYS
65553
+ },
65554
+ {
65555
+ groupName: "security",
65556
+ overrideKeys: QWEN_OVERRIDE_SECURITY_KEYS,
65557
+ scopedKeys: {
65558
+ allowPrivateNetworkHooks: { rule: "workspace-stripped" },
65559
+ allowedInsecureVoiceBaseUrls: {
65560
+ rule: "workspace-stripped",
65561
+ grants: isNonEmptyArray
65562
+ },
65563
+ allowedHttpHookUrls: { rule: "workspace-non-overriding" },
65564
+ folderTrust: { rule: "user-scope-trust-check" }
65565
+ }
65566
+ },
65567
+ {
65568
+ groupName: "skills",
65569
+ overrideKeys: QWEN_OVERRIDE_SKILLS_KEYS,
65570
+ scopedKeys: QWEN_SCOPED_SKILLS_KEYS
65571
+ },
65572
+ {
65573
+ groupName: "general",
65574
+ overrideKeys: QWEN_OVERRIDE_GENERAL_KEYS,
65575
+ scopedKeys: { outputStyle: {
64741
65576
  rule: "global-machine-wide",
64742
- projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so it decides whether the built-in \`todo_write\` tool is registered in this repository.`,
64743
- globalNote: "Qwen Code honors this key wherever it is written, so in the global scope this decides whether the built-in `todo_write` tool is registered for every project on this machine."
64744
- }
64745
- }
64746
- }, {
64747
- groupName: "security",
64748
- overrideKeys: QWEN_OVERRIDE_SECURITY_KEYS,
64749
- scopedKeys: {
64750
- allowPrivateNetworkHooks: { rule: "workspace-stripped" },
64751
- allowedInsecureVoiceBaseUrls: {
64752
- rule: "workspace-stripped",
64753
- grants: isNonEmptyArray
64754
- },
64755
- allowedHttpHookUrls: { rule: "workspace-non-overriding" },
64756
- folderTrust: { rule: "user-scope-trust-check" }
65577
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so it picks the output style for this repository. A custom style adds the instructions in its style file to the system prompt and can drop the built-in coding guidance (\`keep-coding-instructions\`) — a project style file is read only when the workspace is trusted. Check it if the override arrived with a repository you cloned.`,
65578
+ globalNote: "Qwen Code honors this key wherever it is written, so in the global scope it picks the output style for every project on this machine that does not choose its own. A custom style adds the instructions in its style file to the system prompt and can drop the built-in coding guidance (`keep-coding-instructions`)."
65579
+ } }
64757
65580
  }
64758
- }];
65581
+ ];
64759
65582
  const QWEN_OVERRIDE_PERMISSIONS_KEYS = ["autoMode"];
64760
65583
  const QWEN_SCOPED_PERMISSIONS_KEYS = { autoMode: {
64761
65584
  rule: "global-machine-wide",
@@ -64886,7 +65709,7 @@ function scopeOverrideGroup(overrideGroup, { groupName, scopedKeys, existingGrou
64886
65709
  return scoped;
64887
65710
  }
64888
65711
  /**
64889
- * Build the `tools`/`security` patch groups contributed by the `qwencode`
65712
+ * Build the `tools`/`security`/`skills`/`general` patch groups contributed by the `qwencode`
64890
65713
  * override. Each group is shallow-merged over what `settings.json` already has,
64891
65714
  * after the scope gate has handled the keys Qwen Code honors in one scope only.
64892
65715
  */
@@ -65055,7 +65878,9 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
65055
65878
  });
65056
65879
  const overrideGroups = {
65057
65880
  tools: pickQwenOverrideKeys(settings.tools, QWEN_OVERRIDE_TOOLS_KEYS),
65058
- security: pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS)
65881
+ security: pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS),
65882
+ skills: pickQwenOverrideKeys(settings.skills, QWEN_OVERRIDE_SKILLS_KEYS),
65883
+ general: pickQwenOverrideKeys(settings.general, QWEN_OVERRIDE_GENERAL_KEYS)
65059
65884
  };
65060
65885
  if (!this.global) warnAboutImportedScopedKeys(overrideGroups);
65061
65886
  const overridePermissions = pickQwenOverrideKeys(settings.permissions, QWEN_OVERRIDE_PERMISSIONS_KEYS);
@@ -65358,6 +66183,37 @@ function applyAllowDynamicBash({ permissions, authored }) {
65358
66183
  reason: "lets an Allow fallback, Auto included, run the nested and indirect Bash that otherwise needs a human or an exact-literal grant — command and process substitution, a dynamic command name, 'eval', 'source', 'sh -c' and their kind"
65359
66184
  }];
65360
66185
  }
66186
+ /** The writer fallbacks `[permissions] mode` accepts. */
66187
+ const REASONIX_PERMISSION_MODES = /* @__PURE__ */ new Set([
66188
+ "ask",
66189
+ "allow",
66190
+ "deny"
66191
+ ]);
66192
+ /**
66193
+ * Writes the override's `mode` into `[permissions]`. Reasonix honors the key
66194
+ * only in the user config and ignores a project value as user-scope only, so a
66195
+ * project-scope generate skips it with a warning and leaves whatever the file
66196
+ * had. A global `allow` is reported when it is newly written: it lets every
66197
+ * writer call that no rule matches run without a prompt.
66198
+ *
66199
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
66200
+ */
66201
+ function applyPermissionsMode({ permissions, authored, global, relativeFilePath, logger }) {
66202
+ if (authored === void 0) return [];
66203
+ if (!global) {
66204
+ logger?.warn(`Reasonix permissions: skipping reasonix.mode = ${quoteValueForWarning(authored)} for the project-scoped ${relativeFilePath}; Reasonix honors [permissions] mode only in the user config and ignores a project value, so generate it with --global instead.`);
66205
+ return [];
66206
+ }
66207
+ const previous = permissions.mode;
66208
+ permissions.mode = authored;
66209
+ const normalize = (value) => typeof value === "string" ? value.trim().toLowerCase() : void 0;
66210
+ const mode = normalize(authored);
66211
+ if (mode === "ask" || mode === "deny" || normalize(previous) === mode) return [];
66212
+ return [{
66213
+ label: "permissions.mode",
66214
+ reason: "lets every writer call that no rule matches run without a prompt"
66215
+ }];
66216
+ }
65361
66217
  function parseReasonixConfig(fileContent) {
65362
66218
  const parsed = smol_toml.parse(fileContent || smol_toml.stringify({}));
65363
66219
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -65408,7 +66264,8 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
65408
66264
  relativeDirPath: paths.relativeDirPath,
65409
66265
  relativeFilePath: paths.relativeFilePath,
65410
66266
  fileContent,
65411
- validate
66267
+ validate,
66268
+ global
65412
66269
  });
65413
66270
  }
65414
66271
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
@@ -65463,6 +66320,13 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
65463
66320
  permissions: mergedPermissions,
65464
66321
  authored: override?.allowDynamicBash
65465
66322
  });
66323
+ trustAffecting.push(...applyPermissionsMode({
66324
+ permissions: mergedPermissions,
66325
+ authored: override?.mode,
66326
+ global,
66327
+ relativeFilePath: relativeFilePathForLog,
66328
+ logger
66329
+ }));
65466
66330
  const patch = { permissions: mergedPermissions };
65467
66331
  if (override?.sandbox !== void 0) {
65468
66332
  const mergedSandbox = {
@@ -65504,7 +66368,8 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
65504
66368
  patch,
65505
66369
  filePath
65506
66370
  }),
65507
- validate
66371
+ validate,
66372
+ global
65508
66373
  });
65509
66374
  }
65510
66375
  toRulesyncPermissions() {
@@ -65529,6 +66394,7 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
65529
66394
  const reasonixOverride = {};
65530
66395
  const allowDynamicBash = permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY];
65531
66396
  if (typeof allowDynamicBash === "boolean") reasonixOverride.allowDynamicBash = allowDynamicBash;
66397
+ if (this.global && typeof permissions.mode === "string" && REASONIX_PERMISSION_MODES.has(permissions.mode)) reasonixOverride.mode = permissions.mode;
65532
66398
  if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
65533
66399
  if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
65534
66400
  if (allowSplit.exact.length > 0) reasonixOverride.rawAllow = allowSplit.exact;
@@ -67094,10 +67960,15 @@ var TaktPermissions = class TaktPermissions extends ToolPermissions {
67094
67960
  outputRoot,
67095
67961
  logger
67096
67962
  });
67963
+ const inheritedProvider = global ? void 0 : await readGlobalTaktProvider({
67964
+ projectFilePath: filePath,
67965
+ logger
67966
+ });
67097
67967
  const rulesyncJson = rulesyncPermissions.getJson();
67098
67968
  const provider = resolveActiveProvider({
67099
67969
  config,
67100
- runtime: runtime.active ? runtime.config : void 0
67970
+ runtime: runtime.active ? runtime.config : void 0,
67971
+ inheritedProvider
67101
67972
  });
67102
67973
  const mode = deriveTaktPermissionMode(rulesyncJson);
67103
67974
  const override = isPlainObject$1(rulesyncJson.takt) ? rulesyncJson.takt : void 0;
@@ -67207,10 +68078,12 @@ var TaktPermissions = class TaktPermissions extends ToolPermissions {
67207
68078
  * `provider_profiles` (unless that key is the pre-0.68.0 `claude` fallback),
67208
68079
  * else the `claude-sdk` default (Takt 0.68.0+).
67209
68080
  */
67210
- function resolveActiveProvider({ config, runtime }) {
68081
+ function resolveActiveProvider({ config, runtime, inheritedProvider }) {
67211
68082
  const fromRuntime = runtime === void 0 ? void 0 : resolveRuntimeProvider(runtime);
67212
68083
  if (fromRuntime !== void 0) return fromRuntime;
67213
- if (typeof config[TAKT_PROVIDER_KEY] === "string" && config[TAKT_PROVIDER_KEY].trim() !== "") return config[TAKT_PROVIDER_KEY];
68084
+ const local = readProviderName(config);
68085
+ if (local !== void 0) return local;
68086
+ if (inheritedProvider !== void 0) return inheritedProvider;
67214
68087
  const profiles = config[TAKT_PROVIDER_PROFILES_KEY];
67215
68088
  if (isPlainObject$1(profiles)) {
67216
68089
  const keys = Object.keys(profiles);
@@ -67218,6 +68091,43 @@ function resolveActiveProvider({ config, runtime }) {
67218
68091
  }
67219
68092
  return TAKT_DEFAULT_PROVIDER;
67220
68093
  }
68094
+ /**
68095
+ * The top-level `provider:` of a Takt `config.yaml`, when it names one: either
68096
+ * the plain name or the block form `provider: { type: <name>, ... }` Takt also
68097
+ * accepts (`ConfigProviderReference`).
68098
+ */
68099
+ function readProviderName(config) {
68100
+ const raw = config[TAKT_PROVIDER_KEY];
68101
+ const provider = isPlainObject$1(raw) ? raw.type : raw;
68102
+ return typeof provider === "string" && provider.trim() !== "" ? provider : void 0;
68103
+ }
68104
+ /**
68105
+ * The top-level `provider:` of the global `~/.takt/config.yaml`, read for a
68106
+ * project-scope generate. An unresolvable home directory, a missing or
68107
+ * unparsable file, or one that names no provider all mean "nothing inherited".
68108
+ */
68109
+ async function readGlobalTaktProvider({ projectFilePath, logger }) {
68110
+ let globalPath;
68111
+ try {
68112
+ globalPath = (0, node_path.join)(getHomeDirectory(), TAKT_DIR, TAKT_CONFIG_FILE_NAME);
68113
+ } catch {
68114
+ return;
68115
+ }
68116
+ if (globalPath === projectFilePath) return;
68117
+ try {
68118
+ const content = await readFileContentOrNull(globalPath);
68119
+ if (content === null) return;
68120
+ return readProviderName(parseSharedConfig({
68121
+ format: "yaml",
68122
+ fileContent: content,
68123
+ filePath: globalPath,
68124
+ invalidRootPolicy: "error"
68125
+ }));
68126
+ } catch (error) {
68127
+ logger?.warn(`Takt permissions: could not read the provider from ${globalPath} (${formatError(error)}); falling back to the project config alone.`);
68128
+ return;
68129
+ }
68130
+ }
67221
68131
  /** A plain object carrying at least one entry (Takt's "assignment" test). */
67222
68132
  function hasEntries(value) {
67223
68133
  return isPlainObject$1(value) && Object.keys(value).length > 0;
@@ -69248,6 +70158,9 @@ const WARP_OVERRIDE_KEYS = [
69248
70158
  "agent_mode_execute_readonly_commands"
69249
70159
  ];
69250
70160
  const WARP_EXECUTION_PROFILE_OVERRIDE_KEY = "execution_profile";
70161
+ const WARP_AUTO_APPROVE_BYPASS_KEY = "auto_approve_bypasses_command_denylist";
70162
+ const WARP_AGENT_KEY = "warp_agent";
70163
+ const WARP_AGENT_OTHER_KEY = "other";
69251
70164
  const WARP_EXECUTION_PROFILE_KEYS = [
69252
70165
  "read_files",
69253
70166
  "apply_code_diffs",
@@ -69396,7 +70309,7 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
69396
70309
  const override = config.warp;
69397
70310
  const executionProfileOverride = isRecord$1(override) && isRecord$1(override["execution_profile"]) ? override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY] : void 0;
69398
70311
  if (isRecord$1(override)) {
69399
- const { [WARP_EXECUTION_PROFILE_OVERRIDE_KEY]: _executionProfile, ...legacyOverride } = override;
70312
+ const { [WARP_EXECUTION_PROFILE_OVERRIDE_KEY]: _executionProfile, [WARP_AUTO_APPROVE_BYPASS_KEY]: _autoApproveBypass, ...legacyOverride } = override;
69400
70313
  Object.assign(profiles, legacyOverride);
69401
70314
  }
69402
70315
  const mergedAllow = (0, es_toolkit.uniq)(allow.toSorted());
@@ -69406,9 +70319,15 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
69406
70319
  if (mergedDeny.length > 0) profiles[DENYLIST_KEY] = mergedDeny;
69407
70320
  else delete profiles[DENYLIST_KEY];
69408
70321
  agents.profiles = profiles;
70322
+ applyAutoApproveBypassOverride({
70323
+ agents,
70324
+ override
70325
+ });
69409
70326
  warnAboutDenylistReplacement({
69410
70327
  toolLabel: "Warp",
70328
+ overrideKey: "warp",
69411
70329
  denyCount: mergedDeny.length,
70330
+ autoApproveBypassesDenylist: readAutoApproveBypass(agents),
69412
70331
  logger
69413
70332
  });
69414
70333
  mergeIntoDefaultExecutionProfile({
@@ -69447,6 +70366,8 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
69447
70366
  for (const key of WARP_OVERRIDE_KEYS) if (profiles[key] !== void 0) warpOverride[key] = profiles[key];
69448
70367
  const executionProfileOverride = defaultProfile ? liftExecutionProfileOverride(defaultProfile) : void 0;
69449
70368
  if (executionProfileOverride) warpOverride[WARP_EXECUTION_PROFILE_OVERRIDE_KEY] = executionProfileOverride;
70369
+ const autoApproveBypass = readAutoApproveBypass(agents);
70370
+ if (autoApproveBypass !== void 0) warpOverride[WARP_AUTO_APPROVE_BYPASS_KEY] = autoApproveBypass;
69450
70371
  const result = { ...config };
69451
70372
  if (Object.keys(warpOverride).length > 0) result.warp = warpOverride;
69452
70373
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
@@ -69470,12 +70391,40 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
69470
70391
  };
69471
70392
  /**
69472
70393
  * Writing `command_denylist` at all replaces Warp's built-in default denylist,
69473
- * so any non-empty list rulesync writes deserves a heads-up.
70394
+ * so any non-empty list rulesync writes deserves a heads-up. Unless
70395
+ * `auto_approve_bypasses_command_denylist` is `false`, auto-approve also runs
70396
+ * commands that match the list, so the warning says so.
69474
70397
  * https://docs.warp.dev/agents/cli/permissions-and-profiles/
69475
70398
  */
69476
- function warnAboutDenylistReplacement({ toolLabel, denyCount, logger }) {
70399
+ function warnAboutDenylistReplacement({ toolLabel, overrideKey, denyCount, autoApproveBypassesDenylist, logger }) {
69477
70400
  if (denyCount === 0 || !logger) return;
69478
- logger.warn(`${toolLabel}'s command_denylist replaces its built-in default denylist, which covers rm, curl, wget, eval, ssh, shells, and other risky command patterns. The ${denyCount} deny rule(s) from .rulesync/permissions.jsonc are now the whole denylist — add equivalents for the built-in patterns you want to keep.`);
70401
+ const bypassNote = autoApproveBypassesDenylist === false ? "" : ` Auto-approve also runs commands that match the denylist unless ${overrideKey}.${WARP_AUTO_APPROVE_BYPASS_KEY} is set to false.`;
70402
+ logger.warn(`${toolLabel}'s command_denylist replaces its built-in default denylist, which covers rm, curl, wget, eval, ssh, shells, and other risky command patterns. The ${denyCount} deny rule(s) from .rulesync/permissions.jsonc are now the whole denylist — add equivalents for the built-in patterns you want to keep.${bypassNote}`);
70403
+ }
70404
+ /**
70405
+ * Write the override's `auto_approve_bypasses_command_denylist` into
70406
+ * `[agents.warp_agent.other]`, keeping every sibling key of `agents.warp_agent`
70407
+ * and of its `other` table. Nothing is written when the override omits it.
70408
+ */
70409
+ function applyAutoApproveBypassOverride({ agents, override }) {
70410
+ if (!isRecord$1(override)) return;
70411
+ const value = override[WARP_AUTO_APPROVE_BYPASS_KEY];
70412
+ if (typeof value !== "boolean") return;
70413
+ const warpAgent = isRecord$1(agents[WARP_AGENT_KEY]) ? { ...agents[WARP_AGENT_KEY] } : {};
70414
+ const other = isRecord$1(warpAgent[WARP_AGENT_OTHER_KEY]) ? { ...warpAgent[WARP_AGENT_OTHER_KEY] } : {};
70415
+ other[WARP_AUTO_APPROVE_BYPASS_KEY] = value;
70416
+ warpAgent[WARP_AGENT_OTHER_KEY] = other;
70417
+ agents[WARP_AGENT_KEY] = warpAgent;
70418
+ }
70419
+ /**
70420
+ * Read `[agents.warp_agent.other] auto_approve_bypasses_command_denylist`, or
70421
+ * `undefined` when it is unset (Warp then defaults to `true`).
70422
+ */
70423
+ function readAutoApproveBypass(agents) {
70424
+ const warpAgent = agents[WARP_AGENT_KEY];
70425
+ const other = isRecord$1(warpAgent) ? warpAgent[WARP_AGENT_OTHER_KEY] : void 0;
70426
+ const value = isRecord$1(other) ? other[WARP_AUTO_APPROVE_BYPASS_KEY] : void 0;
70427
+ return typeof value === "boolean" ? value : void 0;
69479
70428
  }
69480
70429
  /**
69481
70430
  * On migrated installs the legacy `[agents.profiles]` keys are inert: runtime
@@ -69829,6 +70778,9 @@ const WARPCLI_GLOBAL_ONLY_MESSAGE = "Warp Agent CLI permissions are global-only;
69829
70778
  * (`read_files`, `apply_code_diffs`, `run_agents`, ...), merged first so the
69830
70779
  * rulesync-owned command lists always win.
69831
70780
  *
70781
+ * The `warpcli.auto_approve_bypasses_command_denylist` override is written to
70782
+ * the sibling `[agents.warp_agent.other]` table.
70783
+ *
69832
70784
  * Unlike the `warp` target, the CLI never read the app's legacy
69833
70785
  * `[agents.profiles]` keys, so none are written, and there is no settings
69834
70786
  * migration to wait for: the CLI's settings file "is created the first time you
@@ -69895,11 +70847,6 @@ var WarpcliPermissions = class WarpcliPermissions extends ToolPermissions {
69895
70847
  });
69896
70848
  const mergedAllow = (0, es_toolkit.uniq)(allow.toSorted());
69897
70849
  const mergedDeny = (0, es_toolkit.uniq)(deny.toSorted());
69898
- warnAboutDenylistReplacement({
69899
- toolLabel: "Warp Agent CLI",
69900
- denyCount: mergedDeny.length,
69901
- logger
69902
- });
69903
70850
  const override = config.warpcli;
69904
70851
  const executionProfileOverride = isRecord$1(override) && isRecord$1(override["execution_profile"]) ? override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY] : void 0;
69905
70852
  const agents = isRecord$1(settings.agents) ? { ...settings.agents } : {};
@@ -69909,7 +70856,18 @@ var WarpcliPermissions = class WarpcliPermissions extends ToolPermissions {
69909
70856
  mergedDeny,
69910
70857
  executionProfileOverride
69911
70858
  });
70859
+ applyAutoApproveBypassOverride({
70860
+ agents,
70861
+ override
70862
+ });
69912
70863
  settings.agents = agents;
70864
+ warnAboutDenylistReplacement({
70865
+ toolLabel: "Warp Agent CLI",
70866
+ overrideKey: "warpcli",
70867
+ denyCount: mergedDeny.length,
70868
+ autoApproveBypassesDenylist: readAutoApproveBypass(agents),
70869
+ logger
70870
+ });
69913
70871
  return new WarpcliPermissions({
69914
70872
  outputRoot,
69915
70873
  relativeDirPath: paths.relativeDirPath,
@@ -69936,8 +70894,12 @@ var WarpcliPermissions = class WarpcliPermissions extends ToolPermissions {
69936
70894
  deny: isStringArray$2(denylist) ? denylist : []
69937
70895
  });
69938
70896
  const executionProfileOverride = liftExecutionProfileOverride(defaultProfile);
70897
+ const warpcliOverride = {};
70898
+ if (executionProfileOverride) warpcliOverride[WARP_EXECUTION_PROFILE_OVERRIDE_KEY] = executionProfileOverride;
70899
+ const autoApproveBypass = readAutoApproveBypass(agents);
70900
+ if (autoApproveBypass !== void 0) warpcliOverride[WARP_AUTO_APPROVE_BYPASS_KEY] = autoApproveBypass;
69939
70901
  const result = { ...config };
69940
- if (executionProfileOverride) result.warpcli = { [WARP_EXECUTION_PROFILE_OVERRIDE_KEY]: executionProfileOverride };
70902
+ if (Object.keys(warpcliOverride).length > 0) result.warpcli = warpcliOverride;
69941
70903
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
69942
70904
  }
69943
70905
  validate() {
@@ -82011,9 +82973,8 @@ var CodebuddySubagent = class CodebuddySubagent extends ToolSubagent {
82011
82973
  * Keys of the rulesync `codewhale` section copied into the profile. Codewhale
82012
82974
  * parses agent profiles with `deny_unknown_fields`, so an unknown key would
82013
82975
  * make the whole profile fail to load; anything else in the section is dropped
82014
- * with a warning. `[tools]` and `[permissions]` are left out on purpose:
82015
- * Codewhale only accepts values there that narrow the defaults, and rejects a
82016
- * profile that tries to widen them.
82976
+ * with a warning. The `[tools]` and `[permissions]` tables are handled
82977
+ * separately (see `CODEWHALE_NARROWING_TABLES`).
82017
82978
  */
82018
82979
  const CODEWHALE_SECTION_KEYS = [
82019
82980
  "role_hint",
@@ -82034,6 +82995,48 @@ const CODEWHALE_SECTION_KEY_ALIASES = {
82034
82995
  reasoning_effort: ["thinking", "reasoning"]
82035
82996
  };
82036
82997
  const CODEWHALE_SECTION_ALIAS_TO_KEY = new Map(Object.entries(CODEWHALE_SECTION_KEY_ALIASES).flatMap(([key, aliases]) => (aliases ?? []).map((alias) => [alias, key])));
82998
+ /**
82999
+ * The `[tools]` and `[permissions]` tables of an agent profile, with the only
83000
+ * values Codewhale accepts in each key. Codewhale rejects a profile that tries
83001
+ * to widen access through them (`reject_permission_expansion` in
83002
+ * `fleet/profile.rs`), and both tables deny unknown fields, so any other key
83003
+ * or value is dropped with a warning.
83004
+ */
83005
+ const CODEWHALE_NARROWING_TABLES = {
83006
+ tools: { posture: (value) => typeof value === "string" && [
83007
+ "read-only",
83008
+ "readonly",
83009
+ "read_only"
83010
+ ].includes(value) },
83011
+ permissions: {
83012
+ allow_shell: (value) => value === false,
83013
+ trust: (value) => value === false,
83014
+ approval_required: (value) => value === true
83015
+ }
83016
+ };
83017
+ /**
83018
+ * Keeps the narrowing-only entries of a `[tools]` / `[permissions]` table and
83019
+ * reports every other entry by its dotted path. A non-table value is dropped
83020
+ * as a whole.
83021
+ */
83022
+ function filterNarrowingTable(tableName, value) {
83023
+ const allowed = CODEWHALE_NARROWING_TABLES[tableName] ?? {};
83024
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {
83025
+ kept: void 0,
83026
+ dropped: [tableName]
83027
+ };
83028
+ const kept = {};
83029
+ const dropped = [];
83030
+ for (const [key, entry] of Object.entries(value)) {
83031
+ const normalized = typeof entry === "string" ? entry.trim() : entry;
83032
+ if (Object.hasOwn(allowed, key) && allowed[key]?.(normalized)) kept[key] = normalized;
83033
+ else dropped.push(`${tableName}.${key}`);
83034
+ }
83035
+ return {
83036
+ kept: Object.keys(kept).length > 0 ? kept : void 0,
83037
+ dropped
83038
+ };
83039
+ }
82037
83040
  const CodewhaleSubagentTomlSchema = zod_mini.z.looseObject({
82038
83041
  id: zod_mini.z.optional(zod_mini.z.string()),
82039
83042
  name: zod_mini.z.optional(zod_mini.z.string()),
@@ -82060,7 +83063,8 @@ function stringifyCodewhaleSubagentToml(fields, instructions) {
82060
83063
  *
82061
83064
  * Generation writes `id` (the file stem), `display_name` (the rulesync name),
82062
83065
  * `description`, and the body as `[instructions] text`, plus the allowlisted
82063
- * keys of the rulesync `codewhale` section.
83066
+ * keys of the rulesync `codewhale` section and its narrowing-only `[tools]` /
83067
+ * `[permissions]` entries.
82064
83068
  *
82065
83069
  * @see https://github.com/Hmbown/Codewhale/blob/main/docs/SUBAGENTS.md
82066
83070
  * @see https://github.com/Hmbown/Codewhale/blob/main/crates/tui/src/fleet/profile.rs
@@ -82095,6 +83099,10 @@ var CodewhaleSubagent = class CodewhaleSubagent extends ToolSubagent {
82095
83099
  const spelling = [key, ...CODEWHALE_SECTION_KEY_ALIASES[key] ?? []].find((candidate) => record[candidate] !== void 0);
82096
83100
  if (spelling !== void 0) codewhaleSection[key] = record[spelling];
82097
83101
  }
83102
+ for (const tableName of Object.keys(CODEWHALE_NARROWING_TABLES)) if (record[tableName] !== void 0) {
83103
+ const { kept } = filterNarrowingTable(tableName, record[tableName]);
83104
+ if (kept !== void 0) codewhaleSection[tableName] = kept;
83105
+ }
82098
83106
  return new RulesyncSubagent({
82099
83107
  outputRoot: ".",
82100
83108
  frontmatter: {
@@ -82117,6 +83125,12 @@ var CodewhaleSubagent = class CodewhaleSubagent extends ToolSubagent {
82117
83125
  const droppedKeys = [];
82118
83126
  const sectionSpellings = /* @__PURE__ */ new Map();
82119
83127
  for (const [rawKey, value] of Object.entries(rawSection)) {
83128
+ if (Object.hasOwn(CODEWHALE_NARROWING_TABLES, rawKey)) {
83129
+ const { kept, dropped } = filterNarrowingTable(rawKey, value);
83130
+ if (kept !== void 0) sectionFields[rawKey] = kept;
83131
+ droppedKeys.push(...dropped);
83132
+ continue;
83133
+ }
82120
83134
  const key = CODEWHALE_SECTION_ALIAS_TO_KEY.get(rawKey) ?? rawKey;
82121
83135
  if (CODEWHALE_SECTION_KEYS.includes(key) && typeof value === "string") {
82122
83136
  if (key === rawKey || sectionFields[key] === void 0) {
@@ -82126,7 +83140,7 @@ var CodewhaleSubagent = class CodewhaleSubagent extends ToolSubagent {
82126
83140
  } else droppedKeys.push(rawKey);
82127
83141
  } else droppedKeys.push(rawKey);
82128
83142
  }
82129
- if (droppedKeys.length > 0) logger?.warn(`Dropping unsupported or duplicate codewhale subagent keys in ${rulesyncSubagent.getRelativeFilePath()}: ${droppedKeys.join(", ")}. Codewhale rejects agent profiles with unknown fields, non-string values there, or two spellings of one key; supported keys are ${CODEWHALE_SECTION_KEYS.join(", ")}, each a string (the canonical spelling wins over an alias).`);
83143
+ if (droppedKeys.length > 0) logger?.warn(`Dropping unsupported or duplicate codewhale subagent keys in ${rulesyncSubagent.getRelativeFilePath()}: ${droppedKeys.join(", ")}. Codewhale rejects agent profiles with unknown fields, non-string values there, [tools] / [permissions] values that would widen access, or two spellings of one key; supported keys are ${CODEWHALE_SECTION_KEYS.join(", ")}, each a string (the canonical spelling wins over an alias), plus the narrowing-only tools.posture = "read-only" and permissions.allow_shell = false, permissions.trust = false, permissions.approval_required = true.`);
82130
83144
  const body = stringifyCodewhaleSubagentToml({
82131
83145
  id: toProfileId(relativeFilePath),
82132
83146
  display_name: frontmatter.name,
@@ -88623,6 +89637,99 @@ const AntigravityRuleFrontmatterSchema = zod_mini.z.looseObject({
88623
89637
  function toGlobalRuleFileName(relativeFilePath) {
88624
89638
  return relativeFilePath.split(/[\\/]/).join("-");
88625
89639
  }
89640
+ /** The project rules directory both Antigravity targets share, as a POSIX path. */
89641
+ const PROJECT_RULES_DIR = `${ANTIGRAVITY_IDE_AGENTS_DIR}/rules`;
89642
+ /**
89643
+ * The subproject directory a rule is scoped to, or `undefined` when it goes to
89644
+ * the project-root `.agents/rules/`. Antigravity reads a `.agents/rules/`
89645
+ * directory in any subdirectory and loads it for files under that directory,
89646
+ * so a non-root rule carrying `agentsmd.subprojectPath` is written there. Project
89647
+ * scope only: there is no workspace tree to nest under in global scope.
89648
+ *
89649
+ * @see https://antigravity.google/docs/rules
89650
+ */
89651
+ function resolveAntigravitySubprojectPath({ rulesyncRule, global }) {
89652
+ const { root, agentsmd } = rulesyncRule.getFrontmatter();
89653
+ const subprojectPath = !global && root !== true ? agentsmd?.subprojectPath : void 0;
89654
+ return subprojectPath && ![
89655
+ "",
89656
+ ".",
89657
+ "./"
89658
+ ].includes(subprojectPath) ? subprojectPath : void 0;
89659
+ }
89660
+ /**
89661
+ * File name of a rule in a subproject's `.agents/rules/`. Antigravity reads
89662
+ * only the direct children of a rules directory, so a nested rulesync rule
89663
+ * (`frontend/style.md`) is flattened (`frontend-style.md`), and the name keeps
89664
+ * its case so the IDE and the CLI write the same file and an imported
89665
+ * `codeStyle.md` is written back to the same place.
89666
+ */
89667
+ function toNestedRuleFileName(relativeFilePath) {
89668
+ return toGlobalRuleFileName(relativeFilePath);
89669
+ }
89670
+ /** The `.agents/rules/` directory of a subproject. */
89671
+ function toAntigravityNestedRulesDirPath(subprojectPath) {
89672
+ return (0, node_path.join)(subprojectPath, ANTIGRAVITY_IDE_AGENTS_DIR, "rules");
89673
+ }
89674
+ /**
89675
+ * Split a nested rule directory such as `packages/api/.agents/rules/sub` into
89676
+ * the subproject (`packages/api`) and the folder inside its rules directory
89677
+ * (`sub`). Returns `undefined` for the project-root `.agents/rules/`, for a
89678
+ * path with no `.agents/rules` segment, and for one under a hidden directory —
89679
+ * another tool's output or a worktree, not a subproject.
89680
+ */
89681
+ function extractAntigravitySubprojectPath(relativeDirPath) {
89682
+ const segments = toPosixPath(relativeDirPath).split("/");
89683
+ const index = segments.findIndex((segment, i) => segment === ".agents" && segments[i + 1] === "rules");
89684
+ if (index <= 0) return;
89685
+ const subprojectSegments = segments.slice(0, index);
89686
+ if (subprojectSegments.some((segment) => segment === "" || segment.startsWith("."))) return;
89687
+ return {
89688
+ subprojectPath: subprojectSegments.join("/"),
89689
+ innerDirPath: segments.slice(index + 2).join("/")
89690
+ };
89691
+ }
89692
+ /**
89693
+ * The `.agents/rules/` folders of project subdirectories. Only their direct
89694
+ * `.md` children are read, because Antigravity ignores files nested in
89695
+ * subfolders of a rules directory. The shared nested
89696
+ * scan ignores every hidden directory, which would skip `.agents` itself, so
89697
+ * only a `.agents/rules/` below another hidden directory is ignored here
89698
+ * (`extractAntigravitySubprojectPath` rejects one again on import). Import-only,
89699
+ * like every other nested scan: these files sit outside the rulesync-owned root
89700
+ * `.agents/rules/`, so `--delete` never sweeps them.
89701
+ */
89702
+ function getAntigravityNestedFilePatterns() {
89703
+ return {
89704
+ include: [`**/${PROJECT_RULES_DIR}/*.md`],
89705
+ ignore: [
89706
+ `${PROJECT_RULES_DIR}/**`,
89707
+ `**/.*/**/${PROJECT_RULES_DIR}/**`,
89708
+ ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `**/${dir}/**`),
89709
+ ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${dir}/**`)
89710
+ ],
89711
+ ownedDirPath: PROJECT_RULES_DIR
89712
+ };
89713
+ }
89714
+ /**
89715
+ * Resolve where a non-root project rule file read from disk lives. A file
89716
+ * discovered by `getNestedFilePatterns` arrives as its own directory plus its
89717
+ * basename; it is re-split into the subproject's rules directory and the path
89718
+ * inside it, matching how the root `.agents/rules/` is read.
89719
+ */
89720
+ function resolveAntigravityNonRootFile({ relativeDirPath, relativeFilePath, defaultDirPath }) {
89721
+ const nested = relativeDirPath === void 0 ? void 0 : extractAntigravitySubprojectPath(relativeDirPath);
89722
+ if (nested === void 0) return {
89723
+ relativeDirPath: defaultDirPath,
89724
+ relativeFilePath
89725
+ };
89726
+ return {
89727
+ relativeDirPath: toAntigravityNestedRulesDirPath(nested.subprojectPath),
89728
+ relativeFilePath: (0, node_path.join)(nested.innerDirPath, relativeFilePath)
89729
+ };
89730
+ }
89731
+ /** The targets a nested rule is imported with: the two that read the shared tree. */
89732
+ const ANTIGRAVITY_NESTED_RULE_TARGETS = ["antigravity-ide", "antigravity-cli"];
88626
89733
  /**
88627
89734
  * Converts a comma-separated globs string or array to an array of globs.
88628
89735
  * @param globs - Comma-separated globs string (e.g., "*.ts,*.js") or array of globs
@@ -88810,12 +89917,17 @@ var AntigravityCliRule = class AntigravityCliRule extends ToolRule {
88810
89917
  });
88811
89918
  }
88812
89919
  if (!paths.nonRoot) throw new Error(`nonRoot path is not set for ${relativeFilePath}`);
88813
- const relativePath = (0, node_path.join)(paths.nonRoot.relativeDirPath, relativeFilePath);
89920
+ const nonRoot = resolveAntigravityNonRootFile({
89921
+ relativeDirPath,
89922
+ relativeFilePath,
89923
+ defaultDirPath: paths.nonRoot.relativeDirPath
89924
+ });
89925
+ const relativePath = (0, node_path.join)(nonRoot.relativeDirPath, nonRoot.relativeFilePath);
88814
89926
  const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
88815
89927
  const rule = new AntigravityCliRule({
88816
89928
  outputRoot,
88817
- relativeDirPath: paths.nonRoot.relativeDirPath,
88818
- relativeFilePath,
89929
+ relativeDirPath: nonRoot.relativeDirPath,
89930
+ relativeFilePath: nonRoot.relativeFilePath,
88819
89931
  fileContent,
88820
89932
  validate,
88821
89933
  root: false,
@@ -88847,11 +89959,21 @@ var AntigravityCliRule = class AntigravityCliRule extends ToolRule {
88847
89959
  if (!strategy) throw new Error(`No strategy found for trigger: ${storedTrigger}`);
88848
89960
  const frontmatter = strategy.generateFrontmatter(normalizeStoredAntigravity(storedAntigravity), rulesyncFrontmatter);
88849
89961
  const fileContent = stringifyFrontmatter(rulesyncRule.getBody(), frontmatter);
88850
- if (!global) return new AntigravityCliRule({
88851
- ...params,
88852
- fileContent,
88853
- global
88854
- });
89962
+ if (!global) {
89963
+ const subprojectPath = resolveAntigravitySubprojectPath({
89964
+ rulesyncRule,
89965
+ global
89966
+ });
89967
+ return new AntigravityCliRule({
89968
+ ...params,
89969
+ ...subprojectPath !== void 0 && {
89970
+ relativeDirPath: toAntigravityNestedRulesDirPath(subprojectPath),
89971
+ relativeFilePath: toNestedRuleFileName(params.relativeFilePath)
89972
+ },
89973
+ fileContent,
89974
+ global
89975
+ });
89976
+ }
88855
89977
  return new AntigravityCliRule({
88856
89978
  ...params,
88857
89979
  relativeFilePath: toGlobalRuleFileName(params.relativeFilePath),
@@ -88867,22 +89989,32 @@ var AntigravityCliRule = class AntigravityCliRule extends ToolRule {
88867
89989
  globs: [],
88868
89990
  antigravity: frontmatter
88869
89991
  };
89992
+ const subprojectPath = this.global ? void 0 : extractAntigravitySubprojectPath(this.getRelativeDirPath())?.subprojectPath;
88870
89993
  return new RulesyncRule({
88871
89994
  outputRoot: process.cwd(),
88872
89995
  relativeDirPath: RulesyncRule.getSettablePaths().recommended.relativeDirPath,
88873
89996
  relativeFilePath: this.getRelativeFilePath(),
88874
89997
  frontmatter: {
88875
89998
  root: false,
88876
- targets: ["*"],
89999
+ targets: subprojectPath === void 0 ? ["*"] : [...ANTIGRAVITY_NESTED_RULE_TARGETS],
88877
90000
  ...rulesyncData,
88878
90001
  antigravity: {
88879
90002
  ...rulesyncData.antigravity,
88880
90003
  globs: frontmatter.globs ? parseGlobsString$1(frontmatter.globs) : void 0
88881
- }
90004
+ },
90005
+ ...subprojectPath !== void 0 && { agentsmd: { subprojectPath } }
88882
90006
  },
88883
90007
  body
88884
90008
  });
88885
90009
  }
90010
+ /**
90011
+ * The `.agents/rules/` folders of project subdirectories, imported back as
90012
+ * directory-scoped rules.
90013
+ * @see https://antigravity.google/docs/rules
90014
+ */
90015
+ static getNestedFilePatterns() {
90016
+ return getAntigravityNestedFilePatterns();
90017
+ }
88886
90018
  validate() {
88887
90019
  if (this.root) return {
88888
90020
  success: true,
@@ -89012,8 +90144,15 @@ var AntigravityIdeRule = class extends ToolRule {
89012
90144
  global
89013
90145
  });
89014
90146
  }
89015
- const nonRootDirPath = paths.nonRoot.relativeDirPath;
89016
- const filePath = (0, node_path.join)(outputRoot, nonRootDirPath, relativeFilePath);
90147
+ const nonRoot = this.supportsSubprojectRules() ? resolveAntigravityNonRootFile({
90148
+ relativeDirPath,
90149
+ relativeFilePath,
90150
+ defaultDirPath: paths.nonRoot.relativeDirPath
90151
+ }) : {
90152
+ relativeDirPath: paths.nonRoot.relativeDirPath,
90153
+ relativeFilePath
90154
+ };
90155
+ const filePath = (0, node_path.join)(outputRoot, nonRoot.relativeDirPath, nonRoot.relativeFilePath);
89017
90156
  const { frontmatter, body } = parseFrontmatter(await readFileContent(filePath), filePath);
89018
90157
  let parsedFrontmatter;
89019
90158
  if (validate) {
@@ -89023,8 +90162,8 @@ var AntigravityIdeRule = class extends ToolRule {
89023
90162
  } else parsedFrontmatter = frontmatter;
89024
90163
  return new this({
89025
90164
  outputRoot,
89026
- relativeDirPath: nonRootDirPath,
89027
- relativeFilePath,
90165
+ relativeDirPath: nonRoot.relativeDirPath,
90166
+ relativeFilePath: nonRoot.relativeFilePath,
89028
90167
  body,
89029
90168
  frontmatter: parsedFrontmatter,
89030
90169
  validate,
@@ -89032,6 +90171,25 @@ var AntigravityIdeRule = class extends ToolRule {
89032
90171
  global
89033
90172
  });
89034
90173
  }
90174
+ /**
90175
+ * Whether a rule scoped to a subproject goes to that subproject's own
90176
+ * `.agents/rules/`. A subclass that writes somewhere other than a project
90177
+ * tree (the plugin bundle) turns it off.
90178
+ */
90179
+ static supportsSubprojectRules() {
90180
+ return true;
90181
+ }
90182
+ /**
90183
+ * The `.agents/rules/` folders of project subdirectories, imported back as
90184
+ * directory-scoped rules.
90185
+ * @see https://antigravity.google/docs/rules
90186
+ */
90187
+ static getNestedFilePatterns() {
90188
+ return this.supportsSubprojectRules() ? getAntigravityNestedFilePatterns() : {
90189
+ include: [],
90190
+ ignore: []
90191
+ };
90192
+ }
89035
90193
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
89036
90194
  const paths = this.getSettablePaths({ global });
89037
90195
  if (rulesyncRule.getFrontmatter().root) {
@@ -89054,10 +90212,14 @@ var AntigravityIdeRule = class extends ToolRule {
89054
90212
  const strategy = STRATEGIES$1.find((s) => s.canHandle(storedTrigger));
89055
90213
  if (!strategy) throw new Error(`No strategy found for trigger: ${storedTrigger}`);
89056
90214
  const frontmatter = strategy.generateFrontmatter(normalized, rulesyncFrontmatter);
89057
- const relativeFilePath = global ? toGlobalRuleFileName(rulesyncRule.getRelativeFilePath()) : toKebabCaseFilename(rulesyncRule.getRelativeFilePath());
90215
+ const subprojectPath = this.supportsSubprojectRules() ? resolveAntigravitySubprojectPath({
90216
+ rulesyncRule,
90217
+ global
90218
+ }) : void 0;
90219
+ const relativeFilePath = global || subprojectPath !== void 0 ? toNestedRuleFileName(rulesyncRule.getRelativeFilePath()) : toKebabCaseFilename(rulesyncRule.getRelativeFilePath());
89058
90220
  return new this({
89059
90221
  outputRoot,
89060
- relativeDirPath: paths.nonRoot.relativeDirPath,
90222
+ relativeDirPath: subprojectPath === void 0 ? paths.nonRoot.relativeDirPath : toAntigravityNestedRulesDirPath(subprojectPath),
89061
90223
  relativeFilePath,
89062
90224
  frontmatter,
89063
90225
  body: rulesyncRule.getBody(),
@@ -89078,15 +90240,17 @@ var AntigravityIdeRule = class extends ToolRule {
89078
90240
  ...rulesyncData.antigravity,
89079
90241
  globs: this.frontmatter.globs ? parseGlobsString$1(this.frontmatter.globs) : void 0
89080
90242
  };
90243
+ const subprojectPath = this.global ? void 0 : extractAntigravitySubprojectPath(this.getRelativeDirPath())?.subprojectPath;
89081
90244
  return new RulesyncRule({
89082
90245
  outputRoot: process.cwd(),
89083
90246
  relativeDirPath: RulesyncRule.getSettablePaths().recommended.relativeDirPath,
89084
90247
  relativeFilePath: this.getRelativeFilePath(),
89085
90248
  frontmatter: {
89086
90249
  root: false,
89087
- targets: ["*"],
90250
+ targets: subprojectPath === void 0 ? ["*"] : [...ANTIGRAVITY_NESTED_RULE_TARGETS],
89088
90251
  ...rulesyncData,
89089
- antigravity: antigravityForRulesync
90252
+ antigravity: antigravityForRulesync,
90253
+ ...subprojectPath !== void 0 && { agentsmd: { subprojectPath } }
89090
90254
  },
89091
90255
  body: this.body
89092
90256
  });
@@ -89140,6 +90304,9 @@ var AntigravityIdeRule = class extends ToolRule {
89140
90304
  //#endregion
89141
90305
  //#region src/features/rules/antigravity-plugin-rule.ts
89142
90306
  var AntigravityPluginRule = class extends AntigravityIdeRule {
90307
+ static supportsSubprojectRules() {
90308
+ return false;
90309
+ }
89143
90310
  static isTargetedByRulesyncRule(rulesyncRule) {
89144
90311
  const targets = rulesyncRule.getFrontmatter().targets;
89145
90312
  return targets.includes("*") || targets.includes("antigravity-plugin");
@@ -95489,6 +96656,12 @@ var VibeRule = class extends NestedAgentsmdRule {
95489
96656
  * file, so every non-root rule without `agentsmd.subprojectPath` folds into
95490
96657
  * the root `./AGENTS.md`.
95491
96658
  *
96659
+ * A legacy root `WARP.md` is read on import when the root `AGENTS.md` is
96660
+ * absent, so a repository that has not moved to `AGENTS.md` yet imports its
96661
+ * rules. It is never written or deleted. When both exist, Warp reads
96662
+ * `WARP.md`, so import keeps reading the `AGENTS.md` rulesync manages and warns
96663
+ * that Warp ignores it, as generate already does.
96664
+ *
95492
96665
  * In global mode, Warp reads a third rule source from `~/.agents/AGENTS.md`
95493
96666
  * (the cross-tool agent config directory), indexed like project rules and also
95494
96667
  * used from remote hosts in SSH sessions. The same root-fold policy applies.
@@ -95503,6 +96676,31 @@ var WarpRule = class extends NestedAgentsmdRule {
95503
96676
  toolTarget: "warp"
95504
96677
  };
95505
96678
  }
96679
+ static getSettablePaths(options = {}) {
96680
+ const paths = super.getSettablePaths(options);
96681
+ if (options.global) return paths;
96682
+ return {
96683
+ ...paths,
96684
+ importOnlyRoots: [{
96685
+ relativeDirPath: ".",
96686
+ relativeFilePath: WARP_LEGACY_RULE_FILE_NAME,
96687
+ onlyWhenRootAbsent: true,
96688
+ outranksRoot: true
96689
+ }]
96690
+ };
96691
+ }
96692
+ static async fromFile(params) {
96693
+ const { outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true } = params;
96694
+ if (!params.global && relativeFilePath === "WARP.md" && (relativeDirPath === void 0 || relativeDirPath === ".")) return new this({
96695
+ outputRoot,
96696
+ relativeDirPath: ".",
96697
+ relativeFilePath: WARP_LEGACY_RULE_FILE_NAME,
96698
+ fileContent: await readFileContent((0, node_path.join)(outputRoot, WARP_LEGACY_RULE_FILE_NAME)),
96699
+ validate,
96700
+ root: true
96701
+ });
96702
+ return super.fromFile(params);
96703
+ }
95506
96704
  };
95507
96705
  //#endregion
95508
96706
  //#region src/features/rules/zcode-rule.ts
@@ -96805,7 +98003,7 @@ var RulesProcessor = class extends FeatureProcessor {
96805
98003
  if (!importOnlyRoots || importOnlyRoots.length === 0) return;
96806
98004
  const existingPaths = [];
96807
98005
  for (const importOnlyRoot of importOnlyRoots) {
96808
- if (importOnlyRoot.onlyWhenRootAbsent !== true) continue;
98006
+ if (importOnlyRoot.onlyWhenRootAbsent !== true || importOnlyRoot.outranksRoot === true) continue;
96809
98007
  const matchedPaths = await findFilesByGlobs(rootRelativeGlob(importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), {
96810
98008
  cwd: this.outputRoot,
96811
98009
  type: "file"
@@ -97506,13 +98704,14 @@ As this project's AI coding tool, you must follow the additional conventions bel
97506
98704
  const rootFilePath = primaryRootFilePaths[0];
97507
98705
  const scannedPaths = [];
97508
98706
  const skippedPaths = [];
98707
+ const skippedOutrankingPaths = [];
97509
98708
  for (const importOnlyRoot of importOnlyRoots) {
97510
98709
  const matchedPaths = await findFilesByGlobs(rootRelativeGlob(importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), {
97511
98710
  cwd: this.outputRoot,
97512
98711
  type: "file"
97513
98712
  });
97514
98713
  if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
97515
- skippedPaths.push(...matchedPaths);
98714
+ (importOnlyRoot.outranksRoot === true ? skippedOutrankingPaths : skippedPaths).push(...matchedPaths);
97516
98715
  continue;
97517
98716
  }
97518
98717
  scannedPaths.push(...matchedPaths);
@@ -97523,6 +98722,13 @@ As this project's AI coding tool, you must follow the additional conventions bel
97523
98722
  const remainingCount = skippedNames.length - listedNames.length;
97524
98723
  this.logger.warn(`Not importing ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} for ${this.toolTarget}: ${stripControlCharacters((0, node_path.relative)(this.outputRoot, rootFilePath))} exists, and the tool reads that file exclusively. Delete them once you have checked that content is in the root file, or move it into ${RULESYNC_RULES_RELATIVE_DIR_PATH} if it is not.`);
97525
98724
  }
98725
+ if (skippedOutrankingPaths.length > 0 && rootFilePath !== void 0) {
98726
+ const rootName = stripControlCharacters((0, node_path.relative)(this.outputRoot, rootFilePath));
98727
+ const skippedNames = skippedOutrankingPaths.map((filePath) => stripControlCharacters((0, node_path.relative)(this.outputRoot, filePath)));
98728
+ const listedNames = skippedNames.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
98729
+ const remainingCount = skippedNames.length - listedNames.length;
98730
+ this.logger.warn(`Not importing ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} for ${this.toolTarget}: ${rootName} is imported instead, but ${this.toolTarget} reads ${listedNames.length === 1 ? "that file" : "those files"} in place of ${rootName} when both exist. Move any content only it has into ${RULESYNC_RULES_RELATIVE_DIR_PATH} and delete it, so the generated ${rootName} takes effect.`);
98731
+ }
97526
98732
  return await buildImportRulesFromPaths(scannedPaths);
97527
98733
  })();
97528
98734
  this.logger.debug(`Found ${importOnlyToolRules.length} import-only tool rule files`);