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.
@@ -478,6 +478,7 @@ const rulesProcessorToolTargetTuple = [
478
478
  "cline",
479
479
  "codebuddy",
480
480
  "codexcli",
481
+ "commandcode",
481
482
  "continue",
482
483
  "copilot",
483
484
  "copilotcli",
@@ -554,10 +555,12 @@ const mcpProcessorToolTargetTuple = [
554
555
  "claudecode-legacy",
555
556
  "cline",
556
557
  "codexcli",
558
+ "commandcode",
557
559
  "continue",
558
560
  "copilot",
559
561
  "copilotcli",
560
562
  "cortexcode",
563
+ "crush",
561
564
  "cursor",
562
565
  "deepagents",
563
566
  "factorydroid",
@@ -596,6 +599,7 @@ const commandsProcessorToolTargetTuple = [
596
599
  "claudecode-legacy",
597
600
  "cline",
598
601
  "codexcli",
602
+ "commandcode",
599
603
  "continue",
600
604
  "copilot",
601
605
  "cursor",
@@ -633,6 +637,7 @@ const subagentsProcessorToolTargetTuple = [
633
637
  "claudecode-legacy",
634
638
  "cline",
635
639
  "codexcli",
640
+ "commandcode",
636
641
  "copilot",
637
642
  "copilotcli",
638
643
  "cortexcode",
@@ -674,6 +679,7 @@ const skillsProcessorToolTargetTuple = [
674
679
  "claudecode-legacy",
675
680
  "cline",
676
681
  "codexcli",
682
+ "commandcode",
677
683
  "continue",
678
684
  "copilot",
679
685
  "copilotcli",
@@ -694,6 +700,7 @@ const skillsProcessorToolTargetTuple = [
694
700
  "musecode",
695
701
  "opencode",
696
702
  "pi",
703
+ "pool",
697
704
  "qwencode",
698
705
  "reasonix",
699
706
  "replit",
@@ -720,10 +727,12 @@ const hooksProcessorToolTargetTuple = [
720
727
  "claudecode",
721
728
  "claudecode-plugin",
722
729
  "codexcli",
730
+ "commandcode",
723
731
  "continue",
724
732
  "copilot",
725
733
  "copilotcli",
726
734
  "cortexcode",
735
+ "crush",
727
736
  "opencode",
728
737
  "pi",
729
738
  "factorydroid",
@@ -753,9 +762,11 @@ const permissionsProcessorToolTargetTuple = [
753
762
  "claudecode",
754
763
  "cline",
755
764
  "codexcli",
765
+ "commandcode",
756
766
  "continue",
757
767
  "copilot",
758
768
  "copilotcli",
769
+ "crush",
759
770
  "cursor",
760
771
  "deepagents",
761
772
  "devin",
@@ -3266,6 +3277,7 @@ const SHARED_USER_MANAGED_CONFIG_PATHS = [
3266
3277
  ".claude/settings.json",
3267
3278
  ".claude/settings.local.json",
3268
3279
  ".codex/config.toml",
3280
+ ".commandcode/settings.json",
3269
3281
  ".continue/settings.json",
3270
3282
  ".copilot/settings.json",
3271
3283
  ".github/copilot/settings.json",
@@ -3283,6 +3295,9 @@ const SHARED_USER_MANAGED_CONFIG_PATHS = [
3283
3295
  "reasonix.toml",
3284
3296
  ".vscode/settings.json",
3285
3297
  ".zed/settings.json",
3298
+ "crush.json",
3299
+ ".crush.json",
3300
+ ".config/crush/crush.json",
3286
3301
  "kilo.json",
3287
3302
  "kilo.jsonc",
3288
3303
  "opencode.json",
@@ -3487,6 +3502,27 @@ function omitPrototypePollutionKeys(record) {
3487
3502
  }
3488
3503
  return sanitized;
3489
3504
  }
3505
+ /**
3506
+ * Like {@link omitPrototypePollutionKeys}, but recursive: every plain object
3507
+ * reachable through the value (directly, or through an array) is copied with
3508
+ * its prototype-pollution keys dropped. Scalars and arrays of scalars come back
3509
+ * as they are.
3510
+ *
3511
+ * Use when passing an unknown, user-supplied value through unchanged — an MCP
3512
+ * server field a tool documents that rulesync does not model — so a
3513
+ * `__proto__` key nested anywhere inside it cannot ride into the generated
3514
+ * config, where the consuming tool may merge the object without the same care.
3515
+ */
3516
+ function omitPrototypePollutionKeysDeep(value) {
3517
+ if (Array.isArray(value)) return value.map((entry) => omitPrototypePollutionKeysDeep(entry));
3518
+ if (value === null || typeof value !== "object") return value;
3519
+ const sanitized = {};
3520
+ for (const [key, entry] of Object.entries(value)) {
3521
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
3522
+ sanitized[key] = omitPrototypePollutionKeysDeep(entry);
3523
+ }
3524
+ return sanitized;
3525
+ }
3490
3526
  //#endregion
3491
3527
  //#region src/utils/type-guards.ts
3492
3528
  /**
@@ -4554,6 +4590,14 @@ const VIBE_HOOK_EVENTS = [
4554
4590
  "stop"
4555
4591
  ];
4556
4592
  /**
4593
+ * Hook events supported by Crush.
4594
+ *
4595
+ * Crush's `hooks` config block currently fires a single event, `PreToolUse`
4596
+ * (Claude Code-compatible payload; `matcher` is a regex on the tool name).
4597
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
4598
+ */
4599
+ const CRUSH_HOOK_EVENTS = ["preToolUse"];
4600
+ /**
4557
4601
  * Hook events supported by JetBrains Junie CLI.
4558
4602
  *
4559
4603
  * Junie CLI exposes seven lifecycle events under the `"hooks"` key of
@@ -4891,6 +4935,30 @@ const CANONICAL_TO_CORTEXCODE_EVENT_NAMES = {
4891
4935
  };
4892
4936
  const CORTEXCODE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CORTEXCODE_EVENT_NAMES).map(([k, v]) => [v, k]));
4893
4937
  /**
4938
+ * Hook events supported by Command Code.
4939
+ *
4940
+ * Command Code reads hooks from the `hooks` key of `.commandcode/settings.json`
4941
+ * (project) and `~/.commandcode/settings.json` (user) in the Claude-Code shape.
4942
+ * Four events are documented: PreToolUse, PostToolUse, Stop and SessionStart.
4943
+ * Only `command` hooks exist; `timeout` is in seconds (default 30, max 600)
4944
+ * and `$COMMANDCODE_PROJECT_DIR` resolves to the project root.
4945
+ *
4946
+ * @see https://commandcode.ai/docs/hooks
4947
+ */
4948
+ const COMMANDCODE_HOOK_EVENTS = [
4949
+ "preToolUse",
4950
+ "postToolUse",
4951
+ "stop",
4952
+ "sessionStart"
4953
+ ];
4954
+ const CANONICAL_TO_COMMANDCODE_EVENT_NAMES = {
4955
+ preToolUse: "PreToolUse",
4956
+ postToolUse: "PostToolUse",
4957
+ stop: "Stop",
4958
+ sessionStart: "SessionStart"
4959
+ };
4960
+ const COMMANDCODE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_COMMANDCODE_EVENT_NAMES).map(([k, v]) => [v, k]));
4961
+ /**
4894
4962
  * Hook events supported by the Continue CLI (`cn`).
4895
4963
  *
4896
4964
  * Continue reads a Claude-Code-compatible `hooks` key from
@@ -5096,8 +5164,10 @@ const HooksConfigSchema = z.looseObject({
5096
5164
  grokcli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5097
5165
  "kimi-code": z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5098
5166
  zcode: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5167
+ crush: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5099
5168
  bob: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5100
5169
  cortexcode: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5170
+ commandcode: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5101
5171
  continue: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5102
5172
  tabnine: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5103
5173
  qwencode: z.optional(z.looseObject({
@@ -5593,6 +5663,13 @@ const VIBE_TO_CANONICAL_EVENT_NAMES = {
5593
5663
  post_agent_turn: "stop"
5594
5664
  };
5595
5665
  /**
5666
+ * Canonical -> Crush event names. Crush spells its events the Claude Code
5667
+ * way (`PreToolUse`).
5668
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
5669
+ */
5670
+ const CANONICAL_TO_CRUSH_EVENT_NAMES = { preToolUse: "PreToolUse" };
5671
+ const CRUSH_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CRUSH_EVENT_NAMES).map(([k, v]) => [v, k]));
5672
+ /**
5596
5673
  * Map canonical camelCase event names to Qwen Code PascalCase.
5597
5674
  *
5598
5675
  * Qwen Code reuses the same Claude-style PascalCase event names for the events
@@ -6032,6 +6109,11 @@ const McpServerSchema = z.looseObject({
6032
6109
  kiroAutoBlock: z.optional(z.array(z.string())),
6033
6110
  musecodeMode: z.optional(z.enum(["required", "optional"])),
6034
6111
  rovodevEnableInstructions: z.optional(z.boolean()),
6112
+ crushOauth: z.optional(z.boolean()),
6113
+ crushOauthClientId: z.optional(z.string()),
6114
+ crushOauthClientSecret: z.optional(z.string()),
6115
+ crushOauthCallbackPort: z.optional(z.int()),
6116
+ crushSessionless: z.optional(z.boolean()),
6035
6117
  headers: z.optional(z.record(z.string(), z.string())),
6036
6118
  /**
6037
6119
  * The canonical per-server tool allowlist.
@@ -6105,9 +6187,12 @@ const RulesyncMcpFileSchema = z.looseObject({
6105
6187
  claudecode: z.optional(toolScopedMcpSchema),
6106
6188
  cline: z.optional(toolScopedMcpSchema),
6107
6189
  codexcli: z.optional(toolScopedMcpSchema),
6190
+ commandcode: z.optional(toolScopedMcpSchema),
6108
6191
  continue: z.optional(toolScopedMcpSchema),
6109
6192
  copilot: z.optional(toolScopedMcpSchema),
6110
6193
  copilotcli: z.optional(toolScopedMcpSchema),
6194
+ cortexcode: z.optional(toolScopedMcpSchema),
6195
+ crush: z.optional(toolScopedMcpSchema),
6111
6196
  cursor: z.optional(toolScopedMcpSchema),
6112
6197
  deepagents: z.optional(toolScopedMcpSchema),
6113
6198
  devin: z.optional(toolScopedMcpSchema),
@@ -6124,7 +6209,6 @@ const RulesyncMcpFileSchema = z.looseObject({
6124
6209
  reasonix: z.optional(toolScopedMcpSchema),
6125
6210
  roo: z.optional(toolScopedMcpSchema),
6126
6211
  rovodev: z.optional(toolScopedMcpSchema),
6127
- cortexcode: z.optional(toolScopedMcpSchema),
6128
6212
  tabnine: z.optional(toolScopedMcpSchema),
6129
6213
  takt: z.optional(toolScopedMcpSchema),
6130
6214
  vibe: z.optional(toolScopedMcpSchema),
@@ -6456,6 +6540,15 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
6456
6540
  "musecodeMode",
6457
6541
  "rovodevEnableInstructions",
6458
6542
  "enable_instructions",
6543
+ "crushOauth",
6544
+ "crushOauthClientId",
6545
+ "crushOauthClientSecret",
6546
+ "crushOauthCallbackPort",
6547
+ "crushSessionless",
6548
+ "oauth_client_id",
6549
+ "oauth_client_secret",
6550
+ "oauth_callback_port",
6551
+ "sessionless",
6459
6552
  "enabled"
6460
6553
  ])];
6461
6554
  }));
@@ -6490,12 +6583,16 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
6490
6583
  * Targets that share one output file resolve identically so the shared
6491
6584
  * file's content never depends on which of them generates last — see
6492
6585
  * `resolveMcpTarget` for the alias groups (kiro trio, claudecode/-legacy,
6493
- * and the Antigravity pair).
6586
+ * the Antigravity pair, and the project-scope Claude Code / Command Code
6587
+ * pair, which is why the scope is part of the resolution).
6494
6588
  *
6495
6589
  * Returns the same instance when neither mechanism is used.
6496
6590
  */
6497
- forTarget({ toolTarget, logger }) {
6498
- const { blockKeys, acceptedTargetNames } = resolveMcpTarget({ toolTarget });
6591
+ forTarget({ toolTarget, global = false, logger }) {
6592
+ const { blockKeys, acceptedTargetNames } = resolveMcpTarget({
6593
+ toolTarget,
6594
+ global
6595
+ });
6499
6596
  const json = this.json;
6500
6597
  const sharedServers = this.json.mcpServers ?? {};
6501
6598
  const serverNamesWithTargets = Object.entries(sharedServers).filter(([, serverConfig]) => serverConfig.targets !== void 0).map(([serverName]) => serverName);
@@ -6582,9 +6679,24 @@ const MCP_IGNORED_ALIAS_SOURCE_KEYS = Object.keys(MCP_BLOCK_KEY_ALIASES);
6582
6679
  * `config`) — so both targets always apply both blocks in a fixed order
6583
6680
  * (`antigravity-ide` first, `antigravity-cli` second — the CLI block wins
6584
6681
  * per server on conflict).
6585
- */
6586
- function resolveMcpTarget({ toolTarget }) {
6587
- if (toolTarget === "claudecode" || toolTarget === "claudecode-legacy") return {
6682
+ * - `claudecode` (and its legacy alias) and `commandcode` share the root
6683
+ * `.mcp.json` in PROJECT mode only (their global files differ:
6684
+ * `~/.claude.json` vs `~/.commandcode/mcp.json`), so in project mode the
6685
+ * three apply the `claudecode` and `commandcode` blocks in that fixed order
6686
+ * (the `commandcode` block wins per server on conflict); in global mode
6687
+ * each reads its own block.
6688
+ */
6689
+ function resolveMcpTarget({ toolTarget, global }) {
6690
+ const isClaudecode = toolTarget === "claudecode" || toolTarget === "claudecode-legacy";
6691
+ if (!global && (isClaudecode || toolTarget === "commandcode")) return {
6692
+ blockKeys: ["claudecode", "commandcode"],
6693
+ acceptedTargetNames: /* @__PURE__ */ new Set([
6694
+ "claudecode",
6695
+ "claudecode-legacy",
6696
+ "commandcode"
6697
+ ])
6698
+ };
6699
+ if (isClaudecode) return {
6588
6700
  blockKeys: ["claudecode"],
6589
6701
  acceptedTargetNames: /* @__PURE__ */ new Set(["claudecode", "claudecode-legacy"])
6590
6702
  };
@@ -6904,7 +7016,7 @@ const CursorPermissionsOverrideSchema = z.looseObject({
6904
7016
  * autonomy/sandbox controls with no canonical permission category — under
6905
7017
  * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
6906
7018
  * `sandbox`, `sandboxImage`, `disabled`, `visible`, `eager`, `listDirectory`,
6907
- * `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
7019
+ * `todoWrite`, `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
6908
7020
  * `allowPrivateNetworkHooks`, `allowedInsecureVoiceBaseUrls`). Qwen Code strips
6909
7021
  * `tools.workflowsEnabled`, `security.allowPrivateNetworkHooks` and
6910
7022
  * `security.allowedInsecureVoiceBaseUrls` out of workspace settings, so generate
@@ -6917,7 +7029,7 @@ const CursorPermissionsOverrideSchema = z.looseObject({
6917
7029
  * changes what the file said is reported in either scope, naming what that key
6918
7030
  * decides there — the autonomy and containment
6919
7031
  * controls (`approvalMode`, `autoAccept`, `sandbox`, `sandboxImage`), the
6920
- * registry controls (`disabled`, `visible`, `listDirectory`, and `eager`, which
7032
+ * registry controls (`disabled`, `visible`, `listDirectory`, `todoWrite`, and `eager`, which
6921
7033
  * demotes an omitted tool to deferred rather than removing it, replacing rather
6922
7034
  * than merging with the list a higher scope set), the Auto Mode
6923
7035
  * classifier config, and, because these groups are loose objects, any key
@@ -6958,6 +7070,14 @@ const QwencodePermissionsOverrideSchema = z.looseObject({
6958
7070
  * `looseObject` (verbatim passthrough). Both project and global scope are
6959
7071
  * supported.
6960
7072
  *
7073
+ * Only `tools` and `general` are written; any other key is ignored with a
7074
+ * warning. Within them, the paths Tabnine CLI runs as a command or sends its
7075
+ * traffic to — `tools.discoveryCommand`, `tools.callCommand`,
7076
+ * `tools.shell.pager`, `tools.sandbox.command`, `general.tabnineHost` and
7077
+ * `general.preferredEditor` — are refused with a warning rather than written,
7078
+ * so a fetched permissions file cannot point the CLI at an executable or a
7079
+ * server of its choosing; set those by hand in `settings.json`.
7080
+ *
6961
7081
  * @example
6962
7082
  * { "tools": { "core": ["read_file", "run_shell_command(git)"] },
6963
7083
  * "general": { "defaultApprovalMode": "plan" } }
@@ -7741,7 +7861,9 @@ const PermissionsConfigSchema = z.looseObject({
7741
7861
  "antigravity-ide": z.optional(CanonicalPermissionsOverrideSchema),
7742
7862
  continue: z.optional(CanonicalPermissionsOverrideSchema),
7743
7863
  copilot: z.optional(CanonicalPermissionsOverrideSchema),
7864
+ commandcode: z.optional(CanonicalPermissionsOverrideSchema),
7744
7865
  copilotcli: z.optional(CanonicalPermissionsOverrideSchema),
7866
+ crush: z.optional(CanonicalPermissionsOverrideSchema),
7745
7867
  goose: z.optional(CanonicalPermissionsOverrideSchema),
7746
7868
  grokcli: z.optional(CanonicalPermissionsOverrideSchema),
7747
7869
  "kimi-code": z.optional(KimiCodePermissionsOverrideSchema),
@@ -9462,6 +9584,7 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
9462
9584
  "user-invocable": z.optional(z.boolean()),
9463
9585
  "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
9464
9586
  })),
9587
+ commandcode: z.optional(z.looseObject({})),
9465
9588
  cortexcode: z.optional(z.looseObject({})),
9466
9589
  continue: z.optional(z.looseObject({})),
9467
9590
  tabnine: z.optional(z.looseObject({})),
@@ -12767,6 +12890,26 @@ const ZCODE_USER_CONFIG_DECLARATION = {
12767
12890
  }
12768
12891
  };
12769
12892
  /**
12893
+ * Crush's JSON config, shared by the project (`crush.json` / `.crush.json`)
12894
+ * and global (`~/.config/crush/crush.json`) spellings so a policy edit lands
12895
+ * on both. See the `SHARED_CONFIG_OWNERSHIP` entries for the key rationale.
12896
+ */
12897
+ const CRUSH_CONFIG_DECLARATION = {
12898
+ format: "json",
12899
+ invalidRootPolicy: "error",
12900
+ features: {
12901
+ mcp: {
12902
+ kind: "replace-owned-keys",
12903
+ ownedKeys: ["mcp"]
12904
+ },
12905
+ hooks: {
12906
+ kind: "replace-owned-keys",
12907
+ ownedKeys: ["hooks"]
12908
+ },
12909
+ permissions: { kind: "deep-merge" }
12910
+ }
12911
+ };
12912
+ /**
12770
12913
  * What the two Claude Code settings files have in common: both are plain JSON,
12771
12914
  * and both validate against the one published schema, which the gateway
12772
12915
  * points every file it writes at. `.claude/settings.local.json` is the same
@@ -13027,6 +13170,8 @@ const SHARED_CONFIG_OWNERSHIP = {
13027
13170
  },
13028
13171
  ".zcode/config.json": ZCODE_WORKSPACE_CONFIG_DECLARATION,
13029
13172
  ".zcode/cli/config.json": ZCODE_USER_CONFIG_DECLARATION,
13173
+ "crush.json": CRUSH_CONFIG_DECLARATION,
13174
+ ".config/crush/crush.json": CRUSH_CONFIG_DECLARATION,
13030
13175
  ".bob/settings.json": {
13031
13176
  format: "json",
13032
13177
  invalidRootPolicy: "error",
@@ -13051,6 +13196,20 @@ const SHARED_CONFIG_OWNERSHIP = {
13051
13196
  ownedKeys: ["hooks"]
13052
13197
  } }
13053
13198
  },
13199
+ ".commandcode/settings.json": {
13200
+ format: "json",
13201
+ invalidRootPolicy: "error",
13202
+ features: {
13203
+ hooks: {
13204
+ kind: "replace-owned-keys",
13205
+ ownedKeys: ["hooks"]
13206
+ },
13207
+ permissions: {
13208
+ kind: "replace-owned-keys",
13209
+ ownedKeys: ["permissions"]
13210
+ }
13211
+ }
13212
+ },
13054
13213
  ".cortex/settings.json": {
13055
13214
  format: "json",
13056
13215
  invalidRootPolicy: "error",
@@ -15252,6 +15411,94 @@ var CodexcliCommand = class CodexcliCommand extends ToolCommand {
15252
15411
  }
15253
15412
  };
15254
15413
  //#endregion
15414
+ //#region src/constants/commandcode-paths.ts
15415
+ const COMMANDCODE_DIR = ".commandcode";
15416
+ const COMMANDCODE_RULE_FILE_NAME = "AGENTS.md";
15417
+ const COMMANDCODE_SETTINGS_FILE_NAME = "settings.json";
15418
+ const COMMANDCODE_PROJECT_MCP_FILE_NAME = ".mcp.json";
15419
+ const COMMANDCODE_GLOBAL_MCP_FILE_NAME = "mcp.json";
15420
+ const COMMANDCODE_COMMANDS_DIR_PATH = join(COMMANDCODE_DIR, "commands");
15421
+ const COMMANDCODE_AGENTS_DIR_PATH = join(COMMANDCODE_DIR, "agents");
15422
+ const COMMANDCODE_SKILLS_DIR_PATH = join(COMMANDCODE_DIR, "skills");
15423
+ //#endregion
15424
+ //#region src/features/commands/commandcode-command.ts
15425
+ /**
15426
+ * Custom slash command for Command Code.
15427
+ *
15428
+ * Command Code reads Markdown files from `.commandcode/commands/` (project)
15429
+ * and `~/.commandcode/commands/` (user), naming each command after the file's
15430
+ * basename; subdirectories only group the files. The file's full trimmed
15431
+ * body is the prompt that runs — YAML frontmatter is not stripped, only
15432
+ * skipped when the slash menu picks a summary line — so rulesync writes the
15433
+ * bare body. On import a hand-written file's frontmatter block is dropped
15434
+ * (Command Code would send it as prompt text; a rulesync command carries its
15435
+ * own frontmatter) and only the body is kept.
15436
+ *
15437
+ * @see https://commandcode.ai/docs/custom-slash-commands
15438
+ */
15439
+ var CommandcodeCommand = class CommandcodeCommand extends ToolCommand {
15440
+ static getSettablePaths(_options = {}) {
15441
+ return { relativeDirPath: COMMANDCODE_COMMANDS_DIR_PATH };
15442
+ }
15443
+ toRulesyncCommand() {
15444
+ return new RulesyncCommand({
15445
+ outputRoot: process.cwd(),
15446
+ frontmatter: { targets: ["*"] },
15447
+ body: this.getFileContent(),
15448
+ relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath,
15449
+ relativeFilePath: this.relativeFilePath,
15450
+ fileContent: this.getFileContent(),
15451
+ validate: true
15452
+ });
15453
+ }
15454
+ static fromRulesyncCommand({ outputRoot = process.cwd(), rulesyncCommand, validate = true }) {
15455
+ const paths = this.getSettablePaths();
15456
+ return new CommandcodeCommand({
15457
+ outputRoot,
15458
+ fileContent: rulesyncCommand.getBody(),
15459
+ relativeDirPath: paths.relativeDirPath,
15460
+ relativeFilePath: rulesyncCommand.getRelativeFilePath(),
15461
+ validate
15462
+ });
15463
+ }
15464
+ validate() {
15465
+ return {
15466
+ success: true,
15467
+ error: null
15468
+ };
15469
+ }
15470
+ getBody() {
15471
+ return this.getFileContent();
15472
+ }
15473
+ static isTargetedByRulesyncCommand(rulesyncCommand) {
15474
+ return this.isTargetedByRulesyncCommandDefault({
15475
+ rulesyncCommand,
15476
+ toolTarget: "commandcode"
15477
+ });
15478
+ }
15479
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
15480
+ const paths = this.getSettablePaths();
15481
+ const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
15482
+ const { body: content } = parseFrontmatter(await readFileContent(filePath), filePath);
15483
+ return new CommandcodeCommand({
15484
+ outputRoot,
15485
+ relativeDirPath: paths.relativeDirPath,
15486
+ relativeFilePath,
15487
+ fileContent: content.trim(),
15488
+ validate
15489
+ });
15490
+ }
15491
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
15492
+ return new CommandcodeCommand({
15493
+ outputRoot,
15494
+ relativeDirPath,
15495
+ relativeFilePath,
15496
+ fileContent: "",
15497
+ validate: false
15498
+ });
15499
+ }
15500
+ };
15501
+ //#endregion
15255
15502
  //#region src/constants/continue-paths.ts
15256
15503
  const CONTINUE_DIR = ".continue";
15257
15504
  const CONTINUE_ROOT_RULE_FILE_NAME = "AGENTS.md";
@@ -18876,15 +19123,12 @@ var TabnineCommand = class TabnineCommand extends ToolCommand {
18876
19123
  return this.body;
18877
19124
  }
18878
19125
  getFrontmatter() {
18879
- return {
18880
- description: this.frontmatter.description,
18881
- prompt: this.frontmatter.prompt
18882
- };
19126
+ return this.frontmatter;
18883
19127
  }
18884
19128
  toRulesyncCommand() {
18885
19129
  const { description, prompt: _prompt, ...restFields } = this.frontmatter;
18886
19130
  const rulesyncFrontmatter = {
18887
- targets: ["tabnine"],
19131
+ targets: ["*"],
18888
19132
  description,
18889
19133
  ...Object.keys(restFields).length > 0 && { tabnine: restFields }
18890
19134
  };
@@ -19498,6 +19742,16 @@ const toolCommandFactories = /* @__PURE__ */ new Map([
19498
19742
  supportsSubdirectory: false
19499
19743
  }
19500
19744
  }],
19745
+ ["commandcode", {
19746
+ class: CommandcodeCommand,
19747
+ meta: {
19748
+ extension: "md",
19749
+ supportsProject: true,
19750
+ supportsGlobal: true,
19751
+ isSimulated: false,
19752
+ supportsSubdirectory: true
19753
+ }
19754
+ }],
19501
19755
  ["continue", {
19502
19756
  class: ContinueCommand,
19503
19757
  meta: {
@@ -20847,7 +21101,20 @@ function stableJson(value) {
20847
21101
  */
20848
21102
  function isSafeStringRecord(value) {
20849
21103
  if (!isPlainObject$1(value) || !isStringRecord(value)) return false;
20850
- return Object.entries(value).every(([key, entry]) => key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || entry.includes(char)));
21104
+ return Object.entries(value).every(([key, entry]) => isSafeEnvEntry({
21105
+ key,
21106
+ value: entry
21107
+ }));
21108
+ }
21109
+ /**
21110
+ * Whether one `env` entry survives the rule {@link isSafeStringRecord} applies
21111
+ * to the whole map. Exported for the adapters that hand a tool an environment
21112
+ * block outside the shared converter (Tabnine, whose hook shape it does not
21113
+ * express) so they drop the same entries this one does.
21114
+ */
21115
+ function isSafeEnvEntry(entry) {
21116
+ const { key, value } = entry;
21117
+ return typeof value === "string" && key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || value.includes(char));
20851
21118
  }
20852
21119
  /**
20853
21120
  * Control characters cannot ride from an existing tool config into a canonical
@@ -21738,135 +22005,6 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
21738
22005
  }
21739
22006
  };
21740
22007
  //#endregion
21741
- //#region src/features/hooks/bob-hooks.ts
21742
- const BOB_CONVERTER_CONFIG = {
21743
- supportedEvents: BOB_HOOK_EVENTS,
21744
- canonicalToToolEventNames: CANONICAL_TO_BOB_EVENT_NAMES,
21745
- toolToCanonicalEventNames: BOB_TO_CANONICAL_EVENT_NAMES,
21746
- projectDirVar: "",
21747
- noMatcherEvents: /* @__PURE__ */ new Set([
21748
- "sessionStart",
21749
- "beforeSubmitPrompt",
21750
- "stop"
21751
- ]),
21752
- supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
21753
- wildcardMatcherMeansAll: true
21754
- };
21755
- /**
21756
- * Single spelling of the settings.json codec/policy: fail closed on an
21757
- * unparseable root rather than replacing the user's Bob settings with
21758
- * generated output.
21759
- */
21760
- function parseBobSettings(fileContent, filePath) {
21761
- return parseSharedConfig({
21762
- format: "json",
21763
- fileContent,
21764
- filePath,
21765
- invalidRootPolicy: "error"
21766
- });
21767
- }
21768
- /**
21769
- * IBM Bob lifecycle hooks.
21770
- *
21771
- * Hooks live under the top-level `hooks` key of Bob's settings file —
21772
- * `<project>/.bob/settings.json` (project scope) and
21773
- * `~/.bob/settings/settings.json` (user scope) — in the Claude-Code shape:
21774
- * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command",
21775
- * "command": "...", "timeout"?: <seconds> }] }] }`. The file also holds
21776
- * settings rulesync does not own, so generation merges the `hooks` key into it
21777
- * (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting the file.
21778
- *
21779
- * @see https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks
21780
- */
21781
- var BobHooks = class BobHooks extends ToolHooks {
21782
- constructor(params) {
21783
- super({
21784
- ...params,
21785
- fileContent: params.fileContent ?? "{}"
21786
- });
21787
- }
21788
- isDeletable() {
21789
- return false;
21790
- }
21791
- static getSettablePaths({ global = false } = {}) {
21792
- return {
21793
- relativeDirPath: global ? BOB_GLOBAL_SETTINGS_DIR_PATH : BOB_DIR,
21794
- relativeFilePath: BOB_SETTINGS_FILE_NAME
21795
- };
21796
- }
21797
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
21798
- const paths = BobHooks.getSettablePaths({ global });
21799
- const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
21800
- return new BobHooks({
21801
- outputRoot,
21802
- relativeDirPath: paths.relativeDirPath,
21803
- relativeFilePath: paths.relativeFilePath,
21804
- fileContent,
21805
- validate
21806
- });
21807
- }
21808
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
21809
- const paths = BobHooks.getSettablePaths({ global });
21810
- const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
21811
- const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
21812
- const config = rulesyncHooks.getJson();
21813
- const hooks = canonicalToToolHooks({
21814
- config,
21815
- toolOverrideHooks: config.bob?.hooks,
21816
- converterConfig: BOB_CONVERTER_CONFIG,
21817
- logger
21818
- });
21819
- const fileContent = applySharedConfigPatch({
21820
- fileKey: sharedConfigFileKey(paths),
21821
- feature: "hooks",
21822
- existingContent,
21823
- patch: { hooks },
21824
- filePath,
21825
- logger
21826
- });
21827
- return new BobHooks({
21828
- outputRoot,
21829
- relativeDirPath: paths.relativeDirPath,
21830
- relativeFilePath: paths.relativeFilePath,
21831
- fileContent,
21832
- validate
21833
- });
21834
- }
21835
- toRulesyncHooks({ logger } = {}) {
21836
- const configPath = join(this.getRelativeDirPath(), this.getRelativeFilePath());
21837
- let settings;
21838
- try {
21839
- settings = parseBobSettings(this.getFileContent(), configPath);
21840
- } catch (error) {
21841
- throw new Error(`Failed to parse Bob hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
21842
- }
21843
- const hooks = toolHooksToCanonical({
21844
- logger,
21845
- hooks: settings.hooks,
21846
- converterConfig: BOB_CONVERTER_CONFIG
21847
- });
21848
- return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
21849
- hooks,
21850
- overrideKey: "bob"
21851
- }), null, 2) });
21852
- }
21853
- validate() {
21854
- return {
21855
- success: true,
21856
- error: null
21857
- };
21858
- }
21859
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
21860
- return new BobHooks({
21861
- outputRoot,
21862
- relativeDirPath,
21863
- relativeFilePath,
21864
- fileContent: JSON.stringify({ hooks: {} }, null, 2),
21865
- validate: false
21866
- });
21867
- }
21868
- };
21869
- //#endregion
21870
22008
  //#region src/features/hooks/preserve-unowned-hook-commands.ts
21871
22009
  /**
21872
22010
  * Read the `hooks` value from a destination JSON file.
@@ -22153,75 +22291,22 @@ function warnSkip({ logger, event, expected }) {
22153
22291
  logger?.warn(`Skipping existing hook entry on ${event}: expected ${expected}`);
22154
22292
  }
22155
22293
  //#endregion
22156
- //#region src/features/hooks/claudecode-hooks.ts
22157
- const CLAUDE_CONVERTER_CONFIG = {
22158
- supportedEvents: CLAUDE_HOOK_EVENTS,
22159
- canonicalToToolEventNames: CANONICAL_TO_CLAUDE_EVENT_NAMES,
22160
- toolToCanonicalEventNames: CLAUDE_TO_CANONICAL_EVENT_NAMES,
22161
- projectDirVar: "$CLAUDE_PROJECT_DIR",
22162
- prefixDotRelativeCommandsOnly: true,
22163
- noMatcherEvents: /* @__PURE__ */ new Set([
22164
- "worktreeCreate",
22165
- "worktreeRemove",
22166
- "messageDisplay",
22167
- "postToolBatch",
22168
- "taskCreated",
22169
- "taskCompleted",
22170
- "teammateIdle",
22171
- "cwdChanged",
22172
- "beforeSubmitPrompt",
22173
- "stop"
22174
- ]),
22175
- supportedHookTypes: /* @__PURE__ */ new Set([
22176
- "command",
22177
- "prompt",
22178
- "http",
22179
- "mcp_tool",
22180
- "agent"
22181
- ]),
22182
- emitsPromptModel: true,
22183
- stringPassthroughFields: [
22184
- {
22185
- canonical: "if",
22186
- tool: "if"
22187
- },
22188
- {
22189
- canonical: "statusMessage",
22190
- tool: "statusMessage"
22191
- },
22192
- {
22193
- canonical: "shell",
22194
- tool: "shell",
22195
- commandOnly: true
22196
- }
22197
- ],
22198
- booleanPassthroughFields: [
22199
- {
22200
- canonical: "once",
22201
- tool: "once"
22202
- },
22203
- {
22204
- canonical: "async",
22205
- tool: "async",
22206
- commandOnly: true
22207
- },
22208
- {
22209
- canonical: "asyncRewake",
22210
- tool: "asyncRewake",
22211
- commandOnly: true
22212
- },
22213
- {
22214
- canonical: "continueOnBlock",
22215
- tool: "continueOnBlock"
22216
- }
22217
- ],
22218
- arrayPassthroughFields: [{
22219
- canonical: "args",
22220
- tool: "args",
22221
- commandOnly: true
22222
- }]
22223
- };
22224
- var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22294
+ //#region src/features/hooks/settings-json-hooks.ts
22295
+ /**
22296
+ * Shared implementation for the tools whose hooks live under the top-level
22297
+ * `hooks` key of a JSON settings file in Claude Code's shape —
22298
+ * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command", ... }] }] }`
22299
+ * — and whose file also holds settings rulesync does not own, so generation
22300
+ * merges the `hooks` key into it (see `SHARED_CONFIG_OWNERSHIP`) instead of
22301
+ * overwriting it, and `--delete` never removes the file wholesale.
22302
+ *
22303
+ * A concrete adapter supplies its {@link SettingsJsonHooksSpec} and its
22304
+ * settable paths; the event mapping, the matcher rules and the per-hook
22305
+ * fields the tool documents are all expressed in the spec's converter config.
22306
+ * An adapter that also supports preserving unowned hooks (Claude Code) opts in
22307
+ * through {@link ToolHooks.supportsPreserveUnowned}.
22308
+ */
22309
+ var SettingsJsonHooks = class extends ToolHooks {
22225
22310
  constructor(params) {
22226
22311
  super({
22227
22312
  ...params,
@@ -22231,24 +22316,42 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22231
22316
  isDeletable() {
22232
22317
  return false;
22233
22318
  }
22319
+ /** The tool-specific half of the adapter. Every concrete class overrides this. */
22320
+ static getSpec() {
22321
+ throw new Error(`${this.name} does not define getSpec()`);
22322
+ }
22234
22323
  /**
22235
- * The converter config used for both directions. Exposed as a static hook so
22236
- * plugin-scoped subclasses can swap tool-specific details (e.g. the project
22237
- * directory variable) without duplicating the rest of the config.
22324
+ * The converter config used for both directions. A separate hook so a
22325
+ * subclass can swap tool-specific details (e.g. the project directory
22326
+ * variable) without redefining the whole spec.
22238
22327
  */
22239
22328
  static getConverterConfig() {
22240
- return CLAUDE_CONVERTER_CONFIG;
22329
+ return this.getSpec().converterConfig;
22241
22330
  }
22242
22331
  static getSettablePaths(_options = {}) {
22243
- return {
22244
- relativeDirPath: CLAUDECODE_DIR,
22245
- relativeFilePath: CLAUDECODE_SETTINGS_FILE_NAME
22246
- };
22332
+ throw new Error(`${this.name} does not define getSettablePaths()`);
22333
+ }
22334
+ /**
22335
+ * The `SHARED_CONFIG_OWNERSHIP` key the settings file is merged under. By
22336
+ * default it is derived from the settable paths; an adapter whose file is
22337
+ * declared under another tool's key (the Claude Code plugin bundle) overrides
22338
+ * it.
22339
+ */
22340
+ static getSharedFileKey(paths) {
22341
+ return sharedConfigFileKey(paths);
22342
+ }
22343
+ /**
22344
+ * `new this(params)` for the concrete adapter the static method was called
22345
+ * on; the cast is what an abstract class needs to be constructed through
22346
+ * `this`, and lives in one place.
22347
+ */
22348
+ static instantiate(params) {
22349
+ return new this(params);
22247
22350
  }
22248
22351
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
22249
22352
  const paths = this.getSettablePaths({ global });
22250
22353
  const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
22251
- return new this({
22354
+ return this.instantiate({
22252
22355
  outputRoot,
22253
22356
  relativeDirPath: paths.relativeDirPath,
22254
22357
  relativeFilePath: paths.relativeFilePath,
@@ -22256,40 +22359,41 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22256
22359
  validate
22257
22360
  });
22258
22361
  }
22259
- static supportsPreserveUnowned() {
22260
- return true;
22261
- }
22262
22362
  static async getAuxiliaryFiles({ toolHooks } = {}) {
22263
- return toolHooks instanceof ClaudecodeHooks ? toolHooks.getOwnershipLockFiles() : [];
22363
+ return toolHooks instanceof this ? toolHooks.getOwnershipLockFiles() : [];
22264
22364
  }
22265
22365
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, preserveUnowned = false, logger }) {
22266
22366
  const paths = this.getSettablePaths({ global });
22267
22367
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22268
22368
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
22269
22369
  const config = rulesyncHooks.getJson();
22270
- const claudeHooks = canonicalToToolHooks({
22370
+ const generatedHooks = canonicalToToolHooks({
22271
22371
  config,
22272
- toolOverrideHooks: config.claudecode?.hooks,
22372
+ toolOverrideHooks: overrideHooksOf({
22373
+ config,
22374
+ overrideKey: this.getSpec().overrideKey
22375
+ }),
22273
22376
  converterConfig: this.getConverterConfig(),
22274
22377
  logger
22275
22378
  });
22276
22379
  const preserving = preserveUnowned && this.supportsPreserveUnowned();
22277
22380
  const merged = mergeGeneratedHookLists({
22278
22381
  existingContent,
22279
- generatedHooks: claudeHooks,
22382
+ generatedHooks,
22280
22383
  shape: "matcher-groups",
22281
22384
  preserveUnowned: preserving,
22282
22385
  previouslyOwned: preserving ? parseHooksOwnershipLock(await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, HOOKS_OWNERSHIP_LOCK_FILE_NAME))) : void 0,
22283
22386
  logger
22284
22387
  });
22285
22388
  const fileContent = applySharedConfigPatch({
22286
- fileKey: CLAUDE_SETTINGS_SHARED_FILE_KEY,
22389
+ fileKey: this.getSharedFileKey(paths),
22287
22390
  feature: "hooks",
22288
22391
  existingContent,
22289
22392
  patch: { hooks: merged.hooks },
22290
- filePath
22393
+ filePath,
22394
+ logger
22291
22395
  });
22292
- return new this({
22396
+ return this.instantiate({
22293
22397
  outputRoot,
22294
22398
  relativeDirPath: paths.relativeDirPath,
22295
22399
  relativeFilePath: paths.relativeFilePath,
@@ -22299,20 +22403,29 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22299
22403
  });
22300
22404
  }
22301
22405
  toRulesyncHooks({ logger } = {}) {
22406
+ const ctor = this.constructor;
22407
+ const spec = ctor.getSpec();
22408
+ const configPath = join(this.getRelativeDirPath(), this.getRelativeFilePath());
22302
22409
  let settings;
22303
22410
  try {
22304
- settings = JSON.parse(this.getFileContent());
22411
+ settings = parseSharedConfig({
22412
+ format: "json",
22413
+ fileContent: this.getFileContent(),
22414
+ filePath: configPath,
22415
+ invalidRootPolicy: "error"
22416
+ });
22305
22417
  } catch (error) {
22306
- throw new Error(`Failed to parse Claude hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
22418
+ const reason = error instanceof Error && error.cause instanceof Error ? error.cause : error;
22419
+ throw new Error(`Failed to parse ${spec.displayName} hooks content in ${configPath}: ${formatError(reason)}`, { cause: error });
22307
22420
  }
22308
22421
  const hooks = toolHooksToCanonical({
22422
+ logger,
22309
22423
  hooks: settings.hooks,
22310
- converterConfig: this.constructor.getConverterConfig(),
22311
- logger
22424
+ converterConfig: ctor.getConverterConfig()
22312
22425
  });
22313
22426
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
22314
22427
  hooks,
22315
- overrideKey: "claudecode"
22428
+ overrideKey: spec.overrideKey
22316
22429
  }), null, 2) });
22317
22430
  }
22318
22431
  validate() {
@@ -22322,7 +22435,7 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22322
22435
  };
22323
22436
  }
22324
22437
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
22325
- return new this({
22438
+ return this.instantiate({
22326
22439
  outputRoot,
22327
22440
  relativeDirPath,
22328
22441
  relativeFilePath,
@@ -22331,6 +22444,158 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22331
22444
  });
22332
22445
  }
22333
22446
  };
22447
+ /**
22448
+ * The `hooks` map of a tool's override block (`config.<overrideKey>.hooks`),
22449
+ * or `undefined` when the block states none. The schema keeps every override
22450
+ * block loose, so the lookup goes through the record form.
22451
+ */
22452
+ function overrideHooksOf({ config, overrideKey }) {
22453
+ const override = lookupOwn({
22454
+ record: config,
22455
+ key: overrideKey
22456
+ });
22457
+ if (!isPlainObject$1(override)) return;
22458
+ const hooks = override.hooks;
22459
+ return isPlainObject$1(hooks) ? hooks : void 0;
22460
+ }
22461
+ //#endregion
22462
+ //#region src/features/hooks/bob-hooks.ts
22463
+ const BOB_SPEC = {
22464
+ displayName: "Bob",
22465
+ overrideKey: "bob",
22466
+ converterConfig: {
22467
+ supportedEvents: BOB_HOOK_EVENTS,
22468
+ canonicalToToolEventNames: CANONICAL_TO_BOB_EVENT_NAMES,
22469
+ toolToCanonicalEventNames: BOB_TO_CANONICAL_EVENT_NAMES,
22470
+ projectDirVar: "",
22471
+ noMatcherEvents: /* @__PURE__ */ new Set([
22472
+ "sessionStart",
22473
+ "beforeSubmitPrompt",
22474
+ "stop"
22475
+ ]),
22476
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
22477
+ wildcardMatcherMeansAll: true
22478
+ }
22479
+ };
22480
+ /**
22481
+ * IBM Bob lifecycle hooks.
22482
+ *
22483
+ * Hooks live under the top-level `hooks` key of Bob's settings file —
22484
+ * `<project>/.bob/settings.json` (project scope) and
22485
+ * `~/.bob/settings/settings.json` (user scope) — in the Claude-Code shape:
22486
+ * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command",
22487
+ * "command": "...", "timeout"?: <seconds> }] }] }`. The file also holds
22488
+ * settings rulesync does not own, so generation merges the `hooks` key into it
22489
+ * (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting the file.
22490
+ *
22491
+ * @see https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks
22492
+ */
22493
+ var BobHooks = class extends SettingsJsonHooks {
22494
+ static getSpec() {
22495
+ return BOB_SPEC;
22496
+ }
22497
+ static getSettablePaths({ global = false } = {}) {
22498
+ return {
22499
+ relativeDirPath: global ? BOB_GLOBAL_SETTINGS_DIR_PATH : BOB_DIR,
22500
+ relativeFilePath: BOB_SETTINGS_FILE_NAME
22501
+ };
22502
+ }
22503
+ };
22504
+ //#endregion
22505
+ //#region src/features/hooks/claudecode-hooks.ts
22506
+ const CLAUDE_SPEC = {
22507
+ displayName: "Claude",
22508
+ overrideKey: "claudecode",
22509
+ converterConfig: {
22510
+ supportedEvents: CLAUDE_HOOK_EVENTS,
22511
+ canonicalToToolEventNames: CANONICAL_TO_CLAUDE_EVENT_NAMES,
22512
+ toolToCanonicalEventNames: CLAUDE_TO_CANONICAL_EVENT_NAMES,
22513
+ projectDirVar: "$CLAUDE_PROJECT_DIR",
22514
+ prefixDotRelativeCommandsOnly: true,
22515
+ noMatcherEvents: /* @__PURE__ */ new Set([
22516
+ "worktreeCreate",
22517
+ "worktreeRemove",
22518
+ "messageDisplay",
22519
+ "postToolBatch",
22520
+ "taskCreated",
22521
+ "taskCompleted",
22522
+ "teammateIdle",
22523
+ "cwdChanged",
22524
+ "beforeSubmitPrompt",
22525
+ "stop"
22526
+ ]),
22527
+ supportedHookTypes: /* @__PURE__ */ new Set([
22528
+ "command",
22529
+ "prompt",
22530
+ "http",
22531
+ "mcp_tool",
22532
+ "agent"
22533
+ ]),
22534
+ emitsPromptModel: true,
22535
+ stringPassthroughFields: [
22536
+ {
22537
+ canonical: "if",
22538
+ tool: "if"
22539
+ },
22540
+ {
22541
+ canonical: "statusMessage",
22542
+ tool: "statusMessage"
22543
+ },
22544
+ {
22545
+ canonical: "shell",
22546
+ tool: "shell",
22547
+ commandOnly: true
22548
+ }
22549
+ ],
22550
+ booleanPassthroughFields: [
22551
+ {
22552
+ canonical: "once",
22553
+ tool: "once"
22554
+ },
22555
+ {
22556
+ canonical: "async",
22557
+ tool: "async",
22558
+ commandOnly: true
22559
+ },
22560
+ {
22561
+ canonical: "asyncRewake",
22562
+ tool: "asyncRewake",
22563
+ commandOnly: true
22564
+ },
22565
+ {
22566
+ canonical: "continueOnBlock",
22567
+ tool: "continueOnBlock"
22568
+ }
22569
+ ],
22570
+ arrayPassthroughFields: [{
22571
+ canonical: "args",
22572
+ tool: "args",
22573
+ commandOnly: true
22574
+ }]
22575
+ }
22576
+ };
22577
+ var ClaudecodeHooks = class extends SettingsJsonHooks {
22578
+ static getSpec() {
22579
+ return CLAUDE_SPEC;
22580
+ }
22581
+ static getSettablePaths(_options = {}) {
22582
+ return {
22583
+ relativeDirPath: CLAUDECODE_DIR,
22584
+ relativeFilePath: CLAUDECODE_SETTINGS_FILE_NAME
22585
+ };
22586
+ }
22587
+ /**
22588
+ * Every Claude-shaped settings file rulesync writes for Claude Code — the
22589
+ * project and user `settings.json` and the plugin bundle's `hooks.json` — is
22590
+ * declared under the one Claude key in `SHARED_CONFIG_OWNERSHIP`.
22591
+ */
22592
+ static getSharedFileKey(_paths) {
22593
+ return CLAUDE_SETTINGS_SHARED_FILE_KEY;
22594
+ }
22595
+ static supportsPreserveUnowned() {
22596
+ return true;
22597
+ }
22598
+ };
22334
22599
  //#endregion
22335
22600
  //#region src/features/hooks/claudecode-plugin-hooks.ts
22336
22601
  var ClaudecodePluginHooks = class extends ClaudecodeHooks {
@@ -22857,47 +23122,22 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
22857
23122
  }
22858
23123
  };
22859
23124
  //#endregion
22860
- //#region src/features/hooks/continue-hooks.ts
22861
- const CONTINUE_CONVERTER_CONFIG = {
22862
- supportedEvents: CONTINUE_HOOK_EVENTS,
22863
- canonicalToToolEventNames: CANONICAL_TO_CONTINUE_EVENT_NAMES,
22864
- toolToCanonicalEventNames: CONTINUE_TO_CANONICAL_EVENT_NAMES,
22865
- projectDirVar: "$CONTINUE_PROJECT_DIR",
23125
+ //#region src/features/hooks/commandcode-hooks.ts
23126
+ const COMMANDCODE_CONVERTER_CONFIG = {
23127
+ supportedEvents: COMMANDCODE_HOOK_EVENTS,
23128
+ canonicalToToolEventNames: CANONICAL_TO_COMMANDCODE_EVENT_NAMES,
23129
+ toolToCanonicalEventNames: COMMANDCODE_TO_CANONICAL_EVENT_NAMES,
23130
+ projectDirVar: "$COMMANDCODE_PROJECT_DIR",
22866
23131
  prefixDotRelativeCommandsOnly: true,
22867
- noMatcherEvents: /* @__PURE__ */ new Set([
22868
- "beforeSubmitPrompt",
22869
- "stop",
22870
- "teammateIdle",
22871
- "taskCompleted",
22872
- "worktreeCreate",
22873
- "worktreeRemove"
22874
- ]),
22875
- supportedHookTypes: /* @__PURE__ */ new Set([
22876
- "command",
22877
- "prompt",
22878
- "http",
22879
- "agent"
22880
- ]),
22881
- emitsPromptModel: true,
22882
- stringPassthroughFields: [{
22883
- canonical: "statusMessage",
22884
- tool: "statusMessage"
22885
- }],
22886
- booleanPassthroughFields: [{
22887
- canonical: "once",
22888
- tool: "once"
22889
- }, {
22890
- canonical: "async",
22891
- tool: "async",
22892
- commandOnly: true
22893
- }]
23132
+ noMatcherEvents: /* @__PURE__ */ new Set(["stop", "sessionStart"]),
23133
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"])
22894
23134
  };
22895
23135
  /**
22896
- * Single spelling of the settings/hooks codec/policy: fail closed on an
22897
- * unparseable root rather than replacing the user's Continue settings with
22898
- * generated output.
23136
+ * Single spelling of the settings codec/policy: fail closed on an unparseable
23137
+ * root rather than replacing the user's Command Code settings with generated
23138
+ * output.
22899
23139
  */
22900
- function parseContinueSettings(fileContent, filePath) {
23140
+ function parseCommandcodeSettings$1(fileContent, filePath) {
22901
23141
  return parseSharedConfig({
22902
23142
  format: "json",
22903
23143
  fileContent,
@@ -22906,22 +23146,20 @@ function parseContinueSettings(fileContent, filePath) {
22906
23146
  });
22907
23147
  }
22908
23148
  /**
22909
- * Continue CLI hooks.
23149
+ * Command Code hooks.
22910
23150
  *
22911
- * Hooks live under the top-level `hooks` key of `<project>/.continue/settings.json`
22912
- * (project scope) and `~/.continue/settings.json` (user scope), in the
23151
+ * Hooks live under the top-level `hooks` key of `<project>/.commandcode/settings.json`
23152
+ * (project scope) and `~/.commandcode/settings.json` (user scope), in the
22913
23153
  * Claude-Code shape: `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{
22914
- * "type": "command" | "http" | "prompt" | "agent", ..., "timeout"?: <seconds>
22915
- * }] }] }`. Both files also hold settings rulesync does not own, so generation
22916
- * merges the `hooks` key into the existing file (see `SHARED_CONFIG_OWNERSHIP`)
22917
- * instead of overwriting it. The CLI additionally reads
22918
- * `.continue/settings.local.json` and the Claude Code settings files; rulesync
22919
- * writes only the committable project file and the user file.
23154
+ * "type": "command", "command": "...", "timeout"?: <seconds> }] }] }`. Both
23155
+ * files also hold settings rulesync does not own (`permissions`, `defaultMode`,
23156
+ * ...), so generation merges the `hooks` key into the existing file (see
23157
+ * `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it.
22920
23158
  *
22921
- * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/hookConfig.ts
22922
- * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/types.ts
23159
+ * @see https://commandcode.ai/docs/hooks
23160
+ * @see https://commandcode.ai/docs/settings
22923
23161
  */
22924
- var ContinueHooks = class ContinueHooks extends ToolHooks {
23162
+ var CommandcodeHooks = class CommandcodeHooks extends ToolHooks {
22925
23163
  constructor(params) {
22926
23164
  super({
22927
23165
  ...params,
@@ -22933,14 +23171,14 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
22933
23171
  }
22934
23172
  static getSettablePaths(_options = {}) {
22935
23173
  return {
22936
- relativeDirPath: CONTINUE_DIR,
22937
- relativeFilePath: CONTINUE_SETTINGS_FILE_NAME
23174
+ relativeDirPath: COMMANDCODE_DIR,
23175
+ relativeFilePath: COMMANDCODE_SETTINGS_FILE_NAME
22938
23176
  };
22939
23177
  }
22940
23178
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
22941
- const paths = ContinueHooks.getSettablePaths({ global });
23179
+ const paths = CommandcodeHooks.getSettablePaths({ global });
22942
23180
  const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
22943
- return new ContinueHooks({
23181
+ return new CommandcodeHooks({
22944
23182
  outputRoot,
22945
23183
  relativeDirPath: paths.relativeDirPath,
22946
23184
  relativeFilePath: paths.relativeFilePath,
@@ -22949,14 +23187,14 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
22949
23187
  });
22950
23188
  }
22951
23189
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
22952
- const paths = ContinueHooks.getSettablePaths({ global });
23190
+ const paths = CommandcodeHooks.getSettablePaths({ global });
22953
23191
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22954
23192
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
22955
23193
  const config = rulesyncHooks.getJson();
22956
23194
  const hooks = canonicalToToolHooks({
22957
23195
  config,
22958
- toolOverrideHooks: config.continue?.hooks,
22959
- converterConfig: CONTINUE_CONVERTER_CONFIG,
23196
+ toolOverrideHooks: config.commandcode?.hooks,
23197
+ converterConfig: COMMANDCODE_CONVERTER_CONFIG,
22960
23198
  logger
22961
23199
  });
22962
23200
  const fileContent = applySharedConfigPatch({
@@ -22967,7 +23205,7 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
22967
23205
  filePath,
22968
23206
  logger
22969
23207
  });
22970
- return new ContinueHooks({
23208
+ return new CommandcodeHooks({
22971
23209
  outputRoot,
22972
23210
  relativeDirPath: paths.relativeDirPath,
22973
23211
  relativeFilePath: paths.relativeFilePath,
@@ -22979,18 +23217,18 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
22979
23217
  const configPath = join(this.getRelativeDirPath(), this.getRelativeFilePath());
22980
23218
  let settings;
22981
23219
  try {
22982
- settings = parseContinueSettings(this.getFileContent(), configPath);
23220
+ settings = parseCommandcodeSettings$1(this.getFileContent(), configPath);
22983
23221
  } catch (error) {
22984
- throw new Error(`Failed to parse Continue hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
23222
+ throw new Error(`Failed to parse Command Code hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
22985
23223
  }
22986
23224
  const hooks = toolHooksToCanonical({
22987
23225
  logger,
22988
23226
  hooks: settings.hooks,
22989
- converterConfig: CONTINUE_CONVERTER_CONFIG
23227
+ converterConfig: COMMANDCODE_CONVERTER_CONFIG
22990
23228
  });
22991
23229
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
22992
23230
  hooks,
22993
- overrideKey: "continue"
23231
+ overrideKey: "commandcode"
22994
23232
  }), null, 2) });
22995
23233
  }
22996
23234
  validate() {
@@ -23000,7 +23238,7 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
23000
23238
  };
23001
23239
  }
23002
23240
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
23003
- return new ContinueHooks({
23241
+ return new CommandcodeHooks({
23004
23242
  outputRoot,
23005
23243
  relativeDirPath,
23006
23244
  relativeFilePath,
@@ -23010,6 +23248,73 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
23010
23248
  }
23011
23249
  };
23012
23250
  //#endregion
23251
+ //#region src/features/hooks/continue-hooks.ts
23252
+ const CONTINUE_SPEC = {
23253
+ displayName: "Continue",
23254
+ overrideKey: "continue",
23255
+ converterConfig: {
23256
+ supportedEvents: CONTINUE_HOOK_EVENTS,
23257
+ canonicalToToolEventNames: CANONICAL_TO_CONTINUE_EVENT_NAMES,
23258
+ toolToCanonicalEventNames: CONTINUE_TO_CANONICAL_EVENT_NAMES,
23259
+ projectDirVar: "$CONTINUE_PROJECT_DIR",
23260
+ prefixDotRelativeCommandsOnly: true,
23261
+ noMatcherEvents: /* @__PURE__ */ new Set([
23262
+ "beforeSubmitPrompt",
23263
+ "stop",
23264
+ "teammateIdle",
23265
+ "taskCompleted",
23266
+ "worktreeCreate",
23267
+ "worktreeRemove"
23268
+ ]),
23269
+ supportedHookTypes: /* @__PURE__ */ new Set([
23270
+ "command",
23271
+ "prompt",
23272
+ "http",
23273
+ "agent"
23274
+ ]),
23275
+ emitsPromptModel: true,
23276
+ stringPassthroughFields: [{
23277
+ canonical: "statusMessage",
23278
+ tool: "statusMessage"
23279
+ }],
23280
+ booleanPassthroughFields: [{
23281
+ canonical: "once",
23282
+ tool: "once"
23283
+ }, {
23284
+ canonical: "async",
23285
+ tool: "async",
23286
+ commandOnly: true
23287
+ }]
23288
+ }
23289
+ };
23290
+ /**
23291
+ * Continue CLI hooks.
23292
+ *
23293
+ * Hooks live under the top-level `hooks` key of `<project>/.continue/settings.json`
23294
+ * (project scope) and `~/.continue/settings.json` (user scope), in the
23295
+ * Claude-Code shape: `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{
23296
+ * "type": "command" | "http" | "prompt" | "agent", ..., "timeout"?: <seconds>
23297
+ * }] }] }`. Both files also hold settings rulesync does not own, so generation
23298
+ * merges the `hooks` key into the existing file (see `SHARED_CONFIG_OWNERSHIP`)
23299
+ * instead of overwriting it. The CLI additionally reads
23300
+ * `.continue/settings.local.json` and the Claude Code settings files; rulesync
23301
+ * writes only the committable project file and the user file.
23302
+ *
23303
+ * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/hookConfig.ts
23304
+ * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/types.ts
23305
+ */
23306
+ var ContinueHooks = class extends SettingsJsonHooks {
23307
+ static getSpec() {
23308
+ return CONTINUE_SPEC;
23309
+ }
23310
+ static getSettablePaths(_options = {}) {
23311
+ return {
23312
+ relativeDirPath: CONTINUE_DIR,
23313
+ relativeFilePath: CONTINUE_SETTINGS_FILE_NAME
23314
+ };
23315
+ }
23316
+ };
23317
+ //#endregion
23013
23318
  //#region src/features/hooks/copilot-hooks.ts
23014
23319
  /**
23015
23320
  * Copilot hook entry as stored in .github/hooks/copilot-hooks.json.
@@ -23598,33 +23903,24 @@ const CORTEXCODE_SKILLS_DIR_PATH = join(CORTEXCODE_DIR, "skills");
23598
23903
  const CORTEXCODE_GLOBAL_SKILLS_DIR_PATH = join(CORTEXCODE_GLOBAL_DIR_PATH, "skills");
23599
23904
  //#endregion
23600
23905
  //#region src/features/hooks/cortexcode-hooks.ts
23601
- const CORTEXCODE_CONVERTER_CONFIG = {
23602
- supportedEvents: CORTEXCODE_HOOK_EVENTS,
23603
- canonicalToToolEventNames: CANONICAL_TO_CORTEXCODE_EVENT_NAMES,
23604
- toolToCanonicalEventNames: CORTEXCODE_TO_CANONICAL_EVENT_NAMES,
23605
- projectDirVar: "$CORTEX_PROJECT_DIR",
23606
- prefixDotRelativeCommandsOnly: true,
23607
- noMatcherEvents: /* @__PURE__ */ new Set(["beforeSubmitPrompt", "stop"]),
23608
- supportedHookTypes: /* @__PURE__ */ new Set(["command", "prompt"]),
23609
- booleanPassthroughFields: [{
23610
- canonical: "enabled",
23611
- tool: "enabled"
23612
- }]
23906
+ const CORTEXCODE_SPEC = {
23907
+ displayName: "Cortex Code",
23908
+ overrideKey: "cortexcode",
23909
+ converterConfig: {
23910
+ supportedEvents: CORTEXCODE_HOOK_EVENTS,
23911
+ canonicalToToolEventNames: CANONICAL_TO_CORTEXCODE_EVENT_NAMES,
23912
+ toolToCanonicalEventNames: CORTEXCODE_TO_CANONICAL_EVENT_NAMES,
23913
+ projectDirVar: "$CORTEX_PROJECT_DIR",
23914
+ prefixDotRelativeCommandsOnly: true,
23915
+ noMatcherEvents: /* @__PURE__ */ new Set(["beforeSubmitPrompt", "stop"]),
23916
+ supportedHookTypes: /* @__PURE__ */ new Set(["command", "prompt"]),
23917
+ booleanPassthroughFields: [{
23918
+ canonical: "enabled",
23919
+ tool: "enabled"
23920
+ }]
23921
+ }
23613
23922
  };
23614
23923
  /**
23615
- * Single spelling of the settings/hooks codec/policy: fail closed on an
23616
- * unparseable root rather than replacing the user's Cortex Code settings with
23617
- * generated output.
23618
- */
23619
- function parseCortexcodeSettings(fileContent, filePath) {
23620
- return parseSharedConfig({
23621
- format: "json",
23622
- fileContent,
23623
- filePath,
23624
- invalidRootPolicy: "error"
23625
- });
23626
- }
23627
- /**
23628
23924
  * Snowflake Cortex Code hooks.
23629
23925
  *
23630
23926
  * Hooks live under the top-level `hooks` key of `<project>/.cortex/settings.json`
@@ -23633,20 +23929,16 @@ function parseCortexcodeSettings(fileContent, filePath) {
23633
23929
  * "<regex>", "hooks": [{ "type": "command" | "prompt", ..., "timeout"?:
23634
23930
  * <seconds>, "enabled"?: <boolean> }] }] }`. The project file also holds
23635
23931
  * settings rulesync does not own, so generation merges the `hooks` key into
23636
- * either file (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it.
23932
+ * either file (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it, and
23933
+ * neither file is removed wholesale (hooks.json sits in the CLI-owned
23934
+ * `~/.snowflake/cortex/` tree).
23637
23935
  *
23638
23936
  * @see https://docs.snowflake.com/en/user-guide/cortex-code/extensibility
23639
23937
  * @see https://docs.snowflake.com/en/user-guide/cortex-code/settings
23640
23938
  */
23641
- var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23642
- constructor(params) {
23643
- super({
23644
- ...params,
23645
- fileContent: params.fileContent ?? "{}"
23646
- });
23647
- }
23648
- isDeletable() {
23649
- return false;
23939
+ var CortexcodeHooks = class extends SettingsJsonHooks {
23940
+ static getSpec() {
23941
+ return CORTEXCODE_SPEC;
23650
23942
  }
23651
23943
  static getSettablePaths({ global = false } = {}) {
23652
23944
  return global ? {
@@ -23657,60 +23949,338 @@ var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23657
23949
  relativeFilePath: CORTEXCODE_SETTINGS_FILE_NAME
23658
23950
  };
23659
23951
  }
23952
+ };
23953
+ //#endregion
23954
+ //#region src/constants/crush-paths.ts
23955
+ const CRUSH_RULE_FILE_NAME = "CRUSH.md";
23956
+ const CRUSH_GLOBAL_DIR = join(".config", "crush");
23957
+ const CRUSH_LOCAL_RULE_FILE_NAME = "CRUSH.local.md";
23958
+ const CRUSH_IGNORE_FILE_NAME = ".crushignore";
23959
+ const CRUSH_SKILLS_PROJECT_DIR = join(".crush", "skills");
23960
+ const CRUSH_SKILLS_GLOBAL_DIR = join(CRUSH_GLOBAL_DIR, "skills");
23961
+ const CRUSH_CONFIG_FILE_NAME = "crush.json";
23962
+ const CRUSH_HIDDEN_CONFIG_FILE_NAME = ".crush.json";
23963
+ const CRUSH_PERMISSIONS_KEY = "permissions";
23964
+ const CRUSH_ALLOWED_TOOLS_KEY = "allowed_tools";
23965
+ const CRUSH_OPTIONS_KEY = "options";
23966
+ const CRUSH_DISABLED_TOOLS_KEY = "disabled_tools";
23967
+ const CRUSH_HOOKS_KEY = "hooks";
23968
+ //#endregion
23969
+ //#region src/features/crush-config.ts
23970
+ /**
23971
+ * Where Crush's JSON config lives for a scope, before the project-scope twin
23972
+ * is resolved: `<project>/crush.json` or `~/.config/crush/crush.json`. This is
23973
+ * also the key every adapter reports through `getSettablePaths`, so the
23974
+ * shared-config ownership declaration, the gitignore derivation and the
23975
+ * shared-write ordering all see one file per scope.
23976
+ */
23977
+ function getCrushConfigSettablePaths({ global = false } = {}) {
23978
+ return {
23979
+ relativeDirPath: global ? CRUSH_GLOBAL_DIR : ".",
23980
+ relativeFilePath: CRUSH_CONFIG_FILE_NAME
23981
+ };
23982
+ }
23983
+ /**
23984
+ * Resolves which Crush config file an adapter should read and write.
23985
+ *
23986
+ * At project scope Crush discovers both `.crush.json` and `crush.json` in the
23987
+ * working directory and merges every file it finds with `.crush.json` on top
23988
+ * (objects merge recursively, lists are concatenated, scalars are overridden).
23989
+ * Neither file therefore hides the other: an entry left in `crush.json` stays
23990
+ * in effect next to whatever `.crush.json` says. An existing `.crush.json` is
23991
+ * preferred because the user chose that spelling; otherwise `crush.json` is
23992
+ * used (and created when neither exists). The global scope has a single
23993
+ * spelling.
23994
+ *
23995
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
23996
+ */
23997
+ async function resolveCrushConfigFile({ outputRoot, global = false }) {
23998
+ const paths = getCrushConfigSettablePaths({ global });
23999
+ const configDir = join(outputRoot, paths.relativeDirPath);
24000
+ const filePath = join(configDir, paths.relativeFilePath);
24001
+ const fileContent = await readFileContentOrNull(filePath);
24002
+ if (!global) {
24003
+ const hiddenPath = join(configDir, CRUSH_HIDDEN_CONFIG_FILE_NAME);
24004
+ const hiddenContent = await readFileContentOrNull(hiddenPath);
24005
+ if (hiddenContent !== null) return {
24006
+ relativeDirPath: paths.relativeDirPath,
24007
+ relativeFilePath: CRUSH_HIDDEN_CONFIG_FILE_NAME,
24008
+ filePath: hiddenPath,
24009
+ fileContent: hiddenContent,
24010
+ ...fileContent === null ? {} : { twin: {
24011
+ filePath,
24012
+ fileContent
24013
+ } }
24014
+ };
24015
+ }
24016
+ return {
24017
+ relativeDirPath: paths.relativeDirPath,
24018
+ relativeFilePath: paths.relativeFilePath,
24019
+ filePath,
24020
+ fileContent
24021
+ };
24022
+ }
24023
+ /**
24024
+ * Single spelling of the crush.json codec/policy, matching the
24025
+ * `SHARED_CONFIG_OWNERSHIP` declaration for both scopes: fail closed on an
24026
+ * unparseable root rather than replacing the user's Crush config with
24027
+ * generated output.
24028
+ */
24029
+ function parseCrushConfig(fileContent, filePath) {
24030
+ return parseSharedConfig({
24031
+ format: "json",
24032
+ fileContent,
24033
+ filePath,
24034
+ invalidRootPolicy: "error"
24035
+ });
24036
+ }
24037
+ /**
24038
+ * Combine two Crush config documents the way Crush itself does
24039
+ * (`github.com/qjebbs/go-jsons`): objects merge recursively, arrays are
24040
+ * concatenated with `base` first, and any other value from `override` wins.
24041
+ */
24042
+ function mergeCrushConfigs({ base, override }) {
24043
+ const merged = { ...base };
24044
+ for (const [key, value] of Object.entries(override)) {
24045
+ if (isPrototypePollutionKey(key)) continue;
24046
+ const current = merged[key];
24047
+ if (isRecord$1(current) && isRecord$1(value)) merged[key] = mergeCrushConfigs({
24048
+ base: current,
24049
+ override: value
24050
+ });
24051
+ else if (Array.isArray(current) && Array.isArray(value)) merged[key] = [...current, ...value];
24052
+ else merged[key] = value;
24053
+ }
24054
+ return merged;
24055
+ }
24056
+ /**
24057
+ * The content an import should read for a resolved location: the chosen file
24058
+ * merged over its twin when both exist, so a server, hook or tool entry that
24059
+ * only the lower-priority `crush.json` declares is imported too — Crush sees
24060
+ * it, so rulesync should as well.
24061
+ */
24062
+ function crushConfigImportContent(location) {
24063
+ const own = location.fileContent ?? "{}";
24064
+ if (location.twin === void 0) return own;
24065
+ const merged = mergeCrushConfigs({
24066
+ base: parseCrushConfig(location.twin.fileContent, location.twin.filePath),
24067
+ override: parseCrushConfig(own, location.filePath)
24068
+ });
24069
+ return JSON.stringify(merged, null, 2);
24070
+ }
24071
+ function lookupPath(root, path) {
24072
+ let current = root;
24073
+ for (const segment of path) {
24074
+ if (!isRecord$1(current) || !Object.hasOwn(current, segment)) return;
24075
+ current = current[segment];
24076
+ }
24077
+ return current;
24078
+ }
24079
+ function isNonEmptyValue(value) {
24080
+ if (Array.isArray(value)) return value.length > 0;
24081
+ if (isRecord$1(value)) return Object.keys(value).length > 0;
24082
+ return value !== void 0;
24083
+ }
24084
+ /**
24085
+ * Warn when the twin Crush merges beneath the written file still carries a
24086
+ * value at one of the paths this feature owns. Rulesync only rewrites the
24087
+ * chosen file, and because Crush concatenates lists and merges objects across
24088
+ * the pair, such an entry — typically one an earlier generate wrote to
24089
+ * `crush.json` before the user added a `.crush.json` — stays in effect until
24090
+ * removed by hand, and cannot be retracted from `.rulesync/`.
24091
+ */
24092
+ function warnCrushTwinLeftovers({ location, ownedPaths, logger }) {
24093
+ if (location.twin === void 0) return;
24094
+ let twin;
24095
+ try {
24096
+ twin = parseCrushConfig(location.twin.fileContent, location.twin.filePath);
24097
+ } catch {
24098
+ return;
24099
+ }
24100
+ const leftovers = ownedPaths.filter((path) => isNonEmptyValue(lookupPath(twin, path))).map((path) => `"${path.join(".")}"`);
24101
+ if (leftovers.length === 0) return;
24102
+ 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.`);
24103
+ }
24104
+ //#endregion
24105
+ //#region src/features/hooks/crush-hooks.ts
24106
+ const SUPPORTED_CRUSH_EVENTS = new Set(CRUSH_HOOK_EVENTS);
24107
+ /**
24108
+ * Crush matches an event key case-insensitively and ignores underscores
24109
+ * (`PreToolUse`, `pre_tool_use` and `PRE_TOOL_USE` all work), so an import
24110
+ * looks the canonical name up by that normalized spelling.
24111
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
24112
+ */
24113
+ const NORMALIZED_CRUSH_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CRUSH_TO_CANONICAL_EVENT_NAMES).map(([crushEvent, canonical]) => [normalizeCrushEventName(crushEvent), canonical]));
24114
+ function normalizeCrushEventName(event) {
24115
+ return event.replaceAll("_", "").toLowerCase();
24116
+ }
24117
+ /**
24118
+ * Build the `hooks` block of `crush.json` from a canonical hooks config.
24119
+ * Crush keys a flat array of `{name, matcher, command, timeout}` entries by
24120
+ * event name; `matcher` is a regex tested against the (lower-case) Crush tool
24121
+ * name and `timeout` is in seconds. Only `type: "command"` canonical hooks are
24122
+ * emitted, since a Crush hook is a shell command. A shared canonical event
24123
+ * Crush does not fire is skipped (the HooksProcessor reports it), while an
24124
+ * event under the `crush.hooks` override — such as one an import filed there
24125
+ * — is written verbatim so it round-trips.
24126
+ */
24127
+ function canonicalToCrushHooks({ config, toolOverride, logger }) {
24128
+ const sharedHooks = {};
24129
+ for (const [event, defs] of Object.entries(config.hooks)) if (SUPPORTED_CRUSH_EVENTS.has(event)) sharedHooks[event] = defs;
24130
+ const effective = {
24131
+ ...sharedHooks,
24132
+ ...toolOverride
24133
+ };
24134
+ const hooks = {};
24135
+ for (const [event, defs] of Object.entries(effective)) {
24136
+ if (isPrototypePollutionKey(event)) continue;
24137
+ const crushEvent = lookupOwn({
24138
+ record: CANONICAL_TO_CRUSH_EVENT_NAMES,
24139
+ key: event
24140
+ }) ?? event;
24141
+ const entries = [];
24142
+ for (const def of defs) {
24143
+ const entry = canonicalDefToCrushEntry({
24144
+ def,
24145
+ event,
24146
+ logger
24147
+ });
24148
+ if (entry !== null) entries.push(entry);
24149
+ }
24150
+ if (entries.length > 0) hooks[crushEvent] = entries;
24151
+ }
24152
+ return hooks;
24153
+ }
24154
+ /** Convert one canonical hook definition to a Crush entry, or null to skip. */
24155
+ function canonicalDefToCrushEntry({ def, event, logger }) {
24156
+ if ((def.type ?? "command") !== "command") return null;
24157
+ if (typeof def.command !== "string" || def.command === "") {
24158
+ logger?.warn(`Crush hook ${quoteValueForWarning(def.name ?? "")} under "${event}" has no "command", which Crush would discard, so it was skipped.`);
24159
+ return null;
24160
+ }
24161
+ const entry = { command: def.command };
24162
+ if (typeof def.name === "string" && def.name !== "") entry.name = def.name;
24163
+ if (typeof def.matcher === "string" && def.matcher !== "" && def.matcher !== "*") entry.matcher = def.matcher;
24164
+ if (typeof def.timeout === "number") entry.timeout = Math.ceil(def.timeout);
24165
+ return entry;
24166
+ }
24167
+ /** Convert one raw hook entry to a canonical definition, or null to skip. */
24168
+ function crushEntryToCanonicalDef(raw) {
24169
+ if (!isRecord$1(raw) || typeof raw.command !== "string") return null;
24170
+ const def = {
24171
+ type: "command",
24172
+ command: raw.command
24173
+ };
24174
+ if (typeof raw.name === "string" && raw.name !== "") def.name = raw.name;
24175
+ if (typeof raw.matcher === "string" && raw.matcher !== "") def.matcher = raw.matcher;
24176
+ if (typeof raw.timeout === "number") def.timeout = raw.timeout;
24177
+ return def;
24178
+ }
24179
+ /**
24180
+ * Reverse {@link canonicalToCrushHooks}: parse the `hooks` block back into a
24181
+ * canonical event → definition[] record. An event Crush does not document is
24182
+ * carried under its own name so `buildImportedHooksConfig` files it under the
24183
+ * `crush.hooks` override.
24184
+ */
24185
+ function crushHooksToCanonical(hooksBlock) {
24186
+ const canonical = {};
24187
+ if (!isRecord$1(hooksBlock)) return canonical;
24188
+ for (const [crushEvent, rawEntries] of Object.entries(hooksBlock)) {
24189
+ if (isPrototypePollutionKey(crushEvent) || !Array.isArray(rawEntries)) continue;
24190
+ const canonicalEvent = lookupOwn({
24191
+ record: NORMALIZED_CRUSH_TO_CANONICAL_EVENT_NAMES,
24192
+ key: normalizeCrushEventName(crushEvent)
24193
+ }) ?? crushEvent;
24194
+ const defs = rawEntries.map((raw) => crushEntryToCanonicalDef(raw)).filter((def) => def !== null);
24195
+ if (defs.length === 0) continue;
24196
+ canonical[canonicalEvent] = [...lookupOwn({
24197
+ record: canonical,
24198
+ key: canonicalEvent
24199
+ }) ?? [], ...defs];
24200
+ }
24201
+ return canonical;
24202
+ }
24203
+ /**
24204
+ * Crush hooks.
24205
+ *
24206
+ * Crush reads hooks from the `hooks` key of its JSON config —
24207
+ * `<project>/crush.json` (or an existing `.crush.json`; Crush merges the pair,
24208
+ * lists concatenated) at project scope and `~/.config/crush/crush.json` at
24209
+ * user scope — as
24210
+ * `hooks.<Event>: [{name?, matcher?, command, timeout?}]`. Only `PreToolUse`
24211
+ * fires today. The `hooks` key is owned outright; every other top-level key
24212
+ * of the file is preserved and the file is never deleted.
24213
+ *
24214
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
24215
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
24216
+ */
24217
+ var CrushHooks = class CrushHooks extends ToolHooks {
24218
+ json;
24219
+ constructor(params) {
24220
+ super(params);
24221
+ this.json = parseCrushConfig(this.fileContent ?? "", join(this.relativeDirPath, this.relativeFilePath));
24222
+ }
24223
+ getJson() {
24224
+ return this.json;
24225
+ }
24226
+ isDeletable() {
24227
+ return false;
24228
+ }
24229
+ static getSettablePaths({ global = false } = {}) {
24230
+ return getCrushConfigSettablePaths({ global });
24231
+ }
23660
24232
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
23661
- const paths = CortexcodeHooks.getSettablePaths({ global });
23662
- const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
23663
- return new CortexcodeHooks({
24233
+ const location = await resolveCrushConfigFile({
23664
24234
  outputRoot,
23665
- relativeDirPath: paths.relativeDirPath,
23666
- relativeFilePath: paths.relativeFilePath,
23667
- fileContent,
23668
- validate
24235
+ global
24236
+ });
24237
+ return new CrushHooks({
24238
+ outputRoot,
24239
+ relativeDirPath: location.relativeDirPath,
24240
+ relativeFilePath: location.relativeFilePath,
24241
+ fileContent: crushConfigImportContent(location),
24242
+ validate,
24243
+ global
23669
24244
  });
23670
24245
  }
23671
24246
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
23672
- const paths = CortexcodeHooks.getSettablePaths({ global });
23673
- const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
23674
- const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
23675
- const config = rulesyncHooks.getJson();
23676
- const hooks = canonicalToToolHooks({
23677
- config,
23678
- toolOverrideHooks: config.cortexcode?.hooks,
23679
- converterConfig: CORTEXCODE_CONVERTER_CONFIG,
24247
+ const location = await resolveCrushConfigFile({
24248
+ outputRoot,
24249
+ global
24250
+ });
24251
+ const existingContent = location.fileContent ?? "";
24252
+ warnCrushTwinLeftovers({
24253
+ location,
24254
+ ownedPaths: [[CRUSH_HOOKS_KEY]],
23680
24255
  logger
23681
24256
  });
23682
- const fileContent = applySharedConfigPatch({
23683
- fileKey: sharedConfigFileKey(paths),
23684
- feature: "hooks",
23685
- existingContent,
23686
- patch: { hooks },
23687
- filePath,
24257
+ const config = rulesyncHooks.getJson();
24258
+ const hooks = canonicalToCrushHooks({
24259
+ config,
24260
+ toolOverride: config.crush?.hooks,
23688
24261
  logger
23689
24262
  });
23690
- return new CortexcodeHooks({
24263
+ return new CrushHooks({
23691
24264
  outputRoot,
23692
- relativeDirPath: paths.relativeDirPath,
23693
- relativeFilePath: paths.relativeFilePath,
23694
- fileContent,
23695
- validate
24265
+ relativeDirPath: location.relativeDirPath,
24266
+ relativeFilePath: location.relativeFilePath,
24267
+ fileContent: applySharedConfigPatch({
24268
+ fileKey: sharedConfigFileKey(this.getSettablePaths({ global })),
24269
+ feature: "hooks",
24270
+ existingContent,
24271
+ patch: { [CRUSH_HOOKS_KEY]: hooks },
24272
+ filePath: location.filePath,
24273
+ logger
24274
+ }),
24275
+ validate,
24276
+ global
23696
24277
  });
23697
24278
  }
23698
- toRulesyncHooks({ logger } = {}) {
23699
- const configPath = join(this.getRelativeDirPath(), this.getRelativeFilePath());
23700
- let settings;
23701
- try {
23702
- settings = parseCortexcodeSettings(this.getFileContent(), configPath);
23703
- } catch (error) {
23704
- throw new Error(`Failed to parse Cortex Code hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
23705
- }
23706
- const hooks = toolHooksToCanonical({
23707
- logger,
23708
- hooks: settings.hooks,
23709
- converterConfig: CORTEXCODE_CONVERTER_CONFIG
23710
- });
24279
+ toRulesyncHooks() {
24280
+ const hooks = crushHooksToCanonical(this.json[CRUSH_HOOKS_KEY]);
23711
24281
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
23712
24282
  hooks,
23713
- overrideKey: "cortexcode"
24283
+ overrideKey: "crush"
23714
24284
  }), null, 2) });
23715
24285
  }
23716
24286
  validate() {
@@ -23719,13 +24289,14 @@ var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23719
24289
  error: null
23720
24290
  };
23721
24291
  }
23722
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
23723
- return new CortexcodeHooks({
24292
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
24293
+ return new CrushHooks({
23724
24294
  outputRoot,
23725
24295
  relativeDirPath,
23726
24296
  relativeFilePath,
23727
- fileContent: JSON.stringify({ hooks: {} }, null, 2),
23728
- validate: false
24297
+ fileContent: JSON.stringify({ [CRUSH_HOOKS_KEY]: {} }, null, 2),
24298
+ validate: false,
24299
+ global
23729
24300
  });
23730
24301
  }
23731
24302
  };
@@ -26959,19 +27530,22 @@ var ReasonixHooks = class ReasonixHooks extends ToolHooks {
26959
27530
  //#endregion
26960
27531
  //#region src/features/hooks/tabnine-hooks.ts
26961
27532
  /**
26962
- * Environment block safe to hand Tabnine for a hook. A tool rebuilds each
26963
- * entry into `KEY=VALUE` for the spawned process, so a key holding `=`, a
26964
- * control character or nothing at all names a different variable than it
26965
- * appears to; such entries are dropped in both directions (with a warning on
26966
- * export, where the value came from an authored `.rulesync/hooks.*`).
27533
+ * Environment block safe to hand Tabnine for a hook. The shared converter
27534
+ * refuses a whole `env` map holding one bad entry; Tabnine's shape is authored
27535
+ * outside it, so the same per-entry rule (`isSafeEnvEntry`: a key holding `=`,
27536
+ * a control character or nothing at all names a different variable than it
27537
+ * appears to) is applied entry by entry and the bad ones are dropped in both
27538
+ * directions, with a warning on export, where the value came from an authored
27539
+ * `.rulesync/hooks.*`.
26967
27540
  */
26968
27541
  function sanitizeEnv({ env, warn }) {
26969
27542
  if (env === null || env === void 0 || typeof env !== "object" || Array.isArray(env)) return;
26970
27543
  const result = {};
26971
27544
  for (const [key, value] of Object.entries(env)) {
26972
- const unsafeKey = key === "" || key.includes("=") || CONTROL_CHARS.some((char) => key.includes(char));
26973
- const unsafeValue = typeof value !== "string" || CONTROL_CHARS.some((char) => value.includes(char));
26974
- if (unsafeKey || unsafeValue) {
27545
+ if (!isSafeEnvEntry({
27546
+ key,
27547
+ value
27548
+ })) {
26975
27549
  warn?.(`Tabnine CLI hook env entry ${JSON.stringify(key)} is not a safe KEY=VALUE pair; skipping it.`);
26976
27550
  continue;
26977
27551
  }
@@ -27072,7 +27646,7 @@ const TabnineMatcherEntrySchema = z.looseObject({
27072
27646
  });
27073
27647
  function tabnineMatcherEntryToCanonical(entry) {
27074
27648
  const sequential = entry.sequential === true;
27075
- const matcher = entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "" ? entry.matcher : void 0;
27649
+ const matcher = entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "" && entry.matcher !== ".*" ? entry.matcher : void 0;
27076
27650
  const defs = [];
27077
27651
  for (const hook of entry.hooks ?? []) {
27078
27652
  if (hook.type !== "command" || typeof hook.command !== "string" || hook.command === "") continue;
@@ -27793,6 +28367,18 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
27793
28367
  supportedHookTypes: ["command"],
27794
28368
  supportsMatcher: true
27795
28369
  }],
28370
+ ["commandcode", {
28371
+ class: CommandcodeHooks,
28372
+ meta: {
28373
+ supportsProject: true,
28374
+ supportsGlobal: true,
28375
+ supportsImport: true
28376
+ },
28377
+ supportedEvents: COMMANDCODE_HOOK_EVENTS,
28378
+ supportedHookTypes: ["command"],
28379
+ supportsMatcher: true,
28380
+ matcherEvents: ["preToolUse", "postToolUse"]
28381
+ }],
27796
28382
  ["copilot", {
27797
28383
  class: CopilotHooks,
27798
28384
  meta: {
@@ -28114,6 +28700,18 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28114
28700
  supportedHookTypes: ["command", "http"],
28115
28701
  supportsMatcher: true
28116
28702
  }],
28703
+ ["crush", {
28704
+ class: CrushHooks,
28705
+ meta: {
28706
+ supportsProject: true,
28707
+ supportsGlobal: true,
28708
+ supportsImport: true
28709
+ },
28710
+ supportedEvents: CRUSH_HOOK_EVENTS,
28711
+ supportedHookTypes: ["command"],
28712
+ supportsMatcher: true,
28713
+ passthroughOverrideEvents: true
28714
+ }],
28117
28715
  ["zcode", {
28118
28716
  class: ZcodeHooks,
28119
28717
  meta: {
@@ -28795,14 +29393,6 @@ var ContinueIgnore = class ContinueIgnore extends ToolIgnore {
28795
29393
  }
28796
29394
  };
28797
29395
  //#endregion
28798
- //#region src/constants/crush-paths.ts
28799
- const CRUSH_RULE_FILE_NAME = "CRUSH.md";
28800
- const CRUSH_GLOBAL_DIR = join(".config", "crush");
28801
- const CRUSH_LOCAL_RULE_FILE_NAME = "CRUSH.local.md";
28802
- const CRUSH_IGNORE_FILE_NAME = ".crushignore";
28803
- const CRUSH_SKILLS_PROJECT_DIR = join(".crush", "skills");
28804
- const CRUSH_SKILLS_GLOBAL_DIR = join(CRUSH_GLOBAL_DIR, "skills");
28805
- //#endregion
28806
29396
  //#region src/features/ignore/crush-ignore.ts
28807
29397
  /**
28808
29398
  * Ignore generator for Crush.
@@ -30804,9 +31394,9 @@ function asBobRemoteType(stated, url) {
30804
31394
  * SSE server carries a bare `url`. The canonical `transport` alias and the
30805
31395
  * Claude-style `httpUrl` alias are folded into `type`/`url`; `env`, `cwd`,
30806
31396
  * `headers`, `timeout`, `alwaysAllow` and `disabled` pass through, as Bob
30807
- * documents all of them (`env` and `headers` with their prototype-pollution
30808
- * keys dropped, since Bob spreads those maps into the process environment and
30809
- * the HTTP requests).
31397
+ * documents all of them, with prototype-pollution keys dropped at every
31398
+ * nesting level (Bob spreads `env` and `headers` into the process environment
31399
+ * and the HTTP requests).
30810
31400
  *
30811
31401
  * Bob Shell documents the same file with an `httpURL` key instead of
30812
31402
  * `type` + `url` for streamable HTTP. rulesync writes the IDE spelling (the two
@@ -30874,7 +31464,7 @@ function convertToBobFormat(mcpServers, logger) {
30874
31464
  }
30875
31465
  for (const [key, value] of Object.entries(rest)) {
30876
31466
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
30877
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
31467
+ converted[key] = omitPrototypePollutionKeysDeep(value);
30878
31468
  }
30879
31469
  result[serverName] = converted;
30880
31470
  }
@@ -30885,7 +31475,9 @@ function convertToBobFormat(mcpServers, logger) {
30885
31475
  * (`type: "streamable-http"` + `url`) is already canonical and passes through;
30886
31476
  * the Bob Shell spelling `httpURL` becomes `url` with `type: "http"`; a bare
30887
31477
  * `url` is an SSE server in Bob, so it gains `type: "sse"` to keep that reading
30888
- * on the next generate.
31478
+ * on the next generate. Every other field passes through with its
31479
+ * prototype-pollution keys dropped at every nesting level, mirroring the
31480
+ * generate side's handling of `env` / `headers`.
30889
31481
  */
30890
31482
  function convertFromBobFormat(mcpServers) {
30891
31483
  if (!isMcpServers(mcpServers)) return {};
@@ -30900,7 +31492,7 @@ function convertFromBobFormat(mcpServers) {
30900
31492
  if (typeof value === "string") httpURL = value;
30901
31493
  continue;
30902
31494
  }
30903
- converted[key] = value;
31495
+ converted[key] = omitPrototypePollutionKeysDeep(value);
30904
31496
  }
30905
31497
  if (httpURL !== void 0) {
30906
31498
  converted.url = httpURL;
@@ -31644,6 +32236,254 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
31644
32236
  }
31645
32237
  };
31646
32238
  //#endregion
32239
+ //#region src/features/mcp/commandcode-mcp.ts
32240
+ /**
32241
+ * Parse a Command Code MCP file. The project file is the `.mcp.json` other
32242
+ * agents (Claude Code among them) share, so a hand-written file is read as
32243
+ * JSONC to tolerate comments and trailing commas; malformed content or a
32244
+ * non-object root (`null`, an array, a scalar) fails closed rather than being
32245
+ * spread into the regenerated file.
32246
+ */
32247
+ function parseCommandcodeMcpConfig({ fileContent, relativePath }) {
32248
+ let parsed;
32249
+ try {
32250
+ parsed = parseJsonc(fileContent);
32251
+ } catch (error) {
32252
+ throw new Error(`Failed to parse Command Code MCP config at ${relativePath}: ${formatError(error)}`, { cause: error });
32253
+ }
32254
+ if (!isPlainObject$1(parsed)) throw new Error(`Failed to parse Command Code MCP config at ${relativePath}: expected a JSON object at the root`);
32255
+ return parsed;
32256
+ }
32257
+ /**
32258
+ * The remote transport Command Code reads a server as. Its loader accepts
32259
+ * `http` (streamable HTTP) and `sse` for a `url` server; a bare `url` defaults
32260
+ * to `http`, and the canonical `streamable-http` spelling is folded into it.
32261
+ * A `ws(s)://` URL or any other stated transport has no Command Code
32262
+ * equivalent, so `undefined` tells the caller to skip the server.
32263
+ * @see https://commandcode.ai/docs/mcp
32264
+ */
32265
+ function asCommandcodeRemoteTransport(stated, url) {
32266
+ if (stated === "sse") return "sse";
32267
+ if (stated === "http" || stated === "streamable-http") return "http";
32268
+ if (stated === void 0) return /^wss?:\/\//i.test(url) ? void 0 : "http";
32269
+ }
32270
+ /**
32271
+ * Convert the canonical server map to the shape Command Code's config schema
32272
+ * documents (used for the global `~/.commandcode/mcp.json` only — the project
32273
+ * `.mcp.json` is shared with Claude Code and written pass-through, see the
32274
+ * class doc): a remote server is `{ transport, url, headers?, env?, oauth? }`
32275
+ * and a stdio server is `{ transport: "stdio", command, args?, env? }`, each
32276
+ * with an optional `enabled` flag. The documented spelling is `transport`
32277
+ * (`type` is only accepted as an alias on read), so that is what is written,
32278
+ * and only the documented keys are emitted — `timeout`, rulesync-only fields
32279
+ * and anything else is left out. The canonical `disabled: true` becomes
32280
+ * Command Code's native `enabled: false` (the rulesync-source-only `enabled`
32281
+ * filter never reaches this function: `getMcpServers()` drops such a server).
32282
+ *
32283
+ * A server Command Code drops at load time — no transport at all, a remote
32284
+ * transport without a URL, a WebSocket URL, or a stdio entry without a
32285
+ * command — is skipped with a warning rather than written in a form the tool
32286
+ * would silently ignore.
32287
+ * @see https://commandcode.ai/docs/mcp
32288
+ */
32289
+ function convertToCommandcodeFormat(mcpServers, logger) {
32290
+ const result = {};
32291
+ for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
32292
+ if (PROTOTYPE_POLLUTION_KEYS.has(serverName) || !isRecord$1(serverConfig)) continue;
32293
+ if (declaresNoTransport(serverConfig)) {
32294
+ warnAndSkipMcpServer({
32295
+ toolName: "Command Code",
32296
+ serverName,
32297
+ reason: "no transport",
32298
+ logger
32299
+ });
32300
+ continue;
32301
+ }
32302
+ const converted = isRemoteMcpServer(serverConfig) ? convertRemoteServer$1({
32303
+ serverName,
32304
+ serverConfig,
32305
+ logger
32306
+ }) : convertStdioServer$1({
32307
+ serverName,
32308
+ serverConfig,
32309
+ logger
32310
+ });
32311
+ if (converted === void 0) continue;
32312
+ if (serverConfig.disabled === true) converted.enabled = false;
32313
+ if (isRecord$1(serverConfig.env)) converted.env = omitPrototypePollutionKeys(serverConfig.env);
32314
+ result[serverName] = converted;
32315
+ }
32316
+ return result;
32317
+ }
32318
+ function convertRemoteServer$1({ serverName, serverConfig, logger }) {
32319
+ const url = resolveRemoteMcpUrl(serverConfig);
32320
+ if (!url) {
32321
+ warnAndSkipMcpServer({
32322
+ toolName: "Command Code",
32323
+ serverName,
32324
+ reason: "a remote transport without a url",
32325
+ logger
32326
+ });
32327
+ return;
32328
+ }
32329
+ const stated = serverConfig.type ?? serverConfig.transport;
32330
+ const transport = asCommandcodeRemoteTransport(typeof stated === "string" ? stated : void 0, url);
32331
+ if (transport === void 0) {
32332
+ warnAndSkipMcpServer({
32333
+ toolName: "Command Code",
32334
+ serverName,
32335
+ 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)`,
32336
+ logger
32337
+ });
32338
+ return;
32339
+ }
32340
+ const converted = {
32341
+ transport,
32342
+ url
32343
+ };
32344
+ if (isRecord$1(serverConfig.headers)) converted.headers = omitPrototypePollutionKeys(serverConfig.headers);
32345
+ if (isRecord$1(serverConfig.oauth)) converted.oauth = omitPrototypePollutionKeys(serverConfig.oauth);
32346
+ return converted;
32347
+ }
32348
+ function convertStdioServer$1({ serverName, serverConfig, logger }) {
32349
+ const [command, ...args] = resolveLocalMcpCommand(serverConfig);
32350
+ if (!command) {
32351
+ warnAndSkipMcpServer({
32352
+ toolName: "Command Code",
32353
+ serverName,
32354
+ reason: "a stdio transport without a command",
32355
+ logger
32356
+ });
32357
+ return;
32358
+ }
32359
+ const converted = {
32360
+ transport: "stdio",
32361
+ command
32362
+ };
32363
+ if (args.length > 0) converted.args = args;
32364
+ return converted;
32365
+ }
32366
+ /**
32367
+ * Convert Command Code's server map back to the canonical shape. Both
32368
+ * spellings Command Code accepts (`transport` and its `type` alias, with
32369
+ * `command`/`url`) are already canonical, so entries pass through with only
32370
+ * prototype-pollution keys dropped; the native `enabled: false` maps to the
32371
+ * canonical `disabled: true` (a bare `enabled` would otherwise be read back
32372
+ * as rulesync's own generation filter and silently drop the server from every
32373
+ * other target).
32374
+ */
32375
+ function convertFromCommandcodeFormat(mcpServers) {
32376
+ if (!isMcpServers(mcpServers)) return {};
32377
+ const result = {};
32378
+ for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
32379
+ if (PROTOTYPE_POLLUTION_KEYS.has(serverName) || !isRecord$1(serverConfig)) continue;
32380
+ const { enabled, ...rest } = omitPrototypePollutionKeys(serverConfig);
32381
+ result[serverName] = enabled === false ? {
32382
+ ...rest,
32383
+ disabled: true
32384
+ } : rest;
32385
+ }
32386
+ return result;
32387
+ }
32388
+ /**
32389
+ * Command Code MCP configuration.
32390
+ *
32391
+ * Command Code reads `.mcp.json` at the project root (project scope, meant to
32392
+ * be committed) and `~/.commandcode/mcp.json` (user scope), both in the
32393
+ * `{ "mcpServers": { ... } }` shape the docs show; the per-machine local
32394
+ * scope under `~/.commandcode/projects/` is left to the tool. Top-level
32395
+ * sibling keys of an existing file are kept. The global file is not deleted
32396
+ * by `--delete` (it lives outside the project), while the project file is
32397
+ * rulesync's own and is.
32398
+ *
32399
+ * The project `.mcp.json` is the very file the `claudecode` target writes, so
32400
+ * at project scope the servers are written in the same pass-through shape
32401
+ * `ClaudecodeMcp` uses: whichever of the two targets generates last leaves
32402
+ * byte-identical content, and Command Code reads that shape natively (`type`
32403
+ * is an alias of `transport`, a bare `url` infers `http`, and unknown keys
32404
+ * are ignored). Only the global file — Command Code's own — gets the
32405
+ * `transport` / `enabled: false` rewrite of `convertToCommandcodeFormat`.
32406
+ *
32407
+ * @see https://commandcode.ai/docs/mcp
32408
+ */
32409
+ var CommandcodeMcp = class CommandcodeMcp extends ToolMcp {
32410
+ json;
32411
+ constructor(params) {
32412
+ super(params);
32413
+ this.json = this.fileContent === void 0 ? {} : parseCommandcodeMcpConfig({
32414
+ fileContent: this.fileContent,
32415
+ relativePath: join(this.relativeDirPath, this.relativeFilePath)
32416
+ });
32417
+ }
32418
+ getJson() {
32419
+ return this.json;
32420
+ }
32421
+ isDeletable() {
32422
+ return !this.global;
32423
+ }
32424
+ static getSettablePaths({ global = false } = {}) {
32425
+ return global ? {
32426
+ relativeDirPath: COMMANDCODE_DIR,
32427
+ relativeFilePath: COMMANDCODE_GLOBAL_MCP_FILE_NAME
32428
+ } : {
32429
+ relativeDirPath: ".",
32430
+ relativeFilePath: COMMANDCODE_PROJECT_MCP_FILE_NAME
32431
+ };
32432
+ }
32433
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32434
+ const paths = this.getSettablePaths({ global });
32435
+ const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}";
32436
+ return new CommandcodeMcp({
32437
+ outputRoot,
32438
+ relativeDirPath: paths.relativeDirPath,
32439
+ relativeFilePath: paths.relativeFilePath,
32440
+ fileContent,
32441
+ validate,
32442
+ global
32443
+ });
32444
+ }
32445
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
32446
+ const paths = this.getSettablePaths({ global });
32447
+ const json = parseCommandcodeMcpConfig({
32448
+ fileContent: await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}",
32449
+ relativePath: join(paths.relativeDirPath, paths.relativeFilePath)
32450
+ });
32451
+ const mcpServers = global ? convertToCommandcodeFormat(rulesyncMcp.getMcpServers(), logger) : rulesyncMcp.getMcpServers();
32452
+ const commandcodeConfig = {
32453
+ ...json,
32454
+ mcpServers
32455
+ };
32456
+ return new CommandcodeMcp({
32457
+ outputRoot,
32458
+ relativeDirPath: paths.relativeDirPath,
32459
+ relativeFilePath: paths.relativeFilePath,
32460
+ fileContent: JSON.stringify(commandcodeConfig, null, 2),
32461
+ validate,
32462
+ global
32463
+ });
32464
+ }
32465
+ toRulesyncMcp() {
32466
+ const mcpServers = convertFromCommandcodeFormat(this.json.mcpServers);
32467
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers }, null, 2) });
32468
+ }
32469
+ validate() {
32470
+ return {
32471
+ success: true,
32472
+ error: null
32473
+ };
32474
+ }
32475
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
32476
+ return new CommandcodeMcp({
32477
+ outputRoot,
32478
+ relativeDirPath,
32479
+ relativeFilePath,
32480
+ fileContent: "{}",
32481
+ validate: false,
32482
+ global
32483
+ });
32484
+ }
32485
+ };
32486
+ //#endregion
31647
32487
  //#region src/features/mcp/continue-mcp.ts
31648
32488
  /**
31649
32489
  * Parse a Continue MCP file. Continue reads the files under `mcpServers/` as
@@ -32306,7 +33146,7 @@ function convertToCortexcodeFormat(mcpServers, logger) {
32306
33146
  }
32307
33147
  for (const [key, value] of Object.entries(rest)) {
32308
33148
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
32309
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
33149
+ converted[key] = omitPrototypePollutionKeysDeep(value);
32310
33150
  }
32311
33151
  result[serverName] = converted;
32312
33152
  }
@@ -32315,14 +33155,15 @@ function convertToCortexcodeFormat(mcpServers, logger) {
32315
33155
  /**
32316
33156
  * Convert Cortex Code's server map back to the canonical shape. The
32317
33157
  * documented spelling (`type` + `command`/`url`) is already canonical, so
32318
- * entries pass through with only prototype-pollution keys dropped.
33158
+ * entries pass through with only prototype-pollution keys dropped — at every
33159
+ * nesting level, mirroring the generate side.
32319
33160
  */
32320
33161
  function convertFromCortexcodeFormat(mcpServers) {
32321
33162
  if (!isMcpServers(mcpServers)) return {};
32322
33163
  const result = {};
32323
33164
  for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
32324
33165
  if (PROTOTYPE_POLLUTION_KEYS.has(serverName) || !isRecord$1(serverConfig)) continue;
32325
- result[serverName] = omitPrototypePollutionKeys(serverConfig);
33166
+ result[serverName] = omitPrototypePollutionKeysDeep(serverConfig);
32326
33167
  }
32327
33168
  return result;
32328
33169
  }
@@ -32361,19 +33202,12 @@ var CortexcodeMcp = class CortexcodeMcp extends ToolMcp {
32361
33202
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32362
33203
  if (!global) throw new Error(CORTEXCODE_GLOBAL_ONLY_MESSAGE);
32363
33204
  const paths = this.getSettablePaths({ global });
32364
- const json = parseCortexcodeMcpConfig({
32365
- fileContent: await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}",
32366
- relativePath: join(paths.relativeDirPath, paths.relativeFilePath)
32367
- });
32368
- const newJson = {
32369
- ...json,
32370
- mcpServers: json.mcpServers ?? {}
32371
- };
33205
+ const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}";
32372
33206
  return new CortexcodeMcp({
32373
33207
  outputRoot,
32374
33208
  relativeDirPath: paths.relativeDirPath,
32375
33209
  relativeFilePath: paths.relativeFilePath,
32376
- fileContent: JSON.stringify(newJson, null, 2),
33210
+ fileContent,
32377
33211
  validate,
32378
33212
  global
32379
33213
  });
@@ -32421,6 +33255,319 @@ var CortexcodeMcp = class CortexcodeMcp extends ToolMcp {
32421
33255
  }
32422
33256
  };
32423
33257
  //#endregion
33258
+ //#region src/features/mcp/crush-mcp.ts
33259
+ /**
33260
+ * Crush-only MCP fields with no canonical counterpart, authored under a
33261
+ * `crush`-prefixed key in `.rulesync/mcp.jsonc` and written under Crush's own
33262
+ * name: `oauth*` drive Crush's OAuth 2.1 flow for HTTP servers and
33263
+ * `sessionless` marks a server that issues no `Mcp-Session-Id`.
33264
+ * `RulesyncMcp.getMcpServers()` strips both spellings so the client secret
33265
+ * never reaches another tool's config; `fromRulesyncMcp` re-merges them from
33266
+ * the raw source JSON, honouring the raw Crush spelling as a fallback for an
33267
+ * entry copied straight out of a `crush.json` (the `experimental_environment`
33268
+ * precedent) — except `oauth`, whose canonical key is Claude Code's
33269
+ * `{ clientId }` object and which only `crushOauth` can set.
33270
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
33271
+ */
33272
+ const CRUSH_ONLY_KEYS = [
33273
+ {
33274
+ canonical: "crushOauth",
33275
+ crush: "oauth",
33276
+ accepts: (v) => typeof v === "boolean",
33277
+ rawFallback: false
33278
+ },
33279
+ {
33280
+ canonical: "crushOauthClientId",
33281
+ crush: "oauth_client_id",
33282
+ accepts: (v) => typeof v === "string",
33283
+ rawFallback: true
33284
+ },
33285
+ {
33286
+ canonical: "crushOauthClientSecret",
33287
+ crush: "oauth_client_secret",
33288
+ accepts: (v) => typeof v === "string",
33289
+ rawFallback: true
33290
+ },
33291
+ {
33292
+ canonical: "crushOauthCallbackPort",
33293
+ crush: "oauth_callback_port",
33294
+ accepts: (v) => Number.isInteger(v),
33295
+ rawFallback: true
33296
+ },
33297
+ {
33298
+ canonical: "crushSessionless",
33299
+ crush: "sessionless",
33300
+ accepts: (v) => typeof v === "boolean",
33301
+ rawFallback: true
33302
+ }
33303
+ ];
33304
+ /**
33305
+ * The Crush-only keys of one raw canonical server entry, keyed by their
33306
+ * canonical name, so `convertToCrushFormat` sees them after the shared map
33307
+ * stripped them.
33308
+ */
33309
+ function readCrushOnlyKeys(rawServer) {
33310
+ const result = {};
33311
+ if (!isRecord$1(rawServer)) return result;
33312
+ for (const { canonical, crush, accepts, rawFallback } of CRUSH_ONLY_KEYS) {
33313
+ const value = accepts(rawServer[canonical]) ? rawServer[canonical] : rawFallback && accepts(rawServer[crush]) ? rawServer[crush] : void 0;
33314
+ if (value !== void 0) result[canonical] = value;
33315
+ }
33316
+ return result;
33317
+ }
33318
+ function copyCrushOnlyKeys({ from, to }) {
33319
+ for (const { canonical, crush, accepts } of CRUSH_ONLY_KEYS) if (accepts(from[canonical])) to[crush] = from[canonical];
33320
+ }
33321
+ /**
33322
+ * The remote transports Crush's `MCPType` enum accepts, spelled the way it
33323
+ * reads them. `streamable-http` is the canonical rulesync name for what Crush
33324
+ * calls `http`; a WebSocket server has no Crush transport and is left to the
33325
+ * caller to reject.
33326
+ */
33327
+ function asCrushRemoteType(stated, url) {
33328
+ if (stated === "sse") return "sse";
33329
+ if (stated === "http" || stated === "streamable-http") return "http";
33330
+ if (stated === void 0) return /^wss?:\/\//i.test(url) ? void 0 : "http";
33331
+ }
33332
+ /**
33333
+ * The transport half of a remote server (`type`, `url`, `headers`), or null
33334
+ * when Crush could not reach it and the server was skipped with a warning.
33335
+ */
33336
+ function convertRemoteTransport({ name, config, logger }) {
33337
+ const url = resolveRemoteMcpUrl(config);
33338
+ if (!url) {
33339
+ warnAndSkipMcpServer({
33340
+ toolName: "Crush",
33341
+ serverName: name,
33342
+ reason: "a remote transport without a url",
33343
+ logger
33344
+ });
33345
+ return null;
33346
+ }
33347
+ const stated = config.type ?? config.transport;
33348
+ const type = asCrushRemoteType(stated, url);
33349
+ if (type === void 0) {
33350
+ warnAndSkipMcpServer({
33351
+ toolName: "Crush",
33352
+ serverName: name,
33353
+ 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)`,
33354
+ logger
33355
+ });
33356
+ return null;
33357
+ }
33358
+ const converted = {
33359
+ type,
33360
+ url
33361
+ };
33362
+ if (config.headers && Object.keys(config.headers).length > 0) converted.headers = config.headers;
33363
+ return converted;
33364
+ }
33365
+ /**
33366
+ * The transport half of a stdio server (`type`, `command`, `args`, `env`), or
33367
+ * null when it has no command and was skipped with a warning.
33368
+ */
33369
+ function convertStdioTransport({ name, config, logger }) {
33370
+ const [command, ...args] = resolveLocalMcpCommand(config);
33371
+ if (!command) {
33372
+ warnAndSkipMcpServer({
33373
+ toolName: "Crush",
33374
+ serverName: name,
33375
+ reason: "a stdio transport without a command",
33376
+ logger
33377
+ });
33378
+ return null;
33379
+ }
33380
+ const converted = {
33381
+ type: "stdio",
33382
+ command
33383
+ };
33384
+ if (args.length > 0) converted.args = args;
33385
+ if (config.env && Object.keys(config.env).length > 0) converted.env = config.env;
33386
+ return converted;
33387
+ }
33388
+ /**
33389
+ * Convert canonical rulesync servers to the `mcp.<name>` shape of crush.json.
33390
+ * `type` is required by Crush's schema, so it is always written: stdio servers
33391
+ * carry `type: "stdio"` with `command`/`args`/`env`, remote servers `http` or
33392
+ * `sse` with `url` and optional `headers`. `disabled`, the per-server
33393
+ * `disabledTools`/`enabledTools` filters (`disabled_tools`/`enabled_tools`)
33394
+ * and `timeout` (which Crush reads in seconds) map onto their Crush fields.
33395
+ *
33396
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
33397
+ */
33398
+ function convertToCrushFormat(mcpServers, logger) {
33399
+ const result = {};
33400
+ for (const [name, config] of Object.entries(mcpServers)) {
33401
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
33402
+ if (!isRecord$1(config)) continue;
33403
+ if (declaresNoTransport(config)) {
33404
+ warnAndSkipMcpServer({
33405
+ toolName: "Crush",
33406
+ serverName: name,
33407
+ reason: "no transport",
33408
+ logger
33409
+ });
33410
+ continue;
33411
+ }
33412
+ const converted = isRemoteMcpServer(config) ? convertRemoteTransport({
33413
+ name,
33414
+ config,
33415
+ logger
33416
+ }) : convertStdioTransport({
33417
+ name,
33418
+ config,
33419
+ logger
33420
+ });
33421
+ if (converted === null) continue;
33422
+ if (config.disabled === true) converted.disabled = true;
33423
+ if (config.disabledTools && config.disabledTools.length > 0) converted.disabled_tools = config.disabledTools;
33424
+ if (config.enabledTools && config.enabledTools.length > 0) converted.enabled_tools = config.enabledTools;
33425
+ if (typeof config.timeout === "number") converted.timeout = Math.ceil(config.timeout);
33426
+ copyCrushOnlyKeys({
33427
+ from: config,
33428
+ to: converted
33429
+ });
33430
+ result[name] = converted;
33431
+ }
33432
+ return result;
33433
+ }
33434
+ /**
33435
+ * Convert the `mcp.<name>` entries of crush.json back to canonical rulesync
33436
+ * servers. `stdio` is Crush's default `type`, so it is dropped (a canonical
33437
+ * server with a `command` is stdio already); `http` and `sse` are kept as the
33438
+ * canonical transport names; `disabled_tools`/`enabled_tools` become the
33439
+ * canonical `disabledTools`/`enabledTools`; the Crush-only OAuth and
33440
+ * `sessionless` fields are lifted into their `crush*` authoring keys (a value
33441
+ * of the wrong type is dropped, as Crush itself would refuse it), and any
33442
+ * other key is kept verbatim so an import preserves what a hand-authored
33443
+ * entry declared.
33444
+ */
33445
+ function convertFromCrushFormat(crushServers) {
33446
+ const result = {};
33447
+ for (const [name, config] of Object.entries(crushServers)) {
33448
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
33449
+ const converted = {};
33450
+ for (const [key, value] of Object.entries(config)) {
33451
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
33452
+ switch (key) {
33453
+ case "type":
33454
+ if (value === "http" || value === "sse") converted.type = value;
33455
+ break;
33456
+ case "disabled_tools":
33457
+ if (isStringArray$2(value)) converted.disabledTools = value;
33458
+ break;
33459
+ case "enabled_tools":
33460
+ if (isStringArray$2(value)) converted.enabledTools = value;
33461
+ break;
33462
+ case "oauth_token": break;
33463
+ default: {
33464
+ const crushOnly = CRUSH_ONLY_KEYS.find((entry) => entry.crush === key);
33465
+ if (crushOnly === void 0) converted[key] = value;
33466
+ else if (crushOnly.accepts(value)) converted[crushOnly.canonical] = value;
33467
+ }
33468
+ }
33469
+ }
33470
+ result[name] = converted;
33471
+ }
33472
+ return result;
33473
+ }
33474
+ /**
33475
+ * Crush MCP servers.
33476
+ *
33477
+ * Crush reads MCP servers from the `mcp` key of its JSON config:
33478
+ * `<project>/crush.json` (or an existing `.crush.json`; Crush merges the pair,
33479
+ * objects recursively) at project scope and `~/.config/crush/crush.json` at
33480
+ * user scope. The
33481
+ * `crushrc` Bash config that Crush now recommends compiles its `mcp add`
33482
+ * builtin into the same `mcp.<name>` entries and overrides the JSON key by
33483
+ * key, so a `crushrc` next to the generated file takes precedence. Every
33484
+ * other top-level key of the file is preserved; the file is never deleted.
33485
+ *
33486
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/config/README.md
33487
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
33488
+ */
33489
+ var CrushMcp = class CrushMcp extends ToolMcp {
33490
+ json;
33491
+ constructor(params) {
33492
+ super(params);
33493
+ this.json = parseCrushConfig(this.fileContent ?? "", join(this.relativeDirPath, this.relativeFilePath));
33494
+ }
33495
+ getJson() {
33496
+ return this.json;
33497
+ }
33498
+ isDeletable() {
33499
+ return false;
33500
+ }
33501
+ static getSettablePaths({ global = false } = {}) {
33502
+ return getCrushConfigSettablePaths({ global });
33503
+ }
33504
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
33505
+ const location = await resolveCrushConfigFile({
33506
+ outputRoot,
33507
+ global
33508
+ });
33509
+ return new CrushMcp({
33510
+ outputRoot,
33511
+ relativeDirPath: location.relativeDirPath,
33512
+ relativeFilePath: location.relativeFilePath,
33513
+ fileContent: crushConfigImportContent(location),
33514
+ validate,
33515
+ global
33516
+ });
33517
+ }
33518
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
33519
+ const location = await resolveCrushConfigFile({
33520
+ outputRoot,
33521
+ global
33522
+ });
33523
+ const existingContent = location.fileContent ?? "";
33524
+ warnCrushTwinLeftovers({
33525
+ location,
33526
+ ownedPaths: [["mcp"]],
33527
+ logger
33528
+ });
33529
+ const converted = convertToCrushFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => [serverName, {
33530
+ ...serverConfig,
33531
+ ...readCrushOnlyKeys(rulesyncMcp.getRawMcpServer(serverName))
33532
+ }])), logger);
33533
+ return new CrushMcp({
33534
+ outputRoot,
33535
+ relativeDirPath: location.relativeDirPath,
33536
+ relativeFilePath: location.relativeFilePath,
33537
+ fileContent: applySharedConfigPatch({
33538
+ fileKey: sharedConfigFileKey(this.getSettablePaths({ global })),
33539
+ feature: "mcp",
33540
+ existingContent,
33541
+ patch: { ["mcp"]: converted },
33542
+ filePath: location.filePath,
33543
+ logger
33544
+ }),
33545
+ validate,
33546
+ global
33547
+ });
33548
+ }
33549
+ toRulesyncMcp() {
33550
+ const converted = convertFromCrushFormat(isRecord$1(this.json["mcp"]) ? this.json["mcp"] : {});
33551
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers: converted }, null, 2) });
33552
+ }
33553
+ validate() {
33554
+ return {
33555
+ success: true,
33556
+ error: null
33557
+ };
33558
+ }
33559
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
33560
+ return new CrushMcp({
33561
+ outputRoot,
33562
+ relativeDirPath,
33563
+ relativeFilePath,
33564
+ fileContent: JSON.stringify({ ["mcp"]: {} }, null, 2),
33565
+ validate: false,
33566
+ global
33567
+ });
33568
+ }
33569
+ };
33570
+ //#endregion
32424
33571
  //#region src/features/mcp/mcp-env-var-format.ts
32425
33572
  /**
32426
33573
  * Canonical rulesync env var reference pattern: `${VAR}` (but not `${env:VAR}`,
@@ -36305,6 +37452,7 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
36305
37452
  static async getAuxiliaryFiles({ outputRoot = process.cwd(), global = false, rulesyncMcp, logger }) {
36306
37453
  const servers = rulesyncMcp.forTarget({
36307
37454
  toolTarget: "rovodev",
37455
+ global,
36308
37456
  logger
36309
37457
  }).getMcpServers();
36310
37458
  const managedNames = Object.keys(servers).filter((name) => toRovodevServer(name, servers[name]) !== null);
@@ -36465,7 +37613,7 @@ function convertToTabnineFormat(mcpServers, logger) {
36465
37613
  }
36466
37614
  for (const [key, value] of Object.entries(rest)) {
36467
37615
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36468
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
37616
+ converted[key] = omitPrototypePollutionKeysDeep(value);
36469
37617
  }
36470
37618
  if (enabledTools !== void 0) converted.includeTools = enabledTools;
36471
37619
  if (disabledTools !== void 0) converted.excludeTools = disabledTools;
@@ -36476,7 +37624,9 @@ function convertToTabnineFormat(mcpServers, logger) {
36476
37624
  /**
36477
37625
  * Convert Tabnine's server map back to the canonical shape: `includeTools` /
36478
37626
  * `excludeTools` become `enabledTools` / `disabledTools`; `type` (`stdio`,
36479
- * `sse`, `http`) and every other documented field are already canonical.
37627
+ * `sse`, `http`) and every other documented field are already canonical and
37628
+ * pass through with their prototype-pollution keys dropped at every nesting
37629
+ * level, mirroring the generate side.
36480
37630
  */
36481
37631
  function convertFromTabnineFormat(mcpServers) {
36482
37632
  if (!isMcpServers(mcpServers)) return {};
@@ -36488,7 +37638,7 @@ function convertFromTabnineFormat(mcpServers) {
36488
37638
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36489
37639
  if (key === "includeTools") converted.enabledTools = value;
36490
37640
  else if (key === "excludeTools") converted.disabledTools = value;
36491
- else converted[key] = value;
37641
+ else converted[key] = omitPrototypePollutionKeysDeep(value);
36492
37642
  }
36493
37643
  result[serverName] = converted;
36494
37644
  }
@@ -37600,6 +38750,15 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
37600
38750
  supportsDisabledTools: true
37601
38751
  }
37602
38752
  }],
38753
+ ["commandcode", {
38754
+ class: CommandcodeMcp,
38755
+ meta: {
38756
+ supportsProject: true,
38757
+ supportsGlobal: true,
38758
+ supportsEnabledTools: false,
38759
+ supportsDisabledTools: false
38760
+ }
38761
+ }],
37603
38762
  ["continue", {
37604
38763
  class: ContinueMcp,
37605
38764
  meta: {
@@ -37636,6 +38795,15 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
37636
38795
  supportsDisabledTools: false
37637
38796
  }
37638
38797
  }],
38798
+ ["crush", {
38799
+ class: CrushMcp,
38800
+ meta: {
38801
+ supportsProject: true,
38802
+ supportsGlobal: true,
38803
+ supportsEnabledTools: true,
38804
+ supportsDisabledTools: true
38805
+ }
38806
+ }],
37639
38807
  ["cursor", {
37640
38808
  class: CursorMcp,
37641
38809
  meta: {
@@ -37984,6 +39152,7 @@ var McpProcessor = class extends FeatureProcessor {
37984
39152
  const toolMcps = await Promise.all([rulesyncMcp].map(async (mcp) => {
37985
39153
  const targetedRulesyncMcp = mcp.forTarget({
37986
39154
  toolTarget: this.toolTarget,
39155
+ global: this.global,
37987
39156
  logger: this.logger
37988
39157
  });
37989
39158
  const fieldsToStrip = [];
@@ -39067,7 +40236,7 @@ const ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES = {
39067
40236
  function toAntigravityCliToolName(canonical) {
39068
40237
  return CANONICAL_TO_ANTIGRAVITY_CLI_TOOL_NAMES[canonical] ?? canonical;
39069
40238
  }
39070
- function toCanonicalToolName$7(cliName) {
40239
+ function toCanonicalToolName$8(cliName) {
39071
40240
  return ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES[cliName] ?? cliName;
39072
40241
  }
39073
40242
  /**
@@ -39283,7 +40452,7 @@ function convertAntigravityCliToRulesyncPermissions(params) {
39283
40452
  const processEntries = (entries, action) => {
39284
40453
  for (const entry of entries) {
39285
40454
  const { toolName, pattern } = parsePermissionEntry$1(entry);
39286
- const canonical = toCanonicalToolName$7(toolName);
40455
+ const canonical = toCanonicalToolName$8(toolName);
39287
40456
  if (!permission[canonical]) permission[canonical] = {};
39288
40457
  permission[canonical][pattern] = action;
39289
40458
  }
@@ -39540,11 +40709,34 @@ const CANONICAL_TO_AUGMENT_TOOL_NAMES = {
39540
40709
  websearch: "web-search"
39541
40710
  };
39542
40711
  const AUGMENT_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_AUGMENT_TOOL_NAMES).map(([k, v]) => [v, k]));
40712
+ const CURRENT_TO_LEGACY_AUGMENT_TOOL_NAMES = {
40713
+ terminal: "launch-process",
40714
+ read: "view",
40715
+ edit: "str-replace-editor",
40716
+ write: "save-file"
40717
+ };
39543
40718
  function toAugmentToolName(canonical) {
39544
- return CANONICAL_TO_AUGMENT_TOOL_NAMES[canonical] ?? canonical;
40719
+ return lookupOwn({
40720
+ record: CANONICAL_TO_AUGMENT_TOOL_NAMES,
40721
+ key: canonical
40722
+ }) ?? canonical;
39545
40723
  }
39546
- function toCanonicalToolName$6(augmentName) {
39547
- return AUGMENT_TO_CANONICAL_TOOL_NAMES[augmentName] ?? augmentName;
40724
+ /**
40725
+ * Resolve an existing entry's `toolName` to the legacy spelling rulesync emits, so that the
40726
+ * current alias and the legacy name are treated as the same managed tool.
40727
+ */
40728
+ function toLegacyAugmentToolName(augmentName) {
40729
+ return lookupOwn({
40730
+ record: CURRENT_TO_LEGACY_AUGMENT_TOOL_NAMES,
40731
+ key: augmentName
40732
+ }) ?? augmentName;
40733
+ }
40734
+ function toCanonicalToolName$7(augmentName) {
40735
+ const legacyName = toLegacyAugmentToolName(augmentName);
40736
+ return lookupOwn({
40737
+ record: AUGMENT_TO_CANONICAL_TOOL_NAMES,
40738
+ key: legacyName
40739
+ }) ?? legacyName;
39548
40740
  }
39549
40741
  function actionToAugmentType(action) {
39550
40742
  switch (action) {
@@ -39760,10 +40952,11 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
39760
40952
  const generatedKeys = new Set(generated.map((e) => `${e.toolName}|${e.shellInputRegex ?? ""}|${e.permission.type}`));
39761
40953
  const preservedBasicEntries = basicExistingEntries.filter((entry) => {
39762
40954
  if (entry.toolName === "*") return false;
39763
- if (!MANAGED_AUGMENT_TOOL_NAMES.has(entry.toolName)) return true;
40955
+ const legacyToolName = toLegacyAugmentToolName(entry.toolName);
40956
+ if (!MANAGED_AUGMENT_TOOL_NAMES.has(legacyToolName)) return true;
39764
40957
  if (entry.permission.type === "deny") {
39765
- const key = `${entry.toolName}|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
39766
- return !generatedKeys.has(key);
40958
+ const suffix = `|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
40959
+ return !generatedKeys.has(`${entry.toolName}${suffix}`) && !generatedKeys.has(`${legacyToolName}${suffix}`);
39767
40960
  }
39768
40961
  return false;
39769
40962
  });
@@ -39990,14 +41183,15 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
39990
41183
  if (isSpecialEntry(entry)) continue;
39991
41184
  const type = entry.permission.type;
39992
41185
  if (!isBasicAugmentType(type)) continue;
39993
- const canonical = toCanonicalToolName$6(entry.toolName);
41186
+ const legacyToolName = toLegacyAugmentToolName(entry.toolName);
41187
+ const canonical = toCanonicalToolName$7(entry.toolName);
39994
41188
  if (forbiddenMapKeys.has(canonical)) {
39995
41189
  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.`);
39996
41190
  continue;
39997
41191
  }
39998
41192
  const action = augmentTypeToAction(type);
39999
41193
  let pattern;
40000
- if (entry.toolName === "launch-process" && entry.shellInputRegex) {
41194
+ if (legacyToolName === "launch-process" && entry.shellInputRegex) {
40001
41195
  const regex = entry.shellInputRegex;
40002
41196
  if (isShellRegexRoundtrippable(regex)) pattern = shellRegexToGlob(regex);
40003
41197
  else if (action === "deny") {
@@ -40012,9 +41206,19 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
40012
41206
  logger?.warn(`AugmentCode permissions: skipping entry for tool '${entry.toolName}' because its pattern resolves to the reserved object key '${pattern}'.`);
40013
41207
  continue;
40014
41208
  }
40015
- if (!permission[canonical]) permission[canonical] = {};
40016
- const existing = permission[canonical][pattern];
40017
- if (existing === void 0 || actionPriority[action] > actionPriority[existing]) permission[canonical][pattern] = action;
41209
+ let bucket = lookupOwn({
41210
+ record: permission,
41211
+ key: canonical
41212
+ });
41213
+ if (bucket === void 0) {
41214
+ bucket = {};
41215
+ permission[canonical] = bucket;
41216
+ }
41217
+ const existing = lookupOwn({
41218
+ record: bucket,
41219
+ key: pattern
41220
+ });
41221
+ if (existing === void 0 || actionPriority[action] > actionPriority[existing]) bucket[pattern] = action;
40018
41222
  }
40019
41223
  return { permission };
40020
41224
  }
@@ -40225,7 +41429,7 @@ const CLAUDE_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONIC
40225
41429
  function toClaudeToolName(canonical) {
40226
41430
  return CANONICAL_TO_CLAUDE_TOOL_NAMES[canonical] ?? canonical;
40227
41431
  }
40228
- function toCanonicalToolName$5(claudeName) {
41432
+ function toCanonicalToolName$6(claudeName) {
40229
41433
  return CLAUDE_TO_CANONICAL_TOOL_NAMES[claudeName] ?? claudeName;
40230
41434
  }
40231
41435
  /**
@@ -41120,7 +42324,7 @@ function convertClaudeToRulesyncPermissions(params) {
41120
42324
  const processEntries = (entries, action) => {
41121
42325
  for (const entry of entries) {
41122
42326
  const { toolName, pattern } = parseClaudePermissionEntry(entry);
41123
- const canonical = toCanonicalToolName$5(toolName);
42327
+ const canonical = toCanonicalToolName$6(toolName);
41124
42328
  if (!permission[canonical]) permission[canonical] = {};
41125
42329
  permission[canonical][pattern] = action;
41126
42330
  }
@@ -41943,9 +43147,634 @@ function mapBashActionToDecision(action) {
41943
43147
  return "forbidden";
41944
43148
  }
41945
43149
  //#endregion
43150
+ //#region src/features/permissions/commandcode-permissions.ts
43151
+ /** Top-level key of `.commandcode/settings.json` that rulesync owns. */
43152
+ const COMMANDCODE_PERMISSIONS_KEY = "permissions";
43153
+ const CATCH_ALL_PATTERN$6 = "*";
43154
+ const MCP_CANONICAL_PREFIX$5 = "mcp__";
43155
+ const COMMANDCODE_ALL_MCP_RULE = "mcp__*";
43156
+ const CATEGORY_TO_COMMANDCODE_TOOL = {
43157
+ bash: "Shell",
43158
+ read: "Read",
43159
+ edit: "Edit",
43160
+ write: "Write",
43161
+ grep: "Grep",
43162
+ glob: "Glob",
43163
+ webfetch: "WebFetch",
43164
+ websearch: "WebSearch"
43165
+ };
43166
+ const COMMANDCODE_TOOL_TO_CATEGORY = {
43167
+ ...Object.fromEntries(Object.entries(CATEGORY_TO_COMMANDCODE_TOOL).map(([category, tool]) => [tool.toLowerCase(), category])),
43168
+ bash: "bash",
43169
+ powershell: "bash",
43170
+ shell_command: "bash",
43171
+ monitor_command: "bash",
43172
+ kill_shell: "bash",
43173
+ notebookedit: "edit",
43174
+ write_file: "write",
43175
+ web_fetch: "webfetch",
43176
+ web_search: "websearch"
43177
+ };
43178
+ const ACTION_RANK$1 = {
43179
+ allow: 0,
43180
+ ask: 1,
43181
+ deny: 2
43182
+ };
43183
+ /** Whether `action` outranks `existing` (deny > ask > allow). */
43184
+ function isStricterAction({ action, existing }) {
43185
+ return existing === void 0 || ACTION_RANK$1[action] > ACTION_RANK$1[existing];
43186
+ }
43187
+ /**
43188
+ * Why a padded `Shell( * )` cannot stand in for the bare `Shell` in `allow`:
43189
+ * the whitespace makes it a pattern rule, and a shell pattern grants only
43190
+ * commands that pass Command Code's pattern gate.
43191
+ */
43192
+ const PADDED_SHELL_WILDCARD_REASON = "a pattern narrower than the bare 'Shell' (the command must parse into words and carry no environment assignment)";
43193
+ /**
43194
+ * Build a Command Code rule (`Shell(git *)`, `Read`, `mcp__github__get_issue`,
43195
+ * `mcp__*`, `*`) from a canonical category + pattern. Returns `null` for
43196
+ * categories Command Code cannot express so the caller can skip them.
43197
+ *
43198
+ * MCP tools are keyed by their canonical `mcp__server__tool` name, so a
43199
+ * scoped `mcp__<remainder>` category is written verbatim and the bare `mcp`
43200
+ * category becomes `mcp__*` (or `mcp__<pattern>` when the pattern names a
43201
+ * server or tool). A pattern on an MCP category is still written as the
43202
+ * `(specifier)` here; `writableCommandcodeRule` decides what to do with it,
43203
+ * because Command Code ignores it. The all-tools `*` category is only
43204
+ * written for its catch-all pattern; narrower `*` patterns are shell
43205
+ * restrictions and reach the `Shell` entries through `honorAllToolsOnBash`.
43206
+ */
43207
+ function buildCommandcodeRule(category, pattern) {
43208
+ const catchAll = pattern === CATCH_ALL_PATTERN$6 || pattern === "";
43209
+ if (category.startsWith(MCP_CANONICAL_PREFIX$5)) return catchAll ? category : `${category}(${pattern})`;
43210
+ if (category === "mcp") return catchAll ? COMMANDCODE_ALL_MCP_RULE : `${MCP_CANONICAL_PREFIX$5}${pattern}`;
43211
+ if (category === "*") return catchAll ? CATCH_ALL_PATTERN$6 : null;
43212
+ const tool = Object.hasOwn(CATEGORY_TO_COMMANDCODE_TOOL, category) ? CATEGORY_TO_COMMANDCODE_TOOL[category] : void 0;
43213
+ if (tool === void 0) return null;
43214
+ return catchAll ? tool : `${tool}(${pattern})`;
43215
+ }
43216
+ /**
43217
+ * Split `Tool(specifier)` into its two halves the way Command Code's
43218
+ * `splitRule` does (`command-code` 1.54.0): the rule is split at the first
43219
+ * unescaped `(` and must end with `)`, the tool half is trimmed (so
43220
+ * `Shell (rm -rf *)` is a `Shell` rule), and a `\(` / `\)` in the specifier
43221
+ * is an escaped parenthesis. `Tool` alone has an empty specifier. A rule that
43222
+ * opens a parenthesis without closing one is not a rule to Command Code at
43223
+ * all, so it comes back with an empty tool half and stays unmodeled. Nothing
43224
+ * else in the specifier is touched: Command Code does not trim it (the shell
43225
+ * matcher normalizes whitespace on its own, see `parseCommandcodeRule`), so
43226
+ * `Read( )` is a rule for the pattern `" "`, which matches nothing, not for
43227
+ * the whole tool.
43228
+ */
43229
+ function splitCommandcodeRule(rule) {
43230
+ const trimmed = rule.trim();
43231
+ const parenIndex = trimmed.search(/(?<!\\)\(/);
43232
+ if (parenIndex === -1) return {
43233
+ tool: trimmed,
43234
+ inner: ""
43235
+ };
43236
+ if (!trimmed.endsWith(")")) return {
43237
+ tool: "",
43238
+ inner: ""
43239
+ };
43240
+ return {
43241
+ tool: trimmed.slice(0, parenIndex).trim(),
43242
+ inner: trimmed.slice(parenIndex + 1, -1).replace(/\\([()])/g, "$1")
43243
+ };
43244
+ }
43245
+ /** Whether a specifier means "no specifier" to Command Code: only `` and `*` do. */
43246
+ function isCatchAllSpecifier(inner) {
43247
+ return inner === "" || inner === CATCH_ALL_PATTERN$6;
43248
+ }
43249
+ /**
43250
+ * Read an MCP-shaped rule the way Command Code does (`command-code` 1.54.0,
43251
+ * `parsePermissionRule` / `parseMcpToken`). The MCP shape is only recognized
43252
+ * by its exact `mcp__` prefix, and such a rule never carries a specifier:
43253
+ * `mcp__<server>__<tool>(owner:foo)` denies, asks or allows the whole tool.
43254
+ * Its names are matched case-sensitively against the registered server and
43255
+ * tool (Command Code never lowercases them), so the remainder keeps its case
43256
+ * as the canonical category. `mcp__<server>`, `mcp__<server>__` and
43257
+ * `mcp__<server>__*` are the whole server (category `mcp__<server>`), and a
43258
+ * `*` server (`mcp__*`, `mcp__*__<tool>`) is every MCP tool, honored in
43259
+ * `deny`/`ask` only. A differently-cased `MCP__...` spelling is read as a
43260
+ * plain tool-name rule instead, matched case-insensitively:
43261
+ * `MCP__<server>__<tool>` matches that tool by name (so it folds onto the
43262
+ * `mcp__` category, remainder as written), `MCP__<server>__<tool>(specifier)`
43263
+ * globs the specifier against the call's `command` / `file_path` / `path` /
43264
+ * `url` / `pattern` argument (in `deny`/`ask` a `param:glob` form is tried
43265
+ * first) — kept as a pattern in `deny`/`ask`, but not imported from `allow`,
43266
+ * where a grant scoped that way cannot be modeled without widening it; the
43267
+ * globs
43268
+ * `MCP__*` (every MCP tool) and `MCP__<server>__*` (the whole server) match
43269
+ * by name in `deny`/`ask` only, and `MCP__<server>` names no tool at all.
43270
+ * Whatever matches nothing anywhere (or a glob rulesync does not model)
43271
+ * returns `null` and is left alone.
43272
+ */
43273
+ function parseMcpCommandcodeRule({ tool, inner }) {
43274
+ const remainder = tool.slice(5);
43275
+ const separator = remainder.indexOf("__");
43276
+ const server = separator === -1 ? remainder : remainder.slice(0, separator);
43277
+ const rest = separator === -1 ? "" : remainder.slice(separator + 2);
43278
+ if (server.length === 0) return null;
43279
+ const everyServer = {
43280
+ category: "mcp",
43281
+ pattern: CATCH_ALL_PATTERN$6,
43282
+ notInAllow: true
43283
+ };
43284
+ if (tool.startsWith(MCP_CANONICAL_PREFIX$5)) {
43285
+ if (server === CATCH_ALL_PATTERN$6) return everyServer;
43286
+ if (server.includes(CATCH_ALL_PATTERN$6)) return null;
43287
+ return {
43288
+ category: rest === "" || rest === CATCH_ALL_PATTERN$6 ? `${MCP_CANONICAL_PREFIX$5}${server}` : `${MCP_CANONICAL_PREFIX$5}${remainder}`,
43289
+ pattern: CATCH_ALL_PATTERN$6,
43290
+ notInAllow: false
43291
+ };
43292
+ }
43293
+ const category = `${MCP_CANONICAL_PREFIX$5}${remainder}`;
43294
+ const namesTool = separator !== -1 && rest.length > 0 && !remainder.includes(CATCH_ALL_PATTERN$6);
43295
+ if (!isCatchAllSpecifier(inner)) return namesTool ? {
43296
+ category,
43297
+ pattern: inner,
43298
+ notInAllow: true
43299
+ } : null;
43300
+ if (remainder === CATCH_ALL_PATTERN$6) return everyServer;
43301
+ if (rest === CATCH_ALL_PATTERN$6 && !server.includes(CATCH_ALL_PATTERN$6)) return {
43302
+ category: `${MCP_CANONICAL_PREFIX$5}${server}`,
43303
+ pattern: CATCH_ALL_PATTERN$6,
43304
+ notInAllow: true
43305
+ };
43306
+ return namesTool ? {
43307
+ category,
43308
+ pattern: CATCH_ALL_PATTERN$6,
43309
+ notInAllow: false
43310
+ } : null;
43311
+ }
43312
+ /**
43313
+ * A rule on one of the friendly tools, with its specifier read as Command
43314
+ * Code matches it. The shell matcher trims and collapses whitespace on both
43315
+ * the pattern and the command, so `Shell( git * )` is `Shell(git *)` and a
43316
+ * pattern that is blank once normalized (`Shell( )`), or a bare `:*` whose
43317
+ * prefix is empty (`Shell(:*)`), matches nothing. A padded `Shell( * )` is
43318
+ * not the bare `Shell`: only a specifier of exactly
43319
+ * `` or `*` is dropped by the rule parser, so `( * )` stays a pattern rule —
43320
+ * in `deny`/`ask` it matches every command (the whole tool), while in `allow`
43321
+ * it is narrower than the bare `Shell` (it needs a command that parses into
43322
+ * words and has no environment-assignment prefix), so it is the whole tool
43323
+ * marked `notInAllow`. The path, web and tool-name matchers use the
43324
+ * specifier as written, so one that is blank or `*` only once trimmed
43325
+ * (`Read( * )`) matches nothing. What matches nothing comes back as `null`:
43326
+ * the rule is unmodeled and left alone.
43327
+ */
43328
+ function friendlyToolRule({ category, inner }) {
43329
+ if (category === "bash") {
43330
+ if (isCatchAllSpecifier(inner)) return {
43331
+ category,
43332
+ pattern: CATCH_ALL_PATTERN$6,
43333
+ notInAllow: false
43334
+ };
43335
+ const normalized = inner.trim().replace(/\s+/g, " ");
43336
+ if (normalized === "" || normalized === ":*") return null;
43337
+ return isCatchAllSpecifier(normalized) ? {
43338
+ category,
43339
+ pattern: CATCH_ALL_PATTERN$6,
43340
+ notInAllow: true
43341
+ } : {
43342
+ category,
43343
+ pattern: normalized,
43344
+ notInAllow: false
43345
+ };
43346
+ }
43347
+ if (isCatchAllSpecifier(inner)) return {
43348
+ category,
43349
+ pattern: CATCH_ALL_PATTERN$6,
43350
+ notInAllow: false
43351
+ };
43352
+ return isCatchAllSpecifier(inner.trim()) ? null : {
43353
+ category,
43354
+ pattern: inner,
43355
+ notInAllow: false
43356
+ };
43357
+ }
43358
+ /**
43359
+ * Parse a Command Code rule back into canonical terms. Tool names fold case;
43360
+ * `Tool`, `Tool()` and `Tool(*)` all mean the whole tool; MCP-shaped rules
43361
+ * go through `parseMcpCommandcodeRule`. Returns `null` for a rule rulesync
43362
+ * cannot model (an exact internal tool name such as `edit_file`, a
43363
+ * name-wildcard like `edit_*`, a specifier on a rule that takes none, or a
43364
+ * rule Command Code enforces nothing for, such as `Agent`).
43365
+ */
43366
+ function parseCommandcodeRule(rule) {
43367
+ const { tool, inner } = splitCommandcodeRule(rule);
43368
+ if (tool === CATCH_ALL_PATTERN$6) return isCatchAllSpecifier(inner) ? {
43369
+ category: "*",
43370
+ pattern: CATCH_ALL_PATTERN$6,
43371
+ notInAllow: true
43372
+ } : null;
43373
+ const lowered = tool.toLowerCase();
43374
+ if (lowered.startsWith(MCP_CANONICAL_PREFIX$5)) return parseMcpCommandcodeRule({
43375
+ tool,
43376
+ inner
43377
+ });
43378
+ const category = Object.hasOwn(COMMANDCODE_TOOL_TO_CATEGORY, lowered) ? COMMANDCODE_TOOL_TO_CATEGORY[lowered] : void 0;
43379
+ if (category === void 0) return null;
43380
+ return friendlyToolRule({
43381
+ category,
43382
+ inner
43383
+ });
43384
+ }
43385
+ /**
43386
+ * Whether an imported rule is a differently-cased `MCP__<server>__<tool>`
43387
+ * name that folded onto an exact-prefix `mcp__` category (which Command Code
43388
+ * matches case-sensitively, unlike the name rule it came from).
43389
+ */
43390
+ function isFoldedMcpToolNameRule({ rule, category }) {
43391
+ const { tool } = splitCommandcodeRule(rule);
43392
+ return category.startsWith(MCP_CANONICAL_PREFIX$5) && !tool.startsWith(MCP_CANONICAL_PREFIX$5);
43393
+ }
43394
+ /** The `Tool` half of `Tool(specifier)`, when the rule is an exact-prefix MCP rule with a specifier. */
43395
+ function scopedMcpRuleTool(rule) {
43396
+ const { tool, inner } = splitCommandcodeRule(rule);
43397
+ return !isCatchAllSpecifier(inner) && tool.startsWith(MCP_CANONICAL_PREFIX$5) ? tool : null;
43398
+ }
43399
+ /**
43400
+ * The string entries of a Command Code permission list. Command Code reads
43401
+ * each entry on its own and skips one that is not a string, so a stray
43402
+ * `null` beside `Shell(rm -rf *)` must not throw the deny away with it; the
43403
+ * skipped entries are counted in a warning because they cannot be kept.
43404
+ */
43405
+ function stringRules({ list, action, outcome, warn }) {
43406
+ if (!Array.isArray(list)) return [];
43407
+ const rules = list.filter((entry) => typeof entry === "string");
43408
+ const skipped = list.length - rules.length;
43409
+ 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}.`);
43410
+ return rules;
43411
+ }
43412
+ /**
43413
+ * The canonical categories this run rebuilds — every category the canonical
43414
+ * config names (an empty `bash: {}` still reclaims the previous `Shell(...)`
43415
+ * entries, as in the Claude Code adapter) plus every rule it emits, keyed the
43416
+ * way an existing entry parses back (so `mcp: { github: "deny" }`, written as
43417
+ * `mcp__github`, claims the `mcp__github` entries of the previous run; case
43418
+ * variants of the friendly tool names fold together, while MCP names are
43419
+ * matched exactly, as Command Code does). An existing entry whose tool folds onto one of them is
43420
+ * rulesync's to replace, whatever list it sits in — otherwise flipping a rule
43421
+ * from deny to allow would leave the old deny behind and win. Every other
43422
+ * entry — an internal tool name rulesync cannot model, or a modeled tool the
43423
+ * canonical config does not mention — is the user's (Command Code writes
43424
+ * interactive approvals into the same lists) and is preserved verbatim.
43425
+ * A `deny`/`ask` entry that is reclaimed without an equally strict rule
43426
+ * taking its place (say a hand-written `mcp__*` deny next to a canonical
43427
+ * `mcp: { github: "deny" }`) is reported, so regenerating never silently
43428
+ * loosens what the user wrote. Mirrors `managedClaudeToolNames` in the
43429
+ * Claude Code adapter.
43430
+ */
43431
+ function preservedRules({ existingPermissions, key, managedCategories, written, logger }) {
43432
+ return stringRules({
43433
+ list: existingPermissions[key],
43434
+ action: key,
43435
+ outcome: "dropped from the regenerated list",
43436
+ warn: (message) => logger?.warn(message)
43437
+ }).filter((rule) => {
43438
+ const parsed = parseCommandcodeRule(rule);
43439
+ if (parsed === null || !managedCategories.has(parsed.category)) return true;
43440
+ const replacement = written.get(`${parsed.category}(${parsed.pattern})`) ?? written.get(`${parsed.category}(${CATCH_ALL_PATTERN$6})`);
43441
+ if (key !== "allow" && isStricterAction({
43442
+ action: key,
43443
+ existing: replacement
43444
+ })) 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.`);
43445
+ return false;
43446
+ });
43447
+ }
43448
+ /**
43449
+ * The rule to write for a canonical rule, or `null` when Command Code would
43450
+ * ignore it in the `action` list. A server-less wildcard (`*`, `mcp__*`) in
43451
+ * `allow` is warned about and left unwritten rather than written dead. A
43452
+ * specifier on an MCP rule is ignored by Command Code in every list, so a
43453
+ * scoped `deny`/`ask` is written as the bare tool (which is what Command
43454
+ * Code would enforce anyway, only now visibly) with a warning, and a scoped
43455
+ * `allow` — which would grant the whole tool — is refused. Judged on the
43456
+ * rule as written, so the bare `mcp` category cannot smuggle a specifier in
43457
+ * through a `<server>__<tool>(specifier)` pattern.
43458
+ */
43459
+ function writableCommandcodeRule({ rule, emitted, action, logger }) {
43460
+ if (action === "allow" && emitted.notInAllow) {
43461
+ 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.`);
43462
+ return null;
43463
+ }
43464
+ const tool = scopedMcpRuleTool(rule);
43465
+ if (tool === null) return rule;
43466
+ if (action === "allow") {
43467
+ 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.`);
43468
+ return null;
43469
+ }
43470
+ logger?.warn(`Command Code ignores the specifier of an MCP rule, so '${rule}' was written as '${tool}' and applies "${action}" to the whole tool.`);
43471
+ return tool;
43472
+ }
43473
+ /**
43474
+ * Record `action` for `rule`, keeping the stricter one (deny > ask > allow)
43475
+ * when two canonical rules collapse onto the same Command Code entry.
43476
+ */
43477
+ function rankCommandcodeRule({ ranked, rule, action, logger }) {
43478
+ const existing = ranked.get(rule);
43479
+ 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).`);
43480
+ if (isStricterAction({
43481
+ action,
43482
+ existing
43483
+ })) ranked.set(rule, action);
43484
+ }
43485
+ /**
43486
+ * Bucket the canonical rules into Command Code's `allow`/`ask`/`deny` lists.
43487
+ * Collisions resolve to the strictest action (deny > ask > allow); categories
43488
+ * Command Code cannot express are skipped with a warning when they carry a
43489
+ * `deny`; rules Command Code would ignore in the target list are dropped or
43490
+ * rewritten by `writableCommandcodeRule`.
43491
+ */
43492
+ function buildCommandcodeRuleLists({ config, existingPermissions, logger }) {
43493
+ const permission = honorAllToolsOnBash(config.permission);
43494
+ const ranked = /* @__PURE__ */ new Map();
43495
+ const managedCategories = /* @__PURE__ */ new Set();
43496
+ for (const [category, rules] of Object.entries(permission)) {
43497
+ const categoryRule = buildCommandcodeRule(category, CATCH_ALL_PATTERN$6);
43498
+ const managedCategory = categoryRule === null ? null : parseCommandcodeRule(categoryRule);
43499
+ if (managedCategory !== null) managedCategories.add(managedCategory.category);
43500
+ for (const [pattern, action] of Object.entries(rules)) {
43501
+ const rule = buildCommandcodeRule(category, pattern);
43502
+ const emitted = rule === null ? null : parseCommandcodeRule(rule);
43503
+ if (rule === null || emitted === null) {
43504
+ const honoredOnShell = category === "*" && permission.bash !== void 0;
43505
+ 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.`);
43506
+ continue;
43507
+ }
43508
+ managedCategories.add(emitted.category);
43509
+ const written = writableCommandcodeRule({
43510
+ rule,
43511
+ emitted,
43512
+ action,
43513
+ logger
43514
+ });
43515
+ if (written !== null) rankCommandcodeRule({
43516
+ ranked,
43517
+ rule: written,
43518
+ action,
43519
+ logger
43520
+ });
43521
+ }
43522
+ }
43523
+ const written = /* @__PURE__ */ new Map();
43524
+ for (const [rule, action] of ranked) {
43525
+ const parsed = parseCommandcodeRule(rule);
43526
+ if (parsed !== null) written.set(`${parsed.category}(${parsed.pattern})`, action);
43527
+ }
43528
+ const preserved = {
43529
+ existingPermissions,
43530
+ managedCategories,
43531
+ written,
43532
+ logger
43533
+ };
43534
+ const allow = preservedRules({
43535
+ ...preserved,
43536
+ key: "allow"
43537
+ });
43538
+ const ask = preservedRules({
43539
+ ...preserved,
43540
+ key: "ask"
43541
+ });
43542
+ const deny = preservedRules({
43543
+ ...preserved,
43544
+ key: "deny"
43545
+ });
43546
+ for (const [rule, action] of ranked) if (action === "allow") allow.push(rule);
43547
+ else if (action === "ask") ask.push(rule);
43548
+ else deny.push(rule);
43549
+ return {
43550
+ allow: uniq(allow.toSorted()),
43551
+ ask: uniq(ask.toSorted()),
43552
+ deny: uniq(deny.toSorted())
43553
+ };
43554
+ }
43555
+ /**
43556
+ * Why a rule marked `notInAllow` is not imported from `allow`: Command Code
43557
+ * ignores a server-less wildcard or a tool-name glob there, while it does
43558
+ * enforce a scoped `MCP__<server>__<tool>(specifier)` — as a glob over the
43559
+ * call's arguments — and a padded `Shell( * )` — as a pattern narrower than
43560
+ * the bare `Shell` — which rulesync cannot model in `allow` without widening
43561
+ * the grant.
43562
+ */
43563
+ function skippedAllowImportMessage({ rule, category }) {
43564
+ const { tool, inner } = splitCommandcodeRule(rule);
43565
+ 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.`;
43566
+ 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.`;
43567
+ 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.`;
43568
+ }
43569
+ /**
43570
+ * Parse Command Code's `permissions` lists back into a canonical permission
43571
+ * map with `deny > ask > allow` precedence, so a tool described more than
43572
+ * once resolves to the strictest action. Each rule is imported as what
43573
+ * Command Code enforces for it, never as what it looks like: a rule Command
43574
+ * Code ignores in `allow` (`*`, `mcp__*`, an `MCP__...` glob) is skipped
43575
+ * there with a warning, since importing it would turn a dead line into a
43576
+ * live grant for every other target, and so is a scoped
43577
+ * `MCP__<server>__<tool>(specifier)` allow, which Command Code enforces as a
43578
+ * glob over the call's arguments but rulesync cannot import without widening
43579
+ * it to the whole tool; the specifier of an exact-prefix `mcp__...` rule,
43580
+ * which Command Code drops, is folded to the whole tool in every list — with
43581
+ * a warning in `allow`, where the fold widens the grant the user wrote. A
43582
+ * tool-half glob in that exact-prefix spelling (`mcp__<server>__get_*`), which
43583
+ * Command Code matches against tool names in every list, is imported as the
43584
+ * category spelled that way, the same one the generator writes back; other
43585
+ * targets read it as a literal tool name.
43586
+ */
43587
+ function parseCommandcodeRuleLists(permissions) {
43588
+ const permission = {};
43589
+ const lists = [
43590
+ ["allow", permissions.allow],
43591
+ ["ask", permissions.ask],
43592
+ ["deny", permissions.deny]
43593
+ ];
43594
+ for (const [action, rawList] of lists) {
43595
+ const list = stringRules({
43596
+ list: rawList,
43597
+ action,
43598
+ outcome: "not imported",
43599
+ warn: (message) => fallbackLogger.warn(message)
43600
+ });
43601
+ for (const rule of list) {
43602
+ const parsed = parseCommandcodeRule(rule);
43603
+ if (parsed === null || isPrototypePollutionKey(parsed.category) || isPrototypePollutionKey(parsed.pattern)) {
43604
+ 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.`);
43605
+ continue;
43606
+ }
43607
+ const { category, pattern } = parsed;
43608
+ if (action === "allow" && parsed.notInAllow) {
43609
+ fallbackLogger.warn(skippedAllowImportMessage({
43610
+ rule,
43611
+ category
43612
+ }));
43613
+ continue;
43614
+ }
43615
+ 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.`);
43616
+ if (isFoldedMcpToolNameRule({
43617
+ rule,
43618
+ category
43619
+ })) 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.`);
43620
+ const rules = permission[category] ??= {};
43621
+ if (isStricterAction({
43622
+ action,
43623
+ existing: rules[pattern]
43624
+ })) rules[pattern] = action;
43625
+ }
43626
+ }
43627
+ return permission;
43628
+ }
43629
+ /**
43630
+ * Permissions generator for Command Code.
43631
+ *
43632
+ * Rules live under the `permissions` key of `.commandcode/settings.json`
43633
+ * (project) and `~/.commandcode/settings.json` (user) as three lists of
43634
+ * Claude-style entries: `deny` (always wins), `ask`, `allow`. The same key
43635
+ * also carries `defaultMode`, `additionalDirectories` and `disableBypass`,
43636
+ * and the file holds `hooks` and other settings, so writes go through the
43637
+ * shared-config gateway: rulesync replaces the three lists, keeps every
43638
+ * sibling key, and never deletes the file.
43639
+ *
43640
+ * Generate: `permission.<category>.<pattern>` becomes `Tool(pattern)` with
43641
+ * the friendly names (`Shell`, `Read`, `Edit`, `Write`, `WebFetch`,
43642
+ * `WebSearch`), `mcp__<server>__<tool>` names pass through, and the all-tools
43643
+ * `*` category becomes the bare `*` rule Command Code accepts in `deny`/`ask`.
43644
+ * Only the entries of the categories the canonical config names are rebuilt;
43645
+ * every other existing entry is preserved verbatim.
43646
+ * Import: the lists are parsed back as what Command Code enforces for each
43647
+ * entry — friendly tool names case-insensitively (with the internal aliases
43648
+ * such as `Bash`, `PowerShell` and `write_file` folding onto their
43649
+ * category), MCP names exactly; rules for internal tool names rulesync does
43650
+ * not model are skipped.
43651
+ *
43652
+ * @see https://commandcode.ai/docs/permissions
43653
+ * @see https://commandcode.ai/docs/settings
43654
+ */
43655
+ var CommandcodePermissions = class CommandcodePermissions extends ToolPermissions {
43656
+ constructor(params) {
43657
+ super({
43658
+ ...params,
43659
+ fileContent: params.fileContent ?? "{}"
43660
+ });
43661
+ }
43662
+ /**
43663
+ * `settings.json` holds hooks and other user settings, so it is never
43664
+ * deleted; clearing permissions happens via an in-place merge.
43665
+ */
43666
+ isDeletable() {
43667
+ return false;
43668
+ }
43669
+ static getSettablePaths(_options = {}) {
43670
+ return {
43671
+ relativeDirPath: COMMANDCODE_DIR,
43672
+ relativeFilePath: COMMANDCODE_SETTINGS_FILE_NAME
43673
+ };
43674
+ }
43675
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
43676
+ const paths = CommandcodePermissions.getSettablePaths({ global });
43677
+ const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{}";
43678
+ return new CommandcodePermissions({
43679
+ outputRoot,
43680
+ relativeDirPath: paths.relativeDirPath,
43681
+ relativeFilePath: paths.relativeFilePath,
43682
+ fileContent,
43683
+ validate,
43684
+ global
43685
+ });
43686
+ }
43687
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, logger }) {
43688
+ const paths = CommandcodePermissions.getSettablePaths({ global });
43689
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43690
+ const existingContent = await readFileContentOrNull(filePath) ?? "{}";
43691
+ const existing = parseCommandcodeSettings({
43692
+ fileContent: existingContent,
43693
+ relativePath: join(paths.relativeDirPath, paths.relativeFilePath)
43694
+ });
43695
+ const existingPermissions = isRecord$1(existing[COMMANDCODE_PERMISSIONS_KEY]) ? existing[COMMANDCODE_PERMISSIONS_KEY] : {};
43696
+ const lists = buildCommandcodeRuleLists({
43697
+ config: rulesyncPermissions.getJson(),
43698
+ existingPermissions,
43699
+ logger
43700
+ });
43701
+ const permissions = { ...existingPermissions };
43702
+ for (const key of [
43703
+ "allow",
43704
+ "ask",
43705
+ "deny"
43706
+ ]) if (lists[key].length > 0) permissions[key] = lists[key];
43707
+ else delete permissions[key];
43708
+ return new CommandcodePermissions({
43709
+ outputRoot,
43710
+ relativeDirPath: paths.relativeDirPath,
43711
+ relativeFilePath: paths.relativeFilePath,
43712
+ fileContent: applySharedConfigPatch({
43713
+ fileKey: sharedConfigFileKey(paths),
43714
+ feature: "permissions",
43715
+ existingContent,
43716
+ patch: { [COMMANDCODE_PERMISSIONS_KEY]: permissions },
43717
+ filePath
43718
+ }),
43719
+ validate: true,
43720
+ global
43721
+ });
43722
+ }
43723
+ toRulesyncPermissions() {
43724
+ const settings = parseCommandcodeSettings({
43725
+ fileContent: this.getFileContent() || "{}",
43726
+ relativePath: join(this.getRelativeDirPath(), this.getRelativeFilePath())
43727
+ });
43728
+ const permissions = isRecord$1(settings[COMMANDCODE_PERMISSIONS_KEY]) ? settings[COMMANDCODE_PERMISSIONS_KEY] : {};
43729
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission: parseCommandcodeRuleLists(permissions) }, null, 2) });
43730
+ }
43731
+ validate() {
43732
+ return {
43733
+ success: true,
43734
+ error: null
43735
+ };
43736
+ }
43737
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
43738
+ return new CommandcodePermissions({
43739
+ outputRoot,
43740
+ relativeDirPath,
43741
+ relativeFilePath,
43742
+ fileContent: "{}",
43743
+ validate: false,
43744
+ global
43745
+ });
43746
+ }
43747
+ };
43748
+ /**
43749
+ * Fail closed on a syntax error or non-object root, matching the write path's
43750
+ * shared-config declaration, so a broken file is surfaced rather than
43751
+ * partially imported or overwritten.
43752
+ */
43753
+ function parseCommandcodeSettings({ fileContent, relativePath }) {
43754
+ try {
43755
+ return parseSharedConfig({
43756
+ format: "json",
43757
+ fileContent,
43758
+ filePath: relativePath,
43759
+ invalidRootPolicy: "error"
43760
+ });
43761
+ } catch (error) {
43762
+ throw new Error(`Failed to parse Command Code settings in ${relativePath}: ${formatError(error)}`, { cause: error });
43763
+ }
43764
+ }
43765
+ //#endregion
41946
43766
  //#region src/features/permissions/continue-permissions.ts
41947
43767
  const CONTINUE_GLOBAL_ONLY_MESSAGE = "Continue permissions are global-only; use --global to sync ~/.continue/permissions.yaml";
41948
- const CATCH_ALL_PATTERN$4 = "*";
43768
+ const CATCH_ALL_PATTERN$5 = "*";
43769
+ /**
43770
+ * A run of `*` (`**`, `***`) is the catch-all spelled another way: Continue
43771
+ * would match `Tool(**)` against every argument just as the bare tool name
43772
+ * does, so it is written — and, under the all-tools category, honored — as
43773
+ * the bare entry instead of being skipped as a pattern-specific rule.
43774
+ */
43775
+ function normalizeCatchAllPattern$1(pattern) {
43776
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$5 : pattern;
43777
+ }
41949
43778
  const CANONICAL_TO_CONTINUE_TOOL_NAMES = {
41950
43779
  bash: "Bash",
41951
43780
  read: "Read",
@@ -41973,7 +43802,7 @@ const CONTINUE_PATTERN_RE = /^([^(]+)(?:\(([^)]*)\))?$/;
41973
43802
  function toContinueToolName(canonical) {
41974
43803
  return Object.hasOwn(CANONICAL_TO_CONTINUE_TOOL_NAMES, canonical) ? CANONICAL_TO_CONTINUE_TOOL_NAMES[canonical] ?? canonical : canonical;
41975
43804
  }
41976
- function toCanonicalToolName$4(continueName) {
43805
+ function toCanonicalToolName$5(continueName) {
41977
43806
  return Object.hasOwn(CONTINUE_TO_CANONICAL_TOOL_NAMES, continueName) ? CONTINUE_TO_CANONICAL_TOOL_NAMES[continueName] ?? continueName : continueName;
41978
43807
  }
41979
43808
  /**
@@ -41981,7 +43810,7 @@ function toCanonicalToolName$4(continueName) {
41981
43810
  * `Tool(pattern)`.
41982
43811
  */
41983
43812
  function buildContinuePermissionEntry(toolName, pattern) {
41984
- return pattern === CATCH_ALL_PATTERN$4 ? toolName : `${toolName}(${pattern})`;
43813
+ return pattern === CATCH_ALL_PATTERN$5 ? toolName : `${toolName}(${pattern})`;
41985
43814
  }
41986
43815
  /**
41987
43816
  * Split a permissions.yaml entry into its tool name and argument pattern, or
@@ -41995,7 +43824,7 @@ function parseContinuePermissionEntry(entry) {
41995
43824
  if (toolName === "") return;
41996
43825
  return {
41997
43826
  toolName,
41998
- pattern: pattern === "" ? CATCH_ALL_PATTERN$4 : pattern
43827
+ pattern: pattern === "" ? CATCH_ALL_PATTERN$5 : pattern
41999
43828
  };
42000
43829
  }
42001
43830
  /**
@@ -42162,13 +43991,14 @@ function convertRulesyncToContinuePermissions({ config, logger }) {
42162
43991
  for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
42163
43992
  if (isPrototypePollutionKey(category)) continue;
42164
43993
  const toolName = category === "*" ? "*" : toContinueToolName(category);
42165
- for (const [pattern, action] of Object.entries(rules)) {
42166
- if (isPrototypePollutionKey(pattern)) continue;
43994
+ for (const [rawPattern, action] of Object.entries(rules)) {
43995
+ if (isPrototypePollutionKey(rawPattern)) continue;
43996
+ const pattern = normalizeCatchAllPattern$1(rawPattern);
42167
43997
  if (pattern.includes("(") || pattern.includes(")")) {
42168
43998
  logger?.warn(`Continue permissions.yaml cannot hold a parenthesis inside a pattern, so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
42169
43999
  continue;
42170
44000
  }
42171
- if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$4) {
44001
+ if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$5) {
42172
44002
  logger?.warn(`Continue permissions.yaml cannot scope a pattern to every tool, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
42173
44003
  continue;
42174
44004
  }
@@ -42193,7 +44023,7 @@ function convertContinuePermissionsToRulesync(lists) {
42193
44023
  const parsedEntry = parseContinuePermissionEntry(entry);
42194
44024
  if (parsedEntry === void 0) continue;
42195
44025
  if (isPrototypePollutionKey(parsedEntry.toolName) || isPrototypePollutionKey(parsedEntry.pattern)) continue;
42196
- const category = toCanonicalToolName$4(parsedEntry.toolName);
44026
+ const category = toCanonicalToolName$5(parsedEntry.toolName);
42197
44027
  permission[category] ??= {};
42198
44028
  permission[category][parsedEntry.pattern] ??= action;
42199
44029
  }
@@ -42516,6 +44346,295 @@ var CopilotcliPermissions = class CopilotcliPermissions extends ToolPermissions
42516
44346
  }
42517
44347
  };
42518
44348
  //#endregion
44349
+ //#region src/features/permissions/crush-permissions.ts
44350
+ const CATCH_ALL_PATTERN$4 = "*";
44351
+ /**
44352
+ * A run of `*` (`**`, `***`) is the catch-all spelled another way: Crush has no
44353
+ * argument patterns at all, so it is the only pattern a rule can carry and
44354
+ * still be written.
44355
+ */
44356
+ function normalizeCatchAllPattern(pattern) {
44357
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$4 : pattern;
44358
+ }
44359
+ const CANONICAL_TO_CRUSH_TOOL_NAMES = {
44360
+ bash: "bash",
44361
+ read: "view",
44362
+ edit: "edit",
44363
+ write: "write",
44364
+ grep: "grep",
44365
+ glob: "glob",
44366
+ webfetch: "fetch"
44367
+ };
44368
+ const CRUSH_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CRUSH_TOOL_NAMES).map(([k, v]) => [v, k]));
44369
+ const MCP_CANONICAL_PREFIX$4 = "mcp__";
44370
+ const MCP_CRUSH_PREFIX = "mcp_";
44371
+ function toCrushToolName(canonical) {
44372
+ if (Object.hasOwn(CANONICAL_TO_CRUSH_TOOL_NAMES, canonical)) return CANONICAL_TO_CRUSH_TOOL_NAMES[canonical] ?? canonical;
44373
+ if (canonical.startsWith(MCP_CANONICAL_PREFIX$4) && canonical.length > 5) return `${MCP_CRUSH_PREFIX}${canonical.slice(5).replaceAll("__", "_")}`;
44374
+ return canonical;
44375
+ }
44376
+ function toCanonicalToolName$4(crushName) {
44377
+ return Object.hasOwn(CRUSH_TO_CANONICAL_TOOL_NAMES, crushName) ? CRUSH_TO_CANONICAL_TOOL_NAMES[crushName] ?? crushName : crushName;
44378
+ }
44379
+ function isMcpToolName(crushName) {
44380
+ return crushName.startsWith(MCP_CRUSH_PREFIX) && crushName.length > 4;
44381
+ }
44382
+ function uniq$1(values) {
44383
+ return [...new Set(values)];
44384
+ }
44385
+ /**
44386
+ * Permissions adapter for Crush.
44387
+ *
44388
+ * Crush has no per-argument permission rules. Its JSON config carries two
44389
+ * tool-level lists instead:
44390
+ * - `permissions.allowed_tools`: tools (or `tool:action` pairs) that run
44391
+ * without a permission prompt; everything else prompts.
44392
+ * - `options.disabled_tools`: built-in tools removed from the agent
44393
+ * entirely.
44394
+ *
44395
+ * Mapping (rulesync canonical -> Crush), catch-all (`*`) rules only:
44396
+ * - `allow` -> an `allowed_tools` entry.
44397
+ * - `deny` -> a `disabled_tools` entry. Crush only filters its built-ins
44398
+ * through that list, so a deny for an MCP tool is reported and skipped
44399
+ * (disable the tool in the MCP server's `disabled_tools` instead).
44400
+ * - `ask` -> nothing (prompting is Crush's default).
44401
+ * - Tool name: `bash` -> `bash`, `read` -> `view`, `edit` -> `edit`,
44402
+ * `write` -> `write`, `grep` -> `grep`, `glob` -> `glob`,
44403
+ * `webfetch` -> `fetch`, `mcp__<server>__<tool>` -> `mcp_<server>_<tool>`;
44404
+ * any other category passes through verbatim.
44405
+ *
44406
+ * A pattern-specific rule cannot be expressed and is reported and skipped.
44407
+ * Because Crush cannot narrow an allowed tool, a category's catch-all `allow`
44408
+ * is not written while the same category carries a pattern-specific `deny` or
44409
+ * `ask`: the tool keeps prompting (fail closed) rather than being widened to
44410
+ * everything. The all-tools `*` category is mirrored onto `bash` by
44411
+ * `honorAllToolsOnBash` and otherwise skipped, since Crush has no allow-all
44412
+ * or disable-all list.
44413
+ *
44414
+ * `crush.json` is Crush's main config: entries naming a tool the canonical
44415
+ * config does not manage, and every `tool:action` entry (a form rulesync
44416
+ * never derives), are preserved verbatim; the managed tools' bare entries
44417
+ * are rebuilt, every other key of `permissions` / `options` and of the file is
44418
+ * kept, and the file is never deleted. Project scope writes `crush.json`, or
44419
+ * an existing `.crush.json`; Crush merges the pair (lists concatenated), so an
44420
+ * entry left in the other file stays in effect and is reported.
44421
+ *
44422
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/config/README.md
44423
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
44424
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/permission/permission.go
44425
+ */
44426
+ var CrushPermissions = class CrushPermissions extends ToolPermissions {
44427
+ json;
44428
+ constructor(params) {
44429
+ super({
44430
+ ...params,
44431
+ fileContent: params.fileContent ?? ""
44432
+ });
44433
+ this.json = parseCrushConfig(this.fileContent ?? "", join(this.relativeDirPath, this.relativeFilePath));
44434
+ }
44435
+ getJson() {
44436
+ return this.json;
44437
+ }
44438
+ isDeletable() {
44439
+ return false;
44440
+ }
44441
+ /**
44442
+ * `crush.json` is Crush's file: rulesync merges into it when it exists but
44443
+ * does not create one that holds nothing of its own — an absent file and an
44444
+ * absent `allowed_tools` both mean "prompt for everything".
44445
+ */
44446
+ shouldSkipCreationWhenPayloadEmpty() {
44447
+ return true;
44448
+ }
44449
+ static getSettablePaths({ global = false } = {}) {
44450
+ return getCrushConfigSettablePaths({ global });
44451
+ }
44452
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
44453
+ const location = await resolveCrushConfigFile({
44454
+ outputRoot,
44455
+ global
44456
+ });
44457
+ return new CrushPermissions({
44458
+ outputRoot,
44459
+ relativeDirPath: location.relativeDirPath,
44460
+ relativeFilePath: location.relativeFilePath,
44461
+ fileContent: crushConfigImportContent(location),
44462
+ validate,
44463
+ global
44464
+ });
44465
+ }
44466
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
44467
+ const location = await resolveCrushConfigFile({
44468
+ outputRoot,
44469
+ global
44470
+ });
44471
+ const existingContent = location.fileContent ?? "";
44472
+ warnCrushTwinLeftovers({
44473
+ location,
44474
+ ownedPaths: [[CRUSH_PERMISSIONS_KEY, CRUSH_ALLOWED_TOOLS_KEY], [CRUSH_OPTIONS_KEY, CRUSH_DISABLED_TOOLS_KEY]],
44475
+ logger
44476
+ });
44477
+ const existing = parseCrushConfig(existingContent, location.filePath);
44478
+ const existingPermissions = isRecord$1(existing["permissions"]) ? existing[CRUSH_PERMISSIONS_KEY] : void 0;
44479
+ const existingOptions = isRecord$1(existing["options"]) ? existing[CRUSH_OPTIONS_KEY] : void 0;
44480
+ const config = rulesyncPermissions.getJson();
44481
+ const generated = convertRulesyncToCrushLists({
44482
+ config,
44483
+ logger
44484
+ });
44485
+ const managedToolNames = managedCrushToolNames(config);
44486
+ const preservedAllowed = (isStringArray$2(existingPermissions?.["allowed_tools"]) ? existingPermissions[CRUSH_ALLOWED_TOOLS_KEY] : []).filter((entry) => entry.includes(":") || !managedToolNames.has(entry));
44487
+ const preservedDisabled = (isStringArray$2(existingOptions?.["disabled_tools"]) ? existingOptions[CRUSH_DISABLED_TOOLS_KEY] : []).filter((entry) => !managedToolNames.has(entry));
44488
+ const allowedList = uniq$1([...preservedAllowed, ...generated.allowed]);
44489
+ const disabledList = uniq$1([...preservedDisabled, ...generated.disabled]);
44490
+ const patch = {};
44491
+ if (allowedList.length > 0 || existingPermissions !== void 0) patch[CRUSH_PERMISSIONS_KEY] = { [CRUSH_ALLOWED_TOOLS_KEY]: allowedList.length > 0 ? allowedList : void 0 };
44492
+ if (disabledList.length > 0 || existingOptions !== void 0) patch[CRUSH_OPTIONS_KEY] = { [CRUSH_DISABLED_TOOLS_KEY]: disabledList.length > 0 ? disabledList : void 0 };
44493
+ return new CrushPermissions({
44494
+ outputRoot,
44495
+ relativeDirPath: location.relativeDirPath,
44496
+ relativeFilePath: location.relativeFilePath,
44497
+ fileContent: applySharedConfigPatch({
44498
+ fileKey: sharedConfigFileKey(CrushPermissions.getSettablePaths({ global })),
44499
+ feature: "permissions",
44500
+ existingContent,
44501
+ patch,
44502
+ filePath: location.filePath,
44503
+ logger
44504
+ }),
44505
+ validate: true,
44506
+ global
44507
+ });
44508
+ }
44509
+ toRulesyncPermissions() {
44510
+ const permissions = isRecord$1(this.json["permissions"]) ? this.json[CRUSH_PERMISSIONS_KEY] : {};
44511
+ const options = isRecord$1(this.json["options"]) ? this.json[CRUSH_OPTIONS_KEY] : {};
44512
+ const rulesyncConfig = convertCrushListsToRulesync({
44513
+ allowed: isStringArray$2(permissions["allowed_tools"]) ? permissions[CRUSH_ALLOWED_TOOLS_KEY] : [],
44514
+ disabled: isStringArray$2(options["disabled_tools"]) ? options[CRUSH_DISABLED_TOOLS_KEY] : []
44515
+ });
44516
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
44517
+ }
44518
+ validate() {
44519
+ return {
44520
+ success: true,
44521
+ error: null
44522
+ };
44523
+ }
44524
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
44525
+ return new CrushPermissions({
44526
+ outputRoot,
44527
+ relativeDirPath,
44528
+ relativeFilePath,
44529
+ fileContent: "{}",
44530
+ validate: false,
44531
+ global
44532
+ });
44533
+ }
44534
+ };
44535
+ /**
44536
+ * The Crush tool names the canonical config manages, i.e. the names its
44537
+ * categories map to. Entries naming any other tool are the user's and survive
44538
+ * a generate. The all-tools `*` category names no Crush tool of its own.
44539
+ */
44540
+ function managedCrushToolNames(config) {
44541
+ return new Set(Object.keys(config.permission).filter((category) => category !== "*" && !isPrototypePollutionKey(category)).map((category) => toCrushToolName(category)));
44542
+ }
44543
+ /**
44544
+ * Reduce one category's rules to the single tool-wide action Crush can carry,
44545
+ * or undefined when nothing should be written for the tool: no catch-all rule,
44546
+ * a catch-all allow next to a narrower deny/ask (Crush cannot narrow an
44547
+ * allowed tool, so it keeps prompting instead), or a deny on an MCP tool
44548
+ * (`options.disabled_tools` only covers built-ins).
44549
+ */
44550
+ function resolveCategoryAction({ category, toolName, rules, logger }) {
44551
+ let catchAllAction;
44552
+ let restricted = false;
44553
+ for (const [rawPattern, action] of Object.entries(rules)) {
44554
+ if (isPrototypePollutionKey(rawPattern)) continue;
44555
+ const pattern = normalizeCatchAllPattern(rawPattern);
44556
+ if (pattern === CATCH_ALL_PATTERN$4) {
44557
+ catchAllAction = action;
44558
+ continue;
44559
+ }
44560
+ logger?.warn(`Crush permissions are tool-wide (no argument patterns), so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
44561
+ if (action !== "allow") restricted = true;
44562
+ }
44563
+ if (catchAllAction === "allow" && restricted) {
44564
+ 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}".`);
44565
+ return;
44566
+ }
44567
+ if (catchAllAction === "deny" && isMcpToolName(toolName)) {
44568
+ 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.`);
44569
+ return;
44570
+ }
44571
+ return catchAllAction;
44572
+ }
44573
+ /**
44574
+ * Convert a rulesync permissions config into Crush's two tool lists.
44575
+ */
44576
+ function convertRulesyncToCrushLists({ config, logger }) {
44577
+ const lists = {
44578
+ allowed: [],
44579
+ disabled: []
44580
+ };
44581
+ const actionByTool = /* @__PURE__ */ new Map();
44582
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
44583
+ if (isPrototypePollutionKey(category)) continue;
44584
+ if (category === "*") {
44585
+ for (const [rawPattern, action] of Object.entries(rules)) {
44586
+ if (isPrototypePollutionKey(rawPattern)) continue;
44587
+ logger?.warn(`Crush has no allow-all or disable-all tool list, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(rawPattern)}) was skipped.`);
44588
+ }
44589
+ continue;
44590
+ }
44591
+ const toolName = toCrushToolName(category);
44592
+ const catchAllAction = resolveCategoryAction({
44593
+ category,
44594
+ toolName,
44595
+ rules,
44596
+ logger
44597
+ });
44598
+ if (catchAllAction === void 0) continue;
44599
+ const previous = actionByTool.get(toolName);
44600
+ 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.`);
44601
+ actionByTool.set(toolName, catchAllAction);
44602
+ if (catchAllAction === "allow") lists.allowed.push(toolName);
44603
+ else if (catchAllAction === "deny") lists.disabled.push(toolName);
44604
+ }
44605
+ return {
44606
+ allowed: uniq$1(lists.allowed),
44607
+ disabled: uniq$1(lists.disabled)
44608
+ };
44609
+ }
44610
+ /**
44611
+ * Convert Crush's two tool lists back into a rulesync config. A `tool:action`
44612
+ * entry scopes an allow to one action of a tool, which the canonical model
44613
+ * cannot express, so it is left out rather than widened to the whole tool.
44614
+ */
44615
+ function convertCrushListsToRulesync({ allowed, disabled }) {
44616
+ const permission = {};
44617
+ const bucketFor = (category) => {
44618
+ const own = lookupOwn({
44619
+ record: permission,
44620
+ key: category
44621
+ });
44622
+ if (own !== void 0) return own;
44623
+ const created = {};
44624
+ permission[category] = created;
44625
+ return created;
44626
+ };
44627
+ for (const entry of disabled) {
44628
+ if (entry === "" || isPrototypePollutionKey(entry)) continue;
44629
+ bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] = "deny";
44630
+ }
44631
+ for (const entry of allowed) {
44632
+ if (entry === "" || entry.includes(":") || isPrototypePollutionKey(entry)) continue;
44633
+ bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] ??= "allow";
44634
+ }
44635
+ return { permission };
44636
+ }
44637
+ //#endregion
42519
44638
  //#region src/features/permissions/cursor-permissions.ts
42520
44639
  /**
42521
44640
  * Mapping from rulesync canonical tool category names (lowercase) to Cursor CLI
@@ -43670,6 +45789,40 @@ function convertDeepagentsToRulesyncPermissions({ allowList }) {
43670
45789
  return Object.keys(bash).length > 0 ? { permission: { bash } } : { permission: {} };
43671
45790
  }
43672
45791
  //#endregion
45792
+ //#region src/features/permissions/single-action-collapse.ts
45793
+ /**
45794
+ * Strictness order shared by the tools whose permission model evaluates
45795
+ * `deny > ask > allow`. Used wherever several canonical rules have to fold
45796
+ * into one entry, so the strictest action always wins.
45797
+ */
45798
+ const PERMISSION_ACTION_PRIORITY = {
45799
+ allow: 0,
45800
+ ask: 1,
45801
+ deny: 2
45802
+ };
45803
+ /**
45804
+ * Collapse a pattern map to the single action a tool can hold for a scope
45805
+ * that has no pattern matcher, using deny > ask > allow precedence.
45806
+ *
45807
+ * A map without a catch-all grants nothing to unmatched inputs, so an
45808
+ * implicit `ask` joins the candidates and a narrow allowlist can never widen
45809
+ * into a blanket allow. Returns `undefined` for an empty map; each caller
45810
+ * decides what an empty map means for its tool (e.g. emit nothing, or fall
45811
+ * back to `deny` when the tool's own default is allow).
45812
+ */
45813
+ function collapseRulesToSingleAction({ rules }) {
45814
+ const actions = Object.values(rules);
45815
+ if (actions.length === 0) return;
45816
+ return (Object.hasOwn(rules, "*") ? actions : [...actions, "ask"]).reduce((current, candidate) => PERMISSION_ACTION_PRIORITY[candidate] > PERMISSION_ACTION_PRIORITY[current] ? candidate : current);
45817
+ }
45818
+ /**
45819
+ * Whether a pattern map carries anything beyond the `*` catch-all, i.e.
45820
+ * whether collapsing it to a single action loses information worth a warning.
45821
+ */
45822
+ function hasPatternSpecificRules(rules) {
45823
+ return Object.keys(rules).some((pattern) => pattern !== "*");
45824
+ }
45825
+ //#endregion
43673
45826
  //#region src/features/permissions/devin-permissions.ts
43674
45827
  /**
43675
45828
  * Mapping from rulesync canonical tool category names to Devin Local permission
@@ -43679,17 +45832,21 @@ function convertDeepagentsToRulesyncPermissions({ allowList }) {
43679
45832
  * `Write(glob)`, `Exec(prefix)`, and `Fetch(pattern)` — plus MCP tool patterns
43680
45833
  * (`mcp__server__tool`). The canonical `edit` and `write` categories both map
43681
45834
  * onto Devin's single `Write` scope; on import `Write` maps back to `write`, so
43682
- * `edit` rules round-trip as `write` (a lossy but documented collapse). Unknown
45835
+ * `edit` rules round-trip as `write` (a lossy but documented collapse). The
45836
+ * canonical `websearch` category maps onto the bare `web_search` tool name,
45837
+ * accepted in the permission lists since CLI v3000.10.21 (2026-09-10). Unknown
43683
45838
  * names (e.g. `mcp__github__list_issues`) pass through verbatim.
43684
45839
  *
43685
45840
  * @see https://docs.devin.ai/cli/reference/permissions
45841
+ * @see https://docs.devin.ai/cli/changelog/stable — v3000.10.21, `web_search`
43686
45842
  */
43687
45843
  const CANONICAL_TO_DEVIN_SCOPE = {
43688
45844
  read: "Read",
43689
45845
  write: "Write",
43690
45846
  edit: "Write",
43691
45847
  bash: "Exec",
43692
- webfetch: "Fetch"
45848
+ webfetch: "Fetch",
45849
+ websearch: "web_search"
43693
45850
  };
43694
45851
  /**
43695
45852
  * Reverse mapping from Devin scope matchers to rulesync canonical names.
@@ -43698,8 +45855,15 @@ const DEVIN_SCOPE_TO_CANONICAL = {
43698
45855
  Read: "read",
43699
45856
  Write: "write",
43700
45857
  Exec: "bash",
43701
- Fetch: "webfetch"
45858
+ Fetch: "webfetch",
45859
+ web_search: "websearch"
43702
45860
  };
45861
+ /**
45862
+ * Devin scopes that exist only as a bare tool name: there is no
45863
+ * `web_search(pattern)` matcher, so a pattern-specific rule under the canonical
45864
+ * category cannot be expressed and is collapsed to one action instead.
45865
+ */
45866
+ const DEVIN_BARE_ONLY_SCOPES = /* @__PURE__ */ new Set(["web_search"]);
43703
45867
  function toDevinScope(canonical) {
43704
45868
  return CANONICAL_TO_DEVIN_SCOPE[canonical] ?? canonical;
43705
45869
  }
@@ -43884,7 +46048,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
43884
46048
  throw new Error(`Failed to parse existing Devin config at ${filePath}: ${formatError(error)}`, { cause: error });
43885
46049
  }
43886
46050
  const config = rulesyncPermissions.getJson();
43887
- const { allow, ask, deny } = convertRulesyncToDevinPermissions(config);
46051
+ const { allow, ask, deny } = convertRulesyncToDevinPermissions({
46052
+ config,
46053
+ logger
46054
+ });
43888
46055
  const managedScopes = new Set(Object.keys(config.permission).map((category) => toDevinScope(category)));
43889
46056
  const existingPermissions = isRecord$1(settings.permissions) ? settings.permissions : {};
43890
46057
  const preserve = (entries) => (entries ?? []).filter((entry) => !managedScopes.has(parseDevinPermissionEntry(entry).scope));
@@ -43988,26 +46155,50 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
43988
46155
  }
43989
46156
  };
43990
46157
  /**
46158
+ * Collapse the pattern rules of a bare-only scope to the single action Devin
46159
+ * can hold for it, using deny > ask > allow precedence. A map without a
46160
+ * catch-all grants nothing to unmatched inputs, so an implicit `ask` joins the
46161
+ * candidates and a narrow allowlist can never widen into a blanket allow.
46162
+ * Returns `undefined` for an empty map: like any other empty category it
46163
+ * emits nothing, which leaves Devin's own auto-approve default in place.
46164
+ */
46165
+ function collapseBareOnlyScopeRules({ category, scope, rules, logger }) {
46166
+ const action = collapseRulesToSingleAction({ rules });
46167
+ if (action === void 0) return;
46168
+ 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.`);
46169
+ return action;
46170
+ }
46171
+ /**
43991
46172
  * Convert rulesync permissions config to Devin allow/ask/deny arrays.
43992
46173
  */
43993
- function convertRulesyncToDevinPermissions(config) {
46174
+ function convertRulesyncToDevinPermissions({ config, logger }) {
43994
46175
  const allow = [];
43995
46176
  const ask = [];
43996
46177
  const deny = [];
46178
+ const push = (entry, action) => {
46179
+ switch (action) {
46180
+ case "allow":
46181
+ allow.push(entry);
46182
+ break;
46183
+ case "ask":
46184
+ ask.push(entry);
46185
+ break;
46186
+ case "deny": deny.push(entry);
46187
+ }
46188
+ };
43997
46189
  for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
43998
46190
  const scope = toDevinScope(category);
43999
- for (const [pattern, action] of Object.entries(rules)) {
44000
- const entry = buildDevinPermissionEntry(scope, pattern);
44001
- switch (action) {
44002
- case "allow":
44003
- allow.push(entry);
44004
- break;
44005
- case "ask":
44006
- ask.push(entry);
44007
- break;
44008
- case "deny": deny.push(entry);
44009
- }
46191
+ if (DEVIN_BARE_ONLY_SCOPES.has(scope)) {
46192
+ const action = collapseBareOnlyScopeRules({
46193
+ category,
46194
+ scope,
46195
+ rules,
46196
+ logger
46197
+ });
46198
+ if (action !== void 0) push(scope, action);
46199
+ continue;
44010
46200
  }
46201
+ for (const [pattern, action] of Object.entries(rules)) push(buildDevinPermissionEntry(scope, pattern), action);
44011
46202
  }
44012
46203
  return {
44013
46204
  allow,
@@ -46253,20 +48444,14 @@ const OPENCODE_ACTION_ONLY_PERMISSION_KEYS = /* @__PURE__ */ new Set([
46253
48444
  "question",
46254
48445
  "doom_loop"
46255
48446
  ]);
46256
- const PERMISSION_ACTION_PRIORITY = {
46257
- allow: 0,
46258
- ask: 1,
46259
- deny: 2
46260
- };
46261
48447
  function toOpencodePermission({ category, value, logger }) {
46262
48448
  if (typeof value === "string" || !OPENCODE_ACTION_ONLY_PERMISSION_KEYS.has(category)) return value;
46263
- const actions = Object.values(value);
46264
- if (actions.length === 0) {
48449
+ const action = collapseRulesToSingleAction({ rules: value });
48450
+ if (action === void 0) {
46265
48451
  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.`);
46266
48452
  return "deny";
46267
48453
  }
46268
- const action = (Object.hasOwn(value, "*") ? actions : [...actions, "ask"]).reduce((current, candidate) => PERMISSION_ACTION_PRIORITY[candidate] > PERMISSION_ACTION_PRIORITY[current] ? candidate : current);
46269
- 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.`);
48454
+ 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.`);
46270
48455
  return action;
46271
48456
  }
46272
48457
  /**
@@ -46644,6 +48829,7 @@ const QWEN_OVERRIDE_TOOLS_KEYS = [
46644
48829
  "visible",
46645
48830
  "eager",
46646
48831
  "listDirectory",
48832
+ "todoWrite",
46647
48833
  "workflowsEnabled"
46648
48834
  ];
46649
48835
  const QWEN_OVERRIDE_SECURITY_KEYS = [
@@ -46749,6 +48935,11 @@ const QWEN_OVERRIDE_GROUPS = [{
46749
48935
  rule: "global-machine-wide",
46750
48936
  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.`,
46751
48937
  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."
48938
+ },
48939
+ todoWrite: {
48940
+ rule: "global-machine-wide",
48941
+ 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.`,
48942
+ 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."
46752
48943
  }
46753
48944
  }
46754
48945
  }, {
@@ -48555,9 +50746,25 @@ function widenToPrefixGlob(pattern) {
48555
50746
  function toShellEntry(prefix) {
48556
50747
  return prefix === "" ? SHELL_TOOL_NAME : `${SHELL_TOOL_NAME}(${prefix})`;
48557
50748
  }
48558
- /** The `bash` pattern a `run_shell_command(<prefix>)` entry stands for. */
50749
+ /**
50750
+ * The `bash` pattern a `run_shell_command(<prefix>)` entry stands for. A
50751
+ * prefix Tabnine matches literally, so one an author spelled glob-like
50752
+ * (`git *`, `*`) is read as the glob it looks like rather than doubled into
50753
+ * `git * *`, which no comparison or warning could make sense of.
50754
+ */
48559
50755
  function toShellPattern(prefix) {
48560
- return prefix === void 0 || prefix === "" ? "*" : `${prefix} *`;
50756
+ if (prefix === void 0 || prefix === "" || prefix === "*") return "*";
50757
+ return prefix.endsWith(" *") ? prefix : `${prefix} *`;
50758
+ }
50759
+ /**
50760
+ * Whether {@link toShellPattern} read the prefix as a glob: such an entry
50761
+ * would never fire on Tabnine as spelled (the prefix is matched literally, so
50762
+ * `run_shell_command(git *)` only matches a command line starting with the
50763
+ * two characters `git *`), and rulesync writes it back as the prefix the glob
50764
+ * denotes — a wider entry that the author is told about.
50765
+ */
50766
+ function isGlobSpelledPrefix(prefix) {
50767
+ return prefix !== void 0 && (prefix === "*" || prefix.endsWith(" *"));
48561
50768
  }
48562
50769
  /**
48563
50770
  * Split a `tools.allowed`/`tools.exclude` entry into its tool name and the
@@ -48683,7 +50890,7 @@ function collectTrustAffectingOverrideEntries({ tools, general }) {
48683
50890
  const allowed = isStringArray$2(tools[ALLOWED_KEY]) ? tools[ALLOWED_KEY] : [];
48684
50891
  if (allowed.length > 0) entries.push({
48685
50892
  label: `tools.allowed (${allowed.map(quoteValueForWarning).join(", ")})`,
48686
- reason: "auto-approves what it names as the override spells it; a non-shell entry has no canonical rule to be checked against"
50893
+ 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"
48687
50894
  });
48688
50895
  if (Object.hasOwn(tools, SANDBOX_KEY) && isNotTrue(tools[SANDBOX_KEY])) entries.push({
48689
50896
  label: `tools.sandbox = ${quoteValueForWarning(tools[SANDBOX_KEY])}`,
@@ -48794,6 +51001,54 @@ function buildToolLists({ permission, overrideShellAllowPatterns, logger }) {
48794
51001
  };
48795
51002
  }
48796
51003
  /**
51004
+ * Names the glob-spelled `run_shell_command(...)` override entries that were
51005
+ * written as the prefix their glob denotes. Said only for the entries that
51006
+ * made it into the list: one that a deny or ask overlaps is announced as
51007
+ * withheld by the comparison instead, and one whose glob names no prefix at
51008
+ * all is skipped by it.
51009
+ */
51010
+ function warnAboutGlobSpelledOverrideEntries({ entries, allowed, logger }) {
51011
+ for (const { entry, pattern } of entries) {
51012
+ const prefix = toShellPrefix(pattern);
51013
+ const written = prefix === void 0 ? void 0 : toShellEntry(prefix);
51014
+ if (written === void 0 || !allowed.includes(written)) continue;
51015
+ 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.`);
51016
+ }
51017
+ }
51018
+ /**
51019
+ * A verbatim override allow naming a tool the canonical block denies is
51020
+ * withheld the way a shadowed `bash` allow is: Tabnine never loads an excluded
51021
+ * tool whatever `allowed` says, so the entry would only ever contradict the
51022
+ * deny it sits beside — and an override is not a way around a canonical rule.
51023
+ * Returns the entries that may be written.
51024
+ */
51025
+ function withholdDeniedOverrideAllowed({ overrideVerbatimAllowed, exclude, logger }) {
51026
+ const denied = overrideVerbatimAllowed.filter((entry) => {
51027
+ const toolName = parseTabnineEntry(entry)?.toolName;
51028
+ return toolName !== void 0 && exclude.includes(toolName);
51029
+ });
51030
+ 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.`);
51031
+ return overrideVerbatimAllowed.filter((entry) => !denied.includes(entry));
51032
+ }
51033
+ /**
51034
+ * The entries of a managed tool that the canonical block did not re-derive are
51035
+ * named rather than removed silently. Dropping an exclude loosens the policy,
51036
+ * so it is a warning; dropping an allow only brings back Tabnine's confirmation
51037
+ * prompt, so it is said at info level for the author who wonders where a
51038
+ * hand-written entry went.
51039
+ */
51040
+ function reportDroppedManagedEntries({ existingTools, allowedList, excludeList, logger }) {
51041
+ const dropped = (key, list) => uniq(isStringArray$2(existingTools?.[key]) ? existingTools[key] : []).filter((entry) => {
51042
+ if (list.includes(entry)) return false;
51043
+ const toolName = parseTabnineEntry(entry)?.toolName;
51044
+ return toolName === void 0 || !list.includes(toolName);
51045
+ });
51046
+ const droppedExclude = dropped(EXCLUDE_KEY, excludeList);
51047
+ 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.`);
51048
+ const droppedAllowed = dropped(ALLOWED_KEY, allowedList);
51049
+ 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.`);
51050
+ }
51051
+ /**
48797
51052
  * Permissions generator for the Tabnine CLI.
48798
51053
  *
48799
51054
  * Tabnine CLI keeps its tool policy in `.tabnine/agent/settings.json` (project)
@@ -48859,7 +51114,10 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48859
51114
  const config = rulesyncPermissions.getJson();
48860
51115
  const override = isRecord$1(config.tabnine) ? config.tabnine : {};
48861
51116
  const { tools: overrideTools, general: overrideGeneral, refused: refusedPaths } = stripRefusedOverridePaths(override);
48862
- 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 ${join(paths.relativeDirPath, paths.relativeFilePath)} if you need it.`);
51117
+ if (refusedPaths.length > 0) {
51118
+ const one = refusedPaths.length === 1;
51119
+ 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 ${join(paths.relativeDirPath, paths.relativeFilePath)} if you need ${one ? "it" : "them"}.`);
51120
+ }
48863
51121
  const overrideList = (key) => {
48864
51122
  const value = overrideTools[key];
48865
51123
  if (value === void 0 || isStringArray$2(value)) return value ?? [];
@@ -48868,10 +51126,17 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48868
51126
  };
48869
51127
  const overrideShellAllowPatterns = [];
48870
51128
  const overrideVerbatimAllowed = [];
51129
+ const globSpelledOverrideEntries = [];
48871
51130
  for (const entry of overrideList(ALLOWED_KEY)) {
48872
51131
  const parsed = parseTabnineEntry(entry);
48873
- if (parsed?.toolName === SHELL_TOOL_NAME) overrideShellAllowPatterns.push(toShellPattern(parsed.prefix));
48874
- else overrideVerbatimAllowed.push(entry);
51132
+ if (parsed?.toolName === SHELL_TOOL_NAME) {
51133
+ const pattern = toShellPattern(parsed.prefix);
51134
+ if (isGlobSpelledPrefix(parsed.prefix)) globSpelledOverrideEntries.push({
51135
+ entry,
51136
+ pattern
51137
+ });
51138
+ overrideShellAllowPatterns.push(pattern);
51139
+ } else overrideVerbatimAllowed.push(entry);
48875
51140
  }
48876
51141
  const overrideExclude = overrideList(EXCLUDE_KEY);
48877
51142
  const { allowed, exclude } = buildToolLists({
@@ -48879,6 +51144,16 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48879
51144
  overrideShellAllowPatterns,
48880
51145
  logger
48881
51146
  });
51147
+ warnAboutGlobSpelledOverrideEntries({
51148
+ entries: globSpelledOverrideEntries,
51149
+ allowed,
51150
+ logger
51151
+ });
51152
+ const writableOverrideAllowed = withholdDeniedOverrideAllowed({
51153
+ overrideVerbatimAllowed,
51154
+ exclude,
51155
+ logger
51156
+ });
48882
51157
  const ignoredOverrideKeys = Object.keys(override).filter((key) => key !== TOOLS_KEY && key !== GENERAL_KEY);
48883
51158
  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.`);
48884
51159
  const existingTools = this.existingToolsGroup(existingContent);
@@ -48889,7 +51164,7 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48889
51164
  });
48890
51165
  const allowedList = uniq([
48891
51166
  ...allowed,
48892
- ...overrideVerbatimAllowed,
51167
+ ...writableOverrideAllowed,
48893
51168
  ...preservedEntries(ALLOWED_KEY)
48894
51169
  ]);
48895
51170
  const excludeList = uniq([
@@ -48897,12 +51172,12 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48897
51172
  ...overrideExclude,
48898
51173
  ...preservedEntries(EXCLUDE_KEY)
48899
51174
  ]);
48900
- const droppedExcludeEntries = uniq(isStringArray$2(existingTools?.[EXCLUDE_KEY]) ? existingTools[EXCLUDE_KEY] : []).filter((entry) => {
48901
- if (excludeList.includes(entry)) return false;
48902
- const toolName = parseTabnineEntry(entry)?.toolName;
48903
- return toolName === void 0 || !excludeList.includes(toolName);
51175
+ reportDroppedManagedEntries({
51176
+ existingTools,
51177
+ allowedList,
51178
+ excludeList,
51179
+ logger
48904
51180
  });
48905
- 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.`);
48906
51181
  const tools = {
48907
51182
  ...overrideTools,
48908
51183
  [ALLOWED_KEY]: allowedList.length > 0 ? allowedList : void 0,
@@ -48916,7 +51191,7 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48916
51191
  entries: collectTrustAffectingOverrideEntries({
48917
51192
  tools: {
48918
51193
  ...overrideTools,
48919
- [ALLOWED_KEY]: overrideVerbatimAllowed
51194
+ [ALLOWED_KEY]: writableOverrideAllowed
48920
51195
  },
48921
51196
  general: overrideGeneral
48922
51197
  }),
@@ -52249,6 +54524,14 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
52249
54524
  supportsImport: true
52250
54525
  }
52251
54526
  }],
54527
+ ["commandcode", {
54528
+ class: CommandcodePermissions,
54529
+ meta: {
54530
+ supportsProject: true,
54531
+ supportsGlobal: true,
54532
+ supportsImport: true
54533
+ }
54534
+ }],
52252
54535
  ["continue", {
52253
54536
  class: ContinuePermissions,
52254
54537
  meta: {
@@ -52273,6 +54556,14 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
52273
54556
  supportsImport: true
52274
54557
  }
52275
54558
  }],
54559
+ ["crush", {
54560
+ class: CrushPermissions,
54561
+ meta: {
54562
+ supportsProject: true,
54563
+ supportsGlobal: true,
54564
+ supportsImport: true
54565
+ }
54566
+ }],
52276
54567
  ["cursor", {
52277
54568
  class: CursorPermissions,
52278
54569
  meta: {
@@ -55007,6 +57298,154 @@ var CodexCliSkill = class CodexCliSkill extends ToolSkill {
55007
57298
  }
55008
57299
  };
55009
57300
  //#endregion
57301
+ //#region src/features/skills/commandcode-skill.ts
57302
+ const CommandcodeSkillFrontmatterSchema = z.looseObject({
57303
+ name: z.string(),
57304
+ description: z.string()
57305
+ });
57306
+ /**
57307
+ * Represents a Command Code skill directory.
57308
+ *
57309
+ * Command Code discovers Anthropic-style `<name>/SKILL.md` directories under
57310
+ * `<project>/.commandcode/skills/` (project scope) and
57311
+ * `~/.commandcode/skills/` (user scope). The frontmatter requires `name`
57312
+ * (matching the directory name) and `description`; supporting files next to
57313
+ * `SKILL.md` are carried along.
57314
+ *
57315
+ * @see https://commandcode.ai/docs/skills
57316
+ */
57317
+ var CommandcodeSkill = class CommandcodeSkill extends ToolSkill {
57318
+ constructor({ outputRoot = process.cwd(), relativeDirPath = COMMANDCODE_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
57319
+ super({
57320
+ outputRoot,
57321
+ relativeDirPath,
57322
+ dirName,
57323
+ mainFile: {
57324
+ name: SKILL_FILE_NAME,
57325
+ body,
57326
+ frontmatter: { ...frontmatter }
57327
+ },
57328
+ otherFiles,
57329
+ global
57330
+ });
57331
+ if (validate) {
57332
+ const result = this.validate();
57333
+ if (!result.success) throw result.error;
57334
+ }
57335
+ }
57336
+ static getSettablePaths(_options = {}) {
57337
+ return { relativeDirPath: COMMANDCODE_SKILLS_DIR_PATH };
57338
+ }
57339
+ getFrontmatter() {
57340
+ return CommandcodeSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
57341
+ }
57342
+ getBody() {
57343
+ return this.mainFile?.body ?? "";
57344
+ }
57345
+ validate() {
57346
+ if (!this.mainFile) return {
57347
+ success: false,
57348
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`)
57349
+ };
57350
+ const result = CommandcodeSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter);
57351
+ if (!result.success) return {
57352
+ success: false,
57353
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`)
57354
+ };
57355
+ if (result.data.name !== this.getDirName()) return {
57356
+ success: false,
57357
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: frontmatter name (${result.data.name}) must match directory name (${this.getDirName()})`)
57358
+ };
57359
+ return {
57360
+ success: true,
57361
+ error: null
57362
+ };
57363
+ }
57364
+ toRulesyncSkill() {
57365
+ const { name, description, ...commandcodeSection } = this.getFrontmatter();
57366
+ const rulesyncFrontmatter = {
57367
+ name,
57368
+ description,
57369
+ targets: ["*"],
57370
+ ...Object.keys(commandcodeSection).length > 0 && { commandcode: commandcodeSection }
57371
+ };
57372
+ return new RulesyncSkill({
57373
+ outputRoot: this.outputRoot,
57374
+ relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
57375
+ dirName: this.getDirName(),
57376
+ frontmatter: rulesyncFrontmatter,
57377
+ body: this.getBody(),
57378
+ otherFiles: this.getOtherFiles(),
57379
+ validate: true,
57380
+ global: this.global
57381
+ });
57382
+ }
57383
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
57384
+ const settablePaths = CommandcodeSkill.getSettablePaths({ global });
57385
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
57386
+ const { name: _sectionName, description: _sectionDescription, ...commandcodeSection } = rulesyncFrontmatter.commandcode ?? {};
57387
+ const commandcodeFrontmatter = {
57388
+ ...commandcodeSection,
57389
+ name: rulesyncFrontmatter.name,
57390
+ description: rulesyncFrontmatter.description
57391
+ };
57392
+ return new CommandcodeSkill({
57393
+ outputRoot,
57394
+ relativeDirPath: settablePaths.relativeDirPath,
57395
+ dirName: commandcodeFrontmatter.name,
57396
+ frontmatter: commandcodeFrontmatter,
57397
+ body: rulesyncSkill.getBody(),
57398
+ otherFiles: rulesyncSkill.getOtherFiles(),
57399
+ validate,
57400
+ global
57401
+ });
57402
+ }
57403
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
57404
+ const targets = rulesyncSkill.getFrontmatter().targets;
57405
+ return targets.includes("*") || targets.includes("commandcode");
57406
+ }
57407
+ static async fromDir(params) {
57408
+ const loaded = await this.loadSkillDirContent({
57409
+ ...params,
57410
+ getSettablePaths: CommandcodeSkill.getSettablePaths
57411
+ });
57412
+ const result = CommandcodeSkillFrontmatterSchema.safeParse(loaded.frontmatter);
57413
+ if (!result.success) {
57414
+ const skillDirPath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName);
57415
+ throw new Error(`Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`);
57416
+ }
57417
+ if (result.data.name !== loaded.dirName) {
57418
+ const skillFilePath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName, SKILL_FILE_NAME);
57419
+ throw new Error(`Frontmatter name (${result.data.name}) must match directory name (${loaded.dirName}) in ${skillFilePath}`);
57420
+ }
57421
+ return new CommandcodeSkill({
57422
+ outputRoot: loaded.outputRoot,
57423
+ relativeDirPath: loaded.relativeDirPath,
57424
+ dirName: loaded.dirName,
57425
+ frontmatter: result.data,
57426
+ body: loaded.body,
57427
+ otherFiles: loaded.otherFiles,
57428
+ validate: true,
57429
+ global: loaded.global
57430
+ });
57431
+ }
57432
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, dirName, global = false }) {
57433
+ return new CommandcodeSkill({
57434
+ outputRoot,
57435
+ relativeDirPath,
57436
+ dirName,
57437
+ frontmatter: {
57438
+ name: "",
57439
+ description: ""
57440
+ },
57441
+ body: "",
57442
+ otherFiles: [],
57443
+ validate: false,
57444
+ global
57445
+ });
57446
+ }
57447
+ };
57448
+ //#endregion
55010
57449
  //#region src/features/skills/continue-skill.ts
55011
57450
  const ContinueSkillFrontmatterSchema = z.looseObject({
55012
57451
  name: z.string(),
@@ -58294,6 +60733,183 @@ var PiSkill = class PiSkill extends ToolSkill {
58294
60733
  }
58295
60734
  };
58296
60735
  //#endregion
60736
+ //#region src/constants/pool-paths.ts
60737
+ /**
60738
+ * Pool (Poolside's coding agent CLI) configuration-layout conventions.
60739
+ *
60740
+ * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
60741
+ * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
60742
+ * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
60743
+ * path), the project-root `AGENTS.md`, and nested per-directory
60744
+ * `AGENTS.md` files from the repository root down through the working
60745
+ * directory, deeper files taking precedence. It skips ignored directories
60746
+ * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
60747
+ *
60748
+ * Skills follow the Agent Skills format (`<name>/SKILL.md` bundles). Pool
60749
+ * scans `.poolside/skills/` (project) and `~/.config/poolside/skills/`
60750
+ * (global) plus the shared `.agents/skills/` / `~/.agents/skills/` roots and
60751
+ * the skill directories of other Agent Skills tools; rulesync writes only the
60752
+ * Pool-specific roots and leaves the shared ones to their own targets.
60753
+ *
60754
+ * @see https://docs.poolside.ai/agent-instructions
60755
+ * @see https://docs.poolside.ai/skills
60756
+ * @see https://github.com/poolsideai/pool
60757
+ */
60758
+ /** Project-scoped `.poolside/` directory at the project root. */
60759
+ const POOL_DIR = ".poolside";
60760
+ /** Global config directory for Pool, relative to the home directory. */
60761
+ const POOL_GLOBAL_DIR = join(".config", "poolside");
60762
+ /** Project skills root, relative to the project root. */
60763
+ const POOL_SKILLS_DIR_PATH = join(POOL_DIR, "skills");
60764
+ /** Global skills root, relative to the home directory. */
60765
+ const POOL_GLOBAL_SKILLS_DIR_PATH = join(POOL_GLOBAL_DIR, "skills");
60766
+ //#endregion
60767
+ //#region src/features/skills/pool-skill.ts
60768
+ const PoolSkillFrontmatterSchema = z.looseObject({
60769
+ name: z.string(),
60770
+ description: z.string()
60771
+ });
60772
+ /**
60773
+ * Represents a Pool (Poolside) skill directory.
60774
+ *
60775
+ * Pool discovers Agent Skills (`<name>/SKILL.md` bundles, optionally with
60776
+ * supporting files) from `.poolside/skills/` (project) and
60777
+ * `~/.config/poolside/skills/` (global). It also scans the shared
60778
+ * `.agents/skills/` / `~/.agents/skills/` roots and the skill directories of
60779
+ * other Agent Skills tools; those belong to their own targets, so this class
60780
+ * writes only the two Pool-specific roots and a skill is written exactly once.
60781
+ * Pool requires the directory name to equal the frontmatter `name`, otherwise
60782
+ * it skips the skill. Unlike adapters that reject such a skill outright, this
60783
+ * one warns and still writes it: the directory name is the canonical identity
60784
+ * shared with every other target, and Pool merely skips the skill rather than
60785
+ * failing, so a hard error here would block the other targets for a
60786
+ * Pool-only concern. Import is lenient for the same reason.
60787
+ * @see https://docs.poolside.ai/skills
60788
+ */
60789
+ var PoolSkill = class PoolSkill extends ToolSkill {
60790
+ constructor({ outputRoot = process.cwd(), relativeDirPath = POOL_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
60791
+ super({
60792
+ outputRoot,
60793
+ relativeDirPath,
60794
+ dirName,
60795
+ mainFile: {
60796
+ name: SKILL_FILE_NAME,
60797
+ body,
60798
+ frontmatter: { ...frontmatter }
60799
+ },
60800
+ otherFiles,
60801
+ global
60802
+ });
60803
+ if (validate) {
60804
+ const result = this.validate();
60805
+ if (!result.success) throw result.error;
60806
+ }
60807
+ }
60808
+ static getSettablePaths({ global = false } = {}) {
60809
+ return { relativeDirPath: global ? POOL_GLOBAL_SKILLS_DIR_PATH : POOL_SKILLS_DIR_PATH };
60810
+ }
60811
+ getFrontmatter() {
60812
+ return PoolSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
60813
+ }
60814
+ getBody() {
60815
+ return this.mainFile?.body ?? "";
60816
+ }
60817
+ validate() {
60818
+ if (!this.mainFile) return {
60819
+ success: false,
60820
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`)
60821
+ };
60822
+ const result = PoolSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter);
60823
+ if (!result.success) return {
60824
+ success: false,
60825
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`)
60826
+ };
60827
+ return {
60828
+ success: true,
60829
+ error: null
60830
+ };
60831
+ }
60832
+ toRulesyncSkill() {
60833
+ const frontmatter = this.getFrontmatter();
60834
+ const rulesyncFrontmatter = {
60835
+ name: frontmatter.name,
60836
+ description: frontmatter.description,
60837
+ targets: ["*"]
60838
+ };
60839
+ return new RulesyncSkill({
60840
+ outputRoot: this.outputRoot,
60841
+ relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
60842
+ dirName: this.getDirName(),
60843
+ frontmatter: rulesyncFrontmatter,
60844
+ body: this.getBody(),
60845
+ otherFiles: this.getOtherFiles(),
60846
+ validate: true,
60847
+ global: this.global
60848
+ });
60849
+ }
60850
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false, logger }) {
60851
+ const settablePaths = PoolSkill.getSettablePaths({ global });
60852
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
60853
+ const dirName = rulesyncSkill.getDirName();
60854
+ const poolFrontmatter = {
60855
+ name: rulesyncFrontmatter.name,
60856
+ description: rulesyncFrontmatter.description
60857
+ };
60858
+ if (poolFrontmatter.name !== dirName) warnWithFallback(logger, `${stripControlCharacters(toPosixPath(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\``);
60859
+ return new PoolSkill({
60860
+ outputRoot,
60861
+ relativeDirPath: settablePaths.relativeDirPath,
60862
+ dirName,
60863
+ frontmatter: poolFrontmatter,
60864
+ body: rulesyncSkill.getBody(),
60865
+ otherFiles: rulesyncSkill.getOtherFiles(),
60866
+ validate,
60867
+ global
60868
+ });
60869
+ }
60870
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
60871
+ const targets = rulesyncSkill.getFrontmatter().targets;
60872
+ return targets.includes("*") || targets.includes("pool");
60873
+ }
60874
+ static async fromDir(params) {
60875
+ const loaded = await this.loadSkillDirContent({
60876
+ ...params,
60877
+ getSettablePaths: PoolSkill.getSettablePaths
60878
+ });
60879
+ const result = PoolSkillFrontmatterSchema.safeParse(loaded.frontmatter);
60880
+ if (!result.success) {
60881
+ const skillDirPath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName);
60882
+ throw new Error(`Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`);
60883
+ }
60884
+ return new PoolSkill({
60885
+ outputRoot: loaded.outputRoot,
60886
+ relativeDirPath: loaded.relativeDirPath,
60887
+ dirName: loaded.dirName,
60888
+ frontmatter: result.data,
60889
+ body: loaded.body,
60890
+ otherFiles: loaded.otherFiles,
60891
+ validate: true,
60892
+ global: loaded.global
60893
+ });
60894
+ }
60895
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, dirName, global = false }) {
60896
+ const settablePaths = PoolSkill.getSettablePaths({ global });
60897
+ return new PoolSkill({
60898
+ outputRoot,
60899
+ relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath,
60900
+ dirName,
60901
+ frontmatter: {
60902
+ name: "",
60903
+ description: ""
60904
+ },
60905
+ body: "",
60906
+ otherFiles: [],
60907
+ validate: false,
60908
+ global
60909
+ });
60910
+ }
60911
+ };
60912
+ //#endregion
58297
60913
  //#region src/features/skills/qwencode-skill.ts
58298
60914
  const QwencodeSkillFrontmatterSchema = z.looseObject({
58299
60915
  name: z.string(),
@@ -60019,6 +62635,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
60019
62635
  supportsGlobal: true
60020
62636
  }
60021
62637
  }],
62638
+ ["commandcode", {
62639
+ class: CommandcodeSkill,
62640
+ meta: {
62641
+ supportsProject: true,
62642
+ supportsSimulated: false,
62643
+ supportsGlobal: true
62644
+ }
62645
+ }],
60022
62646
  ["continue", {
60023
62647
  class: ContinueSkill,
60024
62648
  meta: {
@@ -60180,6 +62804,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
60180
62804
  supportsGlobal: true
60181
62805
  }
60182
62806
  }],
62807
+ ["pool", {
62808
+ class: PoolSkill,
62809
+ meta: {
62810
+ supportsProject: true,
62811
+ supportsSimulated: false,
62812
+ supportsGlobal: true
62813
+ }
62814
+ }],
60183
62815
  ["qwencode", {
60184
62816
  class: QwencodeSkill,
60185
62817
  meta: {
@@ -62114,6 +64746,143 @@ var CodexCliSubagent = class CodexCliSubagent extends ToolSubagent {
62114
64746
  }
62115
64747
  };
62116
64748
  //#endregion
64749
+ //#region src/features/subagents/commandcode-subagent.ts
64750
+ const CommandcodeSubagentFrontmatterSchema = z.looseObject({
64751
+ name: z.string(),
64752
+ description: z.optional(z.string()),
64753
+ tools: z.optional(z.union([z.string(), z.array(z.string())])),
64754
+ disallowedTools: z.optional(z.union([z.string(), z.array(z.string())])),
64755
+ model: z.optional(z.string()),
64756
+ reasoningEffort: z.optional(z.string()),
64757
+ maxTurns: z.optional(z.number()),
64758
+ permissionMode: z.optional(z.string()),
64759
+ background: z.optional(z.boolean()),
64760
+ showOutput: z.optional(z.boolean())
64761
+ });
64762
+ /**
64763
+ * Command Code custom agent: a Markdown file with YAML frontmatter under
64764
+ * `.commandcode/agents/` (project) or `~/.commandcode/agents/` (user); the
64765
+ * body is the agent's system prompt.
64766
+ *
64767
+ * @see https://commandcode.ai/docs/custom-agents
64768
+ */
64769
+ var CommandcodeSubagent = class CommandcodeSubagent extends ToolSubagent {
64770
+ frontmatter;
64771
+ body;
64772
+ constructor({ frontmatter, body, fileContent, ...rest }) {
64773
+ if (rest.validate !== false) {
64774
+ const result = CommandcodeSubagentFrontmatterSchema.safeParse(frontmatter);
64775
+ if (!result.success) throw new Error(`Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
64776
+ }
64777
+ super({
64778
+ ...rest,
64779
+ fileContent: fileContent ?? stringifyFrontmatter(body, frontmatter)
64780
+ });
64781
+ this.frontmatter = frontmatter;
64782
+ this.body = body;
64783
+ }
64784
+ static getSettablePaths(_options = {}) {
64785
+ return { relativeDirPath: COMMANDCODE_AGENTS_DIR_PATH };
64786
+ }
64787
+ getFrontmatter() {
64788
+ return this.frontmatter;
64789
+ }
64790
+ getBody() {
64791
+ return this.body;
64792
+ }
64793
+ toRulesyncSubagent() {
64794
+ const { name, description, ...rest } = this.frontmatter;
64795
+ return new RulesyncSubagent({
64796
+ outputRoot: ".",
64797
+ frontmatter: {
64798
+ targets: ["*"],
64799
+ name,
64800
+ description,
64801
+ commandcode: { ...rest }
64802
+ },
64803
+ body: this.body,
64804
+ relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
64805
+ relativeFilePath: this.getRelativeFilePath(),
64806
+ validate: true
64807
+ });
64808
+ }
64809
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
64810
+ const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
64811
+ const commandcodeSection = rulesyncFrontmatter.commandcode ?? {};
64812
+ const commandcodeSubagentFrontmatter = {
64813
+ name: rulesyncFrontmatter.name,
64814
+ description: rulesyncFrontmatter.description,
64815
+ ...commandcodeSection
64816
+ };
64817
+ const body = rulesyncSubagent.getBody();
64818
+ const fileContent = stringifyFrontmatter(body, commandcodeSubagentFrontmatter, { avoidBlockScalars: true });
64819
+ const paths = this.getSettablePaths({ global });
64820
+ return new CommandcodeSubagent({
64821
+ outputRoot,
64822
+ frontmatter: commandcodeSubagentFrontmatter,
64823
+ body,
64824
+ relativeDirPath: paths.relativeDirPath,
64825
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
64826
+ fileContent,
64827
+ validate,
64828
+ global
64829
+ });
64830
+ }
64831
+ validate() {
64832
+ if (!this.frontmatter) return {
64833
+ success: true,
64834
+ error: null
64835
+ };
64836
+ const result = CommandcodeSubagentFrontmatterSchema.safeParse(this.frontmatter);
64837
+ if (result.success) return {
64838
+ success: true,
64839
+ error: null
64840
+ };
64841
+ else return {
64842
+ success: false,
64843
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
64844
+ };
64845
+ }
64846
+ static isTargetedByRulesyncSubagent(rulesyncSubagent) {
64847
+ return this.isTargetedByRulesyncSubagentDefault({
64848
+ rulesyncSubagent,
64849
+ toolTarget: "commandcode"
64850
+ });
64851
+ }
64852
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
64853
+ const paths = this.getSettablePaths({ global });
64854
+ const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
64855
+ const fileContent = await readFileContent(filePath);
64856
+ const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
64857
+ const result = CommandcodeSubagentFrontmatterSchema.safeParse(frontmatter);
64858
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
64859
+ return new CommandcodeSubagent({
64860
+ outputRoot,
64861
+ relativeDirPath: paths.relativeDirPath,
64862
+ relativeFilePath,
64863
+ frontmatter: result.data,
64864
+ body: content.trim(),
64865
+ fileContent,
64866
+ validate,
64867
+ global
64868
+ });
64869
+ }
64870
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
64871
+ return new CommandcodeSubagent({
64872
+ outputRoot,
64873
+ relativeDirPath,
64874
+ relativeFilePath,
64875
+ frontmatter: {
64876
+ name: "",
64877
+ description: ""
64878
+ },
64879
+ body: "",
64880
+ fileContent: "",
64881
+ validate: false
64882
+ });
64883
+ }
64884
+ };
64885
+ //#endregion
62117
64886
  //#region src/features/subagents/copilot-subagent.ts
62118
64887
  const CopilotSubagentFrontmatterSchema = z.looseObject({
62119
64888
  name: z.string(),
@@ -65014,7 +67783,7 @@ var RooSubagent = class extends ToolSubagent {
65014
67783
  //#region src/features/subagents/tabnine-subagent.ts
65015
67784
  const TabnineSubagentFrontmatterSchema = z.looseObject({
65016
67785
  name: z.string(),
65017
- description: z.optional(z.string()),
67786
+ description: z.string(),
65018
67787
  /** "local" (default) runs inside the CLI; "remote" delegates to a Tabnine cloud agent. */
65019
67788
  kind: z.optional(z.string()),
65020
67789
  /** Built-in tool names the subagent may use; omitted = every tool. */
@@ -65073,14 +67842,23 @@ var TabnineSubagent = class TabnineSubagent extends ToolSubagent {
65073
67842
  validate: true
65074
67843
  });
65075
67844
  }
65076
- static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
67845
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false, logger }) {
65077
67846
  const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
65078
67847
  const tabnineSection = rulesyncFrontmatter.tabnine ?? {};
65079
- const tabnineSubagentFrontmatter = {
67848
+ const merged = {
65080
67849
  name: rulesyncFrontmatter.name,
65081
67850
  description: rulesyncFrontmatter.description,
65082
67851
  ...tabnineSection
65083
67852
  };
67853
+ let description = merged.description;
67854
+ if (description === void 0 || description === "") {
67855
+ description = rulesyncFrontmatter.name ? `${rulesyncFrontmatter.name} subagent` : "subagent";
67856
+ logger?.warn(`Tabnine CLI subagent ${rulesyncSubagent.getRelativeFilePath()} has no description, which Tabnine requires; wrote ${JSON.stringify(description)} as a placeholder.`);
67857
+ }
67858
+ const tabnineSubagentFrontmatter = {
67859
+ ...merged,
67860
+ description
67861
+ };
65084
67862
  const body = rulesyncSubagent.getBody();
65085
67863
  const fileContent = stringifyFrontmatter(body, tabnineSubagentFrontmatter, { avoidBlockScalars: true });
65086
67864
  const paths = this.getSettablePaths({ global });
@@ -65763,6 +68541,15 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
65763
68541
  filePattern: "*.toml"
65764
68542
  }
65765
68543
  }],
68544
+ ["commandcode", {
68545
+ class: CommandcodeSubagent,
68546
+ meta: {
68547
+ supportsProject: true,
68548
+ supportsSimulated: false,
68549
+ supportsGlobal: true,
68550
+ filePattern: "*.md"
68551
+ }
68552
+ }],
65766
68553
  ["copilot", {
65767
68554
  class: CopilotSubagent,
65768
68555
  meta: {
@@ -68595,6 +71382,73 @@ var CodexcliRule = class CodexcliRule extends ToolRule {
68595
71382
  }
68596
71383
  };
68597
71384
  //#endregion
71385
+ //#region src/features/rules/commandcode-rule.ts
71386
+ var CommandcodeRule = class CommandcodeRule extends ToolRule {
71387
+ constructor({ fileContent, root, ...rest }) {
71388
+ super({
71389
+ ...rest,
71390
+ fileContent,
71391
+ root: root ?? false
71392
+ });
71393
+ }
71394
+ static getSettablePaths({ global = false } = {}) {
71395
+ return { root: {
71396
+ relativeDirPath: global ? COMMANDCODE_DIR : ".",
71397
+ relativeFilePath: COMMANDCODE_RULE_FILE_NAME
71398
+ } };
71399
+ }
71400
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath: _relativeFilePath, validate = true, global = false }) {
71401
+ const { root } = this.getSettablePaths({ global });
71402
+ const relativePath = join(root.relativeDirPath, root.relativeFilePath);
71403
+ const fileContent = await readFileContent(join(outputRoot, relativePath));
71404
+ return new CommandcodeRule({
71405
+ outputRoot,
71406
+ relativeDirPath: root.relativeDirPath,
71407
+ relativeFilePath: root.relativeFilePath,
71408
+ fileContent,
71409
+ validate,
71410
+ root: true
71411
+ });
71412
+ }
71413
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
71414
+ const { root } = this.getSettablePaths({ global });
71415
+ const isRoot = rulesyncRule.getFrontmatter().root ?? false;
71416
+ return new CommandcodeRule({
71417
+ outputRoot,
71418
+ relativeDirPath: root.relativeDirPath,
71419
+ relativeFilePath: root.relativeFilePath,
71420
+ fileContent: rulesyncRule.getBody(),
71421
+ validate,
71422
+ root: isRoot
71423
+ });
71424
+ }
71425
+ toRulesyncRule() {
71426
+ return this.toRulesyncRuleDefault();
71427
+ }
71428
+ validate() {
71429
+ return {
71430
+ success: true,
71431
+ error: null
71432
+ };
71433
+ }
71434
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
71435
+ return new CommandcodeRule({
71436
+ outputRoot,
71437
+ relativeDirPath,
71438
+ relativeFilePath,
71439
+ fileContent: "",
71440
+ validate: false,
71441
+ root: relativeFilePath === "AGENTS.md" && (relativeDirPath === "." || relativeDirPath === ".commandcode")
71442
+ });
71443
+ }
71444
+ static isTargetedByRulesyncRule(rulesyncRule) {
71445
+ return this.isTargetedByRulesyncRuleDefault({
71446
+ rulesyncRule,
71447
+ toolTarget: "commandcode"
71448
+ });
71449
+ }
71450
+ };
71451
+ //#endregion
68598
71452
  //#region src/features/rules/continue-rule.ts
68599
71453
  /**
68600
71454
  * Frontmatter schema for Continue rule files (`.continue/rules/*.md`).
@@ -71343,24 +74197,6 @@ var PiRule = class PiRule extends ToolRule {
71343
74197
  }
71344
74198
  };
71345
74199
  //#endregion
71346
- //#region src/constants/pool-paths.ts
71347
- /**
71348
- * Pool (Poolside's coding agent CLI) configuration-layout conventions.
71349
- *
71350
- * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
71351
- * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
71352
- * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
71353
- * path), the project-root `AGENTS.md`, and nested per-directory
71354
- * `AGENTS.md` files from the repository root down through the working
71355
- * directory, deeper files taking precedence. It skips ignored directories
71356
- * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
71357
- *
71358
- * @see https://docs.poolside.ai/agent-instructions
71359
- * @see https://github.com/poolsideai/pool
71360
- */
71361
- /** Global config directory for Pool, relative to the home directory. */
71362
- const POOL_GLOBAL_DIR = join(".config", "poolside");
71363
- //#endregion
71364
74200
  //#region src/features/rules/pool-rule.ts
71365
74201
  var PoolRule = class PoolRule extends ToolRule {
71366
74202
  static getSettablePaths({ global = false } = {}) {
@@ -72933,6 +75769,15 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
72933
75769
  collisionPolicy: "fold"
72934
75770
  }
72935
75771
  }],
75772
+ ["commandcode", {
75773
+ class: CommandcodeRule,
75774
+ meta: {
75775
+ extension: "md",
75776
+ supportsGlobal: true,
75777
+ ruleDiscoveryMode: "auto",
75778
+ collisionPolicy: "fold"
75779
+ }
75780
+ }],
72936
75781
  ["continue", {
72937
75782
  class: ContinueRule,
72938
75783
  meta: {
@@ -76427,6 +79272,6 @@ async function importChecksCore(params) {
76427
79272
  return writtenCount;
76428
79273
  }
76429
79274
  //#endregion
76430
- export { RulesyncCheck as $, writeFileContent as $t, CLAUDECODE_SKILLS_DIR_PATH as A, RULESYNC_SKILLS_RELATIVE_DIR_PATH as An, directoryExists as At, RulesyncSkill as B, truncateText as Bn, listSubdirectoryNames as Bt, ChecksProcessor as C, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as Cn, ErrorCodes as Ct, CLAUDECODE_LOCAL_RULE_FILE_NAME as D, RULESYNC_PERMISSIONS_SCHEMA_URL as Dn, assertWritablePathInsideRoot as Dt, CLAUDECODE_DIR as E, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as En, assertTreeContainsNoSymlinks as Et, AUGMENTCODE_DIR as F, parseCommaSeparatedList as Fn, isFileNotFoundError as Ft, RulesyncMcp as G, stripControlCharactersKeepingLineFeeds as Gn, removeDirectoryStrict as Gt, RulesyncRule as H, hasEnclosingMarkOutsideKeycap as Hn, readFileContent as Ht, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as I, ALL_FEATURES as In, isFileSystemError as It, getRulesyncSourceCandidates as J, removeTempDirectory as Jt, RulesyncIgnore as K, stripHiddenCharacters as Kn, removeFile as Kt, getLocalSkillDirNames as L, ALL_FEATURES_WITH_WILDCARD as Ln, isSymlink as Lt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as M, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as Mn, fileExists as Mt, caseFoldIdentity as N, RULESYNC_USER_CONFIG_DIR_NAME as Nn, getFileSize as Nt, CLAUDECODE_MEMORIES_DIR_NAME as O, RULESYNC_RELATIVE_DIR_PATH as On, checkPathTraversal as Ot, groupSpellingsByCaseFoldedIdentity as P, RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME as Pn, getHomeDirectory as Pt, RulesyncCommandFrontmatterSchema as Q, writeFileBuffer as Qt, RulesyncSubagent as R, DEPRECATED_FEATURE_REPLACEMENTS as Rn, listDirectoryEntryNames as Rt, QWENCODE_LOCAL_RULE_FILE_NAME as S, RULESYNC_MCP_SCHEMA_URL as Sn, CLIError as St, CODEXCLI_DIR as T, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as Tn, assertDirectoryIfExists as Tt, RulesyncRuleFrontmatterSchema as U, quoteForLog as Un, readFileContentOrNull as Ut, RulesyncSkillFrontmatterSchema as V, hasDeceptiveHiddenCharacters as Vn, pathEscapesRoot as Vt, RulesyncPermissions as W, stripControlCharacters as Wn, removeDirectory as Wt, parseJsonc as X, runWithDirectoryRollback as Xt, resolveRulesyncSourceWritePath as Y, resolvePath as Yt, RulesyncCommand as Z, toPosixPath as Zt, IgnoreProcessor as _, RULESYNC_IGNORE_RELATIVE_FILE_PATH as _n, fallbackLogger as _t, getProcessorRegistryEntry as a, MAX_FILE_SIZE as an, ConfigResolver as at, CommandsProcessor as b, RULESYNC_MCP_LEGACY_FILE_NAME as bn, resetRunWarningState as bt, RulesProcessor as c, RULESYNC_CHECKS_RELATIVE_DIR_PATH as cn, CONFLICTING_TARGET_PAIRS as ct, CODEBUDDY_DIR as d, RULESYNC_CONFIG_SCHEMA_URL as dn, SourceEntrySchema as dt, ALL_TOOL_TARGETS as en, RulesyncCheckFrontmatterSchema as et, CODEBUDDY_LOCAL_RULE_FILE_NAME as f, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as fn, assertTargetsFeaturesExclusive as ft, McpProcessor as g, RULESYNC_HOOKS_RELATIVE_FILE_PATH as gn, WarningCollectingLogger as gt, shortenToWidth as h, RULESYNC_HOOKS_LEGACY_FILE_NAME as hn, JsonLogger as ht, inspectInputRoots as i, CURATED_RULES_FEATURE_SUBDIR as in, SKILL_FILE_NAME as it, FACTORYDROID_DIR as j, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as jn, ensureDir as jt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as k, RULESYNC_RULES_RELATIVE_DIR_PATH as kn, createTempDirectory as kt, SubagentsProcessor as l, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as ln, ConfigFileSchema as lt, displayWidthOf as m, RULESYNC_HOOKS_FILE_NAME as mn, ConsoleLogger as mt, formatSourceLoadFailure as n, PACKAGING_TOOL_TARGETS as nn, loadYaml as nt, convertFromTool as o, RULESYNC_AIIGNORE_FILE_NAME as on, mergeInputRootConfigs as ot, ELLIPSIS_WIDTH as p, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as pn, findControlCharacter as pt, RulesyncHooks as q, removeFileStrict as qt, generate as r, ToolTargetSchema as rn, SHARED_USER_MANAGED_CONFIG_PATHS as rt, isPackagingToolTarget as s, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as sn, resolveEffectiveInputRoots as st, importFromTool as t, ALL_TOOL_TARGETS_WITH_WILDCARD as tn, stringifyFrontmatter as tt, SkillsProcessor as u, RULESYNC_CONFIG_RELATIVE_FILE_PATH as un, GITIGNORE_DESTINATION_KEY as ut, CRUSH_LOCAL_RULE_FILE_NAME as v, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as vn, warnOnConflictingFlags as vt, CODEXCLI_BASH_RULES_FILE_NAME as w, RULESYNC_PERMISSIONS_FILE_NAME as wn, applyFileMode as wt, QWENCODE_DIR as x, RULESYNC_MCP_RELATIVE_FILE_PATH as xn, withWarnOnceScope as xt, HooksProcessor as y, RULESYNC_MCP_FILE_NAME as yn, withFallbackLoggerTarget as yt, RulesyncSubagentFrontmatterSchema as z, formatError as zn, listFilePathsRecursively as zt };
79275
+ export { RulesyncCheck as $, writeFileContent as $t, CLAUDECODE_SKILLS_DIR_PATH as A, RULESYNC_SKILLS_RELATIVE_DIR_PATH as An, directoryExists as At, RulesyncSkill as B, truncateText as Bn, listSubdirectoryNames as Bt, ChecksProcessor as C, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as Cn, ErrorCodes as Ct, CLAUDECODE_LOCAL_RULE_FILE_NAME as D, RULESYNC_PERMISSIONS_SCHEMA_URL as Dn, assertWritablePathInsideRoot as Dt, CLAUDECODE_DIR as E, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as En, assertTreeContainsNoSymlinks as Et, AUGMENTCODE_DIR as F, parseCommaSeparatedList as Fn, isFileNotFoundError as Ft, RulesyncMcp as G, stripControlCharactersKeepingLineFeeds as Gn, removeDirectoryStrict as Gt, RulesyncRule as H, hasEnclosingMarkOutsideKeycap as Hn, readFileContent as Ht, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as I, ALL_FEATURES as In, isFileSystemError as It, getRulesyncSourceCandidates as J, removeTempDirectory as Jt, RulesyncIgnore as K, stripHiddenCharacters as Kn, removeFile as Kt, getLocalSkillDirNames as L, ALL_FEATURES_WITH_WILDCARD as Ln, isSymlink as Lt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as M, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as Mn, fileExists as Mt, caseFoldIdentity as N, RULESYNC_USER_CONFIG_DIR_NAME as Nn, getFileSize as Nt, CLAUDECODE_MEMORIES_DIR_NAME as O, RULESYNC_RELATIVE_DIR_PATH as On, checkPathTraversal as Ot, groupSpellingsByCaseFoldedIdentity as P, RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME as Pn, getHomeDirectory as Pt, RulesyncCommandFrontmatterSchema as Q, writeFileBuffer as Qt, RulesyncSubagent as R, DEPRECATED_FEATURE_REPLACEMENTS as Rn, listDirectoryEntryNames as Rt, QWENCODE_LOCAL_RULE_FILE_NAME as S, RULESYNC_MCP_SCHEMA_URL as Sn, CLIError as St, CODEXCLI_DIR as T, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as Tn, assertDirectoryIfExists as Tt, RulesyncRuleFrontmatterSchema as U, quoteForLog as Un, readFileContentOrNull as Ut, RulesyncSkillFrontmatterSchema as V, hasDeceptiveHiddenCharacters as Vn, pathEscapesRoot as Vt, RulesyncPermissions as W, stripControlCharacters as Wn, removeDirectory as Wt, parseJsonc as X, runWithDirectoryRollback as Xt, resolveRulesyncSourceWritePath as Y, resolvePath as Yt, RulesyncCommand as Z, toPosixPath as Zt, IgnoreProcessor as _, RULESYNC_IGNORE_RELATIVE_FILE_PATH as _n, fallbackLogger as _t, getProcessorRegistryEntry as a, MAX_FILE_SIZE as an, ConfigResolver as at, CommandsProcessor as b, RULESYNC_MCP_LEGACY_FILE_NAME as bn, resetRunWarningState as bt, RulesProcessor as c, RULESYNC_CHECKS_RELATIVE_DIR_PATH as cn, CONFLICTING_TARGET_PAIRS as ct, CODEBUDDY_DIR as d, RULESYNC_CONFIG_SCHEMA_URL as dn, SourceEntrySchema as dt, ALL_TOOL_TARGETS as en, RulesyncCheckFrontmatterSchema as et, CODEBUDDY_LOCAL_RULE_FILE_NAME as f, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as fn, assertTargetsFeaturesExclusive as ft, McpProcessor as g, RULESYNC_HOOKS_RELATIVE_FILE_PATH as gn, WarningCollectingLogger as gt, shortenToWidth as h, RULESYNC_HOOKS_LEGACY_FILE_NAME as hn, JsonLogger as ht, inspectInputRoots as i, CURATED_RULES_FEATURE_SUBDIR as in, SKILL_FILE_NAME as it, FACTORYDROID_DIR as j, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as jn, ensureDir as jt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as k, RULESYNC_RULES_RELATIVE_DIR_PATH as kn, createTempDirectory as kt, SubagentsProcessor as l, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as ln, ConfigFileSchema as lt, displayWidthOf as m, RULESYNC_HOOKS_FILE_NAME as mn, ConsoleLogger as mt, formatSourceLoadFailure as n, PACKAGING_TOOL_TARGETS as nn, loadYaml as nt, convertFromTool as o, RULESYNC_AIIGNORE_FILE_NAME as on, mergeInputRootConfigs as ot, ELLIPSIS_WIDTH as p, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as pn, findControlCharacter as pt, RulesyncHooks as q, removeFileStrict as qt, generate as r, ToolTargetSchema as rn, SHARED_USER_MANAGED_CONFIG_PATHS as rt, isPackagingToolTarget as s, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as sn, resolveEffectiveInputRoots as st, importFromTool as t, ALL_TOOL_TARGETS_WITH_WILDCARD as tn, stringifyFrontmatter as tt, SkillsProcessor as u, RULESYNC_CONFIG_RELATIVE_FILE_PATH as un, GITIGNORE_DESTINATION_KEY as ut, HooksProcessor as v, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as vn, warnOnConflictingFlags as vt, CODEXCLI_BASH_RULES_FILE_NAME as w, RULESYNC_PERMISSIONS_FILE_NAME as wn, applyFileMode as wt, QWENCODE_DIR as x, RULESYNC_MCP_RELATIVE_FILE_PATH as xn, withWarnOnceScope as xt, CRUSH_LOCAL_RULE_FILE_NAME as y, RULESYNC_MCP_FILE_NAME as yn, withFallbackLoggerTarget as yt, RulesyncSubagentFrontmatterSchema as z, formatError as zn, listFilePathsRecursively as zt };
76431
79276
 
76432
- //# sourceMappingURL=import-CT7-pKL6.js.map
79277
+ //# sourceMappingURL=import-De13gNpF.js.map