rulesync 16.32.1 → 16.34.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.
@@ -444,36 +444,36 @@ const isFeatureValueEnabled = (value) => {
444
444
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
445
445
  //#endregion
446
446
  //#region src/constants/rulesync-paths.ts
447
- const { join: join$340 } = node_path.posix;
447
+ const { join: join$350 } = node_path.posix;
448
448
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
449
449
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
450
450
  const RULESYNC_USER_CONFIG_DIR_NAME = "rulesync";
451
451
  const RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME = ".config";
452
452
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
453
453
  const RULES_FEATURE_SUBDIR = "rules";
454
- const CURATED_RULES_FEATURE_SUBDIR = join$340(RULES_FEATURE_SUBDIR, ".curated");
454
+ const CURATED_RULES_FEATURE_SUBDIR = join$350(RULES_FEATURE_SUBDIR, ".curated");
455
455
  const COMMANDS_FEATURE_SUBDIR = "commands";
456
456
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
457
457
  const CHECKS_FEATURE_SUBDIR = "checks";
458
458
  const SKILLS_FEATURE_SUBDIR = "skills";
459
- const CURATED_SKILLS_FEATURE_SUBDIR = join$340(SKILLS_FEATURE_SUBDIR, ".curated");
460
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
461
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
462
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
463
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
464
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
465
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
466
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
467
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
468
- join$340(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
469
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
470
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
459
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$350(SKILLS_FEATURE_SUBDIR, ".curated");
460
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
461
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
462
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
463
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
464
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
465
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
466
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
467
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
468
+ join$350(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
469
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
470
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
471
471
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
472
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
472
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
473
473
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
474
474
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
475
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
476
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
475
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
476
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$350(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
477
477
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
478
478
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
479
479
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -503,6 +503,7 @@ const rulesProcessorToolTargetTuple = [
503
503
  "cline",
504
504
  "codebuddy",
505
505
  "codexcli",
506
+ "commandcode",
506
507
  "continue",
507
508
  "copilot",
508
509
  "copilotcli",
@@ -579,10 +580,12 @@ const mcpProcessorToolTargetTuple = [
579
580
  "claudecode-legacy",
580
581
  "cline",
581
582
  "codexcli",
583
+ "commandcode",
582
584
  "continue",
583
585
  "copilot",
584
586
  "copilotcli",
585
587
  "cortexcode",
588
+ "crush",
586
589
  "cursor",
587
590
  "deepagents",
588
591
  "factorydroid",
@@ -621,6 +624,7 @@ const commandsProcessorToolTargetTuple = [
621
624
  "claudecode-legacy",
622
625
  "cline",
623
626
  "codexcli",
627
+ "commandcode",
624
628
  "continue",
625
629
  "copilot",
626
630
  "cursor",
@@ -658,6 +662,7 @@ const subagentsProcessorToolTargetTuple = [
658
662
  "claudecode-legacy",
659
663
  "cline",
660
664
  "codexcli",
665
+ "commandcode",
661
666
  "copilot",
662
667
  "copilotcli",
663
668
  "cortexcode",
@@ -699,6 +704,7 @@ const skillsProcessorToolTargetTuple = [
699
704
  "claudecode-legacy",
700
705
  "cline",
701
706
  "codexcli",
707
+ "commandcode",
702
708
  "continue",
703
709
  "copilot",
704
710
  "copilotcli",
@@ -719,6 +725,7 @@ const skillsProcessorToolTargetTuple = [
719
725
  "musecode",
720
726
  "opencode",
721
727
  "pi",
728
+ "pool",
722
729
  "qwencode",
723
730
  "reasonix",
724
731
  "replit",
@@ -745,10 +752,12 @@ const hooksProcessorToolTargetTuple = [
745
752
  "claudecode",
746
753
  "claudecode-plugin",
747
754
  "codexcli",
755
+ "commandcode",
748
756
  "continue",
749
757
  "copilot",
750
758
  "copilotcli",
751
759
  "cortexcode",
760
+ "crush",
752
761
  "opencode",
753
762
  "pi",
754
763
  "factorydroid",
@@ -778,9 +787,11 @@ const permissionsProcessorToolTargetTuple = [
778
787
  "claudecode",
779
788
  "cline",
780
789
  "codexcli",
790
+ "commandcode",
781
791
  "continue",
782
792
  "copilot",
783
793
  "copilotcli",
794
+ "crush",
784
795
  "cursor",
785
796
  "deepagents",
786
797
  "devin",
@@ -3291,6 +3302,7 @@ const SHARED_USER_MANAGED_CONFIG_PATHS = [
3291
3302
  ".claude/settings.json",
3292
3303
  ".claude/settings.local.json",
3293
3304
  ".codex/config.toml",
3305
+ ".commandcode/settings.json",
3294
3306
  ".continue/settings.json",
3295
3307
  ".copilot/settings.json",
3296
3308
  ".github/copilot/settings.json",
@@ -3308,6 +3320,9 @@ const SHARED_USER_MANAGED_CONFIG_PATHS = [
3308
3320
  "reasonix.toml",
3309
3321
  ".vscode/settings.json",
3310
3322
  ".zed/settings.json",
3323
+ "crush.json",
3324
+ ".crush.json",
3325
+ ".config/crush/crush.json",
3311
3326
  "kilo.json",
3312
3327
  "kilo.jsonc",
3313
3328
  "opencode.json",
@@ -3512,6 +3527,27 @@ function omitPrototypePollutionKeys(record) {
3512
3527
  }
3513
3528
  return sanitized;
3514
3529
  }
3530
+ /**
3531
+ * Like {@link omitPrototypePollutionKeys}, but recursive: every plain object
3532
+ * reachable through the value (directly, or through an array) is copied with
3533
+ * its prototype-pollution keys dropped. Scalars and arrays of scalars come back
3534
+ * as they are.
3535
+ *
3536
+ * Use when passing an unknown, user-supplied value through unchanged — an MCP
3537
+ * server field a tool documents that rulesync does not model — so a
3538
+ * `__proto__` key nested anywhere inside it cannot ride into the generated
3539
+ * config, where the consuming tool may merge the object without the same care.
3540
+ */
3541
+ function omitPrototypePollutionKeysDeep(value) {
3542
+ if (Array.isArray(value)) return value.map((entry) => omitPrototypePollutionKeysDeep(entry));
3543
+ if (value === null || typeof value !== "object") return value;
3544
+ const sanitized = {};
3545
+ for (const [key, entry] of Object.entries(value)) {
3546
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
3547
+ sanitized[key] = omitPrototypePollutionKeysDeep(entry);
3548
+ }
3549
+ return sanitized;
3550
+ }
3515
3551
  //#endregion
3516
3552
  //#region src/utils/type-guards.ts
3517
3553
  /**
@@ -4579,6 +4615,14 @@ const VIBE_HOOK_EVENTS = [
4579
4615
  "stop"
4580
4616
  ];
4581
4617
  /**
4618
+ * Hook events supported by Crush.
4619
+ *
4620
+ * Crush's `hooks` config block currently fires a single event, `PreToolUse`
4621
+ * (Claude Code-compatible payload; `matcher` is a regex on the tool name).
4622
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
4623
+ */
4624
+ const CRUSH_HOOK_EVENTS = ["preToolUse"];
4625
+ /**
4582
4626
  * Hook events supported by JetBrains Junie CLI.
4583
4627
  *
4584
4628
  * Junie CLI exposes seven lifecycle events under the `"hooks"` key of
@@ -4916,6 +4960,30 @@ const CANONICAL_TO_CORTEXCODE_EVENT_NAMES = {
4916
4960
  };
4917
4961
  const CORTEXCODE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CORTEXCODE_EVENT_NAMES).map(([k, v]) => [v, k]));
4918
4962
  /**
4963
+ * Hook events supported by Command Code.
4964
+ *
4965
+ * Command Code reads hooks from the `hooks` key of `.commandcode/settings.json`
4966
+ * (project) and `~/.commandcode/settings.json` (user) in the Claude-Code shape.
4967
+ * Four events are documented: PreToolUse, PostToolUse, Stop and SessionStart.
4968
+ * Only `command` hooks exist; `timeout` is in seconds (default 30, max 600)
4969
+ * and `$COMMANDCODE_PROJECT_DIR` resolves to the project root.
4970
+ *
4971
+ * @see https://commandcode.ai/docs/hooks
4972
+ */
4973
+ const COMMANDCODE_HOOK_EVENTS = [
4974
+ "preToolUse",
4975
+ "postToolUse",
4976
+ "stop",
4977
+ "sessionStart"
4978
+ ];
4979
+ const CANONICAL_TO_COMMANDCODE_EVENT_NAMES = {
4980
+ preToolUse: "PreToolUse",
4981
+ postToolUse: "PostToolUse",
4982
+ stop: "Stop",
4983
+ sessionStart: "SessionStart"
4984
+ };
4985
+ const COMMANDCODE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_COMMANDCODE_EVENT_NAMES).map(([k, v]) => [v, k]));
4986
+ /**
4919
4987
  * Hook events supported by the Continue CLI (`cn`).
4920
4988
  *
4921
4989
  * Continue reads a Claude-Code-compatible `hooks` key from
@@ -5121,8 +5189,10 @@ const HooksConfigSchema = zod_mini.z.looseObject({
5121
5189
  grokcli: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5122
5190
  "kimi-code": zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5123
5191
  zcode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5192
+ crush: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5124
5193
  bob: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5125
5194
  cortexcode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5195
+ commandcode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5126
5196
  continue: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5127
5197
  tabnine: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5128
5198
  qwencode: zod_mini.z.optional(zod_mini.z.looseObject({
@@ -5618,6 +5688,13 @@ const VIBE_TO_CANONICAL_EVENT_NAMES = {
5618
5688
  post_agent_turn: "stop"
5619
5689
  };
5620
5690
  /**
5691
+ * Canonical -> Crush event names. Crush spells its events the Claude Code
5692
+ * way (`PreToolUse`).
5693
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
5694
+ */
5695
+ const CANONICAL_TO_CRUSH_EVENT_NAMES = { preToolUse: "PreToolUse" };
5696
+ const CRUSH_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CRUSH_EVENT_NAMES).map(([k, v]) => [v, k]));
5697
+ /**
5621
5698
  * Map canonical camelCase event names to Qwen Code PascalCase.
5622
5699
  *
5623
5700
  * Qwen Code reuses the same Claude-style PascalCase event names for the events
@@ -6057,6 +6134,11 @@ const McpServerSchema = zod_mini.z.looseObject({
6057
6134
  kiroAutoBlock: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6058
6135
  musecodeMode: zod_mini.z.optional(zod_mini.z.enum(["required", "optional"])),
6059
6136
  rovodevEnableInstructions: zod_mini.z.optional(zod_mini.z.boolean()),
6137
+ crushOauth: zod_mini.z.optional(zod_mini.z.boolean()),
6138
+ crushOauthClientId: zod_mini.z.optional(zod_mini.z.string()),
6139
+ crushOauthClientSecret: zod_mini.z.optional(zod_mini.z.string()),
6140
+ crushOauthCallbackPort: zod_mini.z.optional(zod_mini.z.int()),
6141
+ crushSessionless: zod_mini.z.optional(zod_mini.z.boolean()),
6060
6142
  headers: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.string())),
6061
6143
  /**
6062
6144
  * The canonical per-server tool allowlist.
@@ -6130,9 +6212,12 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
6130
6212
  claudecode: zod_mini.z.optional(toolScopedMcpSchema),
6131
6213
  cline: zod_mini.z.optional(toolScopedMcpSchema),
6132
6214
  codexcli: zod_mini.z.optional(toolScopedMcpSchema),
6215
+ commandcode: zod_mini.z.optional(toolScopedMcpSchema),
6133
6216
  continue: zod_mini.z.optional(toolScopedMcpSchema),
6134
6217
  copilot: zod_mini.z.optional(toolScopedMcpSchema),
6135
6218
  copilotcli: zod_mini.z.optional(toolScopedMcpSchema),
6219
+ cortexcode: zod_mini.z.optional(toolScopedMcpSchema),
6220
+ crush: zod_mini.z.optional(toolScopedMcpSchema),
6136
6221
  cursor: zod_mini.z.optional(toolScopedMcpSchema),
6137
6222
  deepagents: zod_mini.z.optional(toolScopedMcpSchema),
6138
6223
  devin: zod_mini.z.optional(toolScopedMcpSchema),
@@ -6149,7 +6234,6 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
6149
6234
  reasonix: zod_mini.z.optional(toolScopedMcpSchema),
6150
6235
  roo: zod_mini.z.optional(toolScopedMcpSchema),
6151
6236
  rovodev: zod_mini.z.optional(toolScopedMcpSchema),
6152
- cortexcode: zod_mini.z.optional(toolScopedMcpSchema),
6153
6237
  tabnine: zod_mini.z.optional(toolScopedMcpSchema),
6154
6238
  takt: zod_mini.z.optional(toolScopedMcpSchema),
6155
6239
  vibe: zod_mini.z.optional(toolScopedMcpSchema),
@@ -6481,6 +6565,15 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
6481
6565
  "musecodeMode",
6482
6566
  "rovodevEnableInstructions",
6483
6567
  "enable_instructions",
6568
+ "crushOauth",
6569
+ "crushOauthClientId",
6570
+ "crushOauthClientSecret",
6571
+ "crushOauthCallbackPort",
6572
+ "crushSessionless",
6573
+ "oauth_client_id",
6574
+ "oauth_client_secret",
6575
+ "oauth_callback_port",
6576
+ "sessionless",
6484
6577
  "enabled"
6485
6578
  ])];
6486
6579
  }));
@@ -6515,12 +6608,16 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
6515
6608
  * Targets that share one output file resolve identically so the shared
6516
6609
  * file's content never depends on which of them generates last — see
6517
6610
  * `resolveMcpTarget` for the alias groups (kiro trio, claudecode/-legacy,
6518
- * and the Antigravity pair).
6611
+ * the Antigravity pair, and the project-scope Claude Code / Command Code
6612
+ * pair, which is why the scope is part of the resolution).
6519
6613
  *
6520
6614
  * Returns the same instance when neither mechanism is used.
6521
6615
  */
6522
- forTarget({ toolTarget, logger }) {
6523
- const { blockKeys, acceptedTargetNames } = resolveMcpTarget({ toolTarget });
6616
+ forTarget({ toolTarget, global = false, logger }) {
6617
+ const { blockKeys, acceptedTargetNames } = resolveMcpTarget({
6618
+ toolTarget,
6619
+ global
6620
+ });
6524
6621
  const json = this.json;
6525
6622
  const sharedServers = this.json.mcpServers ?? {};
6526
6623
  const serverNamesWithTargets = Object.entries(sharedServers).filter(([, serverConfig]) => serverConfig.targets !== void 0).map(([serverName]) => serverName);
@@ -6607,9 +6704,24 @@ const MCP_IGNORED_ALIAS_SOURCE_KEYS = Object.keys(MCP_BLOCK_KEY_ALIASES);
6607
6704
  * `config`) — so both targets always apply both blocks in a fixed order
6608
6705
  * (`antigravity-ide` first, `antigravity-cli` second — the CLI block wins
6609
6706
  * per server on conflict).
6610
- */
6611
- function resolveMcpTarget({ toolTarget }) {
6612
- if (toolTarget === "claudecode" || toolTarget === "claudecode-legacy") return {
6707
+ * - `claudecode` (and its legacy alias) and `commandcode` share the root
6708
+ * `.mcp.json` in PROJECT mode only (their global files differ:
6709
+ * `~/.claude.json` vs `~/.commandcode/mcp.json`), so in project mode the
6710
+ * three apply the `claudecode` and `commandcode` blocks in that fixed order
6711
+ * (the `commandcode` block wins per server on conflict); in global mode
6712
+ * each reads its own block.
6713
+ */
6714
+ function resolveMcpTarget({ toolTarget, global }) {
6715
+ const isClaudecode = toolTarget === "claudecode" || toolTarget === "claudecode-legacy";
6716
+ if (!global && (isClaudecode || toolTarget === "commandcode")) return {
6717
+ blockKeys: ["claudecode", "commandcode"],
6718
+ acceptedTargetNames: /* @__PURE__ */ new Set([
6719
+ "claudecode",
6720
+ "claudecode-legacy",
6721
+ "commandcode"
6722
+ ])
6723
+ };
6724
+ if (isClaudecode) return {
6613
6725
  blockKeys: ["claudecode"],
6614
6726
  acceptedTargetNames: /* @__PURE__ */ new Set(["claudecode", "claudecode-legacy"])
6615
6727
  };
@@ -6929,7 +7041,7 @@ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
6929
7041
  * autonomy/sandbox controls with no canonical permission category — under
6930
7042
  * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
6931
7043
  * `sandbox`, `sandboxImage`, `disabled`, `visible`, `eager`, `listDirectory`,
6932
- * `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
7044
+ * `todoWrite`, `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
6933
7045
  * `allowPrivateNetworkHooks`, `allowedInsecureVoiceBaseUrls`). Qwen Code strips
6934
7046
  * `tools.workflowsEnabled`, `security.allowPrivateNetworkHooks` and
6935
7047
  * `security.allowedInsecureVoiceBaseUrls` out of workspace settings, so generate
@@ -6942,7 +7054,7 @@ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
6942
7054
  * changes what the file said is reported in either scope, naming what that key
6943
7055
  * decides there — the autonomy and containment
6944
7056
  * controls (`approvalMode`, `autoAccept`, `sandbox`, `sandboxImage`), the
6945
- * registry controls (`disabled`, `visible`, `listDirectory`, and `eager`, which
7057
+ * registry controls (`disabled`, `visible`, `listDirectory`, `todoWrite`, and `eager`, which
6946
7058
  * demotes an omitted tool to deferred rather than removing it, replacing rather
6947
7059
  * than merging with the list a higher scope set), the Auto Mode
6948
7060
  * classifier config, and, because these groups are loose objects, any key
@@ -6983,6 +7095,14 @@ const QwencodePermissionsOverrideSchema = zod_mini.z.looseObject({
6983
7095
  * `looseObject` (verbatim passthrough). Both project and global scope are
6984
7096
  * supported.
6985
7097
  *
7098
+ * Only `tools` and `general` are written; any other key is ignored with a
7099
+ * warning. Within them, the paths Tabnine CLI runs as a command or sends its
7100
+ * traffic to — `tools.discoveryCommand`, `tools.callCommand`,
7101
+ * `tools.shell.pager`, `tools.sandbox.command`, `general.tabnineHost` and
7102
+ * `general.preferredEditor` — are refused with a warning rather than written,
7103
+ * so a fetched permissions file cannot point the CLI at an executable or a
7104
+ * server of its choosing; set those by hand in `settings.json`.
7105
+ *
6986
7106
  * @example
6987
7107
  * { "tools": { "core": ["read_file", "run_shell_command(git)"] },
6988
7108
  * "general": { "defaultApprovalMode": "plan" } }
@@ -7766,7 +7886,9 @@ const PermissionsConfigSchema = zod_mini.z.looseObject({
7766
7886
  "antigravity-ide": zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7767
7887
  continue: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7768
7888
  copilot: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7889
+ commandcode: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7769
7890
  copilotcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7891
+ crush: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7770
7892
  goose: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7771
7893
  grokcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7772
7894
  "kimi-code": zod_mini.z.optional(KimiCodePermissionsOverrideSchema),
@@ -9487,6 +9609,7 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
9487
9609
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
9488
9610
  "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
9489
9611
  })),
9612
+ commandcode: zod_mini.z.optional(zod_mini.z.looseObject({})),
9490
9613
  cortexcode: zod_mini.z.optional(zod_mini.z.looseObject({})),
9491
9614
  continue: zod_mini.z.optional(zod_mini.z.looseObject({})),
9492
9615
  tabnine: zod_mini.z.optional(zod_mini.z.looseObject({})),
@@ -12792,6 +12915,26 @@ const ZCODE_USER_CONFIG_DECLARATION = {
12792
12915
  }
12793
12916
  };
12794
12917
  /**
12918
+ * Crush's JSON config, shared by the project (`crush.json` / `.crush.json`)
12919
+ * and global (`~/.config/crush/crush.json`) spellings so a policy edit lands
12920
+ * on both. See the `SHARED_CONFIG_OWNERSHIP` entries for the key rationale.
12921
+ */
12922
+ const CRUSH_CONFIG_DECLARATION = {
12923
+ format: "json",
12924
+ invalidRootPolicy: "error",
12925
+ features: {
12926
+ mcp: {
12927
+ kind: "replace-owned-keys",
12928
+ ownedKeys: ["mcp"]
12929
+ },
12930
+ hooks: {
12931
+ kind: "replace-owned-keys",
12932
+ ownedKeys: ["hooks"]
12933
+ },
12934
+ permissions: { kind: "deep-merge" }
12935
+ }
12936
+ };
12937
+ /**
12795
12938
  * What the two Claude Code settings files have in common: both are plain JSON,
12796
12939
  * and both validate against the one published schema, which the gateway
12797
12940
  * points every file it writes at. `.claude/settings.local.json` is the same
@@ -13052,6 +13195,8 @@ const SHARED_CONFIG_OWNERSHIP = {
13052
13195
  },
13053
13196
  ".zcode/config.json": ZCODE_WORKSPACE_CONFIG_DECLARATION,
13054
13197
  ".zcode/cli/config.json": ZCODE_USER_CONFIG_DECLARATION,
13198
+ "crush.json": CRUSH_CONFIG_DECLARATION,
13199
+ ".config/crush/crush.json": CRUSH_CONFIG_DECLARATION,
13055
13200
  ".bob/settings.json": {
13056
13201
  format: "json",
13057
13202
  invalidRootPolicy: "error",
@@ -13076,6 +13221,20 @@ const SHARED_CONFIG_OWNERSHIP = {
13076
13221
  ownedKeys: ["hooks"]
13077
13222
  } }
13078
13223
  },
13224
+ ".commandcode/settings.json": {
13225
+ format: "json",
13226
+ invalidRootPolicy: "error",
13227
+ features: {
13228
+ hooks: {
13229
+ kind: "replace-owned-keys",
13230
+ ownedKeys: ["hooks"]
13231
+ },
13232
+ permissions: {
13233
+ kind: "replace-owned-keys",
13234
+ ownedKeys: ["permissions"]
13235
+ }
13236
+ }
13237
+ },
13079
13238
  ".cortex/settings.json": {
13080
13239
  format: "json",
13081
13240
  invalidRootPolicy: "error",
@@ -15277,6 +15436,94 @@ var CodexcliCommand = class CodexcliCommand extends ToolCommand {
15277
15436
  }
15278
15437
  };
15279
15438
  //#endregion
15439
+ //#region src/constants/commandcode-paths.ts
15440
+ const COMMANDCODE_DIR = ".commandcode";
15441
+ const COMMANDCODE_RULE_FILE_NAME = "AGENTS.md";
15442
+ const COMMANDCODE_SETTINGS_FILE_NAME = "settings.json";
15443
+ const COMMANDCODE_PROJECT_MCP_FILE_NAME = ".mcp.json";
15444
+ const COMMANDCODE_GLOBAL_MCP_FILE_NAME = "mcp.json";
15445
+ const COMMANDCODE_COMMANDS_DIR_PATH = (0, node_path.join)(COMMANDCODE_DIR, "commands");
15446
+ const COMMANDCODE_AGENTS_DIR_PATH = (0, node_path.join)(COMMANDCODE_DIR, "agents");
15447
+ const COMMANDCODE_SKILLS_DIR_PATH = (0, node_path.join)(COMMANDCODE_DIR, "skills");
15448
+ //#endregion
15449
+ //#region src/features/commands/commandcode-command.ts
15450
+ /**
15451
+ * Custom slash command for Command Code.
15452
+ *
15453
+ * Command Code reads Markdown files from `.commandcode/commands/` (project)
15454
+ * and `~/.commandcode/commands/` (user), naming each command after the file's
15455
+ * basename; subdirectories only group the files. The file's full trimmed
15456
+ * body is the prompt that runs — YAML frontmatter is not stripped, only
15457
+ * skipped when the slash menu picks a summary line — so rulesync writes the
15458
+ * bare body. On import a hand-written file's frontmatter block is dropped
15459
+ * (Command Code would send it as prompt text; a rulesync command carries its
15460
+ * own frontmatter) and only the body is kept.
15461
+ *
15462
+ * @see https://commandcode.ai/docs/custom-slash-commands
15463
+ */
15464
+ var CommandcodeCommand = class CommandcodeCommand extends ToolCommand {
15465
+ static getSettablePaths(_options = {}) {
15466
+ return { relativeDirPath: COMMANDCODE_COMMANDS_DIR_PATH };
15467
+ }
15468
+ toRulesyncCommand() {
15469
+ return new RulesyncCommand({
15470
+ outputRoot: process.cwd(),
15471
+ frontmatter: { targets: ["*"] },
15472
+ body: this.getFileContent(),
15473
+ relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath,
15474
+ relativeFilePath: this.relativeFilePath,
15475
+ fileContent: this.getFileContent(),
15476
+ validate: true
15477
+ });
15478
+ }
15479
+ static fromRulesyncCommand({ outputRoot = process.cwd(), rulesyncCommand, validate = true }) {
15480
+ const paths = this.getSettablePaths();
15481
+ return new CommandcodeCommand({
15482
+ outputRoot,
15483
+ fileContent: rulesyncCommand.getBody(),
15484
+ relativeDirPath: paths.relativeDirPath,
15485
+ relativeFilePath: rulesyncCommand.getRelativeFilePath(),
15486
+ validate
15487
+ });
15488
+ }
15489
+ validate() {
15490
+ return {
15491
+ success: true,
15492
+ error: null
15493
+ };
15494
+ }
15495
+ getBody() {
15496
+ return this.getFileContent();
15497
+ }
15498
+ static isTargetedByRulesyncCommand(rulesyncCommand) {
15499
+ return this.isTargetedByRulesyncCommandDefault({
15500
+ rulesyncCommand,
15501
+ toolTarget: "commandcode"
15502
+ });
15503
+ }
15504
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
15505
+ const paths = this.getSettablePaths();
15506
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
15507
+ const { body: content } = parseFrontmatter(await readFileContent(filePath), filePath);
15508
+ return new CommandcodeCommand({
15509
+ outputRoot,
15510
+ relativeDirPath: paths.relativeDirPath,
15511
+ relativeFilePath,
15512
+ fileContent: content.trim(),
15513
+ validate
15514
+ });
15515
+ }
15516
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
15517
+ return new CommandcodeCommand({
15518
+ outputRoot,
15519
+ relativeDirPath,
15520
+ relativeFilePath,
15521
+ fileContent: "",
15522
+ validate: false
15523
+ });
15524
+ }
15525
+ };
15526
+ //#endregion
15280
15527
  //#region src/constants/continue-paths.ts
15281
15528
  const CONTINUE_DIR = ".continue";
15282
15529
  const CONTINUE_ROOT_RULE_FILE_NAME = "AGENTS.md";
@@ -18901,15 +19148,12 @@ var TabnineCommand = class TabnineCommand extends ToolCommand {
18901
19148
  return this.body;
18902
19149
  }
18903
19150
  getFrontmatter() {
18904
- return {
18905
- description: this.frontmatter.description,
18906
- prompt: this.frontmatter.prompt
18907
- };
19151
+ return this.frontmatter;
18908
19152
  }
18909
19153
  toRulesyncCommand() {
18910
19154
  const { description, prompt: _prompt, ...restFields } = this.frontmatter;
18911
19155
  const rulesyncFrontmatter = {
18912
- targets: ["tabnine"],
19156
+ targets: ["*"],
18913
19157
  description,
18914
19158
  ...Object.keys(restFields).length > 0 && { tabnine: restFields }
18915
19159
  };
@@ -19523,6 +19767,16 @@ const toolCommandFactories = /* @__PURE__ */ new Map([
19523
19767
  supportsSubdirectory: false
19524
19768
  }
19525
19769
  }],
19770
+ ["commandcode", {
19771
+ class: CommandcodeCommand,
19772
+ meta: {
19773
+ extension: "md",
19774
+ supportsProject: true,
19775
+ supportsGlobal: true,
19776
+ isSimulated: false,
19777
+ supportsSubdirectory: true
19778
+ }
19779
+ }],
19526
19780
  ["continue", {
19527
19781
  class: ContinueCommand,
19528
19782
  meta: {
@@ -20872,7 +21126,20 @@ function stableJson(value) {
20872
21126
  */
20873
21127
  function isSafeStringRecord(value) {
20874
21128
  if (!isPlainObject$1(value) || !isStringRecord(value)) return false;
20875
- return Object.entries(value).every(([key, entry]) => key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || entry.includes(char)));
21129
+ return Object.entries(value).every(([key, entry]) => isSafeEnvEntry({
21130
+ key,
21131
+ value: entry
21132
+ }));
21133
+ }
21134
+ /**
21135
+ * Whether one `env` entry survives the rule {@link isSafeStringRecord} applies
21136
+ * to the whole map. Exported for the adapters that hand a tool an environment
21137
+ * block outside the shared converter (Tabnine, whose hook shape it does not
21138
+ * express) so they drop the same entries this one does.
21139
+ */
21140
+ function isSafeEnvEntry(entry) {
21141
+ const { key, value } = entry;
21142
+ return typeof value === "string" && key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || value.includes(char));
20876
21143
  }
20877
21144
  /**
20878
21145
  * Control characters cannot ride from an existing tool config into a canonical
@@ -21763,135 +22030,6 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
21763
22030
  }
21764
22031
  };
21765
22032
  //#endregion
21766
- //#region src/features/hooks/bob-hooks.ts
21767
- const BOB_CONVERTER_CONFIG = {
21768
- supportedEvents: BOB_HOOK_EVENTS,
21769
- canonicalToToolEventNames: CANONICAL_TO_BOB_EVENT_NAMES,
21770
- toolToCanonicalEventNames: BOB_TO_CANONICAL_EVENT_NAMES,
21771
- projectDirVar: "",
21772
- noMatcherEvents: /* @__PURE__ */ new Set([
21773
- "sessionStart",
21774
- "beforeSubmitPrompt",
21775
- "stop"
21776
- ]),
21777
- supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
21778
- wildcardMatcherMeansAll: true
21779
- };
21780
- /**
21781
- * Single spelling of the settings.json codec/policy: fail closed on an
21782
- * unparseable root rather than replacing the user's Bob settings with
21783
- * generated output.
21784
- */
21785
- function parseBobSettings(fileContent, filePath) {
21786
- return parseSharedConfig({
21787
- format: "json",
21788
- fileContent,
21789
- filePath,
21790
- invalidRootPolicy: "error"
21791
- });
21792
- }
21793
- /**
21794
- * IBM Bob lifecycle hooks.
21795
- *
21796
- * Hooks live under the top-level `hooks` key of Bob's settings file —
21797
- * `<project>/.bob/settings.json` (project scope) and
21798
- * `~/.bob/settings/settings.json` (user scope) — in the Claude-Code shape:
21799
- * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command",
21800
- * "command": "...", "timeout"?: <seconds> }] }] }`. The file also holds
21801
- * settings rulesync does not own, so generation merges the `hooks` key into it
21802
- * (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting the file.
21803
- *
21804
- * @see https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks
21805
- */
21806
- var BobHooks = class BobHooks extends ToolHooks {
21807
- constructor(params) {
21808
- super({
21809
- ...params,
21810
- fileContent: params.fileContent ?? "{}"
21811
- });
21812
- }
21813
- isDeletable() {
21814
- return false;
21815
- }
21816
- static getSettablePaths({ global = false } = {}) {
21817
- return {
21818
- relativeDirPath: global ? BOB_GLOBAL_SETTINGS_DIR_PATH : BOB_DIR,
21819
- relativeFilePath: BOB_SETTINGS_FILE_NAME
21820
- };
21821
- }
21822
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
21823
- const paths = BobHooks.getSettablePaths({ global });
21824
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
21825
- return new BobHooks({
21826
- outputRoot,
21827
- relativeDirPath: paths.relativeDirPath,
21828
- relativeFilePath: paths.relativeFilePath,
21829
- fileContent,
21830
- validate
21831
- });
21832
- }
21833
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
21834
- const paths = BobHooks.getSettablePaths({ global });
21835
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
21836
- const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
21837
- const config = rulesyncHooks.getJson();
21838
- const hooks = canonicalToToolHooks({
21839
- config,
21840
- toolOverrideHooks: config.bob?.hooks,
21841
- converterConfig: BOB_CONVERTER_CONFIG,
21842
- logger
21843
- });
21844
- const fileContent = applySharedConfigPatch({
21845
- fileKey: sharedConfigFileKey(paths),
21846
- feature: "hooks",
21847
- existingContent,
21848
- patch: { hooks },
21849
- filePath,
21850
- logger
21851
- });
21852
- return new BobHooks({
21853
- outputRoot,
21854
- relativeDirPath: paths.relativeDirPath,
21855
- relativeFilePath: paths.relativeFilePath,
21856
- fileContent,
21857
- validate
21858
- });
21859
- }
21860
- toRulesyncHooks({ logger } = {}) {
21861
- const configPath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
21862
- let settings;
21863
- try {
21864
- settings = parseBobSettings(this.getFileContent(), configPath);
21865
- } catch (error) {
21866
- throw new Error(`Failed to parse Bob hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
21867
- }
21868
- const hooks = toolHooksToCanonical({
21869
- logger,
21870
- hooks: settings.hooks,
21871
- converterConfig: BOB_CONVERTER_CONFIG
21872
- });
21873
- return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
21874
- hooks,
21875
- overrideKey: "bob"
21876
- }), null, 2) });
21877
- }
21878
- validate() {
21879
- return {
21880
- success: true,
21881
- error: null
21882
- };
21883
- }
21884
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
21885
- return new BobHooks({
21886
- outputRoot,
21887
- relativeDirPath,
21888
- relativeFilePath,
21889
- fileContent: JSON.stringify({ hooks: {} }, null, 2),
21890
- validate: false
21891
- });
21892
- }
21893
- };
21894
- //#endregion
21895
22033
  //#region src/features/hooks/preserve-unowned-hook-commands.ts
21896
22034
  /**
21897
22035
  * Read the `hooks` value from a destination JSON file.
@@ -22178,75 +22316,22 @@ function warnSkip({ logger, event, expected }) {
22178
22316
  logger?.warn(`Skipping existing hook entry on ${event}: expected ${expected}`);
22179
22317
  }
22180
22318
  //#endregion
22181
- //#region src/features/hooks/claudecode-hooks.ts
22182
- const CLAUDE_CONVERTER_CONFIG = {
22183
- supportedEvents: CLAUDE_HOOK_EVENTS,
22184
- canonicalToToolEventNames: CANONICAL_TO_CLAUDE_EVENT_NAMES,
22185
- toolToCanonicalEventNames: CLAUDE_TO_CANONICAL_EVENT_NAMES,
22186
- projectDirVar: "$CLAUDE_PROJECT_DIR",
22187
- prefixDotRelativeCommandsOnly: true,
22188
- noMatcherEvents: /* @__PURE__ */ new Set([
22189
- "worktreeCreate",
22190
- "worktreeRemove",
22191
- "messageDisplay",
22192
- "postToolBatch",
22193
- "taskCreated",
22194
- "taskCompleted",
22195
- "teammateIdle",
22196
- "cwdChanged",
22197
- "beforeSubmitPrompt",
22198
- "stop"
22199
- ]),
22200
- supportedHookTypes: /* @__PURE__ */ new Set([
22201
- "command",
22202
- "prompt",
22203
- "http",
22204
- "mcp_tool",
22205
- "agent"
22206
- ]),
22207
- emitsPromptModel: true,
22208
- stringPassthroughFields: [
22209
- {
22210
- canonical: "if",
22211
- tool: "if"
22212
- },
22213
- {
22214
- canonical: "statusMessage",
22215
- tool: "statusMessage"
22216
- },
22217
- {
22218
- canonical: "shell",
22219
- tool: "shell",
22220
- commandOnly: true
22221
- }
22222
- ],
22223
- booleanPassthroughFields: [
22224
- {
22225
- canonical: "once",
22226
- tool: "once"
22227
- },
22228
- {
22229
- canonical: "async",
22230
- tool: "async",
22231
- commandOnly: true
22232
- },
22233
- {
22234
- canonical: "asyncRewake",
22235
- tool: "asyncRewake",
22236
- commandOnly: true
22237
- },
22238
- {
22239
- canonical: "continueOnBlock",
22240
- tool: "continueOnBlock"
22241
- }
22242
- ],
22243
- arrayPassthroughFields: [{
22244
- canonical: "args",
22245
- tool: "args",
22246
- commandOnly: true
22247
- }]
22248
- };
22249
- var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22319
+ //#region src/features/hooks/settings-json-hooks.ts
22320
+ /**
22321
+ * Shared implementation for the tools whose hooks live under the top-level
22322
+ * `hooks` key of a JSON settings file in Claude Code's shape —
22323
+ * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command", ... }] }] }`
22324
+ * — and whose file also holds settings rulesync does not own, so generation
22325
+ * merges the `hooks` key into it (see `SHARED_CONFIG_OWNERSHIP`) instead of
22326
+ * overwriting it, and `--delete` never removes the file wholesale.
22327
+ *
22328
+ * A concrete adapter supplies its {@link SettingsJsonHooksSpec} and its
22329
+ * settable paths; the event mapping, the matcher rules and the per-hook
22330
+ * fields the tool documents are all expressed in the spec's converter config.
22331
+ * An adapter that also supports preserving unowned hooks (Claude Code) opts in
22332
+ * through {@link ToolHooks.supportsPreserveUnowned}.
22333
+ */
22334
+ var SettingsJsonHooks = class extends ToolHooks {
22250
22335
  constructor(params) {
22251
22336
  super({
22252
22337
  ...params,
@@ -22256,24 +22341,42 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22256
22341
  isDeletable() {
22257
22342
  return false;
22258
22343
  }
22344
+ /** The tool-specific half of the adapter. Every concrete class overrides this. */
22345
+ static getSpec() {
22346
+ throw new Error(`${this.name} does not define getSpec()`);
22347
+ }
22259
22348
  /**
22260
- * The converter config used for both directions. Exposed as a static hook so
22261
- * plugin-scoped subclasses can swap tool-specific details (e.g. the project
22262
- * directory variable) without duplicating the rest of the config.
22349
+ * The converter config used for both directions. A separate hook so a
22350
+ * subclass can swap tool-specific details (e.g. the project directory
22351
+ * variable) without redefining the whole spec.
22263
22352
  */
22264
22353
  static getConverterConfig() {
22265
- return CLAUDE_CONVERTER_CONFIG;
22354
+ return this.getSpec().converterConfig;
22266
22355
  }
22267
22356
  static getSettablePaths(_options = {}) {
22268
- return {
22269
- relativeDirPath: CLAUDECODE_DIR,
22270
- relativeFilePath: CLAUDECODE_SETTINGS_FILE_NAME
22271
- };
22357
+ throw new Error(`${this.name} does not define getSettablePaths()`);
22358
+ }
22359
+ /**
22360
+ * The `SHARED_CONFIG_OWNERSHIP` key the settings file is merged under. By
22361
+ * default it is derived from the settable paths; an adapter whose file is
22362
+ * declared under another tool's key (the Claude Code plugin bundle) overrides
22363
+ * it.
22364
+ */
22365
+ static getSharedFileKey(paths) {
22366
+ return sharedConfigFileKey(paths);
22367
+ }
22368
+ /**
22369
+ * `new this(params)` for the concrete adapter the static method was called
22370
+ * on; the cast is what an abstract class needs to be constructed through
22371
+ * `this`, and lives in one place.
22372
+ */
22373
+ static instantiate(params) {
22374
+ return new this(params);
22272
22375
  }
22273
22376
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
22274
22377
  const paths = this.getSettablePaths({ global });
22275
22378
  const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
22276
- return new this({
22379
+ return this.instantiate({
22277
22380
  outputRoot,
22278
22381
  relativeDirPath: paths.relativeDirPath,
22279
22382
  relativeFilePath: paths.relativeFilePath,
@@ -22281,40 +22384,41 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22281
22384
  validate
22282
22385
  });
22283
22386
  }
22284
- static supportsPreserveUnowned() {
22285
- return true;
22286
- }
22287
22387
  static async getAuxiliaryFiles({ toolHooks } = {}) {
22288
- return toolHooks instanceof ClaudecodeHooks ? toolHooks.getOwnershipLockFiles() : [];
22388
+ return toolHooks instanceof this ? toolHooks.getOwnershipLockFiles() : [];
22289
22389
  }
22290
22390
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, preserveUnowned = false, logger }) {
22291
22391
  const paths = this.getSettablePaths({ global });
22292
22392
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22293
22393
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
22294
22394
  const config = rulesyncHooks.getJson();
22295
- const claudeHooks = canonicalToToolHooks({
22395
+ const generatedHooks = canonicalToToolHooks({
22296
22396
  config,
22297
- toolOverrideHooks: config.claudecode?.hooks,
22397
+ toolOverrideHooks: overrideHooksOf({
22398
+ config,
22399
+ overrideKey: this.getSpec().overrideKey
22400
+ }),
22298
22401
  converterConfig: this.getConverterConfig(),
22299
22402
  logger
22300
22403
  });
22301
22404
  const preserving = preserveUnowned && this.supportsPreserveUnowned();
22302
22405
  const merged = mergeGeneratedHookLists({
22303
22406
  existingContent,
22304
- generatedHooks: claudeHooks,
22407
+ generatedHooks,
22305
22408
  shape: "matcher-groups",
22306
22409
  preserveUnowned: preserving,
22307
22410
  previouslyOwned: preserving ? parseHooksOwnershipLock(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, HOOKS_OWNERSHIP_LOCK_FILE_NAME))) : void 0,
22308
22411
  logger
22309
22412
  });
22310
22413
  const fileContent = applySharedConfigPatch({
22311
- fileKey: CLAUDE_SETTINGS_SHARED_FILE_KEY,
22414
+ fileKey: this.getSharedFileKey(paths),
22312
22415
  feature: "hooks",
22313
22416
  existingContent,
22314
22417
  patch: { hooks: merged.hooks },
22315
- filePath
22418
+ filePath,
22419
+ logger
22316
22420
  });
22317
- return new this({
22421
+ return this.instantiate({
22318
22422
  outputRoot,
22319
22423
  relativeDirPath: paths.relativeDirPath,
22320
22424
  relativeFilePath: paths.relativeFilePath,
@@ -22324,20 +22428,29 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22324
22428
  });
22325
22429
  }
22326
22430
  toRulesyncHooks({ logger } = {}) {
22431
+ const ctor = this.constructor;
22432
+ const spec = ctor.getSpec();
22433
+ const configPath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
22327
22434
  let settings;
22328
22435
  try {
22329
- settings = JSON.parse(this.getFileContent());
22436
+ settings = parseSharedConfig({
22437
+ format: "json",
22438
+ fileContent: this.getFileContent(),
22439
+ filePath: configPath,
22440
+ invalidRootPolicy: "error"
22441
+ });
22330
22442
  } catch (error) {
22331
- throw new Error(`Failed to parse Claude hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
22443
+ const reason = error instanceof Error && error.cause instanceof Error ? error.cause : error;
22444
+ throw new Error(`Failed to parse ${spec.displayName} hooks content in ${configPath}: ${formatError(reason)}`, { cause: error });
22332
22445
  }
22333
22446
  const hooks = toolHooksToCanonical({
22447
+ logger,
22334
22448
  hooks: settings.hooks,
22335
- converterConfig: this.constructor.getConverterConfig(),
22336
- logger
22449
+ converterConfig: ctor.getConverterConfig()
22337
22450
  });
22338
22451
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
22339
22452
  hooks,
22340
- overrideKey: "claudecode"
22453
+ overrideKey: spec.overrideKey
22341
22454
  }), null, 2) });
22342
22455
  }
22343
22456
  validate() {
@@ -22347,7 +22460,7 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22347
22460
  };
22348
22461
  }
22349
22462
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
22350
- return new this({
22463
+ return this.instantiate({
22351
22464
  outputRoot,
22352
22465
  relativeDirPath,
22353
22466
  relativeFilePath,
@@ -22356,6 +22469,158 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22356
22469
  });
22357
22470
  }
22358
22471
  };
22472
+ /**
22473
+ * The `hooks` map of a tool's override block (`config.<overrideKey>.hooks`),
22474
+ * or `undefined` when the block states none. The schema keeps every override
22475
+ * block loose, so the lookup goes through the record form.
22476
+ */
22477
+ function overrideHooksOf({ config, overrideKey }) {
22478
+ const override = lookupOwn({
22479
+ record: config,
22480
+ key: overrideKey
22481
+ });
22482
+ if (!isPlainObject$1(override)) return;
22483
+ const hooks = override.hooks;
22484
+ return isPlainObject$1(hooks) ? hooks : void 0;
22485
+ }
22486
+ //#endregion
22487
+ //#region src/features/hooks/bob-hooks.ts
22488
+ const BOB_SPEC = {
22489
+ displayName: "Bob",
22490
+ overrideKey: "bob",
22491
+ converterConfig: {
22492
+ supportedEvents: BOB_HOOK_EVENTS,
22493
+ canonicalToToolEventNames: CANONICAL_TO_BOB_EVENT_NAMES,
22494
+ toolToCanonicalEventNames: BOB_TO_CANONICAL_EVENT_NAMES,
22495
+ projectDirVar: "",
22496
+ noMatcherEvents: /* @__PURE__ */ new Set([
22497
+ "sessionStart",
22498
+ "beforeSubmitPrompt",
22499
+ "stop"
22500
+ ]),
22501
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
22502
+ wildcardMatcherMeansAll: true
22503
+ }
22504
+ };
22505
+ /**
22506
+ * IBM Bob lifecycle hooks.
22507
+ *
22508
+ * Hooks live under the top-level `hooks` key of Bob's settings file —
22509
+ * `<project>/.bob/settings.json` (project scope) and
22510
+ * `~/.bob/settings/settings.json` (user scope) — in the Claude-Code shape:
22511
+ * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command",
22512
+ * "command": "...", "timeout"?: <seconds> }] }] }`. The file also holds
22513
+ * settings rulesync does not own, so generation merges the `hooks` key into it
22514
+ * (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting the file.
22515
+ *
22516
+ * @see https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks
22517
+ */
22518
+ var BobHooks = class extends SettingsJsonHooks {
22519
+ static getSpec() {
22520
+ return BOB_SPEC;
22521
+ }
22522
+ static getSettablePaths({ global = false } = {}) {
22523
+ return {
22524
+ relativeDirPath: global ? BOB_GLOBAL_SETTINGS_DIR_PATH : BOB_DIR,
22525
+ relativeFilePath: BOB_SETTINGS_FILE_NAME
22526
+ };
22527
+ }
22528
+ };
22529
+ //#endregion
22530
+ //#region src/features/hooks/claudecode-hooks.ts
22531
+ const CLAUDE_SPEC = {
22532
+ displayName: "Claude",
22533
+ overrideKey: "claudecode",
22534
+ converterConfig: {
22535
+ supportedEvents: CLAUDE_HOOK_EVENTS,
22536
+ canonicalToToolEventNames: CANONICAL_TO_CLAUDE_EVENT_NAMES,
22537
+ toolToCanonicalEventNames: CLAUDE_TO_CANONICAL_EVENT_NAMES,
22538
+ projectDirVar: "$CLAUDE_PROJECT_DIR",
22539
+ prefixDotRelativeCommandsOnly: true,
22540
+ noMatcherEvents: /* @__PURE__ */ new Set([
22541
+ "worktreeCreate",
22542
+ "worktreeRemove",
22543
+ "messageDisplay",
22544
+ "postToolBatch",
22545
+ "taskCreated",
22546
+ "taskCompleted",
22547
+ "teammateIdle",
22548
+ "cwdChanged",
22549
+ "beforeSubmitPrompt",
22550
+ "stop"
22551
+ ]),
22552
+ supportedHookTypes: /* @__PURE__ */ new Set([
22553
+ "command",
22554
+ "prompt",
22555
+ "http",
22556
+ "mcp_tool",
22557
+ "agent"
22558
+ ]),
22559
+ emitsPromptModel: true,
22560
+ stringPassthroughFields: [
22561
+ {
22562
+ canonical: "if",
22563
+ tool: "if"
22564
+ },
22565
+ {
22566
+ canonical: "statusMessage",
22567
+ tool: "statusMessage"
22568
+ },
22569
+ {
22570
+ canonical: "shell",
22571
+ tool: "shell",
22572
+ commandOnly: true
22573
+ }
22574
+ ],
22575
+ booleanPassthroughFields: [
22576
+ {
22577
+ canonical: "once",
22578
+ tool: "once"
22579
+ },
22580
+ {
22581
+ canonical: "async",
22582
+ tool: "async",
22583
+ commandOnly: true
22584
+ },
22585
+ {
22586
+ canonical: "asyncRewake",
22587
+ tool: "asyncRewake",
22588
+ commandOnly: true
22589
+ },
22590
+ {
22591
+ canonical: "continueOnBlock",
22592
+ tool: "continueOnBlock"
22593
+ }
22594
+ ],
22595
+ arrayPassthroughFields: [{
22596
+ canonical: "args",
22597
+ tool: "args",
22598
+ commandOnly: true
22599
+ }]
22600
+ }
22601
+ };
22602
+ var ClaudecodeHooks = class extends SettingsJsonHooks {
22603
+ static getSpec() {
22604
+ return CLAUDE_SPEC;
22605
+ }
22606
+ static getSettablePaths(_options = {}) {
22607
+ return {
22608
+ relativeDirPath: CLAUDECODE_DIR,
22609
+ relativeFilePath: CLAUDECODE_SETTINGS_FILE_NAME
22610
+ };
22611
+ }
22612
+ /**
22613
+ * Every Claude-shaped settings file rulesync writes for Claude Code — the
22614
+ * project and user `settings.json` and the plugin bundle's `hooks.json` — is
22615
+ * declared under the one Claude key in `SHARED_CONFIG_OWNERSHIP`.
22616
+ */
22617
+ static getSharedFileKey(_paths) {
22618
+ return CLAUDE_SETTINGS_SHARED_FILE_KEY;
22619
+ }
22620
+ static supportsPreserveUnowned() {
22621
+ return true;
22622
+ }
22623
+ };
22359
22624
  //#endregion
22360
22625
  //#region src/features/hooks/claudecode-plugin-hooks.ts
22361
22626
  var ClaudecodePluginHooks = class extends ClaudecodeHooks {
@@ -22882,47 +23147,22 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
22882
23147
  }
22883
23148
  };
22884
23149
  //#endregion
22885
- //#region src/features/hooks/continue-hooks.ts
22886
- const CONTINUE_CONVERTER_CONFIG = {
22887
- supportedEvents: CONTINUE_HOOK_EVENTS,
22888
- canonicalToToolEventNames: CANONICAL_TO_CONTINUE_EVENT_NAMES,
22889
- toolToCanonicalEventNames: CONTINUE_TO_CANONICAL_EVENT_NAMES,
22890
- projectDirVar: "$CONTINUE_PROJECT_DIR",
23150
+ //#region src/features/hooks/commandcode-hooks.ts
23151
+ const COMMANDCODE_CONVERTER_CONFIG = {
23152
+ supportedEvents: COMMANDCODE_HOOK_EVENTS,
23153
+ canonicalToToolEventNames: CANONICAL_TO_COMMANDCODE_EVENT_NAMES,
23154
+ toolToCanonicalEventNames: COMMANDCODE_TO_CANONICAL_EVENT_NAMES,
23155
+ projectDirVar: "$COMMANDCODE_PROJECT_DIR",
22891
23156
  prefixDotRelativeCommandsOnly: true,
22892
- noMatcherEvents: /* @__PURE__ */ new Set([
22893
- "beforeSubmitPrompt",
22894
- "stop",
22895
- "teammateIdle",
22896
- "taskCompleted",
22897
- "worktreeCreate",
22898
- "worktreeRemove"
22899
- ]),
22900
- supportedHookTypes: /* @__PURE__ */ new Set([
22901
- "command",
22902
- "prompt",
22903
- "http",
22904
- "agent"
22905
- ]),
22906
- emitsPromptModel: true,
22907
- stringPassthroughFields: [{
22908
- canonical: "statusMessage",
22909
- tool: "statusMessage"
22910
- }],
22911
- booleanPassthroughFields: [{
22912
- canonical: "once",
22913
- tool: "once"
22914
- }, {
22915
- canonical: "async",
22916
- tool: "async",
22917
- commandOnly: true
22918
- }]
23157
+ noMatcherEvents: /* @__PURE__ */ new Set(["stop", "sessionStart"]),
23158
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"])
22919
23159
  };
22920
23160
  /**
22921
- * Single spelling of the settings/hooks codec/policy: fail closed on an
22922
- * unparseable root rather than replacing the user's Continue settings with
22923
- * generated output.
23161
+ * Single spelling of the settings codec/policy: fail closed on an unparseable
23162
+ * root rather than replacing the user's Command Code settings with generated
23163
+ * output.
22924
23164
  */
22925
- function parseContinueSettings(fileContent, filePath) {
23165
+ function parseCommandcodeSettings$1(fileContent, filePath) {
22926
23166
  return parseSharedConfig({
22927
23167
  format: "json",
22928
23168
  fileContent,
@@ -22931,22 +23171,20 @@ function parseContinueSettings(fileContent, filePath) {
22931
23171
  });
22932
23172
  }
22933
23173
  /**
22934
- * Continue CLI hooks.
23174
+ * Command Code hooks.
22935
23175
  *
22936
- * Hooks live under the top-level `hooks` key of `<project>/.continue/settings.json`
22937
- * (project scope) and `~/.continue/settings.json` (user scope), in the
23176
+ * Hooks live under the top-level `hooks` key of `<project>/.commandcode/settings.json`
23177
+ * (project scope) and `~/.commandcode/settings.json` (user scope), in the
22938
23178
  * Claude-Code shape: `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{
22939
- * "type": "command" | "http" | "prompt" | "agent", ..., "timeout"?: <seconds>
22940
- * }] }] }`. Both files also hold settings rulesync does not own, so generation
22941
- * merges the `hooks` key into the existing file (see `SHARED_CONFIG_OWNERSHIP`)
22942
- * instead of overwriting it. The CLI additionally reads
22943
- * `.continue/settings.local.json` and the Claude Code settings files; rulesync
22944
- * writes only the committable project file and the user file.
23179
+ * "type": "command", "command": "...", "timeout"?: <seconds> }] }] }`. Both
23180
+ * files also hold settings rulesync does not own (`permissions`, `defaultMode`,
23181
+ * ...), so generation merges the `hooks` key into the existing file (see
23182
+ * `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it.
22945
23183
  *
22946
- * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/hookConfig.ts
22947
- * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/types.ts
23184
+ * @see https://commandcode.ai/docs/hooks
23185
+ * @see https://commandcode.ai/docs/settings
22948
23186
  */
22949
- var ContinueHooks = class ContinueHooks extends ToolHooks {
23187
+ var CommandcodeHooks = class CommandcodeHooks extends ToolHooks {
22950
23188
  constructor(params) {
22951
23189
  super({
22952
23190
  ...params,
@@ -22958,14 +23196,14 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
22958
23196
  }
22959
23197
  static getSettablePaths(_options = {}) {
22960
23198
  return {
22961
- relativeDirPath: CONTINUE_DIR,
22962
- relativeFilePath: CONTINUE_SETTINGS_FILE_NAME
23199
+ relativeDirPath: COMMANDCODE_DIR,
23200
+ relativeFilePath: COMMANDCODE_SETTINGS_FILE_NAME
22963
23201
  };
22964
23202
  }
22965
23203
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
22966
- const paths = ContinueHooks.getSettablePaths({ global });
23204
+ const paths = CommandcodeHooks.getSettablePaths({ global });
22967
23205
  const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
22968
- return new ContinueHooks({
23206
+ return new CommandcodeHooks({
22969
23207
  outputRoot,
22970
23208
  relativeDirPath: paths.relativeDirPath,
22971
23209
  relativeFilePath: paths.relativeFilePath,
@@ -22974,14 +23212,14 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
22974
23212
  });
22975
23213
  }
22976
23214
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
22977
- const paths = ContinueHooks.getSettablePaths({ global });
23215
+ const paths = CommandcodeHooks.getSettablePaths({ global });
22978
23216
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22979
23217
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
22980
23218
  const config = rulesyncHooks.getJson();
22981
23219
  const hooks = canonicalToToolHooks({
22982
23220
  config,
22983
- toolOverrideHooks: config.continue?.hooks,
22984
- converterConfig: CONTINUE_CONVERTER_CONFIG,
23221
+ toolOverrideHooks: config.commandcode?.hooks,
23222
+ converterConfig: COMMANDCODE_CONVERTER_CONFIG,
22985
23223
  logger
22986
23224
  });
22987
23225
  const fileContent = applySharedConfigPatch({
@@ -22992,7 +23230,7 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
22992
23230
  filePath,
22993
23231
  logger
22994
23232
  });
22995
- return new ContinueHooks({
23233
+ return new CommandcodeHooks({
22996
23234
  outputRoot,
22997
23235
  relativeDirPath: paths.relativeDirPath,
22998
23236
  relativeFilePath: paths.relativeFilePath,
@@ -23004,18 +23242,18 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
23004
23242
  const configPath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
23005
23243
  let settings;
23006
23244
  try {
23007
- settings = parseContinueSettings(this.getFileContent(), configPath);
23245
+ settings = parseCommandcodeSettings$1(this.getFileContent(), configPath);
23008
23246
  } catch (error) {
23009
- throw new Error(`Failed to parse Continue hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
23247
+ throw new Error(`Failed to parse Command Code hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
23010
23248
  }
23011
23249
  const hooks = toolHooksToCanonical({
23012
23250
  logger,
23013
23251
  hooks: settings.hooks,
23014
- converterConfig: CONTINUE_CONVERTER_CONFIG
23252
+ converterConfig: COMMANDCODE_CONVERTER_CONFIG
23015
23253
  });
23016
23254
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
23017
23255
  hooks,
23018
- overrideKey: "continue"
23256
+ overrideKey: "commandcode"
23019
23257
  }), null, 2) });
23020
23258
  }
23021
23259
  validate() {
@@ -23025,7 +23263,7 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
23025
23263
  };
23026
23264
  }
23027
23265
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
23028
- return new ContinueHooks({
23266
+ return new CommandcodeHooks({
23029
23267
  outputRoot,
23030
23268
  relativeDirPath,
23031
23269
  relativeFilePath,
@@ -23035,6 +23273,73 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
23035
23273
  }
23036
23274
  };
23037
23275
  //#endregion
23276
+ //#region src/features/hooks/continue-hooks.ts
23277
+ const CONTINUE_SPEC = {
23278
+ displayName: "Continue",
23279
+ overrideKey: "continue",
23280
+ converterConfig: {
23281
+ supportedEvents: CONTINUE_HOOK_EVENTS,
23282
+ canonicalToToolEventNames: CANONICAL_TO_CONTINUE_EVENT_NAMES,
23283
+ toolToCanonicalEventNames: CONTINUE_TO_CANONICAL_EVENT_NAMES,
23284
+ projectDirVar: "$CONTINUE_PROJECT_DIR",
23285
+ prefixDotRelativeCommandsOnly: true,
23286
+ noMatcherEvents: /* @__PURE__ */ new Set([
23287
+ "beforeSubmitPrompt",
23288
+ "stop",
23289
+ "teammateIdle",
23290
+ "taskCompleted",
23291
+ "worktreeCreate",
23292
+ "worktreeRemove"
23293
+ ]),
23294
+ supportedHookTypes: /* @__PURE__ */ new Set([
23295
+ "command",
23296
+ "prompt",
23297
+ "http",
23298
+ "agent"
23299
+ ]),
23300
+ emitsPromptModel: true,
23301
+ stringPassthroughFields: [{
23302
+ canonical: "statusMessage",
23303
+ tool: "statusMessage"
23304
+ }],
23305
+ booleanPassthroughFields: [{
23306
+ canonical: "once",
23307
+ tool: "once"
23308
+ }, {
23309
+ canonical: "async",
23310
+ tool: "async",
23311
+ commandOnly: true
23312
+ }]
23313
+ }
23314
+ };
23315
+ /**
23316
+ * Continue CLI hooks.
23317
+ *
23318
+ * Hooks live under the top-level `hooks` key of `<project>/.continue/settings.json`
23319
+ * (project scope) and `~/.continue/settings.json` (user scope), in the
23320
+ * Claude-Code shape: `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{
23321
+ * "type": "command" | "http" | "prompt" | "agent", ..., "timeout"?: <seconds>
23322
+ * }] }] }`. Both files also hold settings rulesync does not own, so generation
23323
+ * merges the `hooks` key into the existing file (see `SHARED_CONFIG_OWNERSHIP`)
23324
+ * instead of overwriting it. The CLI additionally reads
23325
+ * `.continue/settings.local.json` and the Claude Code settings files; rulesync
23326
+ * writes only the committable project file and the user file.
23327
+ *
23328
+ * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/hookConfig.ts
23329
+ * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/types.ts
23330
+ */
23331
+ var ContinueHooks = class extends SettingsJsonHooks {
23332
+ static getSpec() {
23333
+ return CONTINUE_SPEC;
23334
+ }
23335
+ static getSettablePaths(_options = {}) {
23336
+ return {
23337
+ relativeDirPath: CONTINUE_DIR,
23338
+ relativeFilePath: CONTINUE_SETTINGS_FILE_NAME
23339
+ };
23340
+ }
23341
+ };
23342
+ //#endregion
23038
23343
  //#region src/features/hooks/copilot-hooks.ts
23039
23344
  /**
23040
23345
  * Copilot hook entry as stored in .github/hooks/copilot-hooks.json.
@@ -23623,33 +23928,24 @@ const CORTEXCODE_SKILLS_DIR_PATH = (0, node_path.join)(CORTEXCODE_DIR, "skills")
23623
23928
  const CORTEXCODE_GLOBAL_SKILLS_DIR_PATH = (0, node_path.join)(CORTEXCODE_GLOBAL_DIR_PATH, "skills");
23624
23929
  //#endregion
23625
23930
  //#region src/features/hooks/cortexcode-hooks.ts
23626
- const CORTEXCODE_CONVERTER_CONFIG = {
23627
- supportedEvents: CORTEXCODE_HOOK_EVENTS,
23628
- canonicalToToolEventNames: CANONICAL_TO_CORTEXCODE_EVENT_NAMES,
23629
- toolToCanonicalEventNames: CORTEXCODE_TO_CANONICAL_EVENT_NAMES,
23630
- projectDirVar: "$CORTEX_PROJECT_DIR",
23631
- prefixDotRelativeCommandsOnly: true,
23632
- noMatcherEvents: /* @__PURE__ */ new Set(["beforeSubmitPrompt", "stop"]),
23633
- supportedHookTypes: /* @__PURE__ */ new Set(["command", "prompt"]),
23634
- booleanPassthroughFields: [{
23635
- canonical: "enabled",
23636
- tool: "enabled"
23637
- }]
23931
+ const CORTEXCODE_SPEC = {
23932
+ displayName: "Cortex Code",
23933
+ overrideKey: "cortexcode",
23934
+ converterConfig: {
23935
+ supportedEvents: CORTEXCODE_HOOK_EVENTS,
23936
+ canonicalToToolEventNames: CANONICAL_TO_CORTEXCODE_EVENT_NAMES,
23937
+ toolToCanonicalEventNames: CORTEXCODE_TO_CANONICAL_EVENT_NAMES,
23938
+ projectDirVar: "$CORTEX_PROJECT_DIR",
23939
+ prefixDotRelativeCommandsOnly: true,
23940
+ noMatcherEvents: /* @__PURE__ */ new Set(["beforeSubmitPrompt", "stop"]),
23941
+ supportedHookTypes: /* @__PURE__ */ new Set(["command", "prompt"]),
23942
+ booleanPassthroughFields: [{
23943
+ canonical: "enabled",
23944
+ tool: "enabled"
23945
+ }]
23946
+ }
23638
23947
  };
23639
23948
  /**
23640
- * Single spelling of the settings/hooks codec/policy: fail closed on an
23641
- * unparseable root rather than replacing the user's Cortex Code settings with
23642
- * generated output.
23643
- */
23644
- function parseCortexcodeSettings(fileContent, filePath) {
23645
- return parseSharedConfig({
23646
- format: "json",
23647
- fileContent,
23648
- filePath,
23649
- invalidRootPolicy: "error"
23650
- });
23651
- }
23652
- /**
23653
23949
  * Snowflake Cortex Code hooks.
23654
23950
  *
23655
23951
  * Hooks live under the top-level `hooks` key of `<project>/.cortex/settings.json`
@@ -23658,20 +23954,16 @@ function parseCortexcodeSettings(fileContent, filePath) {
23658
23954
  * "<regex>", "hooks": [{ "type": "command" | "prompt", ..., "timeout"?:
23659
23955
  * <seconds>, "enabled"?: <boolean> }] }] }`. The project file also holds
23660
23956
  * settings rulesync does not own, so generation merges the `hooks` key into
23661
- * either file (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it.
23957
+ * either file (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it, and
23958
+ * neither file is removed wholesale (hooks.json sits in the CLI-owned
23959
+ * `~/.snowflake/cortex/` tree).
23662
23960
  *
23663
23961
  * @see https://docs.snowflake.com/en/user-guide/cortex-code/extensibility
23664
23962
  * @see https://docs.snowflake.com/en/user-guide/cortex-code/settings
23665
23963
  */
23666
- var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23667
- constructor(params) {
23668
- super({
23669
- ...params,
23670
- fileContent: params.fileContent ?? "{}"
23671
- });
23672
- }
23673
- isDeletable() {
23674
- return false;
23964
+ var CortexcodeHooks = class extends SettingsJsonHooks {
23965
+ static getSpec() {
23966
+ return CORTEXCODE_SPEC;
23675
23967
  }
23676
23968
  static getSettablePaths({ global = false } = {}) {
23677
23969
  return global ? {
@@ -23682,60 +23974,338 @@ var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23682
23974
  relativeFilePath: CORTEXCODE_SETTINGS_FILE_NAME
23683
23975
  };
23684
23976
  }
23977
+ };
23978
+ //#endregion
23979
+ //#region src/constants/crush-paths.ts
23980
+ const CRUSH_RULE_FILE_NAME = "CRUSH.md";
23981
+ const CRUSH_GLOBAL_DIR = (0, node_path.join)(".config", "crush");
23982
+ const CRUSH_LOCAL_RULE_FILE_NAME = "CRUSH.local.md";
23983
+ const CRUSH_IGNORE_FILE_NAME = ".crushignore";
23984
+ const CRUSH_SKILLS_PROJECT_DIR = (0, node_path.join)(".crush", "skills");
23985
+ const CRUSH_SKILLS_GLOBAL_DIR = (0, node_path.join)(CRUSH_GLOBAL_DIR, "skills");
23986
+ const CRUSH_CONFIG_FILE_NAME = "crush.json";
23987
+ const CRUSH_HIDDEN_CONFIG_FILE_NAME = ".crush.json";
23988
+ const CRUSH_PERMISSIONS_KEY = "permissions";
23989
+ const CRUSH_ALLOWED_TOOLS_KEY = "allowed_tools";
23990
+ const CRUSH_OPTIONS_KEY = "options";
23991
+ const CRUSH_DISABLED_TOOLS_KEY = "disabled_tools";
23992
+ const CRUSH_HOOKS_KEY = "hooks";
23993
+ //#endregion
23994
+ //#region src/features/crush-config.ts
23995
+ /**
23996
+ * Where Crush's JSON config lives for a scope, before the project-scope twin
23997
+ * is resolved: `<project>/crush.json` or `~/.config/crush/crush.json`. This is
23998
+ * also the key every adapter reports through `getSettablePaths`, so the
23999
+ * shared-config ownership declaration, the gitignore derivation and the
24000
+ * shared-write ordering all see one file per scope.
24001
+ */
24002
+ function getCrushConfigSettablePaths({ global = false } = {}) {
24003
+ return {
24004
+ relativeDirPath: global ? CRUSH_GLOBAL_DIR : ".",
24005
+ relativeFilePath: CRUSH_CONFIG_FILE_NAME
24006
+ };
24007
+ }
24008
+ /**
24009
+ * Resolves which Crush config file an adapter should read and write.
24010
+ *
24011
+ * At project scope Crush discovers both `.crush.json` and `crush.json` in the
24012
+ * working directory and merges every file it finds with `.crush.json` on top
24013
+ * (objects merge recursively, lists are concatenated, scalars are overridden).
24014
+ * Neither file therefore hides the other: an entry left in `crush.json` stays
24015
+ * in effect next to whatever `.crush.json` says. An existing `.crush.json` is
24016
+ * preferred because the user chose that spelling; otherwise `crush.json` is
24017
+ * used (and created when neither exists). The global scope has a single
24018
+ * spelling.
24019
+ *
24020
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
24021
+ */
24022
+ async function resolveCrushConfigFile({ outputRoot, global = false }) {
24023
+ const paths = getCrushConfigSettablePaths({ global });
24024
+ const configDir = (0, node_path.join)(outputRoot, paths.relativeDirPath);
24025
+ const filePath = (0, node_path.join)(configDir, paths.relativeFilePath);
24026
+ const fileContent = await readFileContentOrNull(filePath);
24027
+ if (!global) {
24028
+ const hiddenPath = (0, node_path.join)(configDir, CRUSH_HIDDEN_CONFIG_FILE_NAME);
24029
+ const hiddenContent = await readFileContentOrNull(hiddenPath);
24030
+ if (hiddenContent !== null) return {
24031
+ relativeDirPath: paths.relativeDirPath,
24032
+ relativeFilePath: CRUSH_HIDDEN_CONFIG_FILE_NAME,
24033
+ filePath: hiddenPath,
24034
+ fileContent: hiddenContent,
24035
+ ...fileContent === null ? {} : { twin: {
24036
+ filePath,
24037
+ fileContent
24038
+ } }
24039
+ };
24040
+ }
24041
+ return {
24042
+ relativeDirPath: paths.relativeDirPath,
24043
+ relativeFilePath: paths.relativeFilePath,
24044
+ filePath,
24045
+ fileContent
24046
+ };
24047
+ }
24048
+ /**
24049
+ * Single spelling of the crush.json codec/policy, matching the
24050
+ * `SHARED_CONFIG_OWNERSHIP` declaration for both scopes: fail closed on an
24051
+ * unparseable root rather than replacing the user's Crush config with
24052
+ * generated output.
24053
+ */
24054
+ function parseCrushConfig(fileContent, filePath) {
24055
+ return parseSharedConfig({
24056
+ format: "json",
24057
+ fileContent,
24058
+ filePath,
24059
+ invalidRootPolicy: "error"
24060
+ });
24061
+ }
24062
+ /**
24063
+ * Combine two Crush config documents the way Crush itself does
24064
+ * (`github.com/qjebbs/go-jsons`): objects merge recursively, arrays are
24065
+ * concatenated with `base` first, and any other value from `override` wins.
24066
+ */
24067
+ function mergeCrushConfigs({ base, override }) {
24068
+ const merged = { ...base };
24069
+ for (const [key, value] of Object.entries(override)) {
24070
+ if (isPrototypePollutionKey(key)) continue;
24071
+ const current = merged[key];
24072
+ if (isRecord$1(current) && isRecord$1(value)) merged[key] = mergeCrushConfigs({
24073
+ base: current,
24074
+ override: value
24075
+ });
24076
+ else if (Array.isArray(current) && Array.isArray(value)) merged[key] = [...current, ...value];
24077
+ else merged[key] = value;
24078
+ }
24079
+ return merged;
24080
+ }
24081
+ /**
24082
+ * The content an import should read for a resolved location: the chosen file
24083
+ * merged over its twin when both exist, so a server, hook or tool entry that
24084
+ * only the lower-priority `crush.json` declares is imported too — Crush sees
24085
+ * it, so rulesync should as well.
24086
+ */
24087
+ function crushConfigImportContent(location) {
24088
+ const own = location.fileContent ?? "{}";
24089
+ if (location.twin === void 0) return own;
24090
+ const merged = mergeCrushConfigs({
24091
+ base: parseCrushConfig(location.twin.fileContent, location.twin.filePath),
24092
+ override: parseCrushConfig(own, location.filePath)
24093
+ });
24094
+ return JSON.stringify(merged, null, 2);
24095
+ }
24096
+ function lookupPath(root, path) {
24097
+ let current = root;
24098
+ for (const segment of path) {
24099
+ if (!isRecord$1(current) || !Object.hasOwn(current, segment)) return;
24100
+ current = current[segment];
24101
+ }
24102
+ return current;
24103
+ }
24104
+ function isNonEmptyValue(value) {
24105
+ if (Array.isArray(value)) return value.length > 0;
24106
+ if (isRecord$1(value)) return Object.keys(value).length > 0;
24107
+ return value !== void 0;
24108
+ }
24109
+ /**
24110
+ * Warn when the twin Crush merges beneath the written file still carries a
24111
+ * value at one of the paths this feature owns. Rulesync only rewrites the
24112
+ * chosen file, and because Crush concatenates lists and merges objects across
24113
+ * the pair, such an entry — typically one an earlier generate wrote to
24114
+ * `crush.json` before the user added a `.crush.json` — stays in effect until
24115
+ * removed by hand, and cannot be retracted from `.rulesync/`.
24116
+ */
24117
+ function warnCrushTwinLeftovers({ location, ownedPaths, logger }) {
24118
+ if (location.twin === void 0) return;
24119
+ let twin;
24120
+ try {
24121
+ twin = parseCrushConfig(location.twin.fileContent, location.twin.filePath);
24122
+ } catch {
24123
+ return;
24124
+ }
24125
+ const leftovers = ownedPaths.filter((path) => isNonEmptyValue(lookupPath(twin, path))).map((path) => `"${path.join(".")}"`);
24126
+ if (leftovers.length === 0) return;
24127
+ logger?.warn(`Crush merges ${location.twin.filePath} beneath ${location.filePath} (objects recursively, lists concatenated), so the ${leftovers.join(", ")} entries it still carries stay in effect and are not managed by rulesync; remove them from that file by hand.`);
24128
+ }
24129
+ //#endregion
24130
+ //#region src/features/hooks/crush-hooks.ts
24131
+ const SUPPORTED_CRUSH_EVENTS = new Set(CRUSH_HOOK_EVENTS);
24132
+ /**
24133
+ * Crush matches an event key case-insensitively and ignores underscores
24134
+ * (`PreToolUse`, `pre_tool_use` and `PRE_TOOL_USE` all work), so an import
24135
+ * looks the canonical name up by that normalized spelling.
24136
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
24137
+ */
24138
+ const NORMALIZED_CRUSH_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CRUSH_TO_CANONICAL_EVENT_NAMES).map(([crushEvent, canonical]) => [normalizeCrushEventName(crushEvent), canonical]));
24139
+ function normalizeCrushEventName(event) {
24140
+ return event.replaceAll("_", "").toLowerCase();
24141
+ }
24142
+ /**
24143
+ * Build the `hooks` block of `crush.json` from a canonical hooks config.
24144
+ * Crush keys a flat array of `{name, matcher, command, timeout}` entries by
24145
+ * event name; `matcher` is a regex tested against the (lower-case) Crush tool
24146
+ * name and `timeout` is in seconds. Only `type: "command"` canonical hooks are
24147
+ * emitted, since a Crush hook is a shell command. A shared canonical event
24148
+ * Crush does not fire is skipped (the HooksProcessor reports it), while an
24149
+ * event under the `crush.hooks` override — such as one an import filed there
24150
+ * — is written verbatim so it round-trips.
24151
+ */
24152
+ function canonicalToCrushHooks({ config, toolOverride, logger }) {
24153
+ const sharedHooks = {};
24154
+ for (const [event, defs] of Object.entries(config.hooks)) if (SUPPORTED_CRUSH_EVENTS.has(event)) sharedHooks[event] = defs;
24155
+ const effective = {
24156
+ ...sharedHooks,
24157
+ ...toolOverride
24158
+ };
24159
+ const hooks = {};
24160
+ for (const [event, defs] of Object.entries(effective)) {
24161
+ if (isPrototypePollutionKey(event)) continue;
24162
+ const crushEvent = lookupOwn({
24163
+ record: CANONICAL_TO_CRUSH_EVENT_NAMES,
24164
+ key: event
24165
+ }) ?? event;
24166
+ const entries = [];
24167
+ for (const def of defs) {
24168
+ const entry = canonicalDefToCrushEntry({
24169
+ def,
24170
+ event,
24171
+ logger
24172
+ });
24173
+ if (entry !== null) entries.push(entry);
24174
+ }
24175
+ if (entries.length > 0) hooks[crushEvent] = entries;
24176
+ }
24177
+ return hooks;
24178
+ }
24179
+ /** Convert one canonical hook definition to a Crush entry, or null to skip. */
24180
+ function canonicalDefToCrushEntry({ def, event, logger }) {
24181
+ if ((def.type ?? "command") !== "command") return null;
24182
+ if (typeof def.command !== "string" || def.command === "") {
24183
+ logger?.warn(`Crush hook ${quoteValueForWarning(def.name ?? "")} under "${event}" has no "command", which Crush would discard, so it was skipped.`);
24184
+ return null;
24185
+ }
24186
+ const entry = { command: def.command };
24187
+ if (typeof def.name === "string" && def.name !== "") entry.name = def.name;
24188
+ if (typeof def.matcher === "string" && def.matcher !== "" && def.matcher !== "*") entry.matcher = def.matcher;
24189
+ if (typeof def.timeout === "number") entry.timeout = Math.ceil(def.timeout);
24190
+ return entry;
24191
+ }
24192
+ /** Convert one raw hook entry to a canonical definition, or null to skip. */
24193
+ function crushEntryToCanonicalDef(raw) {
24194
+ if (!isRecord$1(raw) || typeof raw.command !== "string") return null;
24195
+ const def = {
24196
+ type: "command",
24197
+ command: raw.command
24198
+ };
24199
+ if (typeof raw.name === "string" && raw.name !== "") def.name = raw.name;
24200
+ if (typeof raw.matcher === "string" && raw.matcher !== "") def.matcher = raw.matcher;
24201
+ if (typeof raw.timeout === "number") def.timeout = raw.timeout;
24202
+ return def;
24203
+ }
24204
+ /**
24205
+ * Reverse {@link canonicalToCrushHooks}: parse the `hooks` block back into a
24206
+ * canonical event → definition[] record. An event Crush does not document is
24207
+ * carried under its own name so `buildImportedHooksConfig` files it under the
24208
+ * `crush.hooks` override.
24209
+ */
24210
+ function crushHooksToCanonical(hooksBlock) {
24211
+ const canonical = {};
24212
+ if (!isRecord$1(hooksBlock)) return canonical;
24213
+ for (const [crushEvent, rawEntries] of Object.entries(hooksBlock)) {
24214
+ if (isPrototypePollutionKey(crushEvent) || !Array.isArray(rawEntries)) continue;
24215
+ const canonicalEvent = lookupOwn({
24216
+ record: NORMALIZED_CRUSH_TO_CANONICAL_EVENT_NAMES,
24217
+ key: normalizeCrushEventName(crushEvent)
24218
+ }) ?? crushEvent;
24219
+ const defs = rawEntries.map((raw) => crushEntryToCanonicalDef(raw)).filter((def) => def !== null);
24220
+ if (defs.length === 0) continue;
24221
+ canonical[canonicalEvent] = [...lookupOwn({
24222
+ record: canonical,
24223
+ key: canonicalEvent
24224
+ }) ?? [], ...defs];
24225
+ }
24226
+ return canonical;
24227
+ }
24228
+ /**
24229
+ * Crush hooks.
24230
+ *
24231
+ * Crush reads hooks from the `hooks` key of its JSON config —
24232
+ * `<project>/crush.json` (or an existing `.crush.json`; Crush merges the pair,
24233
+ * lists concatenated) at project scope and `~/.config/crush/crush.json` at
24234
+ * user scope — as
24235
+ * `hooks.<Event>: [{name?, matcher?, command, timeout?}]`. Only `PreToolUse`
24236
+ * fires today. The `hooks` key is owned outright; every other top-level key
24237
+ * of the file is preserved and the file is never deleted.
24238
+ *
24239
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
24240
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
24241
+ */
24242
+ var CrushHooks = class CrushHooks extends ToolHooks {
24243
+ json;
24244
+ constructor(params) {
24245
+ super(params);
24246
+ this.json = parseCrushConfig(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
24247
+ }
24248
+ getJson() {
24249
+ return this.json;
24250
+ }
24251
+ isDeletable() {
24252
+ return false;
24253
+ }
24254
+ static getSettablePaths({ global = false } = {}) {
24255
+ return getCrushConfigSettablePaths({ global });
24256
+ }
23685
24257
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
23686
- const paths = CortexcodeHooks.getSettablePaths({ global });
23687
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
23688
- return new CortexcodeHooks({
24258
+ const location = await resolveCrushConfigFile({
23689
24259
  outputRoot,
23690
- relativeDirPath: paths.relativeDirPath,
23691
- relativeFilePath: paths.relativeFilePath,
23692
- fileContent,
23693
- validate
24260
+ global
24261
+ });
24262
+ return new CrushHooks({
24263
+ outputRoot,
24264
+ relativeDirPath: location.relativeDirPath,
24265
+ relativeFilePath: location.relativeFilePath,
24266
+ fileContent: crushConfigImportContent(location),
24267
+ validate,
24268
+ global
23694
24269
  });
23695
24270
  }
23696
24271
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
23697
- const paths = CortexcodeHooks.getSettablePaths({ global });
23698
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
23699
- const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
23700
- const config = rulesyncHooks.getJson();
23701
- const hooks = canonicalToToolHooks({
23702
- config,
23703
- toolOverrideHooks: config.cortexcode?.hooks,
23704
- converterConfig: CORTEXCODE_CONVERTER_CONFIG,
24272
+ const location = await resolveCrushConfigFile({
24273
+ outputRoot,
24274
+ global
24275
+ });
24276
+ const existingContent = location.fileContent ?? "";
24277
+ warnCrushTwinLeftovers({
24278
+ location,
24279
+ ownedPaths: [[CRUSH_HOOKS_KEY]],
23705
24280
  logger
23706
24281
  });
23707
- const fileContent = applySharedConfigPatch({
23708
- fileKey: sharedConfigFileKey(paths),
23709
- feature: "hooks",
23710
- existingContent,
23711
- patch: { hooks },
23712
- filePath,
24282
+ const config = rulesyncHooks.getJson();
24283
+ const hooks = canonicalToCrushHooks({
24284
+ config,
24285
+ toolOverride: config.crush?.hooks,
23713
24286
  logger
23714
24287
  });
23715
- return new CortexcodeHooks({
24288
+ return new CrushHooks({
23716
24289
  outputRoot,
23717
- relativeDirPath: paths.relativeDirPath,
23718
- relativeFilePath: paths.relativeFilePath,
23719
- fileContent,
23720
- validate
24290
+ relativeDirPath: location.relativeDirPath,
24291
+ relativeFilePath: location.relativeFilePath,
24292
+ fileContent: applySharedConfigPatch({
24293
+ fileKey: sharedConfigFileKey(this.getSettablePaths({ global })),
24294
+ feature: "hooks",
24295
+ existingContent,
24296
+ patch: { [CRUSH_HOOKS_KEY]: hooks },
24297
+ filePath: location.filePath,
24298
+ logger
24299
+ }),
24300
+ validate,
24301
+ global
23721
24302
  });
23722
24303
  }
23723
- toRulesyncHooks({ logger } = {}) {
23724
- const configPath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
23725
- let settings;
23726
- try {
23727
- settings = parseCortexcodeSettings(this.getFileContent(), configPath);
23728
- } catch (error) {
23729
- throw new Error(`Failed to parse Cortex Code hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
23730
- }
23731
- const hooks = toolHooksToCanonical({
23732
- logger,
23733
- hooks: settings.hooks,
23734
- converterConfig: CORTEXCODE_CONVERTER_CONFIG
23735
- });
24304
+ toRulesyncHooks() {
24305
+ const hooks = crushHooksToCanonical(this.json[CRUSH_HOOKS_KEY]);
23736
24306
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
23737
24307
  hooks,
23738
- overrideKey: "cortexcode"
24308
+ overrideKey: "crush"
23739
24309
  }), null, 2) });
23740
24310
  }
23741
24311
  validate() {
@@ -23744,13 +24314,14 @@ var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23744
24314
  error: null
23745
24315
  };
23746
24316
  }
23747
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
23748
- return new CortexcodeHooks({
24317
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
24318
+ return new CrushHooks({
23749
24319
  outputRoot,
23750
24320
  relativeDirPath,
23751
24321
  relativeFilePath,
23752
- fileContent: JSON.stringify({ hooks: {} }, null, 2),
23753
- validate: false
24322
+ fileContent: JSON.stringify({ [CRUSH_HOOKS_KEY]: {} }, null, 2),
24323
+ validate: false,
24324
+ global
23754
24325
  });
23755
24326
  }
23756
24327
  };
@@ -26984,19 +27555,22 @@ var ReasonixHooks = class ReasonixHooks extends ToolHooks {
26984
27555
  //#endregion
26985
27556
  //#region src/features/hooks/tabnine-hooks.ts
26986
27557
  /**
26987
- * Environment block safe to hand Tabnine for a hook. A tool rebuilds each
26988
- * entry into `KEY=VALUE` for the spawned process, so a key holding `=`, a
26989
- * control character or nothing at all names a different variable than it
26990
- * appears to; such entries are dropped in both directions (with a warning on
26991
- * export, where the value came from an authored `.rulesync/hooks.*`).
27558
+ * Environment block safe to hand Tabnine for a hook. The shared converter
27559
+ * refuses a whole `env` map holding one bad entry; Tabnine's shape is authored
27560
+ * outside it, so the same per-entry rule (`isSafeEnvEntry`: a key holding `=`,
27561
+ * a control character or nothing at all names a different variable than it
27562
+ * appears to) is applied entry by entry and the bad ones are dropped in both
27563
+ * directions, with a warning on export, where the value came from an authored
27564
+ * `.rulesync/hooks.*`.
26992
27565
  */
26993
27566
  function sanitizeEnv({ env, warn }) {
26994
27567
  if (env === null || env === void 0 || typeof env !== "object" || Array.isArray(env)) return;
26995
27568
  const result = {};
26996
27569
  for (const [key, value] of Object.entries(env)) {
26997
- const unsafeKey = key === "" || key.includes("=") || CONTROL_CHARS.some((char) => key.includes(char));
26998
- const unsafeValue = typeof value !== "string" || CONTROL_CHARS.some((char) => value.includes(char));
26999
- if (unsafeKey || unsafeValue) {
27570
+ if (!isSafeEnvEntry({
27571
+ key,
27572
+ value
27573
+ })) {
27000
27574
  warn?.(`Tabnine CLI hook env entry ${JSON.stringify(key)} is not a safe KEY=VALUE pair; skipping it.`);
27001
27575
  continue;
27002
27576
  }
@@ -27097,7 +27671,7 @@ const TabnineMatcherEntrySchema = zod_mini.z.looseObject({
27097
27671
  });
27098
27672
  function tabnineMatcherEntryToCanonical(entry) {
27099
27673
  const sequential = entry.sequential === true;
27100
- const matcher = entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "" ? entry.matcher : void 0;
27674
+ const matcher = entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "" && entry.matcher !== ".*" ? entry.matcher : void 0;
27101
27675
  const defs = [];
27102
27676
  for (const hook of entry.hooks ?? []) {
27103
27677
  if (hook.type !== "command" || typeof hook.command !== "string" || hook.command === "") continue;
@@ -27818,6 +28392,18 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
27818
28392
  supportedHookTypes: ["command"],
27819
28393
  supportsMatcher: true
27820
28394
  }],
28395
+ ["commandcode", {
28396
+ class: CommandcodeHooks,
28397
+ meta: {
28398
+ supportsProject: true,
28399
+ supportsGlobal: true,
28400
+ supportsImport: true
28401
+ },
28402
+ supportedEvents: COMMANDCODE_HOOK_EVENTS,
28403
+ supportedHookTypes: ["command"],
28404
+ supportsMatcher: true,
28405
+ matcherEvents: ["preToolUse", "postToolUse"]
28406
+ }],
27821
28407
  ["copilot", {
27822
28408
  class: CopilotHooks,
27823
28409
  meta: {
@@ -28139,6 +28725,18 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28139
28725
  supportedHookTypes: ["command", "http"],
28140
28726
  supportsMatcher: true
28141
28727
  }],
28728
+ ["crush", {
28729
+ class: CrushHooks,
28730
+ meta: {
28731
+ supportsProject: true,
28732
+ supportsGlobal: true,
28733
+ supportsImport: true
28734
+ },
28735
+ supportedEvents: CRUSH_HOOK_EVENTS,
28736
+ supportedHookTypes: ["command"],
28737
+ supportsMatcher: true,
28738
+ passthroughOverrideEvents: true
28739
+ }],
28142
28740
  ["zcode", {
28143
28741
  class: ZcodeHooks,
28144
28742
  meta: {
@@ -28820,14 +29418,6 @@ var ContinueIgnore = class ContinueIgnore extends ToolIgnore {
28820
29418
  }
28821
29419
  };
28822
29420
  //#endregion
28823
- //#region src/constants/crush-paths.ts
28824
- const CRUSH_RULE_FILE_NAME = "CRUSH.md";
28825
- const CRUSH_GLOBAL_DIR = (0, node_path.join)(".config", "crush");
28826
- const CRUSH_LOCAL_RULE_FILE_NAME = "CRUSH.local.md";
28827
- const CRUSH_IGNORE_FILE_NAME = ".crushignore";
28828
- const CRUSH_SKILLS_PROJECT_DIR = (0, node_path.join)(".crush", "skills");
28829
- const CRUSH_SKILLS_GLOBAL_DIR = (0, node_path.join)(CRUSH_GLOBAL_DIR, "skills");
28830
- //#endregion
28831
29421
  //#region src/features/ignore/crush-ignore.ts
28832
29422
  /**
28833
29423
  * Ignore generator for Crush.
@@ -30829,9 +31419,9 @@ function asBobRemoteType(stated, url) {
30829
31419
  * SSE server carries a bare `url`. The canonical `transport` alias and the
30830
31420
  * Claude-style `httpUrl` alias are folded into `type`/`url`; `env`, `cwd`,
30831
31421
  * `headers`, `timeout`, `alwaysAllow` and `disabled` pass through, as Bob
30832
- * documents all of them (`env` and `headers` with their prototype-pollution
30833
- * keys dropped, since Bob spreads those maps into the process environment and
30834
- * the HTTP requests).
31422
+ * documents all of them, with prototype-pollution keys dropped at every
31423
+ * nesting level (Bob spreads `env` and `headers` into the process environment
31424
+ * and the HTTP requests).
30835
31425
  *
30836
31426
  * Bob Shell documents the same file with an `httpURL` key instead of
30837
31427
  * `type` + `url` for streamable HTTP. rulesync writes the IDE spelling (the two
@@ -30899,7 +31489,7 @@ function convertToBobFormat(mcpServers, logger) {
30899
31489
  }
30900
31490
  for (const [key, value] of Object.entries(rest)) {
30901
31491
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
30902
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
31492
+ converted[key] = omitPrototypePollutionKeysDeep(value);
30903
31493
  }
30904
31494
  result[serverName] = converted;
30905
31495
  }
@@ -30910,7 +31500,9 @@ function convertToBobFormat(mcpServers, logger) {
30910
31500
  * (`type: "streamable-http"` + `url`) is already canonical and passes through;
30911
31501
  * the Bob Shell spelling `httpURL` becomes `url` with `type: "http"`; a bare
30912
31502
  * `url` is an SSE server in Bob, so it gains `type: "sse"` to keep that reading
30913
- * on the next generate.
31503
+ * on the next generate. Every other field passes through with its
31504
+ * prototype-pollution keys dropped at every nesting level, mirroring the
31505
+ * generate side's handling of `env` / `headers`.
30914
31506
  */
30915
31507
  function convertFromBobFormat(mcpServers) {
30916
31508
  if (!isMcpServers(mcpServers)) return {};
@@ -30925,7 +31517,7 @@ function convertFromBobFormat(mcpServers) {
30925
31517
  if (typeof value === "string") httpURL = value;
30926
31518
  continue;
30927
31519
  }
30928
- converted[key] = value;
31520
+ converted[key] = omitPrototypePollutionKeysDeep(value);
30929
31521
  }
30930
31522
  if (httpURL !== void 0) {
30931
31523
  converted.url = httpURL;
@@ -31669,6 +32261,254 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
31669
32261
  }
31670
32262
  };
31671
32263
  //#endregion
32264
+ //#region src/features/mcp/commandcode-mcp.ts
32265
+ /**
32266
+ * Parse a Command Code MCP file. The project file is the `.mcp.json` other
32267
+ * agents (Claude Code among them) share, so a hand-written file is read as
32268
+ * JSONC to tolerate comments and trailing commas; malformed content or a
32269
+ * non-object root (`null`, an array, a scalar) fails closed rather than being
32270
+ * spread into the regenerated file.
32271
+ */
32272
+ function parseCommandcodeMcpConfig({ fileContent, relativePath }) {
32273
+ let parsed;
32274
+ try {
32275
+ parsed = parseJsonc$6(fileContent);
32276
+ } catch (error) {
32277
+ throw new Error(`Failed to parse Command Code MCP config at ${relativePath}: ${formatError(error)}`, { cause: error });
32278
+ }
32279
+ if (!isPlainObject$1(parsed)) throw new Error(`Failed to parse Command Code MCP config at ${relativePath}: expected a JSON object at the root`);
32280
+ return parsed;
32281
+ }
32282
+ /**
32283
+ * The remote transport Command Code reads a server as. Its loader accepts
32284
+ * `http` (streamable HTTP) and `sse` for a `url` server; a bare `url` defaults
32285
+ * to `http`, and the canonical `streamable-http` spelling is folded into it.
32286
+ * A `ws(s)://` URL or any other stated transport has no Command Code
32287
+ * equivalent, so `undefined` tells the caller to skip the server.
32288
+ * @see https://commandcode.ai/docs/mcp
32289
+ */
32290
+ function asCommandcodeRemoteTransport(stated, url) {
32291
+ if (stated === "sse") return "sse";
32292
+ if (stated === "http" || stated === "streamable-http") return "http";
32293
+ if (stated === void 0) return /^wss?:\/\//i.test(url) ? void 0 : "http";
32294
+ }
32295
+ /**
32296
+ * Convert the canonical server map to the shape Command Code's config schema
32297
+ * documents (used for the global `~/.commandcode/mcp.json` only — the project
32298
+ * `.mcp.json` is shared with Claude Code and written pass-through, see the
32299
+ * class doc): a remote server is `{ transport, url, headers?, env?, oauth? }`
32300
+ * and a stdio server is `{ transport: "stdio", command, args?, env? }`, each
32301
+ * with an optional `enabled` flag. The documented spelling is `transport`
32302
+ * (`type` is only accepted as an alias on read), so that is what is written,
32303
+ * and only the documented keys are emitted — `timeout`, rulesync-only fields
32304
+ * and anything else is left out. The canonical `disabled: true` becomes
32305
+ * Command Code's native `enabled: false` (the rulesync-source-only `enabled`
32306
+ * filter never reaches this function: `getMcpServers()` drops such a server).
32307
+ *
32308
+ * A server Command Code drops at load time — no transport at all, a remote
32309
+ * transport without a URL, a WebSocket URL, or a stdio entry without a
32310
+ * command — is skipped with a warning rather than written in a form the tool
32311
+ * would silently ignore.
32312
+ * @see https://commandcode.ai/docs/mcp
32313
+ */
32314
+ function convertToCommandcodeFormat(mcpServers, logger) {
32315
+ const result = {};
32316
+ for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
32317
+ if (PROTOTYPE_POLLUTION_KEYS.has(serverName) || !isRecord$1(serverConfig)) continue;
32318
+ if (declaresNoTransport(serverConfig)) {
32319
+ warnAndSkipMcpServer({
32320
+ toolName: "Command Code",
32321
+ serverName,
32322
+ reason: "no transport",
32323
+ logger
32324
+ });
32325
+ continue;
32326
+ }
32327
+ const converted = isRemoteMcpServer(serverConfig) ? convertRemoteServer$1({
32328
+ serverName,
32329
+ serverConfig,
32330
+ logger
32331
+ }) : convertStdioServer$1({
32332
+ serverName,
32333
+ serverConfig,
32334
+ logger
32335
+ });
32336
+ if (converted === void 0) continue;
32337
+ if (serverConfig.disabled === true) converted.enabled = false;
32338
+ if (isRecord$1(serverConfig.env)) converted.env = omitPrototypePollutionKeys(serverConfig.env);
32339
+ result[serverName] = converted;
32340
+ }
32341
+ return result;
32342
+ }
32343
+ function convertRemoteServer$1({ serverName, serverConfig, logger }) {
32344
+ const url = resolveRemoteMcpUrl(serverConfig);
32345
+ if (!url) {
32346
+ warnAndSkipMcpServer({
32347
+ toolName: "Command Code",
32348
+ serverName,
32349
+ reason: "a remote transport without a url",
32350
+ logger
32351
+ });
32352
+ return;
32353
+ }
32354
+ const stated = serverConfig.type ?? serverConfig.transport;
32355
+ const transport = asCommandcodeRemoteTransport(typeof stated === "string" ? stated : void 0, url);
32356
+ if (transport === void 0) {
32357
+ warnAndSkipMcpServer({
32358
+ toolName: "Command Code",
32359
+ serverName,
32360
+ reason: stated === void 0 ? "a WebSocket url, which Command Code's remote transports (http and sse) cannot reach" : `the "${String(stated)}" transport, which Command Code does not offer for remote servers (only http and sse)`,
32361
+ logger
32362
+ });
32363
+ return;
32364
+ }
32365
+ const converted = {
32366
+ transport,
32367
+ url
32368
+ };
32369
+ if (isRecord$1(serverConfig.headers)) converted.headers = omitPrototypePollutionKeys(serverConfig.headers);
32370
+ if (isRecord$1(serverConfig.oauth)) converted.oauth = omitPrototypePollutionKeys(serverConfig.oauth);
32371
+ return converted;
32372
+ }
32373
+ function convertStdioServer$1({ serverName, serverConfig, logger }) {
32374
+ const [command, ...args] = resolveLocalMcpCommand(serverConfig);
32375
+ if (!command) {
32376
+ warnAndSkipMcpServer({
32377
+ toolName: "Command Code",
32378
+ serverName,
32379
+ reason: "a stdio transport without a command",
32380
+ logger
32381
+ });
32382
+ return;
32383
+ }
32384
+ const converted = {
32385
+ transport: "stdio",
32386
+ command
32387
+ };
32388
+ if (args.length > 0) converted.args = args;
32389
+ return converted;
32390
+ }
32391
+ /**
32392
+ * Convert Command Code's server map back to the canonical shape. Both
32393
+ * spellings Command Code accepts (`transport` and its `type` alias, with
32394
+ * `command`/`url`) are already canonical, so entries pass through with only
32395
+ * prototype-pollution keys dropped; the native `enabled: false` maps to the
32396
+ * canonical `disabled: true` (a bare `enabled` would otherwise be read back
32397
+ * as rulesync's own generation filter and silently drop the server from every
32398
+ * other target).
32399
+ */
32400
+ function convertFromCommandcodeFormat(mcpServers) {
32401
+ if (!isMcpServers(mcpServers)) return {};
32402
+ const result = {};
32403
+ for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
32404
+ if (PROTOTYPE_POLLUTION_KEYS.has(serverName) || !isRecord$1(serverConfig)) continue;
32405
+ const { enabled, ...rest } = omitPrototypePollutionKeys(serverConfig);
32406
+ result[serverName] = enabled === false ? {
32407
+ ...rest,
32408
+ disabled: true
32409
+ } : rest;
32410
+ }
32411
+ return result;
32412
+ }
32413
+ /**
32414
+ * Command Code MCP configuration.
32415
+ *
32416
+ * Command Code reads `.mcp.json` at the project root (project scope, meant to
32417
+ * be committed) and `~/.commandcode/mcp.json` (user scope), both in the
32418
+ * `{ "mcpServers": { ... } }` shape the docs show; the per-machine local
32419
+ * scope under `~/.commandcode/projects/` is left to the tool. Top-level
32420
+ * sibling keys of an existing file are kept. The global file is not deleted
32421
+ * by `--delete` (it lives outside the project), while the project file is
32422
+ * rulesync's own and is.
32423
+ *
32424
+ * The project `.mcp.json` is the very file the `claudecode` target writes, so
32425
+ * at project scope the servers are written in the same pass-through shape
32426
+ * `ClaudecodeMcp` uses: whichever of the two targets generates last leaves
32427
+ * byte-identical content, and Command Code reads that shape natively (`type`
32428
+ * is an alias of `transport`, a bare `url` infers `http`, and unknown keys
32429
+ * are ignored). Only the global file — Command Code's own — gets the
32430
+ * `transport` / `enabled: false` rewrite of `convertToCommandcodeFormat`.
32431
+ *
32432
+ * @see https://commandcode.ai/docs/mcp
32433
+ */
32434
+ var CommandcodeMcp = class CommandcodeMcp extends ToolMcp {
32435
+ json;
32436
+ constructor(params) {
32437
+ super(params);
32438
+ this.json = this.fileContent === void 0 ? {} : parseCommandcodeMcpConfig({
32439
+ fileContent: this.fileContent,
32440
+ relativePath: (0, node_path.join)(this.relativeDirPath, this.relativeFilePath)
32441
+ });
32442
+ }
32443
+ getJson() {
32444
+ return this.json;
32445
+ }
32446
+ isDeletable() {
32447
+ return !this.global;
32448
+ }
32449
+ static getSettablePaths({ global = false } = {}) {
32450
+ return global ? {
32451
+ relativeDirPath: COMMANDCODE_DIR,
32452
+ relativeFilePath: COMMANDCODE_GLOBAL_MCP_FILE_NAME
32453
+ } : {
32454
+ relativeDirPath: ".",
32455
+ relativeFilePath: COMMANDCODE_PROJECT_MCP_FILE_NAME
32456
+ };
32457
+ }
32458
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32459
+ const paths = this.getSettablePaths({ global });
32460
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}";
32461
+ return new CommandcodeMcp({
32462
+ outputRoot,
32463
+ relativeDirPath: paths.relativeDirPath,
32464
+ relativeFilePath: paths.relativeFilePath,
32465
+ fileContent,
32466
+ validate,
32467
+ global
32468
+ });
32469
+ }
32470
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
32471
+ const paths = this.getSettablePaths({ global });
32472
+ const json = parseCommandcodeMcpConfig({
32473
+ fileContent: await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}",
32474
+ relativePath: (0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)
32475
+ });
32476
+ const mcpServers = global ? convertToCommandcodeFormat(rulesyncMcp.getMcpServers(), logger) : rulesyncMcp.getMcpServers();
32477
+ const commandcodeConfig = {
32478
+ ...json,
32479
+ mcpServers
32480
+ };
32481
+ return new CommandcodeMcp({
32482
+ outputRoot,
32483
+ relativeDirPath: paths.relativeDirPath,
32484
+ relativeFilePath: paths.relativeFilePath,
32485
+ fileContent: JSON.stringify(commandcodeConfig, null, 2),
32486
+ validate,
32487
+ global
32488
+ });
32489
+ }
32490
+ toRulesyncMcp() {
32491
+ const mcpServers = convertFromCommandcodeFormat(this.json.mcpServers);
32492
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers }, null, 2) });
32493
+ }
32494
+ validate() {
32495
+ return {
32496
+ success: true,
32497
+ error: null
32498
+ };
32499
+ }
32500
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
32501
+ return new CommandcodeMcp({
32502
+ outputRoot,
32503
+ relativeDirPath,
32504
+ relativeFilePath,
32505
+ fileContent: "{}",
32506
+ validate: false,
32507
+ global
32508
+ });
32509
+ }
32510
+ };
32511
+ //#endregion
31672
32512
  //#region src/features/mcp/continue-mcp.ts
31673
32513
  /**
31674
32514
  * Parse a Continue MCP file. Continue reads the files under `mcpServers/` as
@@ -32331,7 +33171,7 @@ function convertToCortexcodeFormat(mcpServers, logger) {
32331
33171
  }
32332
33172
  for (const [key, value] of Object.entries(rest)) {
32333
33173
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
32334
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
33174
+ converted[key] = omitPrototypePollutionKeysDeep(value);
32335
33175
  }
32336
33176
  result[serverName] = converted;
32337
33177
  }
@@ -32340,14 +33180,15 @@ function convertToCortexcodeFormat(mcpServers, logger) {
32340
33180
  /**
32341
33181
  * Convert Cortex Code's server map back to the canonical shape. The
32342
33182
  * documented spelling (`type` + `command`/`url`) is already canonical, so
32343
- * entries pass through with only prototype-pollution keys dropped.
33183
+ * entries pass through with only prototype-pollution keys dropped — at every
33184
+ * nesting level, mirroring the generate side.
32344
33185
  */
32345
33186
  function convertFromCortexcodeFormat(mcpServers) {
32346
33187
  if (!isMcpServers(mcpServers)) return {};
32347
33188
  const result = {};
32348
33189
  for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
32349
33190
  if (PROTOTYPE_POLLUTION_KEYS.has(serverName) || !isRecord$1(serverConfig)) continue;
32350
- result[serverName] = omitPrototypePollutionKeys(serverConfig);
33191
+ result[serverName] = omitPrototypePollutionKeysDeep(serverConfig);
32351
33192
  }
32352
33193
  return result;
32353
33194
  }
@@ -32386,19 +33227,12 @@ var CortexcodeMcp = class CortexcodeMcp extends ToolMcp {
32386
33227
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32387
33228
  if (!global) throw new Error(CORTEXCODE_GLOBAL_ONLY_MESSAGE);
32388
33229
  const paths = this.getSettablePaths({ global });
32389
- const json = parseCortexcodeMcpConfig({
32390
- fileContent: await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}",
32391
- relativePath: (0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)
32392
- });
32393
- const newJson = {
32394
- ...json,
32395
- mcpServers: json.mcpServers ?? {}
32396
- };
33230
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}";
32397
33231
  return new CortexcodeMcp({
32398
33232
  outputRoot,
32399
33233
  relativeDirPath: paths.relativeDirPath,
32400
33234
  relativeFilePath: paths.relativeFilePath,
32401
- fileContent: JSON.stringify(newJson, null, 2),
33235
+ fileContent,
32402
33236
  validate,
32403
33237
  global
32404
33238
  });
@@ -32446,6 +33280,319 @@ var CortexcodeMcp = class CortexcodeMcp extends ToolMcp {
32446
33280
  }
32447
33281
  };
32448
33282
  //#endregion
33283
+ //#region src/features/mcp/crush-mcp.ts
33284
+ /**
33285
+ * Crush-only MCP fields with no canonical counterpart, authored under a
33286
+ * `crush`-prefixed key in `.rulesync/mcp.jsonc` and written under Crush's own
33287
+ * name: `oauth*` drive Crush's OAuth 2.1 flow for HTTP servers and
33288
+ * `sessionless` marks a server that issues no `Mcp-Session-Id`.
33289
+ * `RulesyncMcp.getMcpServers()` strips both spellings so the client secret
33290
+ * never reaches another tool's config; `fromRulesyncMcp` re-merges them from
33291
+ * the raw source JSON, honouring the raw Crush spelling as a fallback for an
33292
+ * entry copied straight out of a `crush.json` (the `experimental_environment`
33293
+ * precedent) — except `oauth`, whose canonical key is Claude Code's
33294
+ * `{ clientId }` object and which only `crushOauth` can set.
33295
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
33296
+ */
33297
+ const CRUSH_ONLY_KEYS = [
33298
+ {
33299
+ canonical: "crushOauth",
33300
+ crush: "oauth",
33301
+ accepts: (v) => typeof v === "boolean",
33302
+ rawFallback: false
33303
+ },
33304
+ {
33305
+ canonical: "crushOauthClientId",
33306
+ crush: "oauth_client_id",
33307
+ accepts: (v) => typeof v === "string",
33308
+ rawFallback: true
33309
+ },
33310
+ {
33311
+ canonical: "crushOauthClientSecret",
33312
+ crush: "oauth_client_secret",
33313
+ accepts: (v) => typeof v === "string",
33314
+ rawFallback: true
33315
+ },
33316
+ {
33317
+ canonical: "crushOauthCallbackPort",
33318
+ crush: "oauth_callback_port",
33319
+ accepts: (v) => Number.isInteger(v),
33320
+ rawFallback: true
33321
+ },
33322
+ {
33323
+ canonical: "crushSessionless",
33324
+ crush: "sessionless",
33325
+ accepts: (v) => typeof v === "boolean",
33326
+ rawFallback: true
33327
+ }
33328
+ ];
33329
+ /**
33330
+ * The Crush-only keys of one raw canonical server entry, keyed by their
33331
+ * canonical name, so `convertToCrushFormat` sees them after the shared map
33332
+ * stripped them.
33333
+ */
33334
+ function readCrushOnlyKeys(rawServer) {
33335
+ const result = {};
33336
+ if (!isRecord$1(rawServer)) return result;
33337
+ for (const { canonical, crush, accepts, rawFallback } of CRUSH_ONLY_KEYS) {
33338
+ const value = accepts(rawServer[canonical]) ? rawServer[canonical] : rawFallback && accepts(rawServer[crush]) ? rawServer[crush] : void 0;
33339
+ if (value !== void 0) result[canonical] = value;
33340
+ }
33341
+ return result;
33342
+ }
33343
+ function copyCrushOnlyKeys({ from, to }) {
33344
+ for (const { canonical, crush, accepts } of CRUSH_ONLY_KEYS) if (accepts(from[canonical])) to[crush] = from[canonical];
33345
+ }
33346
+ /**
33347
+ * The remote transports Crush's `MCPType` enum accepts, spelled the way it
33348
+ * reads them. `streamable-http` is the canonical rulesync name for what Crush
33349
+ * calls `http`; a WebSocket server has no Crush transport and is left to the
33350
+ * caller to reject.
33351
+ */
33352
+ function asCrushRemoteType(stated, url) {
33353
+ if (stated === "sse") return "sse";
33354
+ if (stated === "http" || stated === "streamable-http") return "http";
33355
+ if (stated === void 0) return /^wss?:\/\//i.test(url) ? void 0 : "http";
33356
+ }
33357
+ /**
33358
+ * The transport half of a remote server (`type`, `url`, `headers`), or null
33359
+ * when Crush could not reach it and the server was skipped with a warning.
33360
+ */
33361
+ function convertRemoteTransport({ name, config, logger }) {
33362
+ const url = resolveRemoteMcpUrl(config);
33363
+ if (!url) {
33364
+ warnAndSkipMcpServer({
33365
+ toolName: "Crush",
33366
+ serverName: name,
33367
+ reason: "a remote transport without a url",
33368
+ logger
33369
+ });
33370
+ return null;
33371
+ }
33372
+ const stated = config.type ?? config.transport;
33373
+ const type = asCrushRemoteType(stated, url);
33374
+ if (type === void 0) {
33375
+ warnAndSkipMcpServer({
33376
+ toolName: "Crush",
33377
+ serverName: name,
33378
+ reason: stated === void 0 ? "a WebSocket url, which Crush's remote transports (http and sse) cannot reach" : `the "${stated}" transport, which Crush does not offer for remote servers (only http and sse)`,
33379
+ logger
33380
+ });
33381
+ return null;
33382
+ }
33383
+ const converted = {
33384
+ type,
33385
+ url
33386
+ };
33387
+ if (config.headers && Object.keys(config.headers).length > 0) converted.headers = config.headers;
33388
+ return converted;
33389
+ }
33390
+ /**
33391
+ * The transport half of a stdio server (`type`, `command`, `args`, `env`), or
33392
+ * null when it has no command and was skipped with a warning.
33393
+ */
33394
+ function convertStdioTransport({ name, config, logger }) {
33395
+ const [command, ...args] = resolveLocalMcpCommand(config);
33396
+ if (!command) {
33397
+ warnAndSkipMcpServer({
33398
+ toolName: "Crush",
33399
+ serverName: name,
33400
+ reason: "a stdio transport without a command",
33401
+ logger
33402
+ });
33403
+ return null;
33404
+ }
33405
+ const converted = {
33406
+ type: "stdio",
33407
+ command
33408
+ };
33409
+ if (args.length > 0) converted.args = args;
33410
+ if (config.env && Object.keys(config.env).length > 0) converted.env = config.env;
33411
+ return converted;
33412
+ }
33413
+ /**
33414
+ * Convert canonical rulesync servers to the `mcp.<name>` shape of crush.json.
33415
+ * `type` is required by Crush's schema, so it is always written: stdio servers
33416
+ * carry `type: "stdio"` with `command`/`args`/`env`, remote servers `http` or
33417
+ * `sse` with `url` and optional `headers`. `disabled`, the per-server
33418
+ * `disabledTools`/`enabledTools` filters (`disabled_tools`/`enabled_tools`)
33419
+ * and `timeout` (which Crush reads in seconds) map onto their Crush fields.
33420
+ *
33421
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
33422
+ */
33423
+ function convertToCrushFormat(mcpServers, logger) {
33424
+ const result = {};
33425
+ for (const [name, config] of Object.entries(mcpServers)) {
33426
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
33427
+ if (!isRecord$1(config)) continue;
33428
+ if (declaresNoTransport(config)) {
33429
+ warnAndSkipMcpServer({
33430
+ toolName: "Crush",
33431
+ serverName: name,
33432
+ reason: "no transport",
33433
+ logger
33434
+ });
33435
+ continue;
33436
+ }
33437
+ const converted = isRemoteMcpServer(config) ? convertRemoteTransport({
33438
+ name,
33439
+ config,
33440
+ logger
33441
+ }) : convertStdioTransport({
33442
+ name,
33443
+ config,
33444
+ logger
33445
+ });
33446
+ if (converted === null) continue;
33447
+ if (config.disabled === true) converted.disabled = true;
33448
+ if (config.disabledTools && config.disabledTools.length > 0) converted.disabled_tools = config.disabledTools;
33449
+ if (config.enabledTools && config.enabledTools.length > 0) converted.enabled_tools = config.enabledTools;
33450
+ if (typeof config.timeout === "number") converted.timeout = Math.ceil(config.timeout);
33451
+ copyCrushOnlyKeys({
33452
+ from: config,
33453
+ to: converted
33454
+ });
33455
+ result[name] = converted;
33456
+ }
33457
+ return result;
33458
+ }
33459
+ /**
33460
+ * Convert the `mcp.<name>` entries of crush.json back to canonical rulesync
33461
+ * servers. `stdio` is Crush's default `type`, so it is dropped (a canonical
33462
+ * server with a `command` is stdio already); `http` and `sse` are kept as the
33463
+ * canonical transport names; `disabled_tools`/`enabled_tools` become the
33464
+ * canonical `disabledTools`/`enabledTools`; the Crush-only OAuth and
33465
+ * `sessionless` fields are lifted into their `crush*` authoring keys (a value
33466
+ * of the wrong type is dropped, as Crush itself would refuse it), and any
33467
+ * other key is kept verbatim so an import preserves what a hand-authored
33468
+ * entry declared.
33469
+ */
33470
+ function convertFromCrushFormat(crushServers) {
33471
+ const result = {};
33472
+ for (const [name, config] of Object.entries(crushServers)) {
33473
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
33474
+ const converted = {};
33475
+ for (const [key, value] of Object.entries(config)) {
33476
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
33477
+ switch (key) {
33478
+ case "type":
33479
+ if (value === "http" || value === "sse") converted.type = value;
33480
+ break;
33481
+ case "disabled_tools":
33482
+ if (isStringArray$2(value)) converted.disabledTools = value;
33483
+ break;
33484
+ case "enabled_tools":
33485
+ if (isStringArray$2(value)) converted.enabledTools = value;
33486
+ break;
33487
+ case "oauth_token": break;
33488
+ default: {
33489
+ const crushOnly = CRUSH_ONLY_KEYS.find((entry) => entry.crush === key);
33490
+ if (crushOnly === void 0) converted[key] = value;
33491
+ else if (crushOnly.accepts(value)) converted[crushOnly.canonical] = value;
33492
+ }
33493
+ }
33494
+ }
33495
+ result[name] = converted;
33496
+ }
33497
+ return result;
33498
+ }
33499
+ /**
33500
+ * Crush MCP servers.
33501
+ *
33502
+ * Crush reads MCP servers from the `mcp` key of its JSON config:
33503
+ * `<project>/crush.json` (or an existing `.crush.json`; Crush merges the pair,
33504
+ * objects recursively) at project scope and `~/.config/crush/crush.json` at
33505
+ * user scope. The
33506
+ * `crushrc` Bash config that Crush now recommends compiles its `mcp add`
33507
+ * builtin into the same `mcp.<name>` entries and overrides the JSON key by
33508
+ * key, so a `crushrc` next to the generated file takes precedence. Every
33509
+ * other top-level key of the file is preserved; the file is never deleted.
33510
+ *
33511
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/config/README.md
33512
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
33513
+ */
33514
+ var CrushMcp = class CrushMcp extends ToolMcp {
33515
+ json;
33516
+ constructor(params) {
33517
+ super(params);
33518
+ this.json = parseCrushConfig(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
33519
+ }
33520
+ getJson() {
33521
+ return this.json;
33522
+ }
33523
+ isDeletable() {
33524
+ return false;
33525
+ }
33526
+ static getSettablePaths({ global = false } = {}) {
33527
+ return getCrushConfigSettablePaths({ global });
33528
+ }
33529
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
33530
+ const location = await resolveCrushConfigFile({
33531
+ outputRoot,
33532
+ global
33533
+ });
33534
+ return new CrushMcp({
33535
+ outputRoot,
33536
+ relativeDirPath: location.relativeDirPath,
33537
+ relativeFilePath: location.relativeFilePath,
33538
+ fileContent: crushConfigImportContent(location),
33539
+ validate,
33540
+ global
33541
+ });
33542
+ }
33543
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
33544
+ const location = await resolveCrushConfigFile({
33545
+ outputRoot,
33546
+ global
33547
+ });
33548
+ const existingContent = location.fileContent ?? "";
33549
+ warnCrushTwinLeftovers({
33550
+ location,
33551
+ ownedPaths: [["mcp"]],
33552
+ logger
33553
+ });
33554
+ const converted = convertToCrushFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => [serverName, {
33555
+ ...serverConfig,
33556
+ ...readCrushOnlyKeys(rulesyncMcp.getRawMcpServer(serverName))
33557
+ }])), logger);
33558
+ return new CrushMcp({
33559
+ outputRoot,
33560
+ relativeDirPath: location.relativeDirPath,
33561
+ relativeFilePath: location.relativeFilePath,
33562
+ fileContent: applySharedConfigPatch({
33563
+ fileKey: sharedConfigFileKey(this.getSettablePaths({ global })),
33564
+ feature: "mcp",
33565
+ existingContent,
33566
+ patch: { ["mcp"]: converted },
33567
+ filePath: location.filePath,
33568
+ logger
33569
+ }),
33570
+ validate,
33571
+ global
33572
+ });
33573
+ }
33574
+ toRulesyncMcp() {
33575
+ const converted = convertFromCrushFormat(isRecord$1(this.json["mcp"]) ? this.json["mcp"] : {});
33576
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers: converted }, null, 2) });
33577
+ }
33578
+ validate() {
33579
+ return {
33580
+ success: true,
33581
+ error: null
33582
+ };
33583
+ }
33584
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
33585
+ return new CrushMcp({
33586
+ outputRoot,
33587
+ relativeDirPath,
33588
+ relativeFilePath,
33589
+ fileContent: JSON.stringify({ ["mcp"]: {} }, null, 2),
33590
+ validate: false,
33591
+ global
33592
+ });
33593
+ }
33594
+ };
33595
+ //#endregion
32449
33596
  //#region src/features/mcp/mcp-env-var-format.ts
32450
33597
  /**
32451
33598
  * Canonical rulesync env var reference pattern: `${VAR}` (but not `${env:VAR}`,
@@ -36330,6 +37477,7 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
36330
37477
  static async getAuxiliaryFiles({ outputRoot = process.cwd(), global = false, rulesyncMcp, logger }) {
36331
37478
  const servers = rulesyncMcp.forTarget({
36332
37479
  toolTarget: "rovodev",
37480
+ global,
36333
37481
  logger
36334
37482
  }).getMcpServers();
36335
37483
  const managedNames = Object.keys(servers).filter((name) => toRovodevServer(name, servers[name]) !== null);
@@ -36490,7 +37638,7 @@ function convertToTabnineFormat(mcpServers, logger) {
36490
37638
  }
36491
37639
  for (const [key, value] of Object.entries(rest)) {
36492
37640
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36493
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
37641
+ converted[key] = omitPrototypePollutionKeysDeep(value);
36494
37642
  }
36495
37643
  if (enabledTools !== void 0) converted.includeTools = enabledTools;
36496
37644
  if (disabledTools !== void 0) converted.excludeTools = disabledTools;
@@ -36501,7 +37649,9 @@ function convertToTabnineFormat(mcpServers, logger) {
36501
37649
  /**
36502
37650
  * Convert Tabnine's server map back to the canonical shape: `includeTools` /
36503
37651
  * `excludeTools` become `enabledTools` / `disabledTools`; `type` (`stdio`,
36504
- * `sse`, `http`) and every other documented field are already canonical.
37652
+ * `sse`, `http`) and every other documented field are already canonical and
37653
+ * pass through with their prototype-pollution keys dropped at every nesting
37654
+ * level, mirroring the generate side.
36505
37655
  */
36506
37656
  function convertFromTabnineFormat(mcpServers) {
36507
37657
  if (!isMcpServers(mcpServers)) return {};
@@ -36513,7 +37663,7 @@ function convertFromTabnineFormat(mcpServers) {
36513
37663
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36514
37664
  if (key === "includeTools") converted.enabledTools = value;
36515
37665
  else if (key === "excludeTools") converted.disabledTools = value;
36516
- else converted[key] = value;
37666
+ else converted[key] = omitPrototypePollutionKeysDeep(value);
36517
37667
  }
36518
37668
  result[serverName] = converted;
36519
37669
  }
@@ -37625,6 +38775,15 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
37625
38775
  supportsDisabledTools: true
37626
38776
  }
37627
38777
  }],
38778
+ ["commandcode", {
38779
+ class: CommandcodeMcp,
38780
+ meta: {
38781
+ supportsProject: true,
38782
+ supportsGlobal: true,
38783
+ supportsEnabledTools: false,
38784
+ supportsDisabledTools: false
38785
+ }
38786
+ }],
37628
38787
  ["continue", {
37629
38788
  class: ContinueMcp,
37630
38789
  meta: {
@@ -37661,6 +38820,15 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
37661
38820
  supportsDisabledTools: false
37662
38821
  }
37663
38822
  }],
38823
+ ["crush", {
38824
+ class: CrushMcp,
38825
+ meta: {
38826
+ supportsProject: true,
38827
+ supportsGlobal: true,
38828
+ supportsEnabledTools: true,
38829
+ supportsDisabledTools: true
38830
+ }
38831
+ }],
37664
38832
  ["cursor", {
37665
38833
  class: CursorMcp,
37666
38834
  meta: {
@@ -38009,6 +39177,7 @@ var McpProcessor = class extends FeatureProcessor {
38009
39177
  const toolMcps = await Promise.all([rulesyncMcp].map(async (mcp) => {
38010
39178
  const targetedRulesyncMcp = mcp.forTarget({
38011
39179
  toolTarget: this.toolTarget,
39180
+ global: this.global,
38012
39181
  logger: this.logger
38013
39182
  });
38014
39183
  const fieldsToStrip = [];
@@ -39092,7 +40261,7 @@ const ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES = {
39092
40261
  function toAntigravityCliToolName(canonical) {
39093
40262
  return CANONICAL_TO_ANTIGRAVITY_CLI_TOOL_NAMES[canonical] ?? canonical;
39094
40263
  }
39095
- function toCanonicalToolName$7(cliName) {
40264
+ function toCanonicalToolName$8(cliName) {
39096
40265
  return ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES[cliName] ?? cliName;
39097
40266
  }
39098
40267
  /**
@@ -39308,7 +40477,7 @@ function convertAntigravityCliToRulesyncPermissions(params) {
39308
40477
  const processEntries = (entries, action) => {
39309
40478
  for (const entry of entries) {
39310
40479
  const { toolName, pattern } = parsePermissionEntry$1(entry);
39311
- const canonical = toCanonicalToolName$7(toolName);
40480
+ const canonical = toCanonicalToolName$8(toolName);
39312
40481
  if (!permission[canonical]) permission[canonical] = {};
39313
40482
  permission[canonical][pattern] = action;
39314
40483
  }
@@ -39565,11 +40734,34 @@ const CANONICAL_TO_AUGMENT_TOOL_NAMES = {
39565
40734
  websearch: "web-search"
39566
40735
  };
39567
40736
  const AUGMENT_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_AUGMENT_TOOL_NAMES).map(([k, v]) => [v, k]));
40737
+ const CURRENT_TO_LEGACY_AUGMENT_TOOL_NAMES = {
40738
+ terminal: "launch-process",
40739
+ read: "view",
40740
+ edit: "str-replace-editor",
40741
+ write: "save-file"
40742
+ };
39568
40743
  function toAugmentToolName(canonical) {
39569
- return CANONICAL_TO_AUGMENT_TOOL_NAMES[canonical] ?? canonical;
40744
+ return lookupOwn({
40745
+ record: CANONICAL_TO_AUGMENT_TOOL_NAMES,
40746
+ key: canonical
40747
+ }) ?? canonical;
39570
40748
  }
39571
- function toCanonicalToolName$6(augmentName) {
39572
- return AUGMENT_TO_CANONICAL_TOOL_NAMES[augmentName] ?? augmentName;
40749
+ /**
40750
+ * Resolve an existing entry's `toolName` to the legacy spelling rulesync emits, so that the
40751
+ * current alias and the legacy name are treated as the same managed tool.
40752
+ */
40753
+ function toLegacyAugmentToolName(augmentName) {
40754
+ return lookupOwn({
40755
+ record: CURRENT_TO_LEGACY_AUGMENT_TOOL_NAMES,
40756
+ key: augmentName
40757
+ }) ?? augmentName;
40758
+ }
40759
+ function toCanonicalToolName$7(augmentName) {
40760
+ const legacyName = toLegacyAugmentToolName(augmentName);
40761
+ return lookupOwn({
40762
+ record: AUGMENT_TO_CANONICAL_TOOL_NAMES,
40763
+ key: legacyName
40764
+ }) ?? legacyName;
39573
40765
  }
39574
40766
  function actionToAugmentType(action) {
39575
40767
  switch (action) {
@@ -39785,10 +40977,11 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
39785
40977
  const generatedKeys = new Set(generated.map((e) => `${e.toolName}|${e.shellInputRegex ?? ""}|${e.permission.type}`));
39786
40978
  const preservedBasicEntries = basicExistingEntries.filter((entry) => {
39787
40979
  if (entry.toolName === "*") return false;
39788
- if (!MANAGED_AUGMENT_TOOL_NAMES.has(entry.toolName)) return true;
40980
+ const legacyToolName = toLegacyAugmentToolName(entry.toolName);
40981
+ if (!MANAGED_AUGMENT_TOOL_NAMES.has(legacyToolName)) return true;
39789
40982
  if (entry.permission.type === "deny") {
39790
- const key = `${entry.toolName}|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
39791
- return !generatedKeys.has(key);
40983
+ const suffix = `|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
40984
+ return !generatedKeys.has(`${entry.toolName}${suffix}`) && !generatedKeys.has(`${legacyToolName}${suffix}`);
39792
40985
  }
39793
40986
  return false;
39794
40987
  });
@@ -40015,14 +41208,15 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
40015
41208
  if (isSpecialEntry(entry)) continue;
40016
41209
  const type = entry.permission.type;
40017
41210
  if (!isBasicAugmentType(type)) continue;
40018
- const canonical = toCanonicalToolName$6(entry.toolName);
41211
+ const legacyToolName = toLegacyAugmentToolName(entry.toolName);
41212
+ const canonical = toCanonicalToolName$7(entry.toolName);
40019
41213
  if (forbiddenMapKeys.has(canonical)) {
40020
41214
  logger?.warn(`AugmentCode permissions: skipping entry for tool '${entry.toolName}' because it maps to the reserved object key '${canonical}', which cannot be used as a permission key.`);
40021
41215
  continue;
40022
41216
  }
40023
41217
  const action = augmentTypeToAction(type);
40024
41218
  let pattern;
40025
- if (entry.toolName === "launch-process" && entry.shellInputRegex) {
41219
+ if (legacyToolName === "launch-process" && entry.shellInputRegex) {
40026
41220
  const regex = entry.shellInputRegex;
40027
41221
  if (isShellRegexRoundtrippable(regex)) pattern = shellRegexToGlob(regex);
40028
41222
  else if (action === "deny") {
@@ -40037,9 +41231,19 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
40037
41231
  logger?.warn(`AugmentCode permissions: skipping entry for tool '${entry.toolName}' because its pattern resolves to the reserved object key '${pattern}'.`);
40038
41232
  continue;
40039
41233
  }
40040
- if (!permission[canonical]) permission[canonical] = {};
40041
- const existing = permission[canonical][pattern];
40042
- if (existing === void 0 || actionPriority[action] > actionPriority[existing]) permission[canonical][pattern] = action;
41234
+ let bucket = lookupOwn({
41235
+ record: permission,
41236
+ key: canonical
41237
+ });
41238
+ if (bucket === void 0) {
41239
+ bucket = {};
41240
+ permission[canonical] = bucket;
41241
+ }
41242
+ const existing = lookupOwn({
41243
+ record: bucket,
41244
+ key: pattern
41245
+ });
41246
+ if (existing === void 0 || actionPriority[action] > actionPriority[existing]) bucket[pattern] = action;
40043
41247
  }
40044
41248
  return { permission };
40045
41249
  }
@@ -40250,7 +41454,7 @@ const CLAUDE_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONIC
40250
41454
  function toClaudeToolName(canonical) {
40251
41455
  return CANONICAL_TO_CLAUDE_TOOL_NAMES[canonical] ?? canonical;
40252
41456
  }
40253
- function toCanonicalToolName$5(claudeName) {
41457
+ function toCanonicalToolName$6(claudeName) {
40254
41458
  return CLAUDE_TO_CANONICAL_TOOL_NAMES[claudeName] ?? claudeName;
40255
41459
  }
40256
41460
  /**
@@ -41145,7 +42349,7 @@ function convertClaudeToRulesyncPermissions(params) {
41145
42349
  const processEntries = (entries, action) => {
41146
42350
  for (const entry of entries) {
41147
42351
  const { toolName, pattern } = parseClaudePermissionEntry(entry);
41148
- const canonical = toCanonicalToolName$5(toolName);
42352
+ const canonical = toCanonicalToolName$6(toolName);
41149
42353
  if (!permission[canonical]) permission[canonical] = {};
41150
42354
  permission[canonical][pattern] = action;
41151
42355
  }
@@ -41968,9 +43172,634 @@ function mapBashActionToDecision(action) {
41968
43172
  return "forbidden";
41969
43173
  }
41970
43174
  //#endregion
43175
+ //#region src/features/permissions/commandcode-permissions.ts
43176
+ /** Top-level key of `.commandcode/settings.json` that rulesync owns. */
43177
+ const COMMANDCODE_PERMISSIONS_KEY = "permissions";
43178
+ const CATCH_ALL_PATTERN$6 = "*";
43179
+ const MCP_CANONICAL_PREFIX$5 = "mcp__";
43180
+ const COMMANDCODE_ALL_MCP_RULE = "mcp__*";
43181
+ const CATEGORY_TO_COMMANDCODE_TOOL = {
43182
+ bash: "Shell",
43183
+ read: "Read",
43184
+ edit: "Edit",
43185
+ write: "Write",
43186
+ grep: "Grep",
43187
+ glob: "Glob",
43188
+ webfetch: "WebFetch",
43189
+ websearch: "WebSearch"
43190
+ };
43191
+ const COMMANDCODE_TOOL_TO_CATEGORY = {
43192
+ ...Object.fromEntries(Object.entries(CATEGORY_TO_COMMANDCODE_TOOL).map(([category, tool]) => [tool.toLowerCase(), category])),
43193
+ bash: "bash",
43194
+ powershell: "bash",
43195
+ shell_command: "bash",
43196
+ monitor_command: "bash",
43197
+ kill_shell: "bash",
43198
+ notebookedit: "edit",
43199
+ write_file: "write",
43200
+ web_fetch: "webfetch",
43201
+ web_search: "websearch"
43202
+ };
43203
+ const ACTION_RANK$1 = {
43204
+ allow: 0,
43205
+ ask: 1,
43206
+ deny: 2
43207
+ };
43208
+ /** Whether `action` outranks `existing` (deny > ask > allow). */
43209
+ function isStricterAction({ action, existing }) {
43210
+ return existing === void 0 || ACTION_RANK$1[action] > ACTION_RANK$1[existing];
43211
+ }
43212
+ /**
43213
+ * Why a padded `Shell( * )` cannot stand in for the bare `Shell` in `allow`:
43214
+ * the whitespace makes it a pattern rule, and a shell pattern grants only
43215
+ * commands that pass Command Code's pattern gate.
43216
+ */
43217
+ const PADDED_SHELL_WILDCARD_REASON = "a pattern narrower than the bare 'Shell' (the command must parse into words and carry no environment assignment)";
43218
+ /**
43219
+ * Build a Command Code rule (`Shell(git *)`, `Read`, `mcp__github__get_issue`,
43220
+ * `mcp__*`, `*`) from a canonical category + pattern. Returns `null` for
43221
+ * categories Command Code cannot express so the caller can skip them.
43222
+ *
43223
+ * MCP tools are keyed by their canonical `mcp__server__tool` name, so a
43224
+ * scoped `mcp__<remainder>` category is written verbatim and the bare `mcp`
43225
+ * category becomes `mcp__*` (or `mcp__<pattern>` when the pattern names a
43226
+ * server or tool). A pattern on an MCP category is still written as the
43227
+ * `(specifier)` here; `writableCommandcodeRule` decides what to do with it,
43228
+ * because Command Code ignores it. The all-tools `*` category is only
43229
+ * written for its catch-all pattern; narrower `*` patterns are shell
43230
+ * restrictions and reach the `Shell` entries through `honorAllToolsOnBash`.
43231
+ */
43232
+ function buildCommandcodeRule(category, pattern) {
43233
+ const catchAll = pattern === CATCH_ALL_PATTERN$6 || pattern === "";
43234
+ if (category.startsWith(MCP_CANONICAL_PREFIX$5)) return catchAll ? category : `${category}(${pattern})`;
43235
+ if (category === "mcp") return catchAll ? COMMANDCODE_ALL_MCP_RULE : `${MCP_CANONICAL_PREFIX$5}${pattern}`;
43236
+ if (category === "*") return catchAll ? CATCH_ALL_PATTERN$6 : null;
43237
+ const tool = Object.hasOwn(CATEGORY_TO_COMMANDCODE_TOOL, category) ? CATEGORY_TO_COMMANDCODE_TOOL[category] : void 0;
43238
+ if (tool === void 0) return null;
43239
+ return catchAll ? tool : `${tool}(${pattern})`;
43240
+ }
43241
+ /**
43242
+ * Split `Tool(specifier)` into its two halves the way Command Code's
43243
+ * `splitRule` does (`command-code` 1.54.0): the rule is split at the first
43244
+ * unescaped `(` and must end with `)`, the tool half is trimmed (so
43245
+ * `Shell (rm -rf *)` is a `Shell` rule), and a `\(` / `\)` in the specifier
43246
+ * is an escaped parenthesis. `Tool` alone has an empty specifier. A rule that
43247
+ * opens a parenthesis without closing one is not a rule to Command Code at
43248
+ * all, so it comes back with an empty tool half and stays unmodeled. Nothing
43249
+ * else in the specifier is touched: Command Code does not trim it (the shell
43250
+ * matcher normalizes whitespace on its own, see `parseCommandcodeRule`), so
43251
+ * `Read( )` is a rule for the pattern `" "`, which matches nothing, not for
43252
+ * the whole tool.
43253
+ */
43254
+ function splitCommandcodeRule(rule) {
43255
+ const trimmed = rule.trim();
43256
+ const parenIndex = trimmed.search(/(?<!\\)\(/);
43257
+ if (parenIndex === -1) return {
43258
+ tool: trimmed,
43259
+ inner: ""
43260
+ };
43261
+ if (!trimmed.endsWith(")")) return {
43262
+ tool: "",
43263
+ inner: ""
43264
+ };
43265
+ return {
43266
+ tool: trimmed.slice(0, parenIndex).trim(),
43267
+ inner: trimmed.slice(parenIndex + 1, -1).replace(/\\([()])/g, "$1")
43268
+ };
43269
+ }
43270
+ /** Whether a specifier means "no specifier" to Command Code: only `` and `*` do. */
43271
+ function isCatchAllSpecifier(inner) {
43272
+ return inner === "" || inner === CATCH_ALL_PATTERN$6;
43273
+ }
43274
+ /**
43275
+ * Read an MCP-shaped rule the way Command Code does (`command-code` 1.54.0,
43276
+ * `parsePermissionRule` / `parseMcpToken`). The MCP shape is only recognized
43277
+ * by its exact `mcp__` prefix, and such a rule never carries a specifier:
43278
+ * `mcp__<server>__<tool>(owner:foo)` denies, asks or allows the whole tool.
43279
+ * Its names are matched case-sensitively against the registered server and
43280
+ * tool (Command Code never lowercases them), so the remainder keeps its case
43281
+ * as the canonical category. `mcp__<server>`, `mcp__<server>__` and
43282
+ * `mcp__<server>__*` are the whole server (category `mcp__<server>`), and a
43283
+ * `*` server (`mcp__*`, `mcp__*__<tool>`) is every MCP tool, honored in
43284
+ * `deny`/`ask` only. A differently-cased `MCP__...` spelling is read as a
43285
+ * plain tool-name rule instead, matched case-insensitively:
43286
+ * `MCP__<server>__<tool>` matches that tool by name (so it folds onto the
43287
+ * `mcp__` category, remainder as written), `MCP__<server>__<tool>(specifier)`
43288
+ * globs the specifier against the call's `command` / `file_path` / `path` /
43289
+ * `url` / `pattern` argument (in `deny`/`ask` a `param:glob` form is tried
43290
+ * first) — kept as a pattern in `deny`/`ask`, but not imported from `allow`,
43291
+ * where a grant scoped that way cannot be modeled without widening it; the
43292
+ * globs
43293
+ * `MCP__*` (every MCP tool) and `MCP__<server>__*` (the whole server) match
43294
+ * by name in `deny`/`ask` only, and `MCP__<server>` names no tool at all.
43295
+ * Whatever matches nothing anywhere (or a glob rulesync does not model)
43296
+ * returns `null` and is left alone.
43297
+ */
43298
+ function parseMcpCommandcodeRule({ tool, inner }) {
43299
+ const remainder = tool.slice(5);
43300
+ const separator = remainder.indexOf("__");
43301
+ const server = separator === -1 ? remainder : remainder.slice(0, separator);
43302
+ const rest = separator === -1 ? "" : remainder.slice(separator + 2);
43303
+ if (server.length === 0) return null;
43304
+ const everyServer = {
43305
+ category: "mcp",
43306
+ pattern: CATCH_ALL_PATTERN$6,
43307
+ notInAllow: true
43308
+ };
43309
+ if (tool.startsWith(MCP_CANONICAL_PREFIX$5)) {
43310
+ if (server === CATCH_ALL_PATTERN$6) return everyServer;
43311
+ if (server.includes(CATCH_ALL_PATTERN$6)) return null;
43312
+ return {
43313
+ category: rest === "" || rest === CATCH_ALL_PATTERN$6 ? `${MCP_CANONICAL_PREFIX$5}${server}` : `${MCP_CANONICAL_PREFIX$5}${remainder}`,
43314
+ pattern: CATCH_ALL_PATTERN$6,
43315
+ notInAllow: false
43316
+ };
43317
+ }
43318
+ const category = `${MCP_CANONICAL_PREFIX$5}${remainder}`;
43319
+ const namesTool = separator !== -1 && rest.length > 0 && !remainder.includes(CATCH_ALL_PATTERN$6);
43320
+ if (!isCatchAllSpecifier(inner)) return namesTool ? {
43321
+ category,
43322
+ pattern: inner,
43323
+ notInAllow: true
43324
+ } : null;
43325
+ if (remainder === CATCH_ALL_PATTERN$6) return everyServer;
43326
+ if (rest === CATCH_ALL_PATTERN$6 && !server.includes(CATCH_ALL_PATTERN$6)) return {
43327
+ category: `${MCP_CANONICAL_PREFIX$5}${server}`,
43328
+ pattern: CATCH_ALL_PATTERN$6,
43329
+ notInAllow: true
43330
+ };
43331
+ return namesTool ? {
43332
+ category,
43333
+ pattern: CATCH_ALL_PATTERN$6,
43334
+ notInAllow: false
43335
+ } : null;
43336
+ }
43337
+ /**
43338
+ * A rule on one of the friendly tools, with its specifier read as Command
43339
+ * Code matches it. The shell matcher trims and collapses whitespace on both
43340
+ * the pattern and the command, so `Shell( git * )` is `Shell(git *)` and a
43341
+ * pattern that is blank once normalized (`Shell( )`), or a bare `:*` whose
43342
+ * prefix is empty (`Shell(:*)`), matches nothing. A padded `Shell( * )` is
43343
+ * not the bare `Shell`: only a specifier of exactly
43344
+ * `` or `*` is dropped by the rule parser, so `( * )` stays a pattern rule —
43345
+ * in `deny`/`ask` it matches every command (the whole tool), while in `allow`
43346
+ * it is narrower than the bare `Shell` (it needs a command that parses into
43347
+ * words and has no environment-assignment prefix), so it is the whole tool
43348
+ * marked `notInAllow`. The path, web and tool-name matchers use the
43349
+ * specifier as written, so one that is blank or `*` only once trimmed
43350
+ * (`Read( * )`) matches nothing. What matches nothing comes back as `null`:
43351
+ * the rule is unmodeled and left alone.
43352
+ */
43353
+ function friendlyToolRule({ category, inner }) {
43354
+ if (category === "bash") {
43355
+ if (isCatchAllSpecifier(inner)) return {
43356
+ category,
43357
+ pattern: CATCH_ALL_PATTERN$6,
43358
+ notInAllow: false
43359
+ };
43360
+ const normalized = inner.trim().replace(/\s+/g, " ");
43361
+ if (normalized === "" || normalized === ":*") return null;
43362
+ return isCatchAllSpecifier(normalized) ? {
43363
+ category,
43364
+ pattern: CATCH_ALL_PATTERN$6,
43365
+ notInAllow: true
43366
+ } : {
43367
+ category,
43368
+ pattern: normalized,
43369
+ notInAllow: false
43370
+ };
43371
+ }
43372
+ if (isCatchAllSpecifier(inner)) return {
43373
+ category,
43374
+ pattern: CATCH_ALL_PATTERN$6,
43375
+ notInAllow: false
43376
+ };
43377
+ return isCatchAllSpecifier(inner.trim()) ? null : {
43378
+ category,
43379
+ pattern: inner,
43380
+ notInAllow: false
43381
+ };
43382
+ }
43383
+ /**
43384
+ * Parse a Command Code rule back into canonical terms. Tool names fold case;
43385
+ * `Tool`, `Tool()` and `Tool(*)` all mean the whole tool; MCP-shaped rules
43386
+ * go through `parseMcpCommandcodeRule`. Returns `null` for a rule rulesync
43387
+ * cannot model (an exact internal tool name such as `edit_file`, a
43388
+ * name-wildcard like `edit_*`, a specifier on a rule that takes none, or a
43389
+ * rule Command Code enforces nothing for, such as `Agent`).
43390
+ */
43391
+ function parseCommandcodeRule(rule) {
43392
+ const { tool, inner } = splitCommandcodeRule(rule);
43393
+ if (tool === CATCH_ALL_PATTERN$6) return isCatchAllSpecifier(inner) ? {
43394
+ category: "*",
43395
+ pattern: CATCH_ALL_PATTERN$6,
43396
+ notInAllow: true
43397
+ } : null;
43398
+ const lowered = tool.toLowerCase();
43399
+ if (lowered.startsWith(MCP_CANONICAL_PREFIX$5)) return parseMcpCommandcodeRule({
43400
+ tool,
43401
+ inner
43402
+ });
43403
+ const category = Object.hasOwn(COMMANDCODE_TOOL_TO_CATEGORY, lowered) ? COMMANDCODE_TOOL_TO_CATEGORY[lowered] : void 0;
43404
+ if (category === void 0) return null;
43405
+ return friendlyToolRule({
43406
+ category,
43407
+ inner
43408
+ });
43409
+ }
43410
+ /**
43411
+ * Whether an imported rule is a differently-cased `MCP__<server>__<tool>`
43412
+ * name that folded onto an exact-prefix `mcp__` category (which Command Code
43413
+ * matches case-sensitively, unlike the name rule it came from).
43414
+ */
43415
+ function isFoldedMcpToolNameRule({ rule, category }) {
43416
+ const { tool } = splitCommandcodeRule(rule);
43417
+ return category.startsWith(MCP_CANONICAL_PREFIX$5) && !tool.startsWith(MCP_CANONICAL_PREFIX$5);
43418
+ }
43419
+ /** The `Tool` half of `Tool(specifier)`, when the rule is an exact-prefix MCP rule with a specifier. */
43420
+ function scopedMcpRuleTool(rule) {
43421
+ const { tool, inner } = splitCommandcodeRule(rule);
43422
+ return !isCatchAllSpecifier(inner) && tool.startsWith(MCP_CANONICAL_PREFIX$5) ? tool : null;
43423
+ }
43424
+ /**
43425
+ * The string entries of a Command Code permission list. Command Code reads
43426
+ * each entry on its own and skips one that is not a string, so a stray
43427
+ * `null` beside `Shell(rm -rf *)` must not throw the deny away with it; the
43428
+ * skipped entries are counted in a warning because they cannot be kept.
43429
+ */
43430
+ function stringRules({ list, action, outcome, warn }) {
43431
+ if (!Array.isArray(list)) return [];
43432
+ const rules = list.filter((entry) => typeof entry === "string");
43433
+ const skipped = list.length - rules.length;
43434
+ if (skipped > 0) warn(`Command Code permission list "${action}" holds ${skipped} ${skipped === 1 ? "entry" : "entries"} that ${skipped === 1 ? "is" : "are"} not a string, which Command Code skips; ${skipped === 1 ? "that entry was" : "those entries were"} ${outcome}.`);
43435
+ return rules;
43436
+ }
43437
+ /**
43438
+ * The canonical categories this run rebuilds — every category the canonical
43439
+ * config names (an empty `bash: {}` still reclaims the previous `Shell(...)`
43440
+ * entries, as in the Claude Code adapter) plus every rule it emits, keyed the
43441
+ * way an existing entry parses back (so `mcp: { github: "deny" }`, written as
43442
+ * `mcp__github`, claims the `mcp__github` entries of the previous run; case
43443
+ * variants of the friendly tool names fold together, while MCP names are
43444
+ * matched exactly, as Command Code does). An existing entry whose tool folds onto one of them is
43445
+ * rulesync's to replace, whatever list it sits in — otherwise flipping a rule
43446
+ * from deny to allow would leave the old deny behind and win. Every other
43447
+ * entry — an internal tool name rulesync cannot model, or a modeled tool the
43448
+ * canonical config does not mention — is the user's (Command Code writes
43449
+ * interactive approvals into the same lists) and is preserved verbatim.
43450
+ * A `deny`/`ask` entry that is reclaimed without an equally strict rule
43451
+ * taking its place (say a hand-written `mcp__*` deny next to a canonical
43452
+ * `mcp: { github: "deny" }`) is reported, so regenerating never silently
43453
+ * loosens what the user wrote. Mirrors `managedClaudeToolNames` in the
43454
+ * Claude Code adapter.
43455
+ */
43456
+ function preservedRules({ existingPermissions, key, managedCategories, written, logger }) {
43457
+ return stringRules({
43458
+ list: existingPermissions[key],
43459
+ action: key,
43460
+ outcome: "dropped from the regenerated list",
43461
+ warn: (message) => logger?.warn(message)
43462
+ }).filter((rule) => {
43463
+ const parsed = parseCommandcodeRule(rule);
43464
+ if (parsed === null || !managedCategories.has(parsed.category)) return true;
43465
+ const replacement = written.get(`${parsed.category}(${parsed.pattern})`) ?? written.get(`${parsed.category}(${CATCH_ALL_PATTERN$6})`);
43466
+ if (key !== "allow" && isStricterAction({
43467
+ action: key,
43468
+ existing: replacement
43469
+ })) logger?.warn(`Command Code permission rule '${rule}' in "${key}" belongs to the '${parsed.category}' category, which the rulesync config now manages, and was replaced by its rules.`);
43470
+ return false;
43471
+ });
43472
+ }
43473
+ /**
43474
+ * The rule to write for a canonical rule, or `null` when Command Code would
43475
+ * ignore it in the `action` list. A server-less wildcard (`*`, `mcp__*`) in
43476
+ * `allow` is warned about and left unwritten rather than written dead. A
43477
+ * specifier on an MCP rule is ignored by Command Code in every list, so a
43478
+ * scoped `deny`/`ask` is written as the bare tool (which is what Command
43479
+ * Code would enforce anyway, only now visibly) with a warning, and a scoped
43480
+ * `allow` — which would grant the whole tool — is refused. Judged on the
43481
+ * rule as written, so the bare `mcp` category cannot smuggle a specifier in
43482
+ * through a `<server>__<tool>(specifier)` pattern.
43483
+ */
43484
+ function writableCommandcodeRule({ rule, emitted, action, logger }) {
43485
+ if (action === "allow" && emitted.notInAllow) {
43486
+ logger?.warn(emitted.category === "bash" ? `Command Code reads '${rule}' in "allow" as ${PADDED_SHELL_WILDCARD_REASON}, so the '${emitted.category}' allow rule was not written; use the '*' pattern for the whole tool.` : `Command Code does not honor '${rule}' in "allow" as written (an allow rule must name what it grants), so the '${emitted.category}' allow rule was not written.`);
43487
+ return null;
43488
+ }
43489
+ const tool = scopedMcpRuleTool(rule);
43490
+ if (tool === null) return rule;
43491
+ if (action === "allow") {
43492
+ logger?.warn(`Command Code ignores the specifier of an MCP rule and would allow the whole '${tool}' tool, so the '${rule}' allow rule was not written.`);
43493
+ return null;
43494
+ }
43495
+ logger?.warn(`Command Code ignores the specifier of an MCP rule, so '${rule}' was written as '${tool}' and applies "${action}" to the whole tool.`);
43496
+ return tool;
43497
+ }
43498
+ /**
43499
+ * Record `action` for `rule`, keeping the stricter one (deny > ask > allow)
43500
+ * when two canonical rules collapse onto the same Command Code entry.
43501
+ */
43502
+ function rankCommandcodeRule({ ranked, rule, action, logger }) {
43503
+ const existing = ranked.get(rule);
43504
+ if (existing !== void 0 && existing !== action) logger?.warn(`Command Code permission rule '${rule}' received conflicting actions ('${existing}' and '${action}'); keeping the stricter one (deny > ask > allow).`);
43505
+ if (isStricterAction({
43506
+ action,
43507
+ existing
43508
+ })) ranked.set(rule, action);
43509
+ }
43510
+ /**
43511
+ * Bucket the canonical rules into Command Code's `allow`/`ask`/`deny` lists.
43512
+ * Collisions resolve to the strictest action (deny > ask > allow); categories
43513
+ * Command Code cannot express are skipped with a warning when they carry a
43514
+ * `deny`; rules Command Code would ignore in the target list are dropped or
43515
+ * rewritten by `writableCommandcodeRule`.
43516
+ */
43517
+ function buildCommandcodeRuleLists({ config, existingPermissions, logger }) {
43518
+ const permission = honorAllToolsOnBash(config.permission);
43519
+ const ranked = /* @__PURE__ */ new Map();
43520
+ const managedCategories = /* @__PURE__ */ new Set();
43521
+ for (const [category, rules] of Object.entries(permission)) {
43522
+ const categoryRule = buildCommandcodeRule(category, CATCH_ALL_PATTERN$6);
43523
+ const managedCategory = categoryRule === null ? null : parseCommandcodeRule(categoryRule);
43524
+ if (managedCategory !== null) managedCategories.add(managedCategory.category);
43525
+ for (const [pattern, action] of Object.entries(rules)) {
43526
+ const rule = buildCommandcodeRule(category, pattern);
43527
+ const emitted = rule === null ? null : parseCommandcodeRule(rule);
43528
+ if (rule === null || emitted === null) {
43529
+ const honoredOnShell = category === "*" && permission.bash !== void 0;
43530
+ if (action === "deny" && !honoredOnShell) logger?.warn(`Command Code has no permission rule for the '${category}' category with pattern '${pattern}'; its 'deny' rule could not be represented and was skipped.`);
43531
+ continue;
43532
+ }
43533
+ managedCategories.add(emitted.category);
43534
+ const written = writableCommandcodeRule({
43535
+ rule,
43536
+ emitted,
43537
+ action,
43538
+ logger
43539
+ });
43540
+ if (written !== null) rankCommandcodeRule({
43541
+ ranked,
43542
+ rule: written,
43543
+ action,
43544
+ logger
43545
+ });
43546
+ }
43547
+ }
43548
+ const written = /* @__PURE__ */ new Map();
43549
+ for (const [rule, action] of ranked) {
43550
+ const parsed = parseCommandcodeRule(rule);
43551
+ if (parsed !== null) written.set(`${parsed.category}(${parsed.pattern})`, action);
43552
+ }
43553
+ const preserved = {
43554
+ existingPermissions,
43555
+ managedCategories,
43556
+ written,
43557
+ logger
43558
+ };
43559
+ const allow = preservedRules({
43560
+ ...preserved,
43561
+ key: "allow"
43562
+ });
43563
+ const ask = preservedRules({
43564
+ ...preserved,
43565
+ key: "ask"
43566
+ });
43567
+ const deny = preservedRules({
43568
+ ...preserved,
43569
+ key: "deny"
43570
+ });
43571
+ for (const [rule, action] of ranked) if (action === "allow") allow.push(rule);
43572
+ else if (action === "ask") ask.push(rule);
43573
+ else deny.push(rule);
43574
+ return {
43575
+ allow: (0, es_toolkit.uniq)(allow.toSorted()),
43576
+ ask: (0, es_toolkit.uniq)(ask.toSorted()),
43577
+ deny: (0, es_toolkit.uniq)(deny.toSorted())
43578
+ };
43579
+ }
43580
+ /**
43581
+ * Why a rule marked `notInAllow` is not imported from `allow`: Command Code
43582
+ * ignores a server-less wildcard or a tool-name glob there, while it does
43583
+ * enforce a scoped `MCP__<server>__<tool>(specifier)` — as a glob over the
43584
+ * call's arguments — and a padded `Shell( * )` — as a pattern narrower than
43585
+ * the bare `Shell` — which rulesync cannot model in `allow` without widening
43586
+ * the grant.
43587
+ */
43588
+ function skippedAllowImportMessage({ rule, category }) {
43589
+ const { tool, inner } = splitCommandcodeRule(rule);
43590
+ if (category === "bash") return `Command Code reads '${rule}' in "allow" as ${PADDED_SHELL_WILDCARD_REASON}, which rulesync cannot import without widening the grant to every command, so it was not imported.`;
43591
+ if (tool.startsWith(MCP_CANONICAL_PREFIX$5) || isCatchAllSpecifier(inner)) return `Command Code ignores '${rule}' in "allow" (an allow rule must name what it grants), so it was not imported.`;
43592
+ return `Command Code globs the specifier of '${rule}' in "allow" against the call's arguments, which rulesync cannot import without widening the grant to the whole tool, so it was not imported.`;
43593
+ }
43594
+ /**
43595
+ * Parse Command Code's `permissions` lists back into a canonical permission
43596
+ * map with `deny > ask > allow` precedence, so a tool described more than
43597
+ * once resolves to the strictest action. Each rule is imported as what
43598
+ * Command Code enforces for it, never as what it looks like: a rule Command
43599
+ * Code ignores in `allow` (`*`, `mcp__*`, an `MCP__...` glob) is skipped
43600
+ * there with a warning, since importing it would turn a dead line into a
43601
+ * live grant for every other target, and so is a scoped
43602
+ * `MCP__<server>__<tool>(specifier)` allow, which Command Code enforces as a
43603
+ * glob over the call's arguments but rulesync cannot import without widening
43604
+ * it to the whole tool; the specifier of an exact-prefix `mcp__...` rule,
43605
+ * which Command Code drops, is folded to the whole tool in every list — with
43606
+ * a warning in `allow`, where the fold widens the grant the user wrote. A
43607
+ * tool-half glob in that exact-prefix spelling (`mcp__<server>__get_*`), which
43608
+ * Command Code matches against tool names in every list, is imported as the
43609
+ * category spelled that way, the same one the generator writes back; other
43610
+ * targets read it as a literal tool name.
43611
+ */
43612
+ function parseCommandcodeRuleLists(permissions) {
43613
+ const permission = {};
43614
+ const lists = [
43615
+ ["allow", permissions.allow],
43616
+ ["ask", permissions.ask],
43617
+ ["deny", permissions.deny]
43618
+ ];
43619
+ for (const [action, rawList] of lists) {
43620
+ const list = stringRules({
43621
+ list: rawList,
43622
+ action,
43623
+ outcome: "not imported",
43624
+ warn: (message) => fallbackLogger.warn(message)
43625
+ });
43626
+ for (const rule of list) {
43627
+ const parsed = parseCommandcodeRule(rule);
43628
+ if (parsed === null || isPrototypePollutionKey(parsed.category) || isPrototypePollutionKey(parsed.pattern)) {
43629
+ if (action !== "allow") fallbackLogger.warn(`Command Code permission rule '${rule}' in "${action}" is not one rulesync can model, so it could not be imported; it stays in the Command Code settings, where a regenerate keeps it unless the canonical config manages its category.`);
43630
+ continue;
43631
+ }
43632
+ const { category, pattern } = parsed;
43633
+ if (action === "allow" && parsed.notInAllow) {
43634
+ fallbackLogger.warn(skippedAllowImportMessage({
43635
+ rule,
43636
+ category
43637
+ }));
43638
+ continue;
43639
+ }
43640
+ if (action === "allow" && scopedMcpRuleTool(rule) !== null) fallbackLogger.warn(`Command Code ignores the specifier of an MCP rule, so '${rule}' in "allow" was imported as the whole '${category}' tool, which is what Command Code grants for it.`);
43641
+ if (isFoldedMcpToolNameRule({
43642
+ rule,
43643
+ category
43644
+ })) fallbackLogger.warn(`Command Code matches '${rule}' in "${action}" against MCP tool names case-insensitively, but the '${category}' rule generated from it matches the server and tool names exactly as written; spell the rule the way the tool is registered if they differ.`);
43645
+ const rules = permission[category] ??= {};
43646
+ if (isStricterAction({
43647
+ action,
43648
+ existing: rules[pattern]
43649
+ })) rules[pattern] = action;
43650
+ }
43651
+ }
43652
+ return permission;
43653
+ }
43654
+ /**
43655
+ * Permissions generator for Command Code.
43656
+ *
43657
+ * Rules live under the `permissions` key of `.commandcode/settings.json`
43658
+ * (project) and `~/.commandcode/settings.json` (user) as three lists of
43659
+ * Claude-style entries: `deny` (always wins), `ask`, `allow`. The same key
43660
+ * also carries `defaultMode`, `additionalDirectories` and `disableBypass`,
43661
+ * and the file holds `hooks` and other settings, so writes go through the
43662
+ * shared-config gateway: rulesync replaces the three lists, keeps every
43663
+ * sibling key, and never deletes the file.
43664
+ *
43665
+ * Generate: `permission.<category>.<pattern>` becomes `Tool(pattern)` with
43666
+ * the friendly names (`Shell`, `Read`, `Edit`, `Write`, `WebFetch`,
43667
+ * `WebSearch`), `mcp__<server>__<tool>` names pass through, and the all-tools
43668
+ * `*` category becomes the bare `*` rule Command Code accepts in `deny`/`ask`.
43669
+ * Only the entries of the categories the canonical config names are rebuilt;
43670
+ * every other existing entry is preserved verbatim.
43671
+ * Import: the lists are parsed back as what Command Code enforces for each
43672
+ * entry — friendly tool names case-insensitively (with the internal aliases
43673
+ * such as `Bash`, `PowerShell` and `write_file` folding onto their
43674
+ * category), MCP names exactly; rules for internal tool names rulesync does
43675
+ * not model are skipped.
43676
+ *
43677
+ * @see https://commandcode.ai/docs/permissions
43678
+ * @see https://commandcode.ai/docs/settings
43679
+ */
43680
+ var CommandcodePermissions = class CommandcodePermissions extends ToolPermissions {
43681
+ constructor(params) {
43682
+ super({
43683
+ ...params,
43684
+ fileContent: params.fileContent ?? "{}"
43685
+ });
43686
+ }
43687
+ /**
43688
+ * `settings.json` holds hooks and other user settings, so it is never
43689
+ * deleted; clearing permissions happens via an in-place merge.
43690
+ */
43691
+ isDeletable() {
43692
+ return false;
43693
+ }
43694
+ static getSettablePaths(_options = {}) {
43695
+ return {
43696
+ relativeDirPath: COMMANDCODE_DIR,
43697
+ relativeFilePath: COMMANDCODE_SETTINGS_FILE_NAME
43698
+ };
43699
+ }
43700
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
43701
+ const paths = CommandcodePermissions.getSettablePaths({ global });
43702
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{}";
43703
+ return new CommandcodePermissions({
43704
+ outputRoot,
43705
+ relativeDirPath: paths.relativeDirPath,
43706
+ relativeFilePath: paths.relativeFilePath,
43707
+ fileContent,
43708
+ validate,
43709
+ global
43710
+ });
43711
+ }
43712
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, logger }) {
43713
+ const paths = CommandcodePermissions.getSettablePaths({ global });
43714
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43715
+ const existingContent = await readFileContentOrNull(filePath) ?? "{}";
43716
+ const existing = parseCommandcodeSettings({
43717
+ fileContent: existingContent,
43718
+ relativePath: (0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)
43719
+ });
43720
+ const existingPermissions = isRecord$1(existing[COMMANDCODE_PERMISSIONS_KEY]) ? existing[COMMANDCODE_PERMISSIONS_KEY] : {};
43721
+ const lists = buildCommandcodeRuleLists({
43722
+ config: rulesyncPermissions.getJson(),
43723
+ existingPermissions,
43724
+ logger
43725
+ });
43726
+ const permissions = { ...existingPermissions };
43727
+ for (const key of [
43728
+ "allow",
43729
+ "ask",
43730
+ "deny"
43731
+ ]) if (lists[key].length > 0) permissions[key] = lists[key];
43732
+ else delete permissions[key];
43733
+ return new CommandcodePermissions({
43734
+ outputRoot,
43735
+ relativeDirPath: paths.relativeDirPath,
43736
+ relativeFilePath: paths.relativeFilePath,
43737
+ fileContent: applySharedConfigPatch({
43738
+ fileKey: sharedConfigFileKey(paths),
43739
+ feature: "permissions",
43740
+ existingContent,
43741
+ patch: { [COMMANDCODE_PERMISSIONS_KEY]: permissions },
43742
+ filePath
43743
+ }),
43744
+ validate: true,
43745
+ global
43746
+ });
43747
+ }
43748
+ toRulesyncPermissions() {
43749
+ const settings = parseCommandcodeSettings({
43750
+ fileContent: this.getFileContent() || "{}",
43751
+ relativePath: (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())
43752
+ });
43753
+ const permissions = isRecord$1(settings[COMMANDCODE_PERMISSIONS_KEY]) ? settings[COMMANDCODE_PERMISSIONS_KEY] : {};
43754
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission: parseCommandcodeRuleLists(permissions) }, null, 2) });
43755
+ }
43756
+ validate() {
43757
+ return {
43758
+ success: true,
43759
+ error: null
43760
+ };
43761
+ }
43762
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
43763
+ return new CommandcodePermissions({
43764
+ outputRoot,
43765
+ relativeDirPath,
43766
+ relativeFilePath,
43767
+ fileContent: "{}",
43768
+ validate: false,
43769
+ global
43770
+ });
43771
+ }
43772
+ };
43773
+ /**
43774
+ * Fail closed on a syntax error or non-object root, matching the write path's
43775
+ * shared-config declaration, so a broken file is surfaced rather than
43776
+ * partially imported or overwritten.
43777
+ */
43778
+ function parseCommandcodeSettings({ fileContent, relativePath }) {
43779
+ try {
43780
+ return parseSharedConfig({
43781
+ format: "json",
43782
+ fileContent,
43783
+ filePath: relativePath,
43784
+ invalidRootPolicy: "error"
43785
+ });
43786
+ } catch (error) {
43787
+ throw new Error(`Failed to parse Command Code settings in ${relativePath}: ${formatError(error)}`, { cause: error });
43788
+ }
43789
+ }
43790
+ //#endregion
41971
43791
  //#region src/features/permissions/continue-permissions.ts
41972
43792
  const CONTINUE_GLOBAL_ONLY_MESSAGE = "Continue permissions are global-only; use --global to sync ~/.continue/permissions.yaml";
41973
- const CATCH_ALL_PATTERN$4 = "*";
43793
+ const CATCH_ALL_PATTERN$5 = "*";
43794
+ /**
43795
+ * A run of `*` (`**`, `***`) is the catch-all spelled another way: Continue
43796
+ * would match `Tool(**)` against every argument just as the bare tool name
43797
+ * does, so it is written — and, under the all-tools category, honored — as
43798
+ * the bare entry instead of being skipped as a pattern-specific rule.
43799
+ */
43800
+ function normalizeCatchAllPattern$1(pattern) {
43801
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$5 : pattern;
43802
+ }
41974
43803
  const CANONICAL_TO_CONTINUE_TOOL_NAMES = {
41975
43804
  bash: "Bash",
41976
43805
  read: "Read",
@@ -41998,7 +43827,7 @@ const CONTINUE_PATTERN_RE = /^([^(]+)(?:\(([^)]*)\))?$/;
41998
43827
  function toContinueToolName(canonical) {
41999
43828
  return Object.hasOwn(CANONICAL_TO_CONTINUE_TOOL_NAMES, canonical) ? CANONICAL_TO_CONTINUE_TOOL_NAMES[canonical] ?? canonical : canonical;
42000
43829
  }
42001
- function toCanonicalToolName$4(continueName) {
43830
+ function toCanonicalToolName$5(continueName) {
42002
43831
  return Object.hasOwn(CONTINUE_TO_CANONICAL_TOOL_NAMES, continueName) ? CONTINUE_TO_CANONICAL_TOOL_NAMES[continueName] ?? continueName : continueName;
42003
43832
  }
42004
43833
  /**
@@ -42006,7 +43835,7 @@ function toCanonicalToolName$4(continueName) {
42006
43835
  * `Tool(pattern)`.
42007
43836
  */
42008
43837
  function buildContinuePermissionEntry(toolName, pattern) {
42009
- return pattern === CATCH_ALL_PATTERN$4 ? toolName : `${toolName}(${pattern})`;
43838
+ return pattern === CATCH_ALL_PATTERN$5 ? toolName : `${toolName}(${pattern})`;
42010
43839
  }
42011
43840
  /**
42012
43841
  * Split a permissions.yaml entry into its tool name and argument pattern, or
@@ -42020,7 +43849,7 @@ function parseContinuePermissionEntry(entry) {
42020
43849
  if (toolName === "") return;
42021
43850
  return {
42022
43851
  toolName,
42023
- pattern: pattern === "" ? CATCH_ALL_PATTERN$4 : pattern
43852
+ pattern: pattern === "" ? CATCH_ALL_PATTERN$5 : pattern
42024
43853
  };
42025
43854
  }
42026
43855
  /**
@@ -42187,13 +44016,14 @@ function convertRulesyncToContinuePermissions({ config, logger }) {
42187
44016
  for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
42188
44017
  if (isPrototypePollutionKey(category)) continue;
42189
44018
  const toolName = category === "*" ? "*" : toContinueToolName(category);
42190
- for (const [pattern, action] of Object.entries(rules)) {
42191
- if (isPrototypePollutionKey(pattern)) continue;
44019
+ for (const [rawPattern, action] of Object.entries(rules)) {
44020
+ if (isPrototypePollutionKey(rawPattern)) continue;
44021
+ const pattern = normalizeCatchAllPattern$1(rawPattern);
42192
44022
  if (pattern.includes("(") || pattern.includes(")")) {
42193
44023
  logger?.warn(`Continue permissions.yaml cannot hold a parenthesis inside a pattern, so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
42194
44024
  continue;
42195
44025
  }
42196
- if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$4) {
44026
+ if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$5) {
42197
44027
  logger?.warn(`Continue permissions.yaml cannot scope a pattern to every tool, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
42198
44028
  continue;
42199
44029
  }
@@ -42218,7 +44048,7 @@ function convertContinuePermissionsToRulesync(lists) {
42218
44048
  const parsedEntry = parseContinuePermissionEntry(entry);
42219
44049
  if (parsedEntry === void 0) continue;
42220
44050
  if (isPrototypePollutionKey(parsedEntry.toolName) || isPrototypePollutionKey(parsedEntry.pattern)) continue;
42221
- const category = toCanonicalToolName$4(parsedEntry.toolName);
44051
+ const category = toCanonicalToolName$5(parsedEntry.toolName);
42222
44052
  permission[category] ??= {};
42223
44053
  permission[category][parsedEntry.pattern] ??= action;
42224
44054
  }
@@ -42541,6 +44371,295 @@ var CopilotcliPermissions = class CopilotcliPermissions extends ToolPermissions
42541
44371
  }
42542
44372
  };
42543
44373
  //#endregion
44374
+ //#region src/features/permissions/crush-permissions.ts
44375
+ const CATCH_ALL_PATTERN$4 = "*";
44376
+ /**
44377
+ * A run of `*` (`**`, `***`) is the catch-all spelled another way: Crush has no
44378
+ * argument patterns at all, so it is the only pattern a rule can carry and
44379
+ * still be written.
44380
+ */
44381
+ function normalizeCatchAllPattern(pattern) {
44382
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$4 : pattern;
44383
+ }
44384
+ const CANONICAL_TO_CRUSH_TOOL_NAMES = {
44385
+ bash: "bash",
44386
+ read: "view",
44387
+ edit: "edit",
44388
+ write: "write",
44389
+ grep: "grep",
44390
+ glob: "glob",
44391
+ webfetch: "fetch"
44392
+ };
44393
+ const CRUSH_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CRUSH_TOOL_NAMES).map(([k, v]) => [v, k]));
44394
+ const MCP_CANONICAL_PREFIX$4 = "mcp__";
44395
+ const MCP_CRUSH_PREFIX = "mcp_";
44396
+ function toCrushToolName(canonical) {
44397
+ if (Object.hasOwn(CANONICAL_TO_CRUSH_TOOL_NAMES, canonical)) return CANONICAL_TO_CRUSH_TOOL_NAMES[canonical] ?? canonical;
44398
+ if (canonical.startsWith(MCP_CANONICAL_PREFIX$4) && canonical.length > 5) return `${MCP_CRUSH_PREFIX}${canonical.slice(5).replaceAll("__", "_")}`;
44399
+ return canonical;
44400
+ }
44401
+ function toCanonicalToolName$4(crushName) {
44402
+ return Object.hasOwn(CRUSH_TO_CANONICAL_TOOL_NAMES, crushName) ? CRUSH_TO_CANONICAL_TOOL_NAMES[crushName] ?? crushName : crushName;
44403
+ }
44404
+ function isMcpToolName(crushName) {
44405
+ return crushName.startsWith(MCP_CRUSH_PREFIX) && crushName.length > 4;
44406
+ }
44407
+ function uniq$9(values) {
44408
+ return [...new Set(values)];
44409
+ }
44410
+ /**
44411
+ * Permissions adapter for Crush.
44412
+ *
44413
+ * Crush has no per-argument permission rules. Its JSON config carries two
44414
+ * tool-level lists instead:
44415
+ * - `permissions.allowed_tools`: tools (or `tool:action` pairs) that run
44416
+ * without a permission prompt; everything else prompts.
44417
+ * - `options.disabled_tools`: built-in tools removed from the agent
44418
+ * entirely.
44419
+ *
44420
+ * Mapping (rulesync canonical -> Crush), catch-all (`*`) rules only:
44421
+ * - `allow` -> an `allowed_tools` entry.
44422
+ * - `deny` -> a `disabled_tools` entry. Crush only filters its built-ins
44423
+ * through that list, so a deny for an MCP tool is reported and skipped
44424
+ * (disable the tool in the MCP server's `disabled_tools` instead).
44425
+ * - `ask` -> nothing (prompting is Crush's default).
44426
+ * - Tool name: `bash` -> `bash`, `read` -> `view`, `edit` -> `edit`,
44427
+ * `write` -> `write`, `grep` -> `grep`, `glob` -> `glob`,
44428
+ * `webfetch` -> `fetch`, `mcp__<server>__<tool>` -> `mcp_<server>_<tool>`;
44429
+ * any other category passes through verbatim.
44430
+ *
44431
+ * A pattern-specific rule cannot be expressed and is reported and skipped.
44432
+ * Because Crush cannot narrow an allowed tool, a category's catch-all `allow`
44433
+ * is not written while the same category carries a pattern-specific `deny` or
44434
+ * `ask`: the tool keeps prompting (fail closed) rather than being widened to
44435
+ * everything. The all-tools `*` category is mirrored onto `bash` by
44436
+ * `honorAllToolsOnBash` and otherwise skipped, since Crush has no allow-all
44437
+ * or disable-all list.
44438
+ *
44439
+ * `crush.json` is Crush's main config: entries naming a tool the canonical
44440
+ * config does not manage, and every `tool:action` entry (a form rulesync
44441
+ * never derives), are preserved verbatim; the managed tools' bare entries
44442
+ * are rebuilt, every other key of `permissions` / `options` and of the file is
44443
+ * kept, and the file is never deleted. Project scope writes `crush.json`, or
44444
+ * an existing `.crush.json`; Crush merges the pair (lists concatenated), so an
44445
+ * entry left in the other file stays in effect and is reported.
44446
+ *
44447
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/config/README.md
44448
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
44449
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/permission/permission.go
44450
+ */
44451
+ var CrushPermissions = class CrushPermissions extends ToolPermissions {
44452
+ json;
44453
+ constructor(params) {
44454
+ super({
44455
+ ...params,
44456
+ fileContent: params.fileContent ?? ""
44457
+ });
44458
+ this.json = parseCrushConfig(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
44459
+ }
44460
+ getJson() {
44461
+ return this.json;
44462
+ }
44463
+ isDeletable() {
44464
+ return false;
44465
+ }
44466
+ /**
44467
+ * `crush.json` is Crush's file: rulesync merges into it when it exists but
44468
+ * does not create one that holds nothing of its own — an absent file and an
44469
+ * absent `allowed_tools` both mean "prompt for everything".
44470
+ */
44471
+ shouldSkipCreationWhenPayloadEmpty() {
44472
+ return true;
44473
+ }
44474
+ static getSettablePaths({ global = false } = {}) {
44475
+ return getCrushConfigSettablePaths({ global });
44476
+ }
44477
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
44478
+ const location = await resolveCrushConfigFile({
44479
+ outputRoot,
44480
+ global
44481
+ });
44482
+ return new CrushPermissions({
44483
+ outputRoot,
44484
+ relativeDirPath: location.relativeDirPath,
44485
+ relativeFilePath: location.relativeFilePath,
44486
+ fileContent: crushConfigImportContent(location),
44487
+ validate,
44488
+ global
44489
+ });
44490
+ }
44491
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
44492
+ const location = await resolveCrushConfigFile({
44493
+ outputRoot,
44494
+ global
44495
+ });
44496
+ const existingContent = location.fileContent ?? "";
44497
+ warnCrushTwinLeftovers({
44498
+ location,
44499
+ ownedPaths: [[CRUSH_PERMISSIONS_KEY, CRUSH_ALLOWED_TOOLS_KEY], [CRUSH_OPTIONS_KEY, CRUSH_DISABLED_TOOLS_KEY]],
44500
+ logger
44501
+ });
44502
+ const existing = parseCrushConfig(existingContent, location.filePath);
44503
+ const existingPermissions = isRecord$1(existing["permissions"]) ? existing[CRUSH_PERMISSIONS_KEY] : void 0;
44504
+ const existingOptions = isRecord$1(existing["options"]) ? existing[CRUSH_OPTIONS_KEY] : void 0;
44505
+ const config = rulesyncPermissions.getJson();
44506
+ const generated = convertRulesyncToCrushLists({
44507
+ config,
44508
+ logger
44509
+ });
44510
+ const managedToolNames = managedCrushToolNames(config);
44511
+ const preservedAllowed = (isStringArray$2(existingPermissions?.["allowed_tools"]) ? existingPermissions[CRUSH_ALLOWED_TOOLS_KEY] : []).filter((entry) => entry.includes(":") || !managedToolNames.has(entry));
44512
+ const preservedDisabled = (isStringArray$2(existingOptions?.["disabled_tools"]) ? existingOptions[CRUSH_DISABLED_TOOLS_KEY] : []).filter((entry) => !managedToolNames.has(entry));
44513
+ const allowedList = uniq$9([...preservedAllowed, ...generated.allowed]);
44514
+ const disabledList = uniq$9([...preservedDisabled, ...generated.disabled]);
44515
+ const patch = {};
44516
+ if (allowedList.length > 0 || existingPermissions !== void 0) patch[CRUSH_PERMISSIONS_KEY] = { [CRUSH_ALLOWED_TOOLS_KEY]: allowedList.length > 0 ? allowedList : void 0 };
44517
+ if (disabledList.length > 0 || existingOptions !== void 0) patch[CRUSH_OPTIONS_KEY] = { [CRUSH_DISABLED_TOOLS_KEY]: disabledList.length > 0 ? disabledList : void 0 };
44518
+ return new CrushPermissions({
44519
+ outputRoot,
44520
+ relativeDirPath: location.relativeDirPath,
44521
+ relativeFilePath: location.relativeFilePath,
44522
+ fileContent: applySharedConfigPatch({
44523
+ fileKey: sharedConfigFileKey(CrushPermissions.getSettablePaths({ global })),
44524
+ feature: "permissions",
44525
+ existingContent,
44526
+ patch,
44527
+ filePath: location.filePath,
44528
+ logger
44529
+ }),
44530
+ validate: true,
44531
+ global
44532
+ });
44533
+ }
44534
+ toRulesyncPermissions() {
44535
+ const permissions = isRecord$1(this.json["permissions"]) ? this.json[CRUSH_PERMISSIONS_KEY] : {};
44536
+ const options = isRecord$1(this.json["options"]) ? this.json[CRUSH_OPTIONS_KEY] : {};
44537
+ const rulesyncConfig = convertCrushListsToRulesync({
44538
+ allowed: isStringArray$2(permissions["allowed_tools"]) ? permissions[CRUSH_ALLOWED_TOOLS_KEY] : [],
44539
+ disabled: isStringArray$2(options["disabled_tools"]) ? options[CRUSH_DISABLED_TOOLS_KEY] : []
44540
+ });
44541
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
44542
+ }
44543
+ validate() {
44544
+ return {
44545
+ success: true,
44546
+ error: null
44547
+ };
44548
+ }
44549
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
44550
+ return new CrushPermissions({
44551
+ outputRoot,
44552
+ relativeDirPath,
44553
+ relativeFilePath,
44554
+ fileContent: "{}",
44555
+ validate: false,
44556
+ global
44557
+ });
44558
+ }
44559
+ };
44560
+ /**
44561
+ * The Crush tool names the canonical config manages, i.e. the names its
44562
+ * categories map to. Entries naming any other tool are the user's and survive
44563
+ * a generate. The all-tools `*` category names no Crush tool of its own.
44564
+ */
44565
+ function managedCrushToolNames(config) {
44566
+ return new Set(Object.keys(config.permission).filter((category) => category !== "*" && !isPrototypePollutionKey(category)).map((category) => toCrushToolName(category)));
44567
+ }
44568
+ /**
44569
+ * Reduce one category's rules to the single tool-wide action Crush can carry,
44570
+ * or undefined when nothing should be written for the tool: no catch-all rule,
44571
+ * a catch-all allow next to a narrower deny/ask (Crush cannot narrow an
44572
+ * allowed tool, so it keeps prompting instead), or a deny on an MCP tool
44573
+ * (`options.disabled_tools` only covers built-ins).
44574
+ */
44575
+ function resolveCategoryAction({ category, toolName, rules, logger }) {
44576
+ let catchAllAction;
44577
+ let restricted = false;
44578
+ for (const [rawPattern, action] of Object.entries(rules)) {
44579
+ if (isPrototypePollutionKey(rawPattern)) continue;
44580
+ const pattern = normalizeCatchAllPattern(rawPattern);
44581
+ if (pattern === CATCH_ALL_PATTERN$4) {
44582
+ catchAllAction = action;
44583
+ continue;
44584
+ }
44585
+ logger?.warn(`Crush permissions are tool-wide (no argument patterns), so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
44586
+ if (action !== "allow") restricted = true;
44587
+ }
44588
+ if (catchAllAction === "allow" && restricted) {
44589
+ logger?.warn(`Crush cannot narrow an allowed tool, so "${category}" is not added to permissions.allowed_tools while it carries a pattern-specific deny/ask rule; Crush keeps prompting for "${toolName}".`);
44590
+ return;
44591
+ }
44592
+ if (catchAllAction === "deny" && isMcpToolName(toolName)) {
44593
+ logger?.warn(`Crush's options.disabled_tools only covers built-in tools, so the "deny" rule for "${category}" was skipped; disable the tool through the MCP server's "disabled_tools" instead.`);
44594
+ return;
44595
+ }
44596
+ return catchAllAction;
44597
+ }
44598
+ /**
44599
+ * Convert a rulesync permissions config into Crush's two tool lists.
44600
+ */
44601
+ function convertRulesyncToCrushLists({ config, logger }) {
44602
+ const lists = {
44603
+ allowed: [],
44604
+ disabled: []
44605
+ };
44606
+ const actionByTool = /* @__PURE__ */ new Map();
44607
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
44608
+ if (isPrototypePollutionKey(category)) continue;
44609
+ if (category === "*") {
44610
+ for (const [rawPattern, action] of Object.entries(rules)) {
44611
+ if (isPrototypePollutionKey(rawPattern)) continue;
44612
+ logger?.warn(`Crush has no allow-all or disable-all tool list, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(rawPattern)}) was skipped.`);
44613
+ }
44614
+ continue;
44615
+ }
44616
+ const toolName = toCrushToolName(category);
44617
+ const catchAllAction = resolveCategoryAction({
44618
+ category,
44619
+ toolName,
44620
+ rules,
44621
+ logger
44622
+ });
44623
+ if (catchAllAction === void 0) continue;
44624
+ const previous = actionByTool.get(toolName);
44625
+ if (previous !== void 0 && previous !== catchAllAction) logger?.warn(`Crush permissions: rules from different categories both resolve to ${quoteValueForWarning(toolName)} with conflicting actions (${previous} and ${catchAllAction}). Both are written; a disabled tool is hidden from the agent regardless of allowed_tools.`);
44626
+ actionByTool.set(toolName, catchAllAction);
44627
+ if (catchAllAction === "allow") lists.allowed.push(toolName);
44628
+ else if (catchAllAction === "deny") lists.disabled.push(toolName);
44629
+ }
44630
+ return {
44631
+ allowed: uniq$9(lists.allowed),
44632
+ disabled: uniq$9(lists.disabled)
44633
+ };
44634
+ }
44635
+ /**
44636
+ * Convert Crush's two tool lists back into a rulesync config. A `tool:action`
44637
+ * entry scopes an allow to one action of a tool, which the canonical model
44638
+ * cannot express, so it is left out rather than widened to the whole tool.
44639
+ */
44640
+ function convertCrushListsToRulesync({ allowed, disabled }) {
44641
+ const permission = {};
44642
+ const bucketFor = (category) => {
44643
+ const own = lookupOwn({
44644
+ record: permission,
44645
+ key: category
44646
+ });
44647
+ if (own !== void 0) return own;
44648
+ const created = {};
44649
+ permission[category] = created;
44650
+ return created;
44651
+ };
44652
+ for (const entry of disabled) {
44653
+ if (entry === "" || isPrototypePollutionKey(entry)) continue;
44654
+ bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] = "deny";
44655
+ }
44656
+ for (const entry of allowed) {
44657
+ if (entry === "" || entry.includes(":") || isPrototypePollutionKey(entry)) continue;
44658
+ bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] ??= "allow";
44659
+ }
44660
+ return { permission };
44661
+ }
44662
+ //#endregion
42544
44663
  //#region src/features/permissions/cursor-permissions.ts
42545
44664
  /**
42546
44665
  * Mapping from rulesync canonical tool category names (lowercase) to Cursor CLI
@@ -43695,6 +45814,40 @@ function convertDeepagentsToRulesyncPermissions({ allowList }) {
43695
45814
  return Object.keys(bash).length > 0 ? { permission: { bash } } : { permission: {} };
43696
45815
  }
43697
45816
  //#endregion
45817
+ //#region src/features/permissions/single-action-collapse.ts
45818
+ /**
45819
+ * Strictness order shared by the tools whose permission model evaluates
45820
+ * `deny > ask > allow`. Used wherever several canonical rules have to fold
45821
+ * into one entry, so the strictest action always wins.
45822
+ */
45823
+ const PERMISSION_ACTION_PRIORITY = {
45824
+ allow: 0,
45825
+ ask: 1,
45826
+ deny: 2
45827
+ };
45828
+ /**
45829
+ * Collapse a pattern map to the single action a tool can hold for a scope
45830
+ * that has no pattern matcher, using deny > ask > allow precedence.
45831
+ *
45832
+ * A map without a catch-all grants nothing to unmatched inputs, so an
45833
+ * implicit `ask` joins the candidates and a narrow allowlist can never widen
45834
+ * into a blanket allow. Returns `undefined` for an empty map; each caller
45835
+ * decides what an empty map means for its tool (e.g. emit nothing, or fall
45836
+ * back to `deny` when the tool's own default is allow).
45837
+ */
45838
+ function collapseRulesToSingleAction({ rules }) {
45839
+ const actions = Object.values(rules);
45840
+ if (actions.length === 0) return;
45841
+ return (Object.hasOwn(rules, "*") ? actions : [...actions, "ask"]).reduce((current, candidate) => PERMISSION_ACTION_PRIORITY[candidate] > PERMISSION_ACTION_PRIORITY[current] ? candidate : current);
45842
+ }
45843
+ /**
45844
+ * Whether a pattern map carries anything beyond the `*` catch-all, i.e.
45845
+ * whether collapsing it to a single action loses information worth a warning.
45846
+ */
45847
+ function hasPatternSpecificRules(rules) {
45848
+ return Object.keys(rules).some((pattern) => pattern !== "*");
45849
+ }
45850
+ //#endregion
43698
45851
  //#region src/features/permissions/devin-permissions.ts
43699
45852
  /**
43700
45853
  * Mapping from rulesync canonical tool category names to Devin Local permission
@@ -43704,17 +45857,21 @@ function convertDeepagentsToRulesyncPermissions({ allowList }) {
43704
45857
  * `Write(glob)`, `Exec(prefix)`, and `Fetch(pattern)` — plus MCP tool patterns
43705
45858
  * (`mcp__server__tool`). The canonical `edit` and `write` categories both map
43706
45859
  * onto Devin's single `Write` scope; on import `Write` maps back to `write`, so
43707
- * `edit` rules round-trip as `write` (a lossy but documented collapse). Unknown
45860
+ * `edit` rules round-trip as `write` (a lossy but documented collapse). The
45861
+ * canonical `websearch` category maps onto the bare `web_search` tool name,
45862
+ * accepted in the permission lists since CLI v3000.10.21 (2026-09-10). Unknown
43708
45863
  * names (e.g. `mcp__github__list_issues`) pass through verbatim.
43709
45864
  *
43710
45865
  * @see https://docs.devin.ai/cli/reference/permissions
45866
+ * @see https://docs.devin.ai/cli/changelog/stable — v3000.10.21, `web_search`
43711
45867
  */
43712
45868
  const CANONICAL_TO_DEVIN_SCOPE = {
43713
45869
  read: "Read",
43714
45870
  write: "Write",
43715
45871
  edit: "Write",
43716
45872
  bash: "Exec",
43717
- webfetch: "Fetch"
45873
+ webfetch: "Fetch",
45874
+ websearch: "web_search"
43718
45875
  };
43719
45876
  /**
43720
45877
  * Reverse mapping from Devin scope matchers to rulesync canonical names.
@@ -43723,8 +45880,15 @@ const DEVIN_SCOPE_TO_CANONICAL = {
43723
45880
  Read: "read",
43724
45881
  Write: "write",
43725
45882
  Exec: "bash",
43726
- Fetch: "webfetch"
45883
+ Fetch: "webfetch",
45884
+ web_search: "websearch"
43727
45885
  };
45886
+ /**
45887
+ * Devin scopes that exist only as a bare tool name: there is no
45888
+ * `web_search(pattern)` matcher, so a pattern-specific rule under the canonical
45889
+ * category cannot be expressed and is collapsed to one action instead.
45890
+ */
45891
+ const DEVIN_BARE_ONLY_SCOPES = /* @__PURE__ */ new Set(["web_search"]);
43728
45892
  function toDevinScope(canonical) {
43729
45893
  return CANONICAL_TO_DEVIN_SCOPE[canonical] ?? canonical;
43730
45894
  }
@@ -43909,7 +46073,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
43909
46073
  throw new Error(`Failed to parse existing Devin config at ${filePath}: ${formatError(error)}`, { cause: error });
43910
46074
  }
43911
46075
  const config = rulesyncPermissions.getJson();
43912
- const { allow, ask, deny } = convertRulesyncToDevinPermissions(config);
46076
+ const { allow, ask, deny } = convertRulesyncToDevinPermissions({
46077
+ config,
46078
+ logger
46079
+ });
43913
46080
  const managedScopes = new Set(Object.keys(config.permission).map((category) => toDevinScope(category)));
43914
46081
  const existingPermissions = isRecord$1(settings.permissions) ? settings.permissions : {};
43915
46082
  const preserve = (entries) => (entries ?? []).filter((entry) => !managedScopes.has(parseDevinPermissionEntry(entry).scope));
@@ -44013,26 +46180,50 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
44013
46180
  }
44014
46181
  };
44015
46182
  /**
46183
+ * Collapse the pattern rules of a bare-only scope to the single action Devin
46184
+ * can hold for it, using deny > ask > allow precedence. A map without a
46185
+ * catch-all grants nothing to unmatched inputs, so an implicit `ask` joins the
46186
+ * candidates and a narrow allowlist can never widen into a blanket allow.
46187
+ * Returns `undefined` for an empty map: like any other empty category it
46188
+ * emits nothing, which leaves Devin's own auto-approve default in place.
46189
+ */
46190
+ function collapseBareOnlyScopeRules({ category, scope, rules, logger }) {
46191
+ const action = collapseRulesToSingleAction({ rules });
46192
+ if (action === void 0) return;
46193
+ if (hasPatternSpecificRules(rules)) logger?.warn(`Devin accepts "${scope}" only as a bare tool name, with no pattern matcher. Collapsed the "${category}" pattern rules to "${action}" using deny > ask > allow precedence.`);
46194
+ return action;
46195
+ }
46196
+ /**
44016
46197
  * Convert rulesync permissions config to Devin allow/ask/deny arrays.
44017
46198
  */
44018
- function convertRulesyncToDevinPermissions(config) {
46199
+ function convertRulesyncToDevinPermissions({ config, logger }) {
44019
46200
  const allow = [];
44020
46201
  const ask = [];
44021
46202
  const deny = [];
46203
+ const push = (entry, action) => {
46204
+ switch (action) {
46205
+ case "allow":
46206
+ allow.push(entry);
46207
+ break;
46208
+ case "ask":
46209
+ ask.push(entry);
46210
+ break;
46211
+ case "deny": deny.push(entry);
46212
+ }
46213
+ };
44022
46214
  for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
44023
46215
  const scope = toDevinScope(category);
44024
- for (const [pattern, action] of Object.entries(rules)) {
44025
- const entry = buildDevinPermissionEntry(scope, pattern);
44026
- switch (action) {
44027
- case "allow":
44028
- allow.push(entry);
44029
- break;
44030
- case "ask":
44031
- ask.push(entry);
44032
- break;
44033
- case "deny": deny.push(entry);
44034
- }
46216
+ if (DEVIN_BARE_ONLY_SCOPES.has(scope)) {
46217
+ const action = collapseBareOnlyScopeRules({
46218
+ category,
46219
+ scope,
46220
+ rules,
46221
+ logger
46222
+ });
46223
+ if (action !== void 0) push(scope, action);
46224
+ continue;
44035
46225
  }
46226
+ for (const [pattern, action] of Object.entries(rules)) push(buildDevinPermissionEntry(scope, pattern), action);
44036
46227
  }
44037
46228
  return {
44038
46229
  allow,
@@ -46278,20 +48469,14 @@ const OPENCODE_ACTION_ONLY_PERMISSION_KEYS = /* @__PURE__ */ new Set([
46278
48469
  "question",
46279
48470
  "doom_loop"
46280
48471
  ]);
46281
- const PERMISSION_ACTION_PRIORITY = {
46282
- allow: 0,
46283
- ask: 1,
46284
- deny: 2
46285
- };
46286
48472
  function toOpencodePermission({ category, value, logger }) {
46287
48473
  if (typeof value === "string" || !OPENCODE_ACTION_ONLY_PERMISSION_KEYS.has(category)) return value;
46288
- const actions = Object.values(value);
46289
- if (actions.length === 0) {
48474
+ const action = collapseRulesToSingleAction({ rules: value });
48475
+ if (action === void 0) {
46290
48476
  logger?.warn(`OpenCode's "${category}" permission accepts only a single action. Collapsed its empty pattern map to "deny" to avoid falling back to OpenCode's default allow behavior.`);
46291
48477
  return "deny";
46292
48478
  }
46293
- const action = (Object.hasOwn(value, "*") ? actions : [...actions, "ask"]).reduce((current, candidate) => PERMISSION_ACTION_PRIORITY[candidate] > PERMISSION_ACTION_PRIORITY[current] ? candidate : current);
46294
- if (Object.keys(value).some((pattern) => pattern !== "*")) logger?.warn(`OpenCode's "${category}" permission accepts only a single action. Collapsed its pattern rules to "${action}" using deny > ask > allow precedence.`);
48479
+ if (hasPatternSpecificRules(value)) logger?.warn(`OpenCode's "${category}" permission accepts only a single action. Collapsed its pattern rules to "${action}" using deny > ask > allow precedence.`);
46295
48480
  return action;
46296
48481
  }
46297
48482
  /**
@@ -46669,6 +48854,7 @@ const QWEN_OVERRIDE_TOOLS_KEYS = [
46669
48854
  "visible",
46670
48855
  "eager",
46671
48856
  "listDirectory",
48857
+ "todoWrite",
46672
48858
  "workflowsEnabled"
46673
48859
  ];
46674
48860
  const QWEN_OVERRIDE_SECURITY_KEYS = [
@@ -46774,6 +48960,11 @@ const QWEN_OVERRIDE_GROUPS = [{
46774
48960
  rule: "global-machine-wide",
46775
48961
  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.`,
46776
48962
  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."
48963
+ },
48964
+ todoWrite: {
48965
+ rule: "global-machine-wide",
48966
+ 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.`,
48967
+ 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."
46777
48968
  }
46778
48969
  }
46779
48970
  }, {
@@ -48580,9 +50771,25 @@ function widenToPrefixGlob(pattern) {
48580
50771
  function toShellEntry(prefix) {
48581
50772
  return prefix === "" ? SHELL_TOOL_NAME : `${SHELL_TOOL_NAME}(${prefix})`;
48582
50773
  }
48583
- /** The `bash` pattern a `run_shell_command(<prefix>)` entry stands for. */
50774
+ /**
50775
+ * The `bash` pattern a `run_shell_command(<prefix>)` entry stands for. A
50776
+ * prefix Tabnine matches literally, so one an author spelled glob-like
50777
+ * (`git *`, `*`) is read as the glob it looks like rather than doubled into
50778
+ * `git * *`, which no comparison or warning could make sense of.
50779
+ */
48584
50780
  function toShellPattern(prefix) {
48585
- return prefix === void 0 || prefix === "" ? "*" : `${prefix} *`;
50781
+ if (prefix === void 0 || prefix === "" || prefix === "*") return "*";
50782
+ return prefix.endsWith(" *") ? prefix : `${prefix} *`;
50783
+ }
50784
+ /**
50785
+ * Whether {@link toShellPattern} read the prefix as a glob: such an entry
50786
+ * would never fire on Tabnine as spelled (the prefix is matched literally, so
50787
+ * `run_shell_command(git *)` only matches a command line starting with the
50788
+ * two characters `git *`), and rulesync writes it back as the prefix the glob
50789
+ * denotes — a wider entry that the author is told about.
50790
+ */
50791
+ function isGlobSpelledPrefix(prefix) {
50792
+ return prefix !== void 0 && (prefix === "*" || prefix.endsWith(" *"));
48586
50793
  }
48587
50794
  /**
48588
50795
  * Split a `tools.allowed`/`tools.exclude` entry into its tool name and the
@@ -48708,7 +50915,7 @@ function collectTrustAffectingOverrideEntries({ tools, general }) {
48708
50915
  const allowed = isStringArray$2(tools[ALLOWED_KEY]) ? tools[ALLOWED_KEY] : [];
48709
50916
  if (allowed.length > 0) entries.push({
48710
50917
  label: `tools.allowed (${allowed.map(quoteValueForWarning).join(", ")})`,
48711
- reason: "auto-approves what it names as the override spells it; a non-shell entry has no canonical rule to be checked against"
50918
+ reason: "auto-approves what it names as the override spells it; a non-shell entry is only checked against a whole-tool deny of its tool"
48712
50919
  });
48713
50920
  if (Object.hasOwn(tools, SANDBOX_KEY) && isNotTrue(tools[SANDBOX_KEY])) entries.push({
48714
50921
  label: `tools.sandbox = ${quoteValueForWarning(tools[SANDBOX_KEY])}`,
@@ -48819,6 +51026,54 @@ function buildToolLists({ permission, overrideShellAllowPatterns, logger }) {
48819
51026
  };
48820
51027
  }
48821
51028
  /**
51029
+ * Names the glob-spelled `run_shell_command(...)` override entries that were
51030
+ * written as the prefix their glob denotes. Said only for the entries that
51031
+ * made it into the list: one that a deny or ask overlaps is announced as
51032
+ * withheld by the comparison instead, and one whose glob names no prefix at
51033
+ * all is skipped by it.
51034
+ */
51035
+ function warnAboutGlobSpelledOverrideEntries({ entries, allowed, logger }) {
51036
+ for (const { entry, pattern } of entries) {
51037
+ const prefix = toShellPrefix(pattern);
51038
+ const written = prefix === void 0 ? void 0 : toShellEntry(prefix);
51039
+ if (written === void 0 || !allowed.includes(written)) continue;
51040
+ warnWithFallback(logger, `Tabnine CLI permissions: read tools.allowed entry ${quoteValueForWarning(entry)} of the tabnine override as the 'bash' allow rule ${quoteValueForWarning(pattern)}; Tabnine matches a prefix literally, so it is written as ${quoteValueForWarning(written)}, which auto-approves every command it covers.`);
51041
+ }
51042
+ }
51043
+ /**
51044
+ * A verbatim override allow naming a tool the canonical block denies is
51045
+ * withheld the way a shadowed `bash` allow is: Tabnine never loads an excluded
51046
+ * tool whatever `allowed` says, so the entry would only ever contradict the
51047
+ * deny it sits beside — and an override is not a way around a canonical rule.
51048
+ * Returns the entries that may be written.
51049
+ */
51050
+ function withholdDeniedOverrideAllowed({ overrideVerbatimAllowed, exclude, logger }) {
51051
+ const denied = overrideVerbatimAllowed.filter((entry) => {
51052
+ const toolName = parseTabnineEntry(entry)?.toolName;
51053
+ return toolName !== void 0 && exclude.includes(toolName);
51054
+ });
51055
+ if (denied.length > 0) warnWithFallback(logger, `Tabnine CLI permissions: withheld ${denied.length} tools.allowed entry(ies) of the tabnine override (${denied.map(quoteValueForWarning).join(", ")}) that name a tool the canonical block denies; Tabnine never loads an excluded tool, so remove the deny rule from .rulesync/permissions.jsonc to allow it.`);
51056
+ return overrideVerbatimAllowed.filter((entry) => !denied.includes(entry));
51057
+ }
51058
+ /**
51059
+ * The entries of a managed tool that the canonical block did not re-derive are
51060
+ * named rather than removed silently. Dropping an exclude loosens the policy,
51061
+ * so it is a warning; dropping an allow only brings back Tabnine's confirmation
51062
+ * prompt, so it is said at info level for the author who wonders where a
51063
+ * hand-written entry went.
51064
+ */
51065
+ function reportDroppedManagedEntries({ existingTools, allowedList, excludeList, logger }) {
51066
+ const dropped = (key, list) => (0, es_toolkit.uniq)(isStringArray$2(existingTools?.[key]) ? existingTools[key] : []).filter((entry) => {
51067
+ if (list.includes(entry)) return false;
51068
+ const toolName = parseTabnineEntry(entry)?.toolName;
51069
+ return toolName === void 0 || !list.includes(toolName);
51070
+ });
51071
+ const droppedExclude = dropped(EXCLUDE_KEY, excludeList);
51072
+ if (droppedExclude.length > 0) warnWithFallback(logger, `Tabnine CLI permissions: removed ${droppedExclude.length} existing tools.exclude entry(ies) (${droppedExclude.map(quoteValueForWarning).join(", ")}) of a tool the canonical block manages; add a deny rule to .rulesync/permissions.jsonc to keep them.`);
51073
+ const droppedAllowed = dropped(ALLOWED_KEY, allowedList);
51074
+ if (droppedAllowed.length > 0) (logger ?? moduleLogger$1).info(`Tabnine CLI permissions: removed ${droppedAllowed.length} existing tools.allowed entry(ies) (${droppedAllowed.map(quoteValueForWarning).join(", ")}) of a tool the canonical block manages; add an allow rule to .rulesync/permissions.jsonc to keep them.`);
51075
+ }
51076
+ /**
48822
51077
  * Permissions generator for the Tabnine CLI.
48823
51078
  *
48824
51079
  * Tabnine CLI keeps its tool policy in `.tabnine/agent/settings.json` (project)
@@ -48884,7 +51139,10 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48884
51139
  const config = rulesyncPermissions.getJson();
48885
51140
  const override = isRecord$1(config.tabnine) ? config.tabnine : {};
48886
51141
  const { tools: overrideTools, general: overrideGeneral, refused: refusedPaths } = stripRefusedOverridePaths(override);
48887
- if (refusedPaths.length > 0) warnWithFallback(logger, `Tabnine CLI permissions: refused to write ${refusedPaths.join(", ")} from the tabnine override; Tabnine CLI runs that value as a command or sends its traffic to it, and a permissions file (one that came from 'rulesync fetch' included) must not be able to point it at an executable or a server of its choosing. Set it by hand in ${(0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)} if you need it.`);
51142
+ if (refusedPaths.length > 0) {
51143
+ const one = refusedPaths.length === 1;
51144
+ warnWithFallback(logger, `Tabnine CLI permissions: refused to write ${refusedPaths.join(", ")} from the tabnine override; Tabnine CLI runs ${one ? "that value" : "those values"} as a command or sends its traffic there, and a permissions file (one that came from 'rulesync fetch' included) must not be able to point it at an executable or a server of its choosing. Set ${one ? "it" : "them"} by hand in ${(0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)} if you need ${one ? "it" : "them"}.`);
51145
+ }
48888
51146
  const overrideList = (key) => {
48889
51147
  const value = overrideTools[key];
48890
51148
  if (value === void 0 || isStringArray$2(value)) return value ?? [];
@@ -48893,10 +51151,17 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48893
51151
  };
48894
51152
  const overrideShellAllowPatterns = [];
48895
51153
  const overrideVerbatimAllowed = [];
51154
+ const globSpelledOverrideEntries = [];
48896
51155
  for (const entry of overrideList(ALLOWED_KEY)) {
48897
51156
  const parsed = parseTabnineEntry(entry);
48898
- if (parsed?.toolName === SHELL_TOOL_NAME) overrideShellAllowPatterns.push(toShellPattern(parsed.prefix));
48899
- else overrideVerbatimAllowed.push(entry);
51157
+ if (parsed?.toolName === SHELL_TOOL_NAME) {
51158
+ const pattern = toShellPattern(parsed.prefix);
51159
+ if (isGlobSpelledPrefix(parsed.prefix)) globSpelledOverrideEntries.push({
51160
+ entry,
51161
+ pattern
51162
+ });
51163
+ overrideShellAllowPatterns.push(pattern);
51164
+ } else overrideVerbatimAllowed.push(entry);
48900
51165
  }
48901
51166
  const overrideExclude = overrideList(EXCLUDE_KEY);
48902
51167
  const { allowed, exclude } = buildToolLists({
@@ -48904,6 +51169,16 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48904
51169
  overrideShellAllowPatterns,
48905
51170
  logger
48906
51171
  });
51172
+ warnAboutGlobSpelledOverrideEntries({
51173
+ entries: globSpelledOverrideEntries,
51174
+ allowed,
51175
+ logger
51176
+ });
51177
+ const writableOverrideAllowed = withholdDeniedOverrideAllowed({
51178
+ overrideVerbatimAllowed,
51179
+ exclude,
51180
+ logger
51181
+ });
48907
51182
  const ignoredOverrideKeys = Object.keys(override).filter((key) => key !== TOOLS_KEY && key !== GENERAL_KEY);
48908
51183
  if (ignoredOverrideKeys.length > 0) warnWithFallback(logger, `Tabnine CLI permissions: ignored ${ignoredOverrideKeys.length} key(s) of the tabnine override (${ignoredOverrideKeys.map(quoteValueForWarning).join(", ")}); only 'tools' and 'general' are written through the permissions feature.`);
48909
51184
  const existingTools = this.existingToolsGroup(existingContent);
@@ -48914,7 +51189,7 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48914
51189
  });
48915
51190
  const allowedList = (0, es_toolkit.uniq)([
48916
51191
  ...allowed,
48917
- ...overrideVerbatimAllowed,
51192
+ ...writableOverrideAllowed,
48918
51193
  ...preservedEntries(ALLOWED_KEY)
48919
51194
  ]);
48920
51195
  const excludeList = (0, es_toolkit.uniq)([
@@ -48922,12 +51197,12 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48922
51197
  ...overrideExclude,
48923
51198
  ...preservedEntries(EXCLUDE_KEY)
48924
51199
  ]);
48925
- const droppedExcludeEntries = (0, es_toolkit.uniq)(isStringArray$2(existingTools?.[EXCLUDE_KEY]) ? existingTools[EXCLUDE_KEY] : []).filter((entry) => {
48926
- if (excludeList.includes(entry)) return false;
48927
- const toolName = parseTabnineEntry(entry)?.toolName;
48928
- return toolName === void 0 || !excludeList.includes(toolName);
51200
+ reportDroppedManagedEntries({
51201
+ existingTools,
51202
+ allowedList,
51203
+ excludeList,
51204
+ logger
48929
51205
  });
48930
- if (droppedExcludeEntries.length > 0) warnWithFallback(logger, `Tabnine CLI permissions: removed ${droppedExcludeEntries.length} existing tools.exclude entry(ies) (${droppedExcludeEntries.map(quoteValueForWarning).join(", ")}) of a tool the canonical block manages; add a deny rule to .rulesync/permissions.jsonc to keep them.`);
48931
51206
  const tools = {
48932
51207
  ...overrideTools,
48933
51208
  [ALLOWED_KEY]: allowedList.length > 0 ? allowedList : void 0,
@@ -48941,7 +51216,7 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48941
51216
  entries: collectTrustAffectingOverrideEntries({
48942
51217
  tools: {
48943
51218
  ...overrideTools,
48944
- [ALLOWED_KEY]: overrideVerbatimAllowed
51219
+ [ALLOWED_KEY]: writableOverrideAllowed
48945
51220
  },
48946
51221
  general: overrideGeneral
48947
51222
  }),
@@ -52274,6 +54549,14 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
52274
54549
  supportsImport: true
52275
54550
  }
52276
54551
  }],
54552
+ ["commandcode", {
54553
+ class: CommandcodePermissions,
54554
+ meta: {
54555
+ supportsProject: true,
54556
+ supportsGlobal: true,
54557
+ supportsImport: true
54558
+ }
54559
+ }],
52277
54560
  ["continue", {
52278
54561
  class: ContinuePermissions,
52279
54562
  meta: {
@@ -52298,6 +54581,14 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
52298
54581
  supportsImport: true
52299
54582
  }
52300
54583
  }],
54584
+ ["crush", {
54585
+ class: CrushPermissions,
54586
+ meta: {
54587
+ supportsProject: true,
54588
+ supportsGlobal: true,
54589
+ supportsImport: true
54590
+ }
54591
+ }],
52301
54592
  ["cursor", {
52302
54593
  class: CursorPermissions,
52303
54594
  meta: {
@@ -55032,6 +57323,154 @@ var CodexCliSkill = class CodexCliSkill extends ToolSkill {
55032
57323
  }
55033
57324
  };
55034
57325
  //#endregion
57326
+ //#region src/features/skills/commandcode-skill.ts
57327
+ const CommandcodeSkillFrontmatterSchema = zod_mini.z.looseObject({
57328
+ name: zod_mini.z.string(),
57329
+ description: zod_mini.z.string()
57330
+ });
57331
+ /**
57332
+ * Represents a Command Code skill directory.
57333
+ *
57334
+ * Command Code discovers Anthropic-style `<name>/SKILL.md` directories under
57335
+ * `<project>/.commandcode/skills/` (project scope) and
57336
+ * `~/.commandcode/skills/` (user scope). The frontmatter requires `name`
57337
+ * (matching the directory name) and `description`; supporting files next to
57338
+ * `SKILL.md` are carried along.
57339
+ *
57340
+ * @see https://commandcode.ai/docs/skills
57341
+ */
57342
+ var CommandcodeSkill = class CommandcodeSkill extends ToolSkill {
57343
+ constructor({ outputRoot = process.cwd(), relativeDirPath = COMMANDCODE_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
57344
+ super({
57345
+ outputRoot,
57346
+ relativeDirPath,
57347
+ dirName,
57348
+ mainFile: {
57349
+ name: SKILL_FILE_NAME,
57350
+ body,
57351
+ frontmatter: { ...frontmatter }
57352
+ },
57353
+ otherFiles,
57354
+ global
57355
+ });
57356
+ if (validate) {
57357
+ const result = this.validate();
57358
+ if (!result.success) throw result.error;
57359
+ }
57360
+ }
57361
+ static getSettablePaths(_options = {}) {
57362
+ return { relativeDirPath: COMMANDCODE_SKILLS_DIR_PATH };
57363
+ }
57364
+ getFrontmatter() {
57365
+ return CommandcodeSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
57366
+ }
57367
+ getBody() {
57368
+ return this.mainFile?.body ?? "";
57369
+ }
57370
+ validate() {
57371
+ if (!this.mainFile) return {
57372
+ success: false,
57373
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`)
57374
+ };
57375
+ const result = CommandcodeSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter);
57376
+ if (!result.success) return {
57377
+ success: false,
57378
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`)
57379
+ };
57380
+ if (result.data.name !== this.getDirName()) return {
57381
+ success: false,
57382
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: frontmatter name (${result.data.name}) must match directory name (${this.getDirName()})`)
57383
+ };
57384
+ return {
57385
+ success: true,
57386
+ error: null
57387
+ };
57388
+ }
57389
+ toRulesyncSkill() {
57390
+ const { name, description, ...commandcodeSection } = this.getFrontmatter();
57391
+ const rulesyncFrontmatter = {
57392
+ name,
57393
+ description,
57394
+ targets: ["*"],
57395
+ ...Object.keys(commandcodeSection).length > 0 && { commandcode: commandcodeSection }
57396
+ };
57397
+ return new RulesyncSkill({
57398
+ outputRoot: this.outputRoot,
57399
+ relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
57400
+ dirName: this.getDirName(),
57401
+ frontmatter: rulesyncFrontmatter,
57402
+ body: this.getBody(),
57403
+ otherFiles: this.getOtherFiles(),
57404
+ validate: true,
57405
+ global: this.global
57406
+ });
57407
+ }
57408
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
57409
+ const settablePaths = CommandcodeSkill.getSettablePaths({ global });
57410
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
57411
+ const { name: _sectionName, description: _sectionDescription, ...commandcodeSection } = rulesyncFrontmatter.commandcode ?? {};
57412
+ const commandcodeFrontmatter = {
57413
+ ...commandcodeSection,
57414
+ name: rulesyncFrontmatter.name,
57415
+ description: rulesyncFrontmatter.description
57416
+ };
57417
+ return new CommandcodeSkill({
57418
+ outputRoot,
57419
+ relativeDirPath: settablePaths.relativeDirPath,
57420
+ dirName: commandcodeFrontmatter.name,
57421
+ frontmatter: commandcodeFrontmatter,
57422
+ body: rulesyncSkill.getBody(),
57423
+ otherFiles: rulesyncSkill.getOtherFiles(),
57424
+ validate,
57425
+ global
57426
+ });
57427
+ }
57428
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
57429
+ const targets = rulesyncSkill.getFrontmatter().targets;
57430
+ return targets.includes("*") || targets.includes("commandcode");
57431
+ }
57432
+ static async fromDir(params) {
57433
+ const loaded = await this.loadSkillDirContent({
57434
+ ...params,
57435
+ getSettablePaths: CommandcodeSkill.getSettablePaths
57436
+ });
57437
+ const result = CommandcodeSkillFrontmatterSchema.safeParse(loaded.frontmatter);
57438
+ if (!result.success) {
57439
+ const skillDirPath = (0, node_path.join)(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName);
57440
+ throw new Error(`Invalid frontmatter in ${(0, node_path.join)(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`);
57441
+ }
57442
+ if (result.data.name !== loaded.dirName) {
57443
+ const skillFilePath = (0, node_path.join)(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName, SKILL_FILE_NAME);
57444
+ throw new Error(`Frontmatter name (${result.data.name}) must match directory name (${loaded.dirName}) in ${skillFilePath}`);
57445
+ }
57446
+ return new CommandcodeSkill({
57447
+ outputRoot: loaded.outputRoot,
57448
+ relativeDirPath: loaded.relativeDirPath,
57449
+ dirName: loaded.dirName,
57450
+ frontmatter: result.data,
57451
+ body: loaded.body,
57452
+ otherFiles: loaded.otherFiles,
57453
+ validate: true,
57454
+ global: loaded.global
57455
+ });
57456
+ }
57457
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, dirName, global = false }) {
57458
+ return new CommandcodeSkill({
57459
+ outputRoot,
57460
+ relativeDirPath,
57461
+ dirName,
57462
+ frontmatter: {
57463
+ name: "",
57464
+ description: ""
57465
+ },
57466
+ body: "",
57467
+ otherFiles: [],
57468
+ validate: false,
57469
+ global
57470
+ });
57471
+ }
57472
+ };
57473
+ //#endregion
55035
57474
  //#region src/features/skills/continue-skill.ts
55036
57475
  const ContinueSkillFrontmatterSchema = zod_mini.z.looseObject({
55037
57476
  name: zod_mini.z.string(),
@@ -58319,6 +60758,183 @@ var PiSkill = class PiSkill extends ToolSkill {
58319
60758
  }
58320
60759
  };
58321
60760
  //#endregion
60761
+ //#region src/constants/pool-paths.ts
60762
+ /**
60763
+ * Pool (Poolside's coding agent CLI) configuration-layout conventions.
60764
+ *
60765
+ * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
60766
+ * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
60767
+ * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
60768
+ * path), the project-root `AGENTS.md`, and nested per-directory
60769
+ * `AGENTS.md` files from the repository root down through the working
60770
+ * directory, deeper files taking precedence. It skips ignored directories
60771
+ * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
60772
+ *
60773
+ * Skills follow the Agent Skills format (`<name>/SKILL.md` bundles). Pool
60774
+ * scans `.poolside/skills/` (project) and `~/.config/poolside/skills/`
60775
+ * (global) plus the shared `.agents/skills/` / `~/.agents/skills/` roots and
60776
+ * the skill directories of other Agent Skills tools; rulesync writes only the
60777
+ * Pool-specific roots and leaves the shared ones to their own targets.
60778
+ *
60779
+ * @see https://docs.poolside.ai/agent-instructions
60780
+ * @see https://docs.poolside.ai/skills
60781
+ * @see https://github.com/poolsideai/pool
60782
+ */
60783
+ /** Project-scoped `.poolside/` directory at the project root. */
60784
+ const POOL_DIR = ".poolside";
60785
+ /** Global config directory for Pool, relative to the home directory. */
60786
+ const POOL_GLOBAL_DIR = (0, node_path.join)(".config", "poolside");
60787
+ /** Project skills root, relative to the project root. */
60788
+ const POOL_SKILLS_DIR_PATH = (0, node_path.join)(POOL_DIR, "skills");
60789
+ /** Global skills root, relative to the home directory. */
60790
+ const POOL_GLOBAL_SKILLS_DIR_PATH = (0, node_path.join)(POOL_GLOBAL_DIR, "skills");
60791
+ //#endregion
60792
+ //#region src/features/skills/pool-skill.ts
60793
+ const PoolSkillFrontmatterSchema = zod_mini.z.looseObject({
60794
+ name: zod_mini.z.string(),
60795
+ description: zod_mini.z.string()
60796
+ });
60797
+ /**
60798
+ * Represents a Pool (Poolside) skill directory.
60799
+ *
60800
+ * Pool discovers Agent Skills (`<name>/SKILL.md` bundles, optionally with
60801
+ * supporting files) from `.poolside/skills/` (project) and
60802
+ * `~/.config/poolside/skills/` (global). It also scans the shared
60803
+ * `.agents/skills/` / `~/.agents/skills/` roots and the skill directories of
60804
+ * other Agent Skills tools; those belong to their own targets, so this class
60805
+ * writes only the two Pool-specific roots and a skill is written exactly once.
60806
+ * Pool requires the directory name to equal the frontmatter `name`, otherwise
60807
+ * it skips the skill. Unlike adapters that reject such a skill outright, this
60808
+ * one warns and still writes it: the directory name is the canonical identity
60809
+ * shared with every other target, and Pool merely skips the skill rather than
60810
+ * failing, so a hard error here would block the other targets for a
60811
+ * Pool-only concern. Import is lenient for the same reason.
60812
+ * @see https://docs.poolside.ai/skills
60813
+ */
60814
+ var PoolSkill = class PoolSkill extends ToolSkill {
60815
+ constructor({ outputRoot = process.cwd(), relativeDirPath = POOL_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
60816
+ super({
60817
+ outputRoot,
60818
+ relativeDirPath,
60819
+ dirName,
60820
+ mainFile: {
60821
+ name: SKILL_FILE_NAME,
60822
+ body,
60823
+ frontmatter: { ...frontmatter }
60824
+ },
60825
+ otherFiles,
60826
+ global
60827
+ });
60828
+ if (validate) {
60829
+ const result = this.validate();
60830
+ if (!result.success) throw result.error;
60831
+ }
60832
+ }
60833
+ static getSettablePaths({ global = false } = {}) {
60834
+ return { relativeDirPath: global ? POOL_GLOBAL_SKILLS_DIR_PATH : POOL_SKILLS_DIR_PATH };
60835
+ }
60836
+ getFrontmatter() {
60837
+ return PoolSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
60838
+ }
60839
+ getBody() {
60840
+ return this.mainFile?.body ?? "";
60841
+ }
60842
+ validate() {
60843
+ if (!this.mainFile) return {
60844
+ success: false,
60845
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`)
60846
+ };
60847
+ const result = PoolSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter);
60848
+ if (!result.success) return {
60849
+ success: false,
60850
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`)
60851
+ };
60852
+ return {
60853
+ success: true,
60854
+ error: null
60855
+ };
60856
+ }
60857
+ toRulesyncSkill() {
60858
+ const frontmatter = this.getFrontmatter();
60859
+ const rulesyncFrontmatter = {
60860
+ name: frontmatter.name,
60861
+ description: frontmatter.description,
60862
+ targets: ["*"]
60863
+ };
60864
+ return new RulesyncSkill({
60865
+ outputRoot: this.outputRoot,
60866
+ relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
60867
+ dirName: this.getDirName(),
60868
+ frontmatter: rulesyncFrontmatter,
60869
+ body: this.getBody(),
60870
+ otherFiles: this.getOtherFiles(),
60871
+ validate: true,
60872
+ global: this.global
60873
+ });
60874
+ }
60875
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false, logger }) {
60876
+ const settablePaths = PoolSkill.getSettablePaths({ global });
60877
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
60878
+ const dirName = rulesyncSkill.getDirName();
60879
+ const poolFrontmatter = {
60880
+ name: rulesyncFrontmatter.name,
60881
+ description: rulesyncFrontmatter.description
60882
+ };
60883
+ if (poolFrontmatter.name !== dirName) warnWithFallback(logger, `${stripControlCharacters(toPosixPath((0, node_path.join)(outputRoot, settablePaths.relativeDirPath, dirName, SKILL_FILE_NAME)))}: \`name\` ${quoteForLog(poolFrontmatter.name)} does not match its directory name ${quoteForLog(dirName)}; Pool only loads a skill whose directory name equals its \`name\``);
60884
+ return new PoolSkill({
60885
+ outputRoot,
60886
+ relativeDirPath: settablePaths.relativeDirPath,
60887
+ dirName,
60888
+ frontmatter: poolFrontmatter,
60889
+ body: rulesyncSkill.getBody(),
60890
+ otherFiles: rulesyncSkill.getOtherFiles(),
60891
+ validate,
60892
+ global
60893
+ });
60894
+ }
60895
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
60896
+ const targets = rulesyncSkill.getFrontmatter().targets;
60897
+ return targets.includes("*") || targets.includes("pool");
60898
+ }
60899
+ static async fromDir(params) {
60900
+ const loaded = await this.loadSkillDirContent({
60901
+ ...params,
60902
+ getSettablePaths: PoolSkill.getSettablePaths
60903
+ });
60904
+ const result = PoolSkillFrontmatterSchema.safeParse(loaded.frontmatter);
60905
+ if (!result.success) {
60906
+ const skillDirPath = (0, node_path.join)(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName);
60907
+ throw new Error(`Invalid frontmatter in ${(0, node_path.join)(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`);
60908
+ }
60909
+ return new PoolSkill({
60910
+ outputRoot: loaded.outputRoot,
60911
+ relativeDirPath: loaded.relativeDirPath,
60912
+ dirName: loaded.dirName,
60913
+ frontmatter: result.data,
60914
+ body: loaded.body,
60915
+ otherFiles: loaded.otherFiles,
60916
+ validate: true,
60917
+ global: loaded.global
60918
+ });
60919
+ }
60920
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, dirName, global = false }) {
60921
+ const settablePaths = PoolSkill.getSettablePaths({ global });
60922
+ return new PoolSkill({
60923
+ outputRoot,
60924
+ relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath,
60925
+ dirName,
60926
+ frontmatter: {
60927
+ name: "",
60928
+ description: ""
60929
+ },
60930
+ body: "",
60931
+ otherFiles: [],
60932
+ validate: false,
60933
+ global
60934
+ });
60935
+ }
60936
+ };
60937
+ //#endregion
58322
60938
  //#region src/features/skills/qwencode-skill.ts
58323
60939
  const QwencodeSkillFrontmatterSchema = zod_mini.z.looseObject({
58324
60940
  name: zod_mini.z.string(),
@@ -60044,6 +62660,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
60044
62660
  supportsGlobal: true
60045
62661
  }
60046
62662
  }],
62663
+ ["commandcode", {
62664
+ class: CommandcodeSkill,
62665
+ meta: {
62666
+ supportsProject: true,
62667
+ supportsSimulated: false,
62668
+ supportsGlobal: true
62669
+ }
62670
+ }],
60047
62671
  ["continue", {
60048
62672
  class: ContinueSkill,
60049
62673
  meta: {
@@ -60205,6 +62829,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
60205
62829
  supportsGlobal: true
60206
62830
  }
60207
62831
  }],
62832
+ ["pool", {
62833
+ class: PoolSkill,
62834
+ meta: {
62835
+ supportsProject: true,
62836
+ supportsSimulated: false,
62837
+ supportsGlobal: true
62838
+ }
62839
+ }],
60208
62840
  ["qwencode", {
60209
62841
  class: QwencodeSkill,
60210
62842
  meta: {
@@ -62139,6 +64771,143 @@ var CodexCliSubagent = class CodexCliSubagent extends ToolSubagent {
62139
64771
  }
62140
64772
  };
62141
64773
  //#endregion
64774
+ //#region src/features/subagents/commandcode-subagent.ts
64775
+ const CommandcodeSubagentFrontmatterSchema = zod_mini.z.looseObject({
64776
+ name: zod_mini.z.string(),
64777
+ description: zod_mini.z.optional(zod_mini.z.string()),
64778
+ tools: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
64779
+ disallowedTools: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
64780
+ model: zod_mini.z.optional(zod_mini.z.string()),
64781
+ reasoningEffort: zod_mini.z.optional(zod_mini.z.string()),
64782
+ maxTurns: zod_mini.z.optional(zod_mini.z.number()),
64783
+ permissionMode: zod_mini.z.optional(zod_mini.z.string()),
64784
+ background: zod_mini.z.optional(zod_mini.z.boolean()),
64785
+ showOutput: zod_mini.z.optional(zod_mini.z.boolean())
64786
+ });
64787
+ /**
64788
+ * Command Code custom agent: a Markdown file with YAML frontmatter under
64789
+ * `.commandcode/agents/` (project) or `~/.commandcode/agents/` (user); the
64790
+ * body is the agent's system prompt.
64791
+ *
64792
+ * @see https://commandcode.ai/docs/custom-agents
64793
+ */
64794
+ var CommandcodeSubagent = class CommandcodeSubagent extends ToolSubagent {
64795
+ frontmatter;
64796
+ body;
64797
+ constructor({ frontmatter, body, fileContent, ...rest }) {
64798
+ if (rest.validate !== false) {
64799
+ const result = CommandcodeSubagentFrontmatterSchema.safeParse(frontmatter);
64800
+ if (!result.success) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
64801
+ }
64802
+ super({
64803
+ ...rest,
64804
+ fileContent: fileContent ?? stringifyFrontmatter(body, frontmatter)
64805
+ });
64806
+ this.frontmatter = frontmatter;
64807
+ this.body = body;
64808
+ }
64809
+ static getSettablePaths(_options = {}) {
64810
+ return { relativeDirPath: COMMANDCODE_AGENTS_DIR_PATH };
64811
+ }
64812
+ getFrontmatter() {
64813
+ return this.frontmatter;
64814
+ }
64815
+ getBody() {
64816
+ return this.body;
64817
+ }
64818
+ toRulesyncSubagent() {
64819
+ const { name, description, ...rest } = this.frontmatter;
64820
+ return new RulesyncSubagent({
64821
+ outputRoot: ".",
64822
+ frontmatter: {
64823
+ targets: ["*"],
64824
+ name,
64825
+ description,
64826
+ commandcode: { ...rest }
64827
+ },
64828
+ body: this.body,
64829
+ relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
64830
+ relativeFilePath: this.getRelativeFilePath(),
64831
+ validate: true
64832
+ });
64833
+ }
64834
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
64835
+ const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
64836
+ const commandcodeSection = rulesyncFrontmatter.commandcode ?? {};
64837
+ const commandcodeSubagentFrontmatter = {
64838
+ name: rulesyncFrontmatter.name,
64839
+ description: rulesyncFrontmatter.description,
64840
+ ...commandcodeSection
64841
+ };
64842
+ const body = rulesyncSubagent.getBody();
64843
+ const fileContent = stringifyFrontmatter(body, commandcodeSubagentFrontmatter, { avoidBlockScalars: true });
64844
+ const paths = this.getSettablePaths({ global });
64845
+ return new CommandcodeSubagent({
64846
+ outputRoot,
64847
+ frontmatter: commandcodeSubagentFrontmatter,
64848
+ body,
64849
+ relativeDirPath: paths.relativeDirPath,
64850
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
64851
+ fileContent,
64852
+ validate,
64853
+ global
64854
+ });
64855
+ }
64856
+ validate() {
64857
+ if (!this.frontmatter) return {
64858
+ success: true,
64859
+ error: null
64860
+ };
64861
+ const result = CommandcodeSubagentFrontmatterSchema.safeParse(this.frontmatter);
64862
+ if (result.success) return {
64863
+ success: true,
64864
+ error: null
64865
+ };
64866
+ else return {
64867
+ success: false,
64868
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
64869
+ };
64870
+ }
64871
+ static isTargetedByRulesyncSubagent(rulesyncSubagent) {
64872
+ return this.isTargetedByRulesyncSubagentDefault({
64873
+ rulesyncSubagent,
64874
+ toolTarget: "commandcode"
64875
+ });
64876
+ }
64877
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
64878
+ const paths = this.getSettablePaths({ global });
64879
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
64880
+ const fileContent = await readFileContent(filePath);
64881
+ const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
64882
+ const result = CommandcodeSubagentFrontmatterSchema.safeParse(frontmatter);
64883
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
64884
+ return new CommandcodeSubagent({
64885
+ outputRoot,
64886
+ relativeDirPath: paths.relativeDirPath,
64887
+ relativeFilePath,
64888
+ frontmatter: result.data,
64889
+ body: content.trim(),
64890
+ fileContent,
64891
+ validate,
64892
+ global
64893
+ });
64894
+ }
64895
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
64896
+ return new CommandcodeSubagent({
64897
+ outputRoot,
64898
+ relativeDirPath,
64899
+ relativeFilePath,
64900
+ frontmatter: {
64901
+ name: "",
64902
+ description: ""
64903
+ },
64904
+ body: "",
64905
+ fileContent: "",
64906
+ validate: false
64907
+ });
64908
+ }
64909
+ };
64910
+ //#endregion
62142
64911
  //#region src/features/subagents/copilot-subagent.ts
62143
64912
  const CopilotSubagentFrontmatterSchema = zod_mini.z.looseObject({
62144
64913
  name: zod_mini.z.string(),
@@ -65039,7 +67808,7 @@ var RooSubagent = class extends ToolSubagent {
65039
67808
  //#region src/features/subagents/tabnine-subagent.ts
65040
67809
  const TabnineSubagentFrontmatterSchema = zod_mini.z.looseObject({
65041
67810
  name: zod_mini.z.string(),
65042
- description: zod_mini.z.optional(zod_mini.z.string()),
67811
+ description: zod_mini.z.string(),
65043
67812
  /** "local" (default) runs inside the CLI; "remote" delegates to a Tabnine cloud agent. */
65044
67813
  kind: zod_mini.z.optional(zod_mini.z.string()),
65045
67814
  /** Built-in tool names the subagent may use; omitted = every tool. */
@@ -65098,14 +67867,23 @@ var TabnineSubagent = class TabnineSubagent extends ToolSubagent {
65098
67867
  validate: true
65099
67868
  });
65100
67869
  }
65101
- static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
67870
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false, logger }) {
65102
67871
  const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
65103
67872
  const tabnineSection = rulesyncFrontmatter.tabnine ?? {};
65104
- const tabnineSubagentFrontmatter = {
67873
+ const merged = {
65105
67874
  name: rulesyncFrontmatter.name,
65106
67875
  description: rulesyncFrontmatter.description,
65107
67876
  ...tabnineSection
65108
67877
  };
67878
+ let description = merged.description;
67879
+ if (description === void 0 || description === "") {
67880
+ description = rulesyncFrontmatter.name ? `${rulesyncFrontmatter.name} subagent` : "subagent";
67881
+ logger?.warn(`Tabnine CLI subagent ${rulesyncSubagent.getRelativeFilePath()} has no description, which Tabnine requires; wrote ${JSON.stringify(description)} as a placeholder.`);
67882
+ }
67883
+ const tabnineSubagentFrontmatter = {
67884
+ ...merged,
67885
+ description
67886
+ };
65109
67887
  const body = rulesyncSubagent.getBody();
65110
67888
  const fileContent = stringifyFrontmatter(body, tabnineSubagentFrontmatter, { avoidBlockScalars: true });
65111
67889
  const paths = this.getSettablePaths({ global });
@@ -65788,6 +68566,15 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
65788
68566
  filePattern: "*.toml"
65789
68567
  }
65790
68568
  }],
68569
+ ["commandcode", {
68570
+ class: CommandcodeSubagent,
68571
+ meta: {
68572
+ supportsProject: true,
68573
+ supportsSimulated: false,
68574
+ supportsGlobal: true,
68575
+ filePattern: "*.md"
68576
+ }
68577
+ }],
65791
68578
  ["copilot", {
65792
68579
  class: CopilotSubagent,
65793
68580
  meta: {
@@ -68620,6 +71407,73 @@ var CodexcliRule = class CodexcliRule extends ToolRule {
68620
71407
  }
68621
71408
  };
68622
71409
  //#endregion
71410
+ //#region src/features/rules/commandcode-rule.ts
71411
+ var CommandcodeRule = class CommandcodeRule extends ToolRule {
71412
+ constructor({ fileContent, root, ...rest }) {
71413
+ super({
71414
+ ...rest,
71415
+ fileContent,
71416
+ root: root ?? false
71417
+ });
71418
+ }
71419
+ static getSettablePaths({ global = false } = {}) {
71420
+ return { root: {
71421
+ relativeDirPath: global ? COMMANDCODE_DIR : ".",
71422
+ relativeFilePath: COMMANDCODE_RULE_FILE_NAME
71423
+ } };
71424
+ }
71425
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath: _relativeFilePath, validate = true, global = false }) {
71426
+ const { root } = this.getSettablePaths({ global });
71427
+ const relativePath = (0, node_path.join)(root.relativeDirPath, root.relativeFilePath);
71428
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
71429
+ return new CommandcodeRule({
71430
+ outputRoot,
71431
+ relativeDirPath: root.relativeDirPath,
71432
+ relativeFilePath: root.relativeFilePath,
71433
+ fileContent,
71434
+ validate,
71435
+ root: true
71436
+ });
71437
+ }
71438
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
71439
+ const { root } = this.getSettablePaths({ global });
71440
+ const isRoot = rulesyncRule.getFrontmatter().root ?? false;
71441
+ return new CommandcodeRule({
71442
+ outputRoot,
71443
+ relativeDirPath: root.relativeDirPath,
71444
+ relativeFilePath: root.relativeFilePath,
71445
+ fileContent: rulesyncRule.getBody(),
71446
+ validate,
71447
+ root: isRoot
71448
+ });
71449
+ }
71450
+ toRulesyncRule() {
71451
+ return this.toRulesyncRuleDefault();
71452
+ }
71453
+ validate() {
71454
+ return {
71455
+ success: true,
71456
+ error: null
71457
+ };
71458
+ }
71459
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
71460
+ return new CommandcodeRule({
71461
+ outputRoot,
71462
+ relativeDirPath,
71463
+ relativeFilePath,
71464
+ fileContent: "",
71465
+ validate: false,
71466
+ root: relativeFilePath === "AGENTS.md" && (relativeDirPath === "." || relativeDirPath === ".commandcode")
71467
+ });
71468
+ }
71469
+ static isTargetedByRulesyncRule(rulesyncRule) {
71470
+ return this.isTargetedByRulesyncRuleDefault({
71471
+ rulesyncRule,
71472
+ toolTarget: "commandcode"
71473
+ });
71474
+ }
71475
+ };
71476
+ //#endregion
68623
71477
  //#region src/features/rules/continue-rule.ts
68624
71478
  /**
68625
71479
  * Frontmatter schema for Continue rule files (`.continue/rules/*.md`).
@@ -71368,24 +74222,6 @@ var PiRule = class PiRule extends ToolRule {
71368
74222
  }
71369
74223
  };
71370
74224
  //#endregion
71371
- //#region src/constants/pool-paths.ts
71372
- /**
71373
- * Pool (Poolside's coding agent CLI) configuration-layout conventions.
71374
- *
71375
- * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
71376
- * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
71377
- * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
71378
- * path), the project-root `AGENTS.md`, and nested per-directory
71379
- * `AGENTS.md` files from the repository root down through the working
71380
- * directory, deeper files taking precedence. It skips ignored directories
71381
- * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
71382
- *
71383
- * @see https://docs.poolside.ai/agent-instructions
71384
- * @see https://github.com/poolsideai/pool
71385
- */
71386
- /** Global config directory for Pool, relative to the home directory. */
71387
- const POOL_GLOBAL_DIR = (0, node_path.join)(".config", "poolside");
71388
- //#endregion
71389
74225
  //#region src/features/rules/pool-rule.ts
71390
74226
  var PoolRule = class PoolRule extends ToolRule {
71391
74227
  static getSettablePaths({ global = false } = {}) {
@@ -72958,6 +75794,15 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
72958
75794
  collisionPolicy: "fold"
72959
75795
  }
72960
75796
  }],
75797
+ ["commandcode", {
75798
+ class: CommandcodeRule,
75799
+ meta: {
75800
+ extension: "md",
75801
+ supportsGlobal: true,
75802
+ ruleDiscoveryMode: "auto",
75803
+ collisionPolicy: "fold"
75804
+ }
75805
+ }],
72961
75806
  ["continue", {
72962
75807
  class: ContinueRule,
72963
75808
  meta: {