rulesync 16.32.0 → 16.33.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.
@@ -558,6 +558,7 @@ const mcpProcessorToolTargetTuple = [
558
558
  "copilot",
559
559
  "copilotcli",
560
560
  "cortexcode",
561
+ "crush",
561
562
  "cursor",
562
563
  "deepagents",
563
564
  "factorydroid",
@@ -694,6 +695,7 @@ const skillsProcessorToolTargetTuple = [
694
695
  "musecode",
695
696
  "opencode",
696
697
  "pi",
698
+ "pool",
697
699
  "qwencode",
698
700
  "reasonix",
699
701
  "replit",
@@ -724,6 +726,7 @@ const hooksProcessorToolTargetTuple = [
724
726
  "copilot",
725
727
  "copilotcli",
726
728
  "cortexcode",
729
+ "crush",
727
730
  "opencode",
728
731
  "pi",
729
732
  "factorydroid",
@@ -756,6 +759,7 @@ const permissionsProcessorToolTargetTuple = [
756
759
  "continue",
757
760
  "copilot",
758
761
  "copilotcli",
762
+ "crush",
759
763
  "cursor",
760
764
  "deepagents",
761
765
  "devin",
@@ -3283,6 +3287,9 @@ const SHARED_USER_MANAGED_CONFIG_PATHS = [
3283
3287
  "reasonix.toml",
3284
3288
  ".vscode/settings.json",
3285
3289
  ".zed/settings.json",
3290
+ "crush.json",
3291
+ ".crush.json",
3292
+ ".config/crush/crush.json",
3286
3293
  "kilo.json",
3287
3294
  "kilo.jsonc",
3288
3295
  "opencode.json",
@@ -3487,6 +3494,27 @@ function omitPrototypePollutionKeys(record) {
3487
3494
  }
3488
3495
  return sanitized;
3489
3496
  }
3497
+ /**
3498
+ * Like {@link omitPrototypePollutionKeys}, but recursive: every plain object
3499
+ * reachable through the value (directly, or through an array) is copied with
3500
+ * its prototype-pollution keys dropped. Scalars and arrays of scalars come back
3501
+ * as they are.
3502
+ *
3503
+ * Use when passing an unknown, user-supplied value through unchanged — an MCP
3504
+ * server field a tool documents that rulesync does not model — so a
3505
+ * `__proto__` key nested anywhere inside it cannot ride into the generated
3506
+ * config, where the consuming tool may merge the object without the same care.
3507
+ */
3508
+ function omitPrototypePollutionKeysDeep(value) {
3509
+ if (Array.isArray(value)) return value.map((entry) => omitPrototypePollutionKeysDeep(entry));
3510
+ if (value === null || typeof value !== "object") return value;
3511
+ const sanitized = {};
3512
+ for (const [key, entry] of Object.entries(value)) {
3513
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
3514
+ sanitized[key] = omitPrototypePollutionKeysDeep(entry);
3515
+ }
3516
+ return sanitized;
3517
+ }
3490
3518
  //#endregion
3491
3519
  //#region src/utils/type-guards.ts
3492
3520
  /**
@@ -4554,6 +4582,14 @@ const VIBE_HOOK_EVENTS = [
4554
4582
  "stop"
4555
4583
  ];
4556
4584
  /**
4585
+ * Hook events supported by Crush.
4586
+ *
4587
+ * Crush's `hooks` config block currently fires a single event, `PreToolUse`
4588
+ * (Claude Code-compatible payload; `matcher` is a regex on the tool name).
4589
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
4590
+ */
4591
+ const CRUSH_HOOK_EVENTS = ["preToolUse"];
4592
+ /**
4557
4593
  * Hook events supported by JetBrains Junie CLI.
4558
4594
  *
4559
4595
  * Junie CLI exposes seven lifecycle events under the `"hooks"` key of
@@ -5096,6 +5132,7 @@ const HooksConfigSchema = z.looseObject({
5096
5132
  grokcli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5097
5133
  "kimi-code": z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5098
5134
  zcode: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5135
+ crush: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5099
5136
  bob: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5100
5137
  cortexcode: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
5101
5138
  continue: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })),
@@ -5593,6 +5630,13 @@ const VIBE_TO_CANONICAL_EVENT_NAMES = {
5593
5630
  post_agent_turn: "stop"
5594
5631
  };
5595
5632
  /**
5633
+ * Canonical -> Crush event names. Crush spells its events the Claude Code
5634
+ * way (`PreToolUse`).
5635
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
5636
+ */
5637
+ const CANONICAL_TO_CRUSH_EVENT_NAMES = { preToolUse: "PreToolUse" };
5638
+ const CRUSH_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CRUSH_EVENT_NAMES).map(([k, v]) => [v, k]));
5639
+ /**
5596
5640
  * Map canonical camelCase event names to Qwen Code PascalCase.
5597
5641
  *
5598
5642
  * Qwen Code reuses the same Claude-style PascalCase event names for the events
@@ -6032,6 +6076,11 @@ const McpServerSchema = z.looseObject({
6032
6076
  kiroAutoBlock: z.optional(z.array(z.string())),
6033
6077
  musecodeMode: z.optional(z.enum(["required", "optional"])),
6034
6078
  rovodevEnableInstructions: z.optional(z.boolean()),
6079
+ crushOauth: z.optional(z.boolean()),
6080
+ crushOauthClientId: z.optional(z.string()),
6081
+ crushOauthClientSecret: z.optional(z.string()),
6082
+ crushOauthCallbackPort: z.optional(z.int()),
6083
+ crushSessionless: z.optional(z.boolean()),
6035
6084
  headers: z.optional(z.record(z.string(), z.string())),
6036
6085
  /**
6037
6086
  * The canonical per-server tool allowlist.
@@ -6108,6 +6157,8 @@ const RulesyncMcpFileSchema = z.looseObject({
6108
6157
  continue: z.optional(toolScopedMcpSchema),
6109
6158
  copilot: z.optional(toolScopedMcpSchema),
6110
6159
  copilotcli: z.optional(toolScopedMcpSchema),
6160
+ cortexcode: z.optional(toolScopedMcpSchema),
6161
+ crush: z.optional(toolScopedMcpSchema),
6111
6162
  cursor: z.optional(toolScopedMcpSchema),
6112
6163
  deepagents: z.optional(toolScopedMcpSchema),
6113
6164
  devin: z.optional(toolScopedMcpSchema),
@@ -6124,7 +6175,6 @@ const RulesyncMcpFileSchema = z.looseObject({
6124
6175
  reasonix: z.optional(toolScopedMcpSchema),
6125
6176
  roo: z.optional(toolScopedMcpSchema),
6126
6177
  rovodev: z.optional(toolScopedMcpSchema),
6127
- cortexcode: z.optional(toolScopedMcpSchema),
6128
6178
  tabnine: z.optional(toolScopedMcpSchema),
6129
6179
  takt: z.optional(toolScopedMcpSchema),
6130
6180
  vibe: z.optional(toolScopedMcpSchema),
@@ -6456,6 +6506,15 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
6456
6506
  "musecodeMode",
6457
6507
  "rovodevEnableInstructions",
6458
6508
  "enable_instructions",
6509
+ "crushOauth",
6510
+ "crushOauthClientId",
6511
+ "crushOauthClientSecret",
6512
+ "crushOauthCallbackPort",
6513
+ "crushSessionless",
6514
+ "oauth_client_id",
6515
+ "oauth_client_secret",
6516
+ "oauth_callback_port",
6517
+ "sessionless",
6459
6518
  "enabled"
6460
6519
  ])];
6461
6520
  }));
@@ -6904,7 +6963,7 @@ const CursorPermissionsOverrideSchema = z.looseObject({
6904
6963
  * autonomy/sandbox controls with no canonical permission category — under
6905
6964
  * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
6906
6965
  * `sandbox`, `sandboxImage`, `disabled`, `visible`, `eager`, `listDirectory`,
6907
- * `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
6966
+ * `todoWrite`, `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
6908
6967
  * `allowPrivateNetworkHooks`, `allowedInsecureVoiceBaseUrls`). Qwen Code strips
6909
6968
  * `tools.workflowsEnabled`, `security.allowPrivateNetworkHooks` and
6910
6969
  * `security.allowedInsecureVoiceBaseUrls` out of workspace settings, so generate
@@ -6917,7 +6976,7 @@ const CursorPermissionsOverrideSchema = z.looseObject({
6917
6976
  * changes what the file said is reported in either scope, naming what that key
6918
6977
  * decides there — the autonomy and containment
6919
6978
  * controls (`approvalMode`, `autoAccept`, `sandbox`, `sandboxImage`), the
6920
- * registry controls (`disabled`, `visible`, `listDirectory`, and `eager`, which
6979
+ * registry controls (`disabled`, `visible`, `listDirectory`, `todoWrite`, and `eager`, which
6921
6980
  * demotes an omitted tool to deferred rather than removing it, replacing rather
6922
6981
  * than merging with the list a higher scope set), the Auto Mode
6923
6982
  * classifier config, and, because these groups are loose objects, any key
@@ -6958,6 +7017,14 @@ const QwencodePermissionsOverrideSchema = z.looseObject({
6958
7017
  * `looseObject` (verbatim passthrough). Both project and global scope are
6959
7018
  * supported.
6960
7019
  *
7020
+ * Only `tools` and `general` are written; any other key is ignored with a
7021
+ * warning. Within them, the paths Tabnine CLI runs as a command or sends its
7022
+ * traffic to — `tools.discoveryCommand`, `tools.callCommand`,
7023
+ * `tools.shell.pager`, `tools.sandbox.command`, `general.tabnineHost` and
7024
+ * `general.preferredEditor` — are refused with a warning rather than written,
7025
+ * so a fetched permissions file cannot point the CLI at an executable or a
7026
+ * server of its choosing; set those by hand in `settings.json`.
7027
+ *
6961
7028
  * @example
6962
7029
  * { "tools": { "core": ["read_file", "run_shell_command(git)"] },
6963
7030
  * "general": { "defaultApprovalMode": "plan" } }
@@ -7742,6 +7809,7 @@ const PermissionsConfigSchema = z.looseObject({
7742
7809
  continue: z.optional(CanonicalPermissionsOverrideSchema),
7743
7810
  copilot: z.optional(CanonicalPermissionsOverrideSchema),
7744
7811
  copilotcli: z.optional(CanonicalPermissionsOverrideSchema),
7812
+ crush: z.optional(CanonicalPermissionsOverrideSchema),
7745
7813
  goose: z.optional(CanonicalPermissionsOverrideSchema),
7746
7814
  grokcli: z.optional(CanonicalPermissionsOverrideSchema),
7747
7815
  "kimi-code": z.optional(KimiCodePermissionsOverrideSchema),
@@ -12767,6 +12835,26 @@ const ZCODE_USER_CONFIG_DECLARATION = {
12767
12835
  }
12768
12836
  };
12769
12837
  /**
12838
+ * Crush's JSON config, shared by the project (`crush.json` / `.crush.json`)
12839
+ * and global (`~/.config/crush/crush.json`) spellings so a policy edit lands
12840
+ * on both. See the `SHARED_CONFIG_OWNERSHIP` entries for the key rationale.
12841
+ */
12842
+ const CRUSH_CONFIG_DECLARATION = {
12843
+ format: "json",
12844
+ invalidRootPolicy: "error",
12845
+ features: {
12846
+ mcp: {
12847
+ kind: "replace-owned-keys",
12848
+ ownedKeys: ["mcp"]
12849
+ },
12850
+ hooks: {
12851
+ kind: "replace-owned-keys",
12852
+ ownedKeys: ["hooks"]
12853
+ },
12854
+ permissions: { kind: "deep-merge" }
12855
+ }
12856
+ };
12857
+ /**
12770
12858
  * What the two Claude Code settings files have in common: both are plain JSON,
12771
12859
  * and both validate against the one published schema, which the gateway
12772
12860
  * points every file it writes at. `.claude/settings.local.json` is the same
@@ -13027,6 +13115,8 @@ const SHARED_CONFIG_OWNERSHIP = {
13027
13115
  },
13028
13116
  ".zcode/config.json": ZCODE_WORKSPACE_CONFIG_DECLARATION,
13029
13117
  ".zcode/cli/config.json": ZCODE_USER_CONFIG_DECLARATION,
13118
+ "crush.json": CRUSH_CONFIG_DECLARATION,
13119
+ ".config/crush/crush.json": CRUSH_CONFIG_DECLARATION,
13030
13120
  ".bob/settings.json": {
13031
13121
  format: "json",
13032
13122
  invalidRootPolicy: "error",
@@ -18876,15 +18966,12 @@ var TabnineCommand = class TabnineCommand extends ToolCommand {
18876
18966
  return this.body;
18877
18967
  }
18878
18968
  getFrontmatter() {
18879
- return {
18880
- description: this.frontmatter.description,
18881
- prompt: this.frontmatter.prompt
18882
- };
18969
+ return this.frontmatter;
18883
18970
  }
18884
18971
  toRulesyncCommand() {
18885
18972
  const { description, prompt: _prompt, ...restFields } = this.frontmatter;
18886
18973
  const rulesyncFrontmatter = {
18887
- targets: ["tabnine"],
18974
+ targets: ["*"],
18888
18975
  description,
18889
18976
  ...Object.keys(restFields).length > 0 && { tabnine: restFields }
18890
18977
  };
@@ -20847,7 +20934,20 @@ function stableJson(value) {
20847
20934
  */
20848
20935
  function isSafeStringRecord(value) {
20849
20936
  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)));
20937
+ return Object.entries(value).every(([key, entry]) => isSafeEnvEntry({
20938
+ key,
20939
+ value: entry
20940
+ }));
20941
+ }
20942
+ /**
20943
+ * Whether one `env` entry survives the rule {@link isSafeStringRecord} applies
20944
+ * to the whole map. Exported for the adapters that hand a tool an environment
20945
+ * block outside the shared converter (Tabnine, whose hook shape it does not
20946
+ * express) so they drop the same entries this one does.
20947
+ */
20948
+ function isSafeEnvEntry(entry) {
20949
+ const { key, value } = entry;
20950
+ return typeof value === "string" && key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || value.includes(char));
20851
20951
  }
20852
20952
  /**
20853
20953
  * Control characters cannot ride from an existing tool config into a canonical
@@ -21738,135 +21838,6 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
21738
21838
  }
21739
21839
  };
21740
21840
  //#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
21841
  //#region src/features/hooks/preserve-unowned-hook-commands.ts
21871
21842
  /**
21872
21843
  * Read the `hooks` value from a destination JSON file.
@@ -22153,75 +22124,22 @@ function warnSkip({ logger, event, expected }) {
22153
22124
  logger?.warn(`Skipping existing hook entry on ${event}: expected ${expected}`);
22154
22125
  }
22155
22126
  //#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 {
22127
+ //#region src/features/hooks/settings-json-hooks.ts
22128
+ /**
22129
+ * Shared implementation for the tools whose hooks live under the top-level
22130
+ * `hooks` key of a JSON settings file in Claude Code's shape —
22131
+ * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command", ... }] }] }`
22132
+ * — and whose file also holds settings rulesync does not own, so generation
22133
+ * merges the `hooks` key into it (see `SHARED_CONFIG_OWNERSHIP`) instead of
22134
+ * overwriting it, and `--delete` never removes the file wholesale.
22135
+ *
22136
+ * A concrete adapter supplies its {@link SettingsJsonHooksSpec} and its
22137
+ * settable paths; the event mapping, the matcher rules and the per-hook
22138
+ * fields the tool documents are all expressed in the spec's converter config.
22139
+ * An adapter that also supports preserving unowned hooks (Claude Code) opts in
22140
+ * through {@link ToolHooks.supportsPreserveUnowned}.
22141
+ */
22142
+ var SettingsJsonHooks = class extends ToolHooks {
22225
22143
  constructor(params) {
22226
22144
  super({
22227
22145
  ...params,
@@ -22231,24 +22149,42 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22231
22149
  isDeletable() {
22232
22150
  return false;
22233
22151
  }
22152
+ /** The tool-specific half of the adapter. Every concrete class overrides this. */
22153
+ static getSpec() {
22154
+ throw new Error(`${this.name} does not define getSpec()`);
22155
+ }
22234
22156
  /**
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.
22157
+ * The converter config used for both directions. A separate hook so a
22158
+ * subclass can swap tool-specific details (e.g. the project directory
22159
+ * variable) without redefining the whole spec.
22238
22160
  */
22239
22161
  static getConverterConfig() {
22240
- return CLAUDE_CONVERTER_CONFIG;
22162
+ return this.getSpec().converterConfig;
22241
22163
  }
22242
22164
  static getSettablePaths(_options = {}) {
22243
- return {
22244
- relativeDirPath: CLAUDECODE_DIR,
22245
- relativeFilePath: CLAUDECODE_SETTINGS_FILE_NAME
22246
- };
22165
+ throw new Error(`${this.name} does not define getSettablePaths()`);
22166
+ }
22167
+ /**
22168
+ * The `SHARED_CONFIG_OWNERSHIP` key the settings file is merged under. By
22169
+ * default it is derived from the settable paths; an adapter whose file is
22170
+ * declared under another tool's key (the Claude Code plugin bundle) overrides
22171
+ * it.
22172
+ */
22173
+ static getSharedFileKey(paths) {
22174
+ return sharedConfigFileKey(paths);
22175
+ }
22176
+ /**
22177
+ * `new this(params)` for the concrete adapter the static method was called
22178
+ * on; the cast is what an abstract class needs to be constructed through
22179
+ * `this`, and lives in one place.
22180
+ */
22181
+ static instantiate(params) {
22182
+ return new this(params);
22247
22183
  }
22248
22184
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
22249
22185
  const paths = this.getSettablePaths({ global });
22250
22186
  const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
22251
- return new this({
22187
+ return this.instantiate({
22252
22188
  outputRoot,
22253
22189
  relativeDirPath: paths.relativeDirPath,
22254
22190
  relativeFilePath: paths.relativeFilePath,
@@ -22256,40 +22192,41 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22256
22192
  validate
22257
22193
  });
22258
22194
  }
22259
- static supportsPreserveUnowned() {
22260
- return true;
22261
- }
22262
22195
  static async getAuxiliaryFiles({ toolHooks } = {}) {
22263
- return toolHooks instanceof ClaudecodeHooks ? toolHooks.getOwnershipLockFiles() : [];
22196
+ return toolHooks instanceof this ? toolHooks.getOwnershipLockFiles() : [];
22264
22197
  }
22265
22198
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, preserveUnowned = false, logger }) {
22266
22199
  const paths = this.getSettablePaths({ global });
22267
22200
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22268
22201
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
22269
22202
  const config = rulesyncHooks.getJson();
22270
- const claudeHooks = canonicalToToolHooks({
22203
+ const generatedHooks = canonicalToToolHooks({
22271
22204
  config,
22272
- toolOverrideHooks: config.claudecode?.hooks,
22205
+ toolOverrideHooks: overrideHooksOf({
22206
+ config,
22207
+ overrideKey: this.getSpec().overrideKey
22208
+ }),
22273
22209
  converterConfig: this.getConverterConfig(),
22274
22210
  logger
22275
22211
  });
22276
22212
  const preserving = preserveUnowned && this.supportsPreserveUnowned();
22277
22213
  const merged = mergeGeneratedHookLists({
22278
22214
  existingContent,
22279
- generatedHooks: claudeHooks,
22215
+ generatedHooks,
22280
22216
  shape: "matcher-groups",
22281
22217
  preserveUnowned: preserving,
22282
22218
  previouslyOwned: preserving ? parseHooksOwnershipLock(await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, HOOKS_OWNERSHIP_LOCK_FILE_NAME))) : void 0,
22283
22219
  logger
22284
22220
  });
22285
22221
  const fileContent = applySharedConfigPatch({
22286
- fileKey: CLAUDE_SETTINGS_SHARED_FILE_KEY,
22222
+ fileKey: this.getSharedFileKey(paths),
22287
22223
  feature: "hooks",
22288
22224
  existingContent,
22289
22225
  patch: { hooks: merged.hooks },
22290
- filePath
22226
+ filePath,
22227
+ logger
22291
22228
  });
22292
- return new this({
22229
+ return this.instantiate({
22293
22230
  outputRoot,
22294
22231
  relativeDirPath: paths.relativeDirPath,
22295
22232
  relativeFilePath: paths.relativeFilePath,
@@ -22299,20 +22236,29 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22299
22236
  });
22300
22237
  }
22301
22238
  toRulesyncHooks({ logger } = {}) {
22239
+ const ctor = this.constructor;
22240
+ const spec = ctor.getSpec();
22241
+ const configPath = join(this.getRelativeDirPath(), this.getRelativeFilePath());
22302
22242
  let settings;
22303
22243
  try {
22304
- settings = JSON.parse(this.getFileContent());
22244
+ settings = parseSharedConfig({
22245
+ format: "json",
22246
+ fileContent: this.getFileContent(),
22247
+ filePath: configPath,
22248
+ invalidRootPolicy: "error"
22249
+ });
22305
22250
  } catch (error) {
22306
- throw new Error(`Failed to parse Claude hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
22251
+ const reason = error instanceof Error && error.cause instanceof Error ? error.cause : error;
22252
+ throw new Error(`Failed to parse ${spec.displayName} hooks content in ${configPath}: ${formatError(reason)}`, { cause: error });
22307
22253
  }
22308
22254
  const hooks = toolHooksToCanonical({
22255
+ logger,
22309
22256
  hooks: settings.hooks,
22310
- converterConfig: this.constructor.getConverterConfig(),
22311
- logger
22257
+ converterConfig: ctor.getConverterConfig()
22312
22258
  });
22313
22259
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
22314
22260
  hooks,
22315
- overrideKey: "claudecode"
22261
+ overrideKey: spec.overrideKey
22316
22262
  }), null, 2) });
22317
22263
  }
22318
22264
  validate() {
@@ -22322,7 +22268,7 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22322
22268
  };
22323
22269
  }
22324
22270
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
22325
- return new this({
22271
+ return this.instantiate({
22326
22272
  outputRoot,
22327
22273
  relativeDirPath,
22328
22274
  relativeFilePath,
@@ -22331,6 +22277,158 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22331
22277
  });
22332
22278
  }
22333
22279
  };
22280
+ /**
22281
+ * The `hooks` map of a tool's override block (`config.<overrideKey>.hooks`),
22282
+ * or `undefined` when the block states none. The schema keeps every override
22283
+ * block loose, so the lookup goes through the record form.
22284
+ */
22285
+ function overrideHooksOf({ config, overrideKey }) {
22286
+ const override = lookupOwn({
22287
+ record: config,
22288
+ key: overrideKey
22289
+ });
22290
+ if (!isPlainObject$1(override)) return;
22291
+ const hooks = override.hooks;
22292
+ return isPlainObject$1(hooks) ? hooks : void 0;
22293
+ }
22294
+ //#endregion
22295
+ //#region src/features/hooks/bob-hooks.ts
22296
+ const BOB_SPEC = {
22297
+ displayName: "Bob",
22298
+ overrideKey: "bob",
22299
+ converterConfig: {
22300
+ supportedEvents: BOB_HOOK_EVENTS,
22301
+ canonicalToToolEventNames: CANONICAL_TO_BOB_EVENT_NAMES,
22302
+ toolToCanonicalEventNames: BOB_TO_CANONICAL_EVENT_NAMES,
22303
+ projectDirVar: "",
22304
+ noMatcherEvents: /* @__PURE__ */ new Set([
22305
+ "sessionStart",
22306
+ "beforeSubmitPrompt",
22307
+ "stop"
22308
+ ]),
22309
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
22310
+ wildcardMatcherMeansAll: true
22311
+ }
22312
+ };
22313
+ /**
22314
+ * IBM Bob lifecycle hooks.
22315
+ *
22316
+ * Hooks live under the top-level `hooks` key of Bob's settings file —
22317
+ * `<project>/.bob/settings.json` (project scope) and
22318
+ * `~/.bob/settings/settings.json` (user scope) — in the Claude-Code shape:
22319
+ * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command",
22320
+ * "command": "...", "timeout"?: <seconds> }] }] }`. The file also holds
22321
+ * settings rulesync does not own, so generation merges the `hooks` key into it
22322
+ * (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting the file.
22323
+ *
22324
+ * @see https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks
22325
+ */
22326
+ var BobHooks = class extends SettingsJsonHooks {
22327
+ static getSpec() {
22328
+ return BOB_SPEC;
22329
+ }
22330
+ static getSettablePaths({ global = false } = {}) {
22331
+ return {
22332
+ relativeDirPath: global ? BOB_GLOBAL_SETTINGS_DIR_PATH : BOB_DIR,
22333
+ relativeFilePath: BOB_SETTINGS_FILE_NAME
22334
+ };
22335
+ }
22336
+ };
22337
+ //#endregion
22338
+ //#region src/features/hooks/claudecode-hooks.ts
22339
+ const CLAUDE_SPEC = {
22340
+ displayName: "Claude",
22341
+ overrideKey: "claudecode",
22342
+ converterConfig: {
22343
+ supportedEvents: CLAUDE_HOOK_EVENTS,
22344
+ canonicalToToolEventNames: CANONICAL_TO_CLAUDE_EVENT_NAMES,
22345
+ toolToCanonicalEventNames: CLAUDE_TO_CANONICAL_EVENT_NAMES,
22346
+ projectDirVar: "$CLAUDE_PROJECT_DIR",
22347
+ prefixDotRelativeCommandsOnly: true,
22348
+ noMatcherEvents: /* @__PURE__ */ new Set([
22349
+ "worktreeCreate",
22350
+ "worktreeRemove",
22351
+ "messageDisplay",
22352
+ "postToolBatch",
22353
+ "taskCreated",
22354
+ "taskCompleted",
22355
+ "teammateIdle",
22356
+ "cwdChanged",
22357
+ "beforeSubmitPrompt",
22358
+ "stop"
22359
+ ]),
22360
+ supportedHookTypes: /* @__PURE__ */ new Set([
22361
+ "command",
22362
+ "prompt",
22363
+ "http",
22364
+ "mcp_tool",
22365
+ "agent"
22366
+ ]),
22367
+ emitsPromptModel: true,
22368
+ stringPassthroughFields: [
22369
+ {
22370
+ canonical: "if",
22371
+ tool: "if"
22372
+ },
22373
+ {
22374
+ canonical: "statusMessage",
22375
+ tool: "statusMessage"
22376
+ },
22377
+ {
22378
+ canonical: "shell",
22379
+ tool: "shell",
22380
+ commandOnly: true
22381
+ }
22382
+ ],
22383
+ booleanPassthroughFields: [
22384
+ {
22385
+ canonical: "once",
22386
+ tool: "once"
22387
+ },
22388
+ {
22389
+ canonical: "async",
22390
+ tool: "async",
22391
+ commandOnly: true
22392
+ },
22393
+ {
22394
+ canonical: "asyncRewake",
22395
+ tool: "asyncRewake",
22396
+ commandOnly: true
22397
+ },
22398
+ {
22399
+ canonical: "continueOnBlock",
22400
+ tool: "continueOnBlock"
22401
+ }
22402
+ ],
22403
+ arrayPassthroughFields: [{
22404
+ canonical: "args",
22405
+ tool: "args",
22406
+ commandOnly: true
22407
+ }]
22408
+ }
22409
+ };
22410
+ var ClaudecodeHooks = class extends SettingsJsonHooks {
22411
+ static getSpec() {
22412
+ return CLAUDE_SPEC;
22413
+ }
22414
+ static getSettablePaths(_options = {}) {
22415
+ return {
22416
+ relativeDirPath: CLAUDECODE_DIR,
22417
+ relativeFilePath: CLAUDECODE_SETTINGS_FILE_NAME
22418
+ };
22419
+ }
22420
+ /**
22421
+ * Every Claude-shaped settings file rulesync writes for Claude Code — the
22422
+ * project and user `settings.json` and the plugin bundle's `hooks.json` — is
22423
+ * declared under the one Claude key in `SHARED_CONFIG_OWNERSHIP`.
22424
+ */
22425
+ static getSharedFileKey(_paths) {
22426
+ return CLAUDE_SETTINGS_SHARED_FILE_KEY;
22427
+ }
22428
+ static supportsPreserveUnowned() {
22429
+ return true;
22430
+ }
22431
+ };
22334
22432
  //#endregion
22335
22433
  //#region src/features/hooks/claudecode-plugin-hooks.ts
22336
22434
  var ClaudecodePluginHooks = class extends ClaudecodeHooks {
@@ -22858,54 +22956,45 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
22858
22956
  };
22859
22957
  //#endregion
22860
22958
  //#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",
22866
- 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
- }]
22959
+ const CONTINUE_SPEC = {
22960
+ displayName: "Continue",
22961
+ overrideKey: "continue",
22962
+ converterConfig: {
22963
+ supportedEvents: CONTINUE_HOOK_EVENTS,
22964
+ canonicalToToolEventNames: CANONICAL_TO_CONTINUE_EVENT_NAMES,
22965
+ toolToCanonicalEventNames: CONTINUE_TO_CANONICAL_EVENT_NAMES,
22966
+ projectDirVar: "$CONTINUE_PROJECT_DIR",
22967
+ prefixDotRelativeCommandsOnly: true,
22968
+ noMatcherEvents: /* @__PURE__ */ new Set([
22969
+ "beforeSubmitPrompt",
22970
+ "stop",
22971
+ "teammateIdle",
22972
+ "taskCompleted",
22973
+ "worktreeCreate",
22974
+ "worktreeRemove"
22975
+ ]),
22976
+ supportedHookTypes: /* @__PURE__ */ new Set([
22977
+ "command",
22978
+ "prompt",
22979
+ "http",
22980
+ "agent"
22981
+ ]),
22982
+ emitsPromptModel: true,
22983
+ stringPassthroughFields: [{
22984
+ canonical: "statusMessage",
22985
+ tool: "statusMessage"
22986
+ }],
22987
+ booleanPassthroughFields: [{
22988
+ canonical: "once",
22989
+ tool: "once"
22990
+ }, {
22991
+ canonical: "async",
22992
+ tool: "async",
22993
+ commandOnly: true
22994
+ }]
22995
+ }
22894
22996
  };
22895
22997
  /**
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.
22899
- */
22900
- function parseContinueSettings(fileContent, filePath) {
22901
- return parseSharedConfig({
22902
- format: "json",
22903
- fileContent,
22904
- filePath,
22905
- invalidRootPolicy: "error"
22906
- });
22907
- }
22908
- /**
22909
22998
  * Continue CLI hooks.
22910
22999
  *
22911
23000
  * Hooks live under the top-level `hooks` key of `<project>/.continue/settings.json`
@@ -22921,15 +23010,9 @@ function parseContinueSettings(fileContent, filePath) {
22921
23010
  * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/hookConfig.ts
22922
23011
  * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/types.ts
22923
23012
  */
22924
- var ContinueHooks = class ContinueHooks extends ToolHooks {
22925
- constructor(params) {
22926
- super({
22927
- ...params,
22928
- fileContent: params.fileContent ?? "{}"
22929
- });
22930
- }
22931
- isDeletable() {
22932
- return false;
23013
+ var ContinueHooks = class extends SettingsJsonHooks {
23014
+ static getSpec() {
23015
+ return CONTINUE_SPEC;
22933
23016
  }
22934
23017
  static getSettablePaths(_options = {}) {
22935
23018
  return {
@@ -22937,77 +23020,6 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
22937
23020
  relativeFilePath: CONTINUE_SETTINGS_FILE_NAME
22938
23021
  };
22939
23022
  }
22940
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
22941
- const paths = ContinueHooks.getSettablePaths({ global });
22942
- const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
22943
- return new ContinueHooks({
22944
- outputRoot,
22945
- relativeDirPath: paths.relativeDirPath,
22946
- relativeFilePath: paths.relativeFilePath,
22947
- fileContent,
22948
- validate
22949
- });
22950
- }
22951
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
22952
- const paths = ContinueHooks.getSettablePaths({ global });
22953
- const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22954
- const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
22955
- const config = rulesyncHooks.getJson();
22956
- const hooks = canonicalToToolHooks({
22957
- config,
22958
- toolOverrideHooks: config.continue?.hooks,
22959
- converterConfig: CONTINUE_CONVERTER_CONFIG,
22960
- logger
22961
- });
22962
- const fileContent = applySharedConfigPatch({
22963
- fileKey: sharedConfigFileKey(paths),
22964
- feature: "hooks",
22965
- existingContent,
22966
- patch: { hooks },
22967
- filePath,
22968
- logger
22969
- });
22970
- return new ContinueHooks({
22971
- outputRoot,
22972
- relativeDirPath: paths.relativeDirPath,
22973
- relativeFilePath: paths.relativeFilePath,
22974
- fileContent,
22975
- validate
22976
- });
22977
- }
22978
- toRulesyncHooks({ logger } = {}) {
22979
- const configPath = join(this.getRelativeDirPath(), this.getRelativeFilePath());
22980
- let settings;
22981
- try {
22982
- settings = parseContinueSettings(this.getFileContent(), configPath);
22983
- } catch (error) {
22984
- throw new Error(`Failed to parse Continue hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
22985
- }
22986
- const hooks = toolHooksToCanonical({
22987
- logger,
22988
- hooks: settings.hooks,
22989
- converterConfig: CONTINUE_CONVERTER_CONFIG
22990
- });
22991
- return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
22992
- hooks,
22993
- overrideKey: "continue"
22994
- }), null, 2) });
22995
- }
22996
- validate() {
22997
- return {
22998
- success: true,
22999
- error: null
23000
- };
23001
- }
23002
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
23003
- return new ContinueHooks({
23004
- outputRoot,
23005
- relativeDirPath,
23006
- relativeFilePath,
23007
- fileContent: JSON.stringify({ hooks: {} }, null, 2),
23008
- validate: false
23009
- });
23010
- }
23011
23023
  };
23012
23024
  //#endregion
23013
23025
  //#region src/features/hooks/copilot-hooks.ts
@@ -23598,33 +23610,24 @@ const CORTEXCODE_SKILLS_DIR_PATH = join(CORTEXCODE_DIR, "skills");
23598
23610
  const CORTEXCODE_GLOBAL_SKILLS_DIR_PATH = join(CORTEXCODE_GLOBAL_DIR_PATH, "skills");
23599
23611
  //#endregion
23600
23612
  //#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
- }]
23613
+ const CORTEXCODE_SPEC = {
23614
+ displayName: "Cortex Code",
23615
+ overrideKey: "cortexcode",
23616
+ converterConfig: {
23617
+ supportedEvents: CORTEXCODE_HOOK_EVENTS,
23618
+ canonicalToToolEventNames: CANONICAL_TO_CORTEXCODE_EVENT_NAMES,
23619
+ toolToCanonicalEventNames: CORTEXCODE_TO_CANONICAL_EVENT_NAMES,
23620
+ projectDirVar: "$CORTEX_PROJECT_DIR",
23621
+ prefixDotRelativeCommandsOnly: true,
23622
+ noMatcherEvents: /* @__PURE__ */ new Set(["beforeSubmitPrompt", "stop"]),
23623
+ supportedHookTypes: /* @__PURE__ */ new Set(["command", "prompt"]),
23624
+ booleanPassthroughFields: [{
23625
+ canonical: "enabled",
23626
+ tool: "enabled"
23627
+ }]
23628
+ }
23613
23629
  };
23614
23630
  /**
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
23631
  * Snowflake Cortex Code hooks.
23629
23632
  *
23630
23633
  * Hooks live under the top-level `hooks` key of `<project>/.cortex/settings.json`
@@ -23633,20 +23636,16 @@ function parseCortexcodeSettings(fileContent, filePath) {
23633
23636
  * "<regex>", "hooks": [{ "type": "command" | "prompt", ..., "timeout"?:
23634
23637
  * <seconds>, "enabled"?: <boolean> }] }] }`. The project file also holds
23635
23638
  * settings rulesync does not own, so generation merges the `hooks` key into
23636
- * either file (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it.
23639
+ * either file (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it, and
23640
+ * neither file is removed wholesale (hooks.json sits in the CLI-owned
23641
+ * `~/.snowflake/cortex/` tree).
23637
23642
  *
23638
23643
  * @see https://docs.snowflake.com/en/user-guide/cortex-code/extensibility
23639
23644
  * @see https://docs.snowflake.com/en/user-guide/cortex-code/settings
23640
23645
  */
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;
23646
+ var CortexcodeHooks = class extends SettingsJsonHooks {
23647
+ static getSpec() {
23648
+ return CORTEXCODE_SPEC;
23650
23649
  }
23651
23650
  static getSettablePaths({ global = false } = {}) {
23652
23651
  return global ? {
@@ -23657,60 +23656,338 @@ var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23657
23656
  relativeFilePath: CORTEXCODE_SETTINGS_FILE_NAME
23658
23657
  };
23659
23658
  }
23659
+ };
23660
+ //#endregion
23661
+ //#region src/constants/crush-paths.ts
23662
+ const CRUSH_RULE_FILE_NAME = "CRUSH.md";
23663
+ const CRUSH_GLOBAL_DIR = join(".config", "crush");
23664
+ const CRUSH_LOCAL_RULE_FILE_NAME = "CRUSH.local.md";
23665
+ const CRUSH_IGNORE_FILE_NAME = ".crushignore";
23666
+ const CRUSH_SKILLS_PROJECT_DIR = join(".crush", "skills");
23667
+ const CRUSH_SKILLS_GLOBAL_DIR = join(CRUSH_GLOBAL_DIR, "skills");
23668
+ const CRUSH_CONFIG_FILE_NAME = "crush.json";
23669
+ const CRUSH_HIDDEN_CONFIG_FILE_NAME = ".crush.json";
23670
+ const CRUSH_PERMISSIONS_KEY = "permissions";
23671
+ const CRUSH_ALLOWED_TOOLS_KEY = "allowed_tools";
23672
+ const CRUSH_OPTIONS_KEY = "options";
23673
+ const CRUSH_DISABLED_TOOLS_KEY = "disabled_tools";
23674
+ const CRUSH_HOOKS_KEY = "hooks";
23675
+ //#endregion
23676
+ //#region src/features/crush-config.ts
23677
+ /**
23678
+ * Where Crush's JSON config lives for a scope, before the project-scope twin
23679
+ * is resolved: `<project>/crush.json` or `~/.config/crush/crush.json`. This is
23680
+ * also the key every adapter reports through `getSettablePaths`, so the
23681
+ * shared-config ownership declaration, the gitignore derivation and the
23682
+ * shared-write ordering all see one file per scope.
23683
+ */
23684
+ function getCrushConfigSettablePaths({ global = false } = {}) {
23685
+ return {
23686
+ relativeDirPath: global ? CRUSH_GLOBAL_DIR : ".",
23687
+ relativeFilePath: CRUSH_CONFIG_FILE_NAME
23688
+ };
23689
+ }
23690
+ /**
23691
+ * Resolves which Crush config file an adapter should read and write.
23692
+ *
23693
+ * At project scope Crush discovers both `.crush.json` and `crush.json` in the
23694
+ * working directory and merges every file it finds with `.crush.json` on top
23695
+ * (objects merge recursively, lists are concatenated, scalars are overridden).
23696
+ * Neither file therefore hides the other: an entry left in `crush.json` stays
23697
+ * in effect next to whatever `.crush.json` says. An existing `.crush.json` is
23698
+ * preferred because the user chose that spelling; otherwise `crush.json` is
23699
+ * used (and created when neither exists). The global scope has a single
23700
+ * spelling.
23701
+ *
23702
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
23703
+ */
23704
+ async function resolveCrushConfigFile({ outputRoot, global = false }) {
23705
+ const paths = getCrushConfigSettablePaths({ global });
23706
+ const configDir = join(outputRoot, paths.relativeDirPath);
23707
+ const filePath = join(configDir, paths.relativeFilePath);
23708
+ const fileContent = await readFileContentOrNull(filePath);
23709
+ if (!global) {
23710
+ const hiddenPath = join(configDir, CRUSH_HIDDEN_CONFIG_FILE_NAME);
23711
+ const hiddenContent = await readFileContentOrNull(hiddenPath);
23712
+ if (hiddenContent !== null) return {
23713
+ relativeDirPath: paths.relativeDirPath,
23714
+ relativeFilePath: CRUSH_HIDDEN_CONFIG_FILE_NAME,
23715
+ filePath: hiddenPath,
23716
+ fileContent: hiddenContent,
23717
+ ...fileContent === null ? {} : { twin: {
23718
+ filePath,
23719
+ fileContent
23720
+ } }
23721
+ };
23722
+ }
23723
+ return {
23724
+ relativeDirPath: paths.relativeDirPath,
23725
+ relativeFilePath: paths.relativeFilePath,
23726
+ filePath,
23727
+ fileContent
23728
+ };
23729
+ }
23730
+ /**
23731
+ * Single spelling of the crush.json codec/policy, matching the
23732
+ * `SHARED_CONFIG_OWNERSHIP` declaration for both scopes: fail closed on an
23733
+ * unparseable root rather than replacing the user's Crush config with
23734
+ * generated output.
23735
+ */
23736
+ function parseCrushConfig(fileContent, filePath) {
23737
+ return parseSharedConfig({
23738
+ format: "json",
23739
+ fileContent,
23740
+ filePath,
23741
+ invalidRootPolicy: "error"
23742
+ });
23743
+ }
23744
+ /**
23745
+ * Combine two Crush config documents the way Crush itself does
23746
+ * (`github.com/qjebbs/go-jsons`): objects merge recursively, arrays are
23747
+ * concatenated with `base` first, and any other value from `override` wins.
23748
+ */
23749
+ function mergeCrushConfigs({ base, override }) {
23750
+ const merged = { ...base };
23751
+ for (const [key, value] of Object.entries(override)) {
23752
+ if (isPrototypePollutionKey(key)) continue;
23753
+ const current = merged[key];
23754
+ if (isRecord$1(current) && isRecord$1(value)) merged[key] = mergeCrushConfigs({
23755
+ base: current,
23756
+ override: value
23757
+ });
23758
+ else if (Array.isArray(current) && Array.isArray(value)) merged[key] = [...current, ...value];
23759
+ else merged[key] = value;
23760
+ }
23761
+ return merged;
23762
+ }
23763
+ /**
23764
+ * The content an import should read for a resolved location: the chosen file
23765
+ * merged over its twin when both exist, so a server, hook or tool entry that
23766
+ * only the lower-priority `crush.json` declares is imported too — Crush sees
23767
+ * it, so rulesync should as well.
23768
+ */
23769
+ function crushConfigImportContent(location) {
23770
+ const own = location.fileContent ?? "{}";
23771
+ if (location.twin === void 0) return own;
23772
+ const merged = mergeCrushConfigs({
23773
+ base: parseCrushConfig(location.twin.fileContent, location.twin.filePath),
23774
+ override: parseCrushConfig(own, location.filePath)
23775
+ });
23776
+ return JSON.stringify(merged, null, 2);
23777
+ }
23778
+ function lookupPath(root, path) {
23779
+ let current = root;
23780
+ for (const segment of path) {
23781
+ if (!isRecord$1(current) || !Object.hasOwn(current, segment)) return;
23782
+ current = current[segment];
23783
+ }
23784
+ return current;
23785
+ }
23786
+ function isNonEmptyValue(value) {
23787
+ if (Array.isArray(value)) return value.length > 0;
23788
+ if (isRecord$1(value)) return Object.keys(value).length > 0;
23789
+ return value !== void 0;
23790
+ }
23791
+ /**
23792
+ * Warn when the twin Crush merges beneath the written file still carries a
23793
+ * value at one of the paths this feature owns. Rulesync only rewrites the
23794
+ * chosen file, and because Crush concatenates lists and merges objects across
23795
+ * the pair, such an entry — typically one an earlier generate wrote to
23796
+ * `crush.json` before the user added a `.crush.json` — stays in effect until
23797
+ * removed by hand, and cannot be retracted from `.rulesync/`.
23798
+ */
23799
+ function warnCrushTwinLeftovers({ location, ownedPaths, logger }) {
23800
+ if (location.twin === void 0) return;
23801
+ let twin;
23802
+ try {
23803
+ twin = parseCrushConfig(location.twin.fileContent, location.twin.filePath);
23804
+ } catch {
23805
+ return;
23806
+ }
23807
+ const leftovers = ownedPaths.filter((path) => isNonEmptyValue(lookupPath(twin, path))).map((path) => `"${path.join(".")}"`);
23808
+ if (leftovers.length === 0) return;
23809
+ 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.`);
23810
+ }
23811
+ //#endregion
23812
+ //#region src/features/hooks/crush-hooks.ts
23813
+ const SUPPORTED_CRUSH_EVENTS = new Set(CRUSH_HOOK_EVENTS);
23814
+ /**
23815
+ * Crush matches an event key case-insensitively and ignores underscores
23816
+ * (`PreToolUse`, `pre_tool_use` and `PRE_TOOL_USE` all work), so an import
23817
+ * looks the canonical name up by that normalized spelling.
23818
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
23819
+ */
23820
+ const NORMALIZED_CRUSH_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CRUSH_TO_CANONICAL_EVENT_NAMES).map(([crushEvent, canonical]) => [normalizeCrushEventName(crushEvent), canonical]));
23821
+ function normalizeCrushEventName(event) {
23822
+ return event.replaceAll("_", "").toLowerCase();
23823
+ }
23824
+ /**
23825
+ * Build the `hooks` block of `crush.json` from a canonical hooks config.
23826
+ * Crush keys a flat array of `{name, matcher, command, timeout}` entries by
23827
+ * event name; `matcher` is a regex tested against the (lower-case) Crush tool
23828
+ * name and `timeout` is in seconds. Only `type: "command"` canonical hooks are
23829
+ * emitted, since a Crush hook is a shell command. A shared canonical event
23830
+ * Crush does not fire is skipped (the HooksProcessor reports it), while an
23831
+ * event under the `crush.hooks` override — such as one an import filed there
23832
+ * — is written verbatim so it round-trips.
23833
+ */
23834
+ function canonicalToCrushHooks({ config, toolOverride, logger }) {
23835
+ const sharedHooks = {};
23836
+ for (const [event, defs] of Object.entries(config.hooks)) if (SUPPORTED_CRUSH_EVENTS.has(event)) sharedHooks[event] = defs;
23837
+ const effective = {
23838
+ ...sharedHooks,
23839
+ ...toolOverride
23840
+ };
23841
+ const hooks = {};
23842
+ for (const [event, defs] of Object.entries(effective)) {
23843
+ if (isPrototypePollutionKey(event)) continue;
23844
+ const crushEvent = lookupOwn({
23845
+ record: CANONICAL_TO_CRUSH_EVENT_NAMES,
23846
+ key: event
23847
+ }) ?? event;
23848
+ const entries = [];
23849
+ for (const def of defs) {
23850
+ const entry = canonicalDefToCrushEntry({
23851
+ def,
23852
+ event,
23853
+ logger
23854
+ });
23855
+ if (entry !== null) entries.push(entry);
23856
+ }
23857
+ if (entries.length > 0) hooks[crushEvent] = entries;
23858
+ }
23859
+ return hooks;
23860
+ }
23861
+ /** Convert one canonical hook definition to a Crush entry, or null to skip. */
23862
+ function canonicalDefToCrushEntry({ def, event, logger }) {
23863
+ if ((def.type ?? "command") !== "command") return null;
23864
+ if (typeof def.command !== "string" || def.command === "") {
23865
+ logger?.warn(`Crush hook ${quoteValueForWarning(def.name ?? "")} under "${event}" has no "command", which Crush would discard, so it was skipped.`);
23866
+ return null;
23867
+ }
23868
+ const entry = { command: def.command };
23869
+ if (typeof def.name === "string" && def.name !== "") entry.name = def.name;
23870
+ if (typeof def.matcher === "string" && def.matcher !== "" && def.matcher !== "*") entry.matcher = def.matcher;
23871
+ if (typeof def.timeout === "number") entry.timeout = Math.ceil(def.timeout);
23872
+ return entry;
23873
+ }
23874
+ /** Convert one raw hook entry to a canonical definition, or null to skip. */
23875
+ function crushEntryToCanonicalDef(raw) {
23876
+ if (!isRecord$1(raw) || typeof raw.command !== "string") return null;
23877
+ const def = {
23878
+ type: "command",
23879
+ command: raw.command
23880
+ };
23881
+ if (typeof raw.name === "string" && raw.name !== "") def.name = raw.name;
23882
+ if (typeof raw.matcher === "string" && raw.matcher !== "") def.matcher = raw.matcher;
23883
+ if (typeof raw.timeout === "number") def.timeout = raw.timeout;
23884
+ return def;
23885
+ }
23886
+ /**
23887
+ * Reverse {@link canonicalToCrushHooks}: parse the `hooks` block back into a
23888
+ * canonical event → definition[] record. An event Crush does not document is
23889
+ * carried under its own name so `buildImportedHooksConfig` files it under the
23890
+ * `crush.hooks` override.
23891
+ */
23892
+ function crushHooksToCanonical(hooksBlock) {
23893
+ const canonical = {};
23894
+ if (!isRecord$1(hooksBlock)) return canonical;
23895
+ for (const [crushEvent, rawEntries] of Object.entries(hooksBlock)) {
23896
+ if (isPrototypePollutionKey(crushEvent) || !Array.isArray(rawEntries)) continue;
23897
+ const canonicalEvent = lookupOwn({
23898
+ record: NORMALIZED_CRUSH_TO_CANONICAL_EVENT_NAMES,
23899
+ key: normalizeCrushEventName(crushEvent)
23900
+ }) ?? crushEvent;
23901
+ const defs = rawEntries.map((raw) => crushEntryToCanonicalDef(raw)).filter((def) => def !== null);
23902
+ if (defs.length === 0) continue;
23903
+ canonical[canonicalEvent] = [...lookupOwn({
23904
+ record: canonical,
23905
+ key: canonicalEvent
23906
+ }) ?? [], ...defs];
23907
+ }
23908
+ return canonical;
23909
+ }
23910
+ /**
23911
+ * Crush hooks.
23912
+ *
23913
+ * Crush reads hooks from the `hooks` key of its JSON config —
23914
+ * `<project>/crush.json` (or an existing `.crush.json`; Crush merges the pair,
23915
+ * lists concatenated) at project scope and `~/.config/crush/crush.json` at
23916
+ * user scope — as
23917
+ * `hooks.<Event>: [{name?, matcher?, command, timeout?}]`. Only `PreToolUse`
23918
+ * fires today. The `hooks` key is owned outright; every other top-level key
23919
+ * of the file is preserved and the file is never deleted.
23920
+ *
23921
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
23922
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
23923
+ */
23924
+ var CrushHooks = class CrushHooks extends ToolHooks {
23925
+ json;
23926
+ constructor(params) {
23927
+ super(params);
23928
+ this.json = parseCrushConfig(this.fileContent ?? "", join(this.relativeDirPath, this.relativeFilePath));
23929
+ }
23930
+ getJson() {
23931
+ return this.json;
23932
+ }
23933
+ isDeletable() {
23934
+ return false;
23935
+ }
23936
+ static getSettablePaths({ global = false } = {}) {
23937
+ return getCrushConfigSettablePaths({ global });
23938
+ }
23660
23939
  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({
23940
+ const location = await resolveCrushConfigFile({
23664
23941
  outputRoot,
23665
- relativeDirPath: paths.relativeDirPath,
23666
- relativeFilePath: paths.relativeFilePath,
23667
- fileContent,
23668
- validate
23942
+ global
23943
+ });
23944
+ return new CrushHooks({
23945
+ outputRoot,
23946
+ relativeDirPath: location.relativeDirPath,
23947
+ relativeFilePath: location.relativeFilePath,
23948
+ fileContent: crushConfigImportContent(location),
23949
+ validate,
23950
+ global
23669
23951
  });
23670
23952
  }
23671
23953
  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,
23954
+ const location = await resolveCrushConfigFile({
23955
+ outputRoot,
23956
+ global
23957
+ });
23958
+ const existingContent = location.fileContent ?? "";
23959
+ warnCrushTwinLeftovers({
23960
+ location,
23961
+ ownedPaths: [[CRUSH_HOOKS_KEY]],
23680
23962
  logger
23681
23963
  });
23682
- const fileContent = applySharedConfigPatch({
23683
- fileKey: sharedConfigFileKey(paths),
23684
- feature: "hooks",
23685
- existingContent,
23686
- patch: { hooks },
23687
- filePath,
23964
+ const config = rulesyncHooks.getJson();
23965
+ const hooks = canonicalToCrushHooks({
23966
+ config,
23967
+ toolOverride: config.crush?.hooks,
23688
23968
  logger
23689
23969
  });
23690
- return new CortexcodeHooks({
23970
+ return new CrushHooks({
23691
23971
  outputRoot,
23692
- relativeDirPath: paths.relativeDirPath,
23693
- relativeFilePath: paths.relativeFilePath,
23694
- fileContent,
23695
- validate
23972
+ relativeDirPath: location.relativeDirPath,
23973
+ relativeFilePath: location.relativeFilePath,
23974
+ fileContent: applySharedConfigPatch({
23975
+ fileKey: sharedConfigFileKey(this.getSettablePaths({ global })),
23976
+ feature: "hooks",
23977
+ existingContent,
23978
+ patch: { [CRUSH_HOOKS_KEY]: hooks },
23979
+ filePath: location.filePath,
23980
+ logger
23981
+ }),
23982
+ validate,
23983
+ global
23696
23984
  });
23697
23985
  }
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
- });
23986
+ toRulesyncHooks() {
23987
+ const hooks = crushHooksToCanonical(this.json[CRUSH_HOOKS_KEY]);
23711
23988
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
23712
23989
  hooks,
23713
- overrideKey: "cortexcode"
23990
+ overrideKey: "crush"
23714
23991
  }), null, 2) });
23715
23992
  }
23716
23993
  validate() {
@@ -23719,13 +23996,14 @@ var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23719
23996
  error: null
23720
23997
  };
23721
23998
  }
23722
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
23723
- return new CortexcodeHooks({
23999
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
24000
+ return new CrushHooks({
23724
24001
  outputRoot,
23725
24002
  relativeDirPath,
23726
24003
  relativeFilePath,
23727
- fileContent: JSON.stringify({ hooks: {} }, null, 2),
23728
- validate: false
24004
+ fileContent: JSON.stringify({ [CRUSH_HOOKS_KEY]: {} }, null, 2),
24005
+ validate: false,
24006
+ global
23729
24007
  });
23730
24008
  }
23731
24009
  };
@@ -26959,19 +27237,22 @@ var ReasonixHooks = class ReasonixHooks extends ToolHooks {
26959
27237
  //#endregion
26960
27238
  //#region src/features/hooks/tabnine-hooks.ts
26961
27239
  /**
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.*`).
27240
+ * Environment block safe to hand Tabnine for a hook. The shared converter
27241
+ * refuses a whole `env` map holding one bad entry; Tabnine's shape is authored
27242
+ * outside it, so the same per-entry rule (`isSafeEnvEntry`: a key holding `=`,
27243
+ * a control character or nothing at all names a different variable than it
27244
+ * appears to) is applied entry by entry and the bad ones are dropped in both
27245
+ * directions, with a warning on export, where the value came from an authored
27246
+ * `.rulesync/hooks.*`.
26967
27247
  */
26968
27248
  function sanitizeEnv({ env, warn }) {
26969
27249
  if (env === null || env === void 0 || typeof env !== "object" || Array.isArray(env)) return;
26970
27250
  const result = {};
26971
27251
  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) {
27252
+ if (!isSafeEnvEntry({
27253
+ key,
27254
+ value
27255
+ })) {
26975
27256
  warn?.(`Tabnine CLI hook env entry ${JSON.stringify(key)} is not a safe KEY=VALUE pair; skipping it.`);
26976
27257
  continue;
26977
27258
  }
@@ -27072,7 +27353,7 @@ const TabnineMatcherEntrySchema = z.looseObject({
27072
27353
  });
27073
27354
  function tabnineMatcherEntryToCanonical(entry) {
27074
27355
  const sequential = entry.sequential === true;
27075
- const matcher = entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "" ? entry.matcher : void 0;
27356
+ const matcher = entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "" && entry.matcher !== ".*" ? entry.matcher : void 0;
27076
27357
  const defs = [];
27077
27358
  for (const hook of entry.hooks ?? []) {
27078
27359
  if (hook.type !== "command" || typeof hook.command !== "string" || hook.command === "") continue;
@@ -28114,6 +28395,18 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28114
28395
  supportedHookTypes: ["command", "http"],
28115
28396
  supportsMatcher: true
28116
28397
  }],
28398
+ ["crush", {
28399
+ class: CrushHooks,
28400
+ meta: {
28401
+ supportsProject: true,
28402
+ supportsGlobal: true,
28403
+ supportsImport: true
28404
+ },
28405
+ supportedEvents: CRUSH_HOOK_EVENTS,
28406
+ supportedHookTypes: ["command"],
28407
+ supportsMatcher: true,
28408
+ passthroughOverrideEvents: true
28409
+ }],
28117
28410
  ["zcode", {
28118
28411
  class: ZcodeHooks,
28119
28412
  meta: {
@@ -28795,14 +29088,6 @@ var ContinueIgnore = class ContinueIgnore extends ToolIgnore {
28795
29088
  }
28796
29089
  };
28797
29090
  //#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
29091
  //#region src/features/ignore/crush-ignore.ts
28807
29092
  /**
28808
29093
  * Ignore generator for Crush.
@@ -30689,13 +30974,17 @@ function isRemoteMcpServer(serverConfig) {
30689
30974
  function resolveRemoteMcpUrl(serverConfig) {
30690
30975
  return serverConfig.url || serverConfig.httpUrl || void 0;
30691
30976
  }
30692
- /** The `command` array a `local` server is spawned with, `args` merged in. */
30977
+ /**
30978
+ * The `command` array a `local` server is spawned with, `args` merged in. An
30979
+ * entry carrying `args` but no `command` comes back empty rather than with
30980
+ * `args[0]` promoted to its program: every adapter writes element 0 back out
30981
+ * as the executable (or the whole array as the command line), so the caller
30982
+ * skips such an entry the same way as one with nothing to spawn at all.
30983
+ */
30693
30984
  function resolveLocalMcpCommand(serverConfig) {
30694
- const commandArray = [];
30695
- if (serverConfig.command) {
30696
- if (Array.isArray(serverConfig.command)) commandArray.push(...serverConfig.command);
30697
- else commandArray.push(serverConfig.command);
30698
- }
30985
+ const { command } = serverConfig;
30986
+ const commandArray = Array.isArray(command) ? [...command] : command ? [command] : [];
30987
+ if (commandArray.length === 0) return [];
30699
30988
  if (serverConfig.args) commandArray.push(...serverConfig.args);
30700
30989
  return commandArray;
30701
30990
  }
@@ -30705,9 +30994,13 @@ function resolveLocalMcpCommand(serverConfig) {
30705
30994
  * spells the transport out. Writing `{type: "local", command: []}` for it would
30706
30995
  * give the tool a server it cannot start and the importer a file it cannot read
30707
30996
  * back, so it is skipped out loud instead.
30997
+ *
30998
+ * The server name comes straight from the user's config, so it is quoted
30999
+ * through `quoteValueForWarning` rather than interpolated: a crafted name could
31000
+ * otherwise carry a line break or control characters into the log.
30708
31001
  */
30709
31002
  function warnAndSkipMcpServer({ toolName, serverName, reason, logger }) {
30710
- logger?.warn(`${toolName} MCP: skipping "${serverName}" because it declares ${reason}.`);
31003
+ logger?.warn(`${toolName} MCP: skipping ${quoteValueForWarning(serverName)} because it declares ${reason}.`);
30711
31004
  return null;
30712
31005
  }
30713
31006
  /**
@@ -30795,8 +31088,10 @@ function asBobRemoteType(stated, url) {
30795
31088
  * streamable HTTP server carries `type: "streamable-http"` and `url`, and an
30796
31089
  * SSE server carries a bare `url`. The canonical `transport` alias and the
30797
31090
  * Claude-style `httpUrl` alias are folded into `type`/`url`; `env`, `cwd`,
30798
- * `headers`, `timeout`, `alwaysAllow` and `disabled` pass through unchanged,
30799
- * as Bob documents all of them.
31091
+ * `headers`, `timeout`, `alwaysAllow` and `disabled` pass through, as Bob
31092
+ * documents all of them, with prototype-pollution keys dropped at every
31093
+ * nesting level (Bob spreads `env` and `headers` into the process environment
31094
+ * and the HTTP requests).
30800
31095
  *
30801
31096
  * Bob Shell documents the same file with an `httpURL` key instead of
30802
31097
  * `type` + `url` for streamable HTTP. rulesync writes the IDE spelling (the two
@@ -30864,7 +31159,7 @@ function convertToBobFormat(mcpServers, logger) {
30864
31159
  }
30865
31160
  for (const [key, value] of Object.entries(rest)) {
30866
31161
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
30867
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
31162
+ converted[key] = omitPrototypePollutionKeysDeep(value);
30868
31163
  }
30869
31164
  result[serverName] = converted;
30870
31165
  }
@@ -30875,7 +31170,9 @@ function convertToBobFormat(mcpServers, logger) {
30875
31170
  * (`type: "streamable-http"` + `url`) is already canonical and passes through;
30876
31171
  * the Bob Shell spelling `httpURL` becomes `url` with `type: "http"`; a bare
30877
31172
  * `url` is an SSE server in Bob, so it gains `type: "sse"` to keep that reading
30878
- * on the next generate.
31173
+ * on the next generate. Every other field passes through with its
31174
+ * prototype-pollution keys dropped at every nesting level, mirroring the
31175
+ * generate side's handling of `env` / `headers`.
30879
31176
  */
30880
31177
  function convertFromBobFormat(mcpServers) {
30881
31178
  if (!isMcpServers(mcpServers)) return {};
@@ -30890,7 +31187,7 @@ function convertFromBobFormat(mcpServers) {
30890
31187
  if (typeof value === "string") httpURL = value;
30891
31188
  continue;
30892
31189
  }
30893
- converted[key] = value;
31190
+ converted[key] = omitPrototypePollutionKeysDeep(value);
30894
31191
  }
30895
31192
  if (httpURL !== void 0) {
30896
31193
  converted.url = httpURL;
@@ -31738,7 +32035,7 @@ function convertRemoteServer({ serverName, serverConfig, logger }) {
31738
32035
  return converted;
31739
32036
  }
31740
32037
  function convertStdioServer({ serverName, serverConfig, logger }) {
31741
- const [command, ...args] = (typeof serverConfig.command === "string" ? serverConfig.command !== "" : Array.isArray(serverConfig.command) && serverConfig.command.length > 0) ? resolveLocalMcpCommand(serverConfig) : [];
32038
+ const [command, ...args] = resolveLocalMcpCommand(serverConfig);
31742
32039
  if (!command) {
31743
32040
  warnAndSkipMcpServer({
31744
32041
  toolName: "Continue",
@@ -31754,7 +32051,7 @@ function convertStdioServer({ serverName, serverConfig, logger }) {
31754
32051
  };
31755
32052
  if (args.length > 0) converted.args = args;
31756
32053
  if (isRecord$1(serverConfig.env)) converted.env = omitPrototypePollutionKeys(serverConfig.env);
31757
- if (typeof serverConfig.envFile === "string") logger?.warn(`Continue ignores "envFile" for MCP servers, so the envFile of server "${serverName}" was not written; put the variables in "env" instead.`);
32054
+ if (typeof serverConfig.envFile === "string") logger?.warn(`Continue ignores "envFile" for MCP servers, so the envFile of server ${quoteValueForWarning(serverName)} was not written; put the variables in "env" instead.`);
31758
32055
  return converted;
31759
32056
  }
31760
32057
  /**
@@ -32296,7 +32593,7 @@ function convertToCortexcodeFormat(mcpServers, logger) {
32296
32593
  }
32297
32594
  for (const [key, value] of Object.entries(rest)) {
32298
32595
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
32299
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
32596
+ converted[key] = omitPrototypePollutionKeysDeep(value);
32300
32597
  }
32301
32598
  result[serverName] = converted;
32302
32599
  }
@@ -32305,14 +32602,15 @@ function convertToCortexcodeFormat(mcpServers, logger) {
32305
32602
  /**
32306
32603
  * Convert Cortex Code's server map back to the canonical shape. The
32307
32604
  * documented spelling (`type` + `command`/`url`) is already canonical, so
32308
- * entries pass through with only prototype-pollution keys dropped.
32605
+ * entries pass through with only prototype-pollution keys dropped — at every
32606
+ * nesting level, mirroring the generate side.
32309
32607
  */
32310
32608
  function convertFromCortexcodeFormat(mcpServers) {
32311
32609
  if (!isMcpServers(mcpServers)) return {};
32312
32610
  const result = {};
32313
32611
  for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
32314
32612
  if (PROTOTYPE_POLLUTION_KEYS.has(serverName) || !isRecord$1(serverConfig)) continue;
32315
- result[serverName] = omitPrototypePollutionKeys(serverConfig);
32613
+ result[serverName] = omitPrototypePollutionKeysDeep(serverConfig);
32316
32614
  }
32317
32615
  return result;
32318
32616
  }
@@ -32351,19 +32649,12 @@ var CortexcodeMcp = class CortexcodeMcp extends ToolMcp {
32351
32649
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32352
32650
  if (!global) throw new Error(CORTEXCODE_GLOBAL_ONLY_MESSAGE);
32353
32651
  const paths = this.getSettablePaths({ global });
32354
- const json = parseCortexcodeMcpConfig({
32355
- fileContent: await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}",
32356
- relativePath: join(paths.relativeDirPath, paths.relativeFilePath)
32357
- });
32358
- const newJson = {
32359
- ...json,
32360
- mcpServers: json.mcpServers ?? {}
32361
- };
32652
+ const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}";
32362
32653
  return new CortexcodeMcp({
32363
32654
  outputRoot,
32364
32655
  relativeDirPath: paths.relativeDirPath,
32365
32656
  relativeFilePath: paths.relativeFilePath,
32366
- fileContent: JSON.stringify(newJson, null, 2),
32657
+ fileContent,
32367
32658
  validate,
32368
32659
  global
32369
32660
  });
@@ -32411,6 +32702,319 @@ var CortexcodeMcp = class CortexcodeMcp extends ToolMcp {
32411
32702
  }
32412
32703
  };
32413
32704
  //#endregion
32705
+ //#region src/features/mcp/crush-mcp.ts
32706
+ /**
32707
+ * Crush-only MCP fields with no canonical counterpart, authored under a
32708
+ * `crush`-prefixed key in `.rulesync/mcp.jsonc` and written under Crush's own
32709
+ * name: `oauth*` drive Crush's OAuth 2.1 flow for HTTP servers and
32710
+ * `sessionless` marks a server that issues no `Mcp-Session-Id`.
32711
+ * `RulesyncMcp.getMcpServers()` strips both spellings so the client secret
32712
+ * never reaches another tool's config; `fromRulesyncMcp` re-merges them from
32713
+ * the raw source JSON, honouring the raw Crush spelling as a fallback for an
32714
+ * entry copied straight out of a `crush.json` (the `experimental_environment`
32715
+ * precedent) — except `oauth`, whose canonical key is Claude Code's
32716
+ * `{ clientId }` object and which only `crushOauth` can set.
32717
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
32718
+ */
32719
+ const CRUSH_ONLY_KEYS = [
32720
+ {
32721
+ canonical: "crushOauth",
32722
+ crush: "oauth",
32723
+ accepts: (v) => typeof v === "boolean",
32724
+ rawFallback: false
32725
+ },
32726
+ {
32727
+ canonical: "crushOauthClientId",
32728
+ crush: "oauth_client_id",
32729
+ accepts: (v) => typeof v === "string",
32730
+ rawFallback: true
32731
+ },
32732
+ {
32733
+ canonical: "crushOauthClientSecret",
32734
+ crush: "oauth_client_secret",
32735
+ accepts: (v) => typeof v === "string",
32736
+ rawFallback: true
32737
+ },
32738
+ {
32739
+ canonical: "crushOauthCallbackPort",
32740
+ crush: "oauth_callback_port",
32741
+ accepts: (v) => Number.isInteger(v),
32742
+ rawFallback: true
32743
+ },
32744
+ {
32745
+ canonical: "crushSessionless",
32746
+ crush: "sessionless",
32747
+ accepts: (v) => typeof v === "boolean",
32748
+ rawFallback: true
32749
+ }
32750
+ ];
32751
+ /**
32752
+ * The Crush-only keys of one raw canonical server entry, keyed by their
32753
+ * canonical name, so `convertToCrushFormat` sees them after the shared map
32754
+ * stripped them.
32755
+ */
32756
+ function readCrushOnlyKeys(rawServer) {
32757
+ const result = {};
32758
+ if (!isRecord$1(rawServer)) return result;
32759
+ for (const { canonical, crush, accepts, rawFallback } of CRUSH_ONLY_KEYS) {
32760
+ const value = accepts(rawServer[canonical]) ? rawServer[canonical] : rawFallback && accepts(rawServer[crush]) ? rawServer[crush] : void 0;
32761
+ if (value !== void 0) result[canonical] = value;
32762
+ }
32763
+ return result;
32764
+ }
32765
+ function copyCrushOnlyKeys({ from, to }) {
32766
+ for (const { canonical, crush, accepts } of CRUSH_ONLY_KEYS) if (accepts(from[canonical])) to[crush] = from[canonical];
32767
+ }
32768
+ /**
32769
+ * The remote transports Crush's `MCPType` enum accepts, spelled the way it
32770
+ * reads them. `streamable-http` is the canonical rulesync name for what Crush
32771
+ * calls `http`; a WebSocket server has no Crush transport and is left to the
32772
+ * caller to reject.
32773
+ */
32774
+ function asCrushRemoteType(stated, url) {
32775
+ if (stated === "sse") return "sse";
32776
+ if (stated === "http" || stated === "streamable-http") return "http";
32777
+ if (stated === void 0) return /^wss?:\/\//i.test(url) ? void 0 : "http";
32778
+ }
32779
+ /**
32780
+ * The transport half of a remote server (`type`, `url`, `headers`), or null
32781
+ * when Crush could not reach it and the server was skipped with a warning.
32782
+ */
32783
+ function convertRemoteTransport({ name, config, logger }) {
32784
+ const url = resolveRemoteMcpUrl(config);
32785
+ if (!url) {
32786
+ warnAndSkipMcpServer({
32787
+ toolName: "Crush",
32788
+ serverName: name,
32789
+ reason: "a remote transport without a url",
32790
+ logger
32791
+ });
32792
+ return null;
32793
+ }
32794
+ const stated = config.type ?? config.transport;
32795
+ const type = asCrushRemoteType(stated, url);
32796
+ if (type === void 0) {
32797
+ warnAndSkipMcpServer({
32798
+ toolName: "Crush",
32799
+ serverName: name,
32800
+ 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)`,
32801
+ logger
32802
+ });
32803
+ return null;
32804
+ }
32805
+ const converted = {
32806
+ type,
32807
+ url
32808
+ };
32809
+ if (config.headers && Object.keys(config.headers).length > 0) converted.headers = config.headers;
32810
+ return converted;
32811
+ }
32812
+ /**
32813
+ * The transport half of a stdio server (`type`, `command`, `args`, `env`), or
32814
+ * null when it has no command and was skipped with a warning.
32815
+ */
32816
+ function convertStdioTransport({ name, config, logger }) {
32817
+ const [command, ...args] = resolveLocalMcpCommand(config);
32818
+ if (!command) {
32819
+ warnAndSkipMcpServer({
32820
+ toolName: "Crush",
32821
+ serverName: name,
32822
+ reason: "a stdio transport without a command",
32823
+ logger
32824
+ });
32825
+ return null;
32826
+ }
32827
+ const converted = {
32828
+ type: "stdio",
32829
+ command
32830
+ };
32831
+ if (args.length > 0) converted.args = args;
32832
+ if (config.env && Object.keys(config.env).length > 0) converted.env = config.env;
32833
+ return converted;
32834
+ }
32835
+ /**
32836
+ * Convert canonical rulesync servers to the `mcp.<name>` shape of crush.json.
32837
+ * `type` is required by Crush's schema, so it is always written: stdio servers
32838
+ * carry `type: "stdio"` with `command`/`args`/`env`, remote servers `http` or
32839
+ * `sse` with `url` and optional `headers`. `disabled`, the per-server
32840
+ * `disabledTools`/`enabledTools` filters (`disabled_tools`/`enabled_tools`)
32841
+ * and `timeout` (which Crush reads in seconds) map onto their Crush fields.
32842
+ *
32843
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
32844
+ */
32845
+ function convertToCrushFormat(mcpServers, logger) {
32846
+ const result = {};
32847
+ for (const [name, config] of Object.entries(mcpServers)) {
32848
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
32849
+ if (!isRecord$1(config)) continue;
32850
+ if (declaresNoTransport(config)) {
32851
+ warnAndSkipMcpServer({
32852
+ toolName: "Crush",
32853
+ serverName: name,
32854
+ reason: "no transport",
32855
+ logger
32856
+ });
32857
+ continue;
32858
+ }
32859
+ const converted = isRemoteMcpServer(config) ? convertRemoteTransport({
32860
+ name,
32861
+ config,
32862
+ logger
32863
+ }) : convertStdioTransport({
32864
+ name,
32865
+ config,
32866
+ logger
32867
+ });
32868
+ if (converted === null) continue;
32869
+ if (config.disabled === true) converted.disabled = true;
32870
+ if (config.disabledTools && config.disabledTools.length > 0) converted.disabled_tools = config.disabledTools;
32871
+ if (config.enabledTools && config.enabledTools.length > 0) converted.enabled_tools = config.enabledTools;
32872
+ if (typeof config.timeout === "number") converted.timeout = Math.ceil(config.timeout);
32873
+ copyCrushOnlyKeys({
32874
+ from: config,
32875
+ to: converted
32876
+ });
32877
+ result[name] = converted;
32878
+ }
32879
+ return result;
32880
+ }
32881
+ /**
32882
+ * Convert the `mcp.<name>` entries of crush.json back to canonical rulesync
32883
+ * servers. `stdio` is Crush's default `type`, so it is dropped (a canonical
32884
+ * server with a `command` is stdio already); `http` and `sse` are kept as the
32885
+ * canonical transport names; `disabled_tools`/`enabled_tools` become the
32886
+ * canonical `disabledTools`/`enabledTools`; the Crush-only OAuth and
32887
+ * `sessionless` fields are lifted into their `crush*` authoring keys (a value
32888
+ * of the wrong type is dropped, as Crush itself would refuse it), and any
32889
+ * other key is kept verbatim so an import preserves what a hand-authored
32890
+ * entry declared.
32891
+ */
32892
+ function convertFromCrushFormat(crushServers) {
32893
+ const result = {};
32894
+ for (const [name, config] of Object.entries(crushServers)) {
32895
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
32896
+ const converted = {};
32897
+ for (const [key, value] of Object.entries(config)) {
32898
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
32899
+ switch (key) {
32900
+ case "type":
32901
+ if (value === "http" || value === "sse") converted.type = value;
32902
+ break;
32903
+ case "disabled_tools":
32904
+ if (isStringArray$2(value)) converted.disabledTools = value;
32905
+ break;
32906
+ case "enabled_tools":
32907
+ if (isStringArray$2(value)) converted.enabledTools = value;
32908
+ break;
32909
+ case "oauth_token": break;
32910
+ default: {
32911
+ const crushOnly = CRUSH_ONLY_KEYS.find((entry) => entry.crush === key);
32912
+ if (crushOnly === void 0) converted[key] = value;
32913
+ else if (crushOnly.accepts(value)) converted[crushOnly.canonical] = value;
32914
+ }
32915
+ }
32916
+ }
32917
+ result[name] = converted;
32918
+ }
32919
+ return result;
32920
+ }
32921
+ /**
32922
+ * Crush MCP servers.
32923
+ *
32924
+ * Crush reads MCP servers from the `mcp` key of its JSON config:
32925
+ * `<project>/crush.json` (or an existing `.crush.json`; Crush merges the pair,
32926
+ * objects recursively) at project scope and `~/.config/crush/crush.json` at
32927
+ * user scope. The
32928
+ * `crushrc` Bash config that Crush now recommends compiles its `mcp add`
32929
+ * builtin into the same `mcp.<name>` entries and overrides the JSON key by
32930
+ * key, so a `crushrc` next to the generated file takes precedence. Every
32931
+ * other top-level key of the file is preserved; the file is never deleted.
32932
+ *
32933
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/config/README.md
32934
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
32935
+ */
32936
+ var CrushMcp = class CrushMcp extends ToolMcp {
32937
+ json;
32938
+ constructor(params) {
32939
+ super(params);
32940
+ this.json = parseCrushConfig(this.fileContent ?? "", join(this.relativeDirPath, this.relativeFilePath));
32941
+ }
32942
+ getJson() {
32943
+ return this.json;
32944
+ }
32945
+ isDeletable() {
32946
+ return false;
32947
+ }
32948
+ static getSettablePaths({ global = false } = {}) {
32949
+ return getCrushConfigSettablePaths({ global });
32950
+ }
32951
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32952
+ const location = await resolveCrushConfigFile({
32953
+ outputRoot,
32954
+ global
32955
+ });
32956
+ return new CrushMcp({
32957
+ outputRoot,
32958
+ relativeDirPath: location.relativeDirPath,
32959
+ relativeFilePath: location.relativeFilePath,
32960
+ fileContent: crushConfigImportContent(location),
32961
+ validate,
32962
+ global
32963
+ });
32964
+ }
32965
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
32966
+ const location = await resolveCrushConfigFile({
32967
+ outputRoot,
32968
+ global
32969
+ });
32970
+ const existingContent = location.fileContent ?? "";
32971
+ warnCrushTwinLeftovers({
32972
+ location,
32973
+ ownedPaths: [["mcp"]],
32974
+ logger
32975
+ });
32976
+ const converted = convertToCrushFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => [serverName, {
32977
+ ...serverConfig,
32978
+ ...readCrushOnlyKeys(rulesyncMcp.getRawMcpServer(serverName))
32979
+ }])), logger);
32980
+ return new CrushMcp({
32981
+ outputRoot,
32982
+ relativeDirPath: location.relativeDirPath,
32983
+ relativeFilePath: location.relativeFilePath,
32984
+ fileContent: applySharedConfigPatch({
32985
+ fileKey: sharedConfigFileKey(this.getSettablePaths({ global })),
32986
+ feature: "mcp",
32987
+ existingContent,
32988
+ patch: { ["mcp"]: converted },
32989
+ filePath: location.filePath,
32990
+ logger
32991
+ }),
32992
+ validate,
32993
+ global
32994
+ });
32995
+ }
32996
+ toRulesyncMcp() {
32997
+ const converted = convertFromCrushFormat(isRecord$1(this.json["mcp"]) ? this.json["mcp"] : {});
32998
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers: converted }, null, 2) });
32999
+ }
33000
+ validate() {
33001
+ return {
33002
+ success: true,
33003
+ error: null
33004
+ };
33005
+ }
33006
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
33007
+ return new CrushMcp({
33008
+ outputRoot,
33009
+ relativeDirPath,
33010
+ relativeFilePath,
33011
+ fileContent: JSON.stringify({ ["mcp"]: {} }, null, 2),
33012
+ validate: false,
33013
+ global
33014
+ });
33015
+ }
33016
+ };
33017
+ //#endregion
32414
33018
  //#region src/features/mcp/mcp-env-var-format.ts
32415
33019
  /**
32416
33020
  * Canonical rulesync env var reference pattern: `${VAR}` (but not `${env:VAR}`,
@@ -36455,7 +37059,7 @@ function convertToTabnineFormat(mcpServers, logger) {
36455
37059
  }
36456
37060
  for (const [key, value] of Object.entries(rest)) {
36457
37061
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36458
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
37062
+ converted[key] = omitPrototypePollutionKeysDeep(value);
36459
37063
  }
36460
37064
  if (enabledTools !== void 0) converted.includeTools = enabledTools;
36461
37065
  if (disabledTools !== void 0) converted.excludeTools = disabledTools;
@@ -36466,7 +37070,9 @@ function convertToTabnineFormat(mcpServers, logger) {
36466
37070
  /**
36467
37071
  * Convert Tabnine's server map back to the canonical shape: `includeTools` /
36468
37072
  * `excludeTools` become `enabledTools` / `disabledTools`; `type` (`stdio`,
36469
- * `sse`, `http`) and every other documented field are already canonical.
37073
+ * `sse`, `http`) and every other documented field are already canonical and
37074
+ * pass through with their prototype-pollution keys dropped at every nesting
37075
+ * level, mirroring the generate side.
36470
37076
  */
36471
37077
  function convertFromTabnineFormat(mcpServers) {
36472
37078
  if (!isMcpServers(mcpServers)) return {};
@@ -36478,7 +37084,7 @@ function convertFromTabnineFormat(mcpServers) {
36478
37084
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36479
37085
  if (key === "includeTools") converted.enabledTools = value;
36480
37086
  else if (key === "excludeTools") converted.disabledTools = value;
36481
- else converted[key] = value;
37087
+ else converted[key] = omitPrototypePollutionKeysDeep(value);
36482
37088
  }
36483
37089
  result[serverName] = converted;
36484
37090
  }
@@ -37626,6 +38232,15 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
37626
38232
  supportsDisabledTools: false
37627
38233
  }
37628
38234
  }],
38235
+ ["crush", {
38236
+ class: CrushMcp,
38237
+ meta: {
38238
+ supportsProject: true,
38239
+ supportsGlobal: true,
38240
+ supportsEnabledTools: true,
38241
+ supportsDisabledTools: true
38242
+ }
38243
+ }],
37629
38244
  ["cursor", {
37630
38245
  class: CursorMcp,
37631
38246
  meta: {
@@ -39057,7 +39672,7 @@ const ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES = {
39057
39672
  function toAntigravityCliToolName(canonical) {
39058
39673
  return CANONICAL_TO_ANTIGRAVITY_CLI_TOOL_NAMES[canonical] ?? canonical;
39059
39674
  }
39060
- function toCanonicalToolName$7(cliName) {
39675
+ function toCanonicalToolName$8(cliName) {
39061
39676
  return ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES[cliName] ?? cliName;
39062
39677
  }
39063
39678
  /**
@@ -39273,7 +39888,7 @@ function convertAntigravityCliToRulesyncPermissions(params) {
39273
39888
  const processEntries = (entries, action) => {
39274
39889
  for (const entry of entries) {
39275
39890
  const { toolName, pattern } = parsePermissionEntry$1(entry);
39276
- const canonical = toCanonicalToolName$7(toolName);
39891
+ const canonical = toCanonicalToolName$8(toolName);
39277
39892
  if (!permission[canonical]) permission[canonical] = {};
39278
39893
  permission[canonical][pattern] = action;
39279
39894
  }
@@ -39530,11 +40145,34 @@ const CANONICAL_TO_AUGMENT_TOOL_NAMES = {
39530
40145
  websearch: "web-search"
39531
40146
  };
39532
40147
  const AUGMENT_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_AUGMENT_TOOL_NAMES).map(([k, v]) => [v, k]));
40148
+ const CURRENT_TO_LEGACY_AUGMENT_TOOL_NAMES = {
40149
+ terminal: "launch-process",
40150
+ read: "view",
40151
+ edit: "str-replace-editor",
40152
+ write: "save-file"
40153
+ };
39533
40154
  function toAugmentToolName(canonical) {
39534
- return CANONICAL_TO_AUGMENT_TOOL_NAMES[canonical] ?? canonical;
40155
+ return lookupOwn({
40156
+ record: CANONICAL_TO_AUGMENT_TOOL_NAMES,
40157
+ key: canonical
40158
+ }) ?? canonical;
39535
40159
  }
39536
- function toCanonicalToolName$6(augmentName) {
39537
- return AUGMENT_TO_CANONICAL_TOOL_NAMES[augmentName] ?? augmentName;
40160
+ /**
40161
+ * Resolve an existing entry's `toolName` to the legacy spelling rulesync emits, so that the
40162
+ * current alias and the legacy name are treated as the same managed tool.
40163
+ */
40164
+ function toLegacyAugmentToolName(augmentName) {
40165
+ return lookupOwn({
40166
+ record: CURRENT_TO_LEGACY_AUGMENT_TOOL_NAMES,
40167
+ key: augmentName
40168
+ }) ?? augmentName;
40169
+ }
40170
+ function toCanonicalToolName$7(augmentName) {
40171
+ const legacyName = toLegacyAugmentToolName(augmentName);
40172
+ return lookupOwn({
40173
+ record: AUGMENT_TO_CANONICAL_TOOL_NAMES,
40174
+ key: legacyName
40175
+ }) ?? legacyName;
39538
40176
  }
39539
40177
  function actionToAugmentType(action) {
39540
40178
  switch (action) {
@@ -39750,10 +40388,11 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
39750
40388
  const generatedKeys = new Set(generated.map((e) => `${e.toolName}|${e.shellInputRegex ?? ""}|${e.permission.type}`));
39751
40389
  const preservedBasicEntries = basicExistingEntries.filter((entry) => {
39752
40390
  if (entry.toolName === "*") return false;
39753
- if (!MANAGED_AUGMENT_TOOL_NAMES.has(entry.toolName)) return true;
40391
+ const legacyToolName = toLegacyAugmentToolName(entry.toolName);
40392
+ if (!MANAGED_AUGMENT_TOOL_NAMES.has(legacyToolName)) return true;
39754
40393
  if (entry.permission.type === "deny") {
39755
- const key = `${entry.toolName}|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
39756
- return !generatedKeys.has(key);
40394
+ const suffix = `|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
40395
+ return !generatedKeys.has(`${entry.toolName}${suffix}`) && !generatedKeys.has(`${legacyToolName}${suffix}`);
39757
40396
  }
39758
40397
  return false;
39759
40398
  });
@@ -39980,14 +40619,15 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
39980
40619
  if (isSpecialEntry(entry)) continue;
39981
40620
  const type = entry.permission.type;
39982
40621
  if (!isBasicAugmentType(type)) continue;
39983
- const canonical = toCanonicalToolName$6(entry.toolName);
40622
+ const legacyToolName = toLegacyAugmentToolName(entry.toolName);
40623
+ const canonical = toCanonicalToolName$7(entry.toolName);
39984
40624
  if (forbiddenMapKeys.has(canonical)) {
39985
40625
  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.`);
39986
40626
  continue;
39987
40627
  }
39988
40628
  const action = augmentTypeToAction(type);
39989
40629
  let pattern;
39990
- if (entry.toolName === "launch-process" && entry.shellInputRegex) {
40630
+ if (legacyToolName === "launch-process" && entry.shellInputRegex) {
39991
40631
  const regex = entry.shellInputRegex;
39992
40632
  if (isShellRegexRoundtrippable(regex)) pattern = shellRegexToGlob(regex);
39993
40633
  else if (action === "deny") {
@@ -40002,9 +40642,19 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
40002
40642
  logger?.warn(`AugmentCode permissions: skipping entry for tool '${entry.toolName}' because its pattern resolves to the reserved object key '${pattern}'.`);
40003
40643
  continue;
40004
40644
  }
40005
- if (!permission[canonical]) permission[canonical] = {};
40006
- const existing = permission[canonical][pattern];
40007
- if (existing === void 0 || actionPriority[action] > actionPriority[existing]) permission[canonical][pattern] = action;
40645
+ let bucket = lookupOwn({
40646
+ record: permission,
40647
+ key: canonical
40648
+ });
40649
+ if (bucket === void 0) {
40650
+ bucket = {};
40651
+ permission[canonical] = bucket;
40652
+ }
40653
+ const existing = lookupOwn({
40654
+ record: bucket,
40655
+ key: pattern
40656
+ });
40657
+ if (existing === void 0 || actionPriority[action] > actionPriority[existing]) bucket[pattern] = action;
40008
40658
  }
40009
40659
  return { permission };
40010
40660
  }
@@ -40215,7 +40865,7 @@ const CLAUDE_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONIC
40215
40865
  function toClaudeToolName(canonical) {
40216
40866
  return CANONICAL_TO_CLAUDE_TOOL_NAMES[canonical] ?? canonical;
40217
40867
  }
40218
- function toCanonicalToolName$5(claudeName) {
40868
+ function toCanonicalToolName$6(claudeName) {
40219
40869
  return CLAUDE_TO_CANONICAL_TOOL_NAMES[claudeName] ?? claudeName;
40220
40870
  }
40221
40871
  /**
@@ -41110,7 +41760,7 @@ function convertClaudeToRulesyncPermissions(params) {
41110
41760
  const processEntries = (entries, action) => {
41111
41761
  for (const entry of entries) {
41112
41762
  const { toolName, pattern } = parseClaudePermissionEntry(entry);
41113
- const canonical = toCanonicalToolName$5(toolName);
41763
+ const canonical = toCanonicalToolName$6(toolName);
41114
41764
  if (!permission[canonical]) permission[canonical] = {};
41115
41765
  permission[canonical][pattern] = action;
41116
41766
  }
@@ -41935,7 +42585,16 @@ function mapBashActionToDecision(action) {
41935
42585
  //#endregion
41936
42586
  //#region src/features/permissions/continue-permissions.ts
41937
42587
  const CONTINUE_GLOBAL_ONLY_MESSAGE = "Continue permissions are global-only; use --global to sync ~/.continue/permissions.yaml";
41938
- const CATCH_ALL_PATTERN$4 = "*";
42588
+ const CATCH_ALL_PATTERN$5 = "*";
42589
+ /**
42590
+ * A run of `*` (`**`, `***`) is the catch-all spelled another way: Continue
42591
+ * would match `Tool(**)` against every argument just as the bare tool name
42592
+ * does, so it is written — and, under the all-tools category, honored — as
42593
+ * the bare entry instead of being skipped as a pattern-specific rule.
42594
+ */
42595
+ function normalizeCatchAllPattern$1(pattern) {
42596
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$5 : pattern;
42597
+ }
41939
42598
  const CANONICAL_TO_CONTINUE_TOOL_NAMES = {
41940
42599
  bash: "Bash",
41941
42600
  read: "Read",
@@ -41963,7 +42622,7 @@ const CONTINUE_PATTERN_RE = /^([^(]+)(?:\(([^)]*)\))?$/;
41963
42622
  function toContinueToolName(canonical) {
41964
42623
  return Object.hasOwn(CANONICAL_TO_CONTINUE_TOOL_NAMES, canonical) ? CANONICAL_TO_CONTINUE_TOOL_NAMES[canonical] ?? canonical : canonical;
41965
42624
  }
41966
- function toCanonicalToolName$4(continueName) {
42625
+ function toCanonicalToolName$5(continueName) {
41967
42626
  return Object.hasOwn(CONTINUE_TO_CANONICAL_TOOL_NAMES, continueName) ? CONTINUE_TO_CANONICAL_TOOL_NAMES[continueName] ?? continueName : continueName;
41968
42627
  }
41969
42628
  /**
@@ -41971,7 +42630,7 @@ function toCanonicalToolName$4(continueName) {
41971
42630
  * `Tool(pattern)`.
41972
42631
  */
41973
42632
  function buildContinuePermissionEntry(toolName, pattern) {
41974
- return pattern === CATCH_ALL_PATTERN$4 ? toolName : `${toolName}(${pattern})`;
42633
+ return pattern === CATCH_ALL_PATTERN$5 ? toolName : `${toolName}(${pattern})`;
41975
42634
  }
41976
42635
  /**
41977
42636
  * Split a permissions.yaml entry into its tool name and argument pattern, or
@@ -41985,7 +42644,7 @@ function parseContinuePermissionEntry(entry) {
41985
42644
  if (toolName === "") return;
41986
42645
  return {
41987
42646
  toolName,
41988
- pattern: pattern === "" ? CATCH_ALL_PATTERN$4 : pattern
42647
+ pattern: pattern === "" ? CATCH_ALL_PATTERN$5 : pattern
41989
42648
  };
41990
42649
  }
41991
42650
  /**
@@ -42152,13 +42811,14 @@ function convertRulesyncToContinuePermissions({ config, logger }) {
42152
42811
  for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
42153
42812
  if (isPrototypePollutionKey(category)) continue;
42154
42813
  const toolName = category === "*" ? "*" : toContinueToolName(category);
42155
- for (const [pattern, action] of Object.entries(rules)) {
42156
- if (isPrototypePollutionKey(pattern)) continue;
42814
+ for (const [rawPattern, action] of Object.entries(rules)) {
42815
+ if (isPrototypePollutionKey(rawPattern)) continue;
42816
+ const pattern = normalizeCatchAllPattern$1(rawPattern);
42157
42817
  if (pattern.includes("(") || pattern.includes(")")) {
42158
42818
  logger?.warn(`Continue permissions.yaml cannot hold a parenthesis inside a pattern, so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
42159
42819
  continue;
42160
42820
  }
42161
- if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$4) {
42821
+ if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$5) {
42162
42822
  logger?.warn(`Continue permissions.yaml cannot scope a pattern to every tool, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
42163
42823
  continue;
42164
42824
  }
@@ -42183,7 +42843,7 @@ function convertContinuePermissionsToRulesync(lists) {
42183
42843
  const parsedEntry = parseContinuePermissionEntry(entry);
42184
42844
  if (parsedEntry === void 0) continue;
42185
42845
  if (isPrototypePollutionKey(parsedEntry.toolName) || isPrototypePollutionKey(parsedEntry.pattern)) continue;
42186
- const category = toCanonicalToolName$4(parsedEntry.toolName);
42846
+ const category = toCanonicalToolName$5(parsedEntry.toolName);
42187
42847
  permission[category] ??= {};
42188
42848
  permission[category][parsedEntry.pattern] ??= action;
42189
42849
  }
@@ -42506,6 +43166,295 @@ var CopilotcliPermissions = class CopilotcliPermissions extends ToolPermissions
42506
43166
  }
42507
43167
  };
42508
43168
  //#endregion
43169
+ //#region src/features/permissions/crush-permissions.ts
43170
+ const CATCH_ALL_PATTERN$4 = "*";
43171
+ /**
43172
+ * A run of `*` (`**`, `***`) is the catch-all spelled another way: Crush has no
43173
+ * argument patterns at all, so it is the only pattern a rule can carry and
43174
+ * still be written.
43175
+ */
43176
+ function normalizeCatchAllPattern(pattern) {
43177
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$4 : pattern;
43178
+ }
43179
+ const CANONICAL_TO_CRUSH_TOOL_NAMES = {
43180
+ bash: "bash",
43181
+ read: "view",
43182
+ edit: "edit",
43183
+ write: "write",
43184
+ grep: "grep",
43185
+ glob: "glob",
43186
+ webfetch: "fetch"
43187
+ };
43188
+ const CRUSH_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CRUSH_TOOL_NAMES).map(([k, v]) => [v, k]));
43189
+ const MCP_CANONICAL_PREFIX$4 = "mcp__";
43190
+ const MCP_CRUSH_PREFIX = "mcp_";
43191
+ function toCrushToolName(canonical) {
43192
+ if (Object.hasOwn(CANONICAL_TO_CRUSH_TOOL_NAMES, canonical)) return CANONICAL_TO_CRUSH_TOOL_NAMES[canonical] ?? canonical;
43193
+ if (canonical.startsWith(MCP_CANONICAL_PREFIX$4) && canonical.length > 5) return `${MCP_CRUSH_PREFIX}${canonical.slice(5).replaceAll("__", "_")}`;
43194
+ return canonical;
43195
+ }
43196
+ function toCanonicalToolName$4(crushName) {
43197
+ return Object.hasOwn(CRUSH_TO_CANONICAL_TOOL_NAMES, crushName) ? CRUSH_TO_CANONICAL_TOOL_NAMES[crushName] ?? crushName : crushName;
43198
+ }
43199
+ function isMcpToolName(crushName) {
43200
+ return crushName.startsWith(MCP_CRUSH_PREFIX) && crushName.length > 4;
43201
+ }
43202
+ function uniq$1(values) {
43203
+ return [...new Set(values)];
43204
+ }
43205
+ /**
43206
+ * Permissions adapter for Crush.
43207
+ *
43208
+ * Crush has no per-argument permission rules. Its JSON config carries two
43209
+ * tool-level lists instead:
43210
+ * - `permissions.allowed_tools`: tools (or `tool:action` pairs) that run
43211
+ * without a permission prompt; everything else prompts.
43212
+ * - `options.disabled_tools`: built-in tools removed from the agent
43213
+ * entirely.
43214
+ *
43215
+ * Mapping (rulesync canonical -> Crush), catch-all (`*`) rules only:
43216
+ * - `allow` -> an `allowed_tools` entry.
43217
+ * - `deny` -> a `disabled_tools` entry. Crush only filters its built-ins
43218
+ * through that list, so a deny for an MCP tool is reported and skipped
43219
+ * (disable the tool in the MCP server's `disabled_tools` instead).
43220
+ * - `ask` -> nothing (prompting is Crush's default).
43221
+ * - Tool name: `bash` -> `bash`, `read` -> `view`, `edit` -> `edit`,
43222
+ * `write` -> `write`, `grep` -> `grep`, `glob` -> `glob`,
43223
+ * `webfetch` -> `fetch`, `mcp__<server>__<tool>` -> `mcp_<server>_<tool>`;
43224
+ * any other category passes through verbatim.
43225
+ *
43226
+ * A pattern-specific rule cannot be expressed and is reported and skipped.
43227
+ * Because Crush cannot narrow an allowed tool, a category's catch-all `allow`
43228
+ * is not written while the same category carries a pattern-specific `deny` or
43229
+ * `ask`: the tool keeps prompting (fail closed) rather than being widened to
43230
+ * everything. The all-tools `*` category is mirrored onto `bash` by
43231
+ * `honorAllToolsOnBash` and otherwise skipped, since Crush has no allow-all
43232
+ * or disable-all list.
43233
+ *
43234
+ * `crush.json` is Crush's main config: entries naming a tool the canonical
43235
+ * config does not manage, and every `tool:action` entry (a form rulesync
43236
+ * never derives), are preserved verbatim; the managed tools' bare entries
43237
+ * are rebuilt, every other key of `permissions` / `options` and of the file is
43238
+ * kept, and the file is never deleted. Project scope writes `crush.json`, or
43239
+ * an existing `.crush.json`; Crush merges the pair (lists concatenated), so an
43240
+ * entry left in the other file stays in effect and is reported.
43241
+ *
43242
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/config/README.md
43243
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
43244
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/permission/permission.go
43245
+ */
43246
+ var CrushPermissions = class CrushPermissions extends ToolPermissions {
43247
+ json;
43248
+ constructor(params) {
43249
+ super({
43250
+ ...params,
43251
+ fileContent: params.fileContent ?? ""
43252
+ });
43253
+ this.json = parseCrushConfig(this.fileContent ?? "", join(this.relativeDirPath, this.relativeFilePath));
43254
+ }
43255
+ getJson() {
43256
+ return this.json;
43257
+ }
43258
+ isDeletable() {
43259
+ return false;
43260
+ }
43261
+ /**
43262
+ * `crush.json` is Crush's file: rulesync merges into it when it exists but
43263
+ * does not create one that holds nothing of its own — an absent file and an
43264
+ * absent `allowed_tools` both mean "prompt for everything".
43265
+ */
43266
+ shouldSkipCreationWhenPayloadEmpty() {
43267
+ return true;
43268
+ }
43269
+ static getSettablePaths({ global = false } = {}) {
43270
+ return getCrushConfigSettablePaths({ global });
43271
+ }
43272
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
43273
+ const location = await resolveCrushConfigFile({
43274
+ outputRoot,
43275
+ global
43276
+ });
43277
+ return new CrushPermissions({
43278
+ outputRoot,
43279
+ relativeDirPath: location.relativeDirPath,
43280
+ relativeFilePath: location.relativeFilePath,
43281
+ fileContent: crushConfigImportContent(location),
43282
+ validate,
43283
+ global
43284
+ });
43285
+ }
43286
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
43287
+ const location = await resolveCrushConfigFile({
43288
+ outputRoot,
43289
+ global
43290
+ });
43291
+ const existingContent = location.fileContent ?? "";
43292
+ warnCrushTwinLeftovers({
43293
+ location,
43294
+ ownedPaths: [[CRUSH_PERMISSIONS_KEY, CRUSH_ALLOWED_TOOLS_KEY], [CRUSH_OPTIONS_KEY, CRUSH_DISABLED_TOOLS_KEY]],
43295
+ logger
43296
+ });
43297
+ const existing = parseCrushConfig(existingContent, location.filePath);
43298
+ const existingPermissions = isRecord$1(existing["permissions"]) ? existing[CRUSH_PERMISSIONS_KEY] : void 0;
43299
+ const existingOptions = isRecord$1(existing["options"]) ? existing[CRUSH_OPTIONS_KEY] : void 0;
43300
+ const config = rulesyncPermissions.getJson();
43301
+ const generated = convertRulesyncToCrushLists({
43302
+ config,
43303
+ logger
43304
+ });
43305
+ const managedToolNames = managedCrushToolNames(config);
43306
+ const preservedAllowed = (isStringArray$2(existingPermissions?.["allowed_tools"]) ? existingPermissions[CRUSH_ALLOWED_TOOLS_KEY] : []).filter((entry) => entry.includes(":") || !managedToolNames.has(entry));
43307
+ const preservedDisabled = (isStringArray$2(existingOptions?.["disabled_tools"]) ? existingOptions[CRUSH_DISABLED_TOOLS_KEY] : []).filter((entry) => !managedToolNames.has(entry));
43308
+ const allowedList = uniq$1([...preservedAllowed, ...generated.allowed]);
43309
+ const disabledList = uniq$1([...preservedDisabled, ...generated.disabled]);
43310
+ const patch = {};
43311
+ if (allowedList.length > 0 || existingPermissions !== void 0) patch[CRUSH_PERMISSIONS_KEY] = { [CRUSH_ALLOWED_TOOLS_KEY]: allowedList.length > 0 ? allowedList : void 0 };
43312
+ if (disabledList.length > 0 || existingOptions !== void 0) patch[CRUSH_OPTIONS_KEY] = { [CRUSH_DISABLED_TOOLS_KEY]: disabledList.length > 0 ? disabledList : void 0 };
43313
+ return new CrushPermissions({
43314
+ outputRoot,
43315
+ relativeDirPath: location.relativeDirPath,
43316
+ relativeFilePath: location.relativeFilePath,
43317
+ fileContent: applySharedConfigPatch({
43318
+ fileKey: sharedConfigFileKey(CrushPermissions.getSettablePaths({ global })),
43319
+ feature: "permissions",
43320
+ existingContent,
43321
+ patch,
43322
+ filePath: location.filePath,
43323
+ logger
43324
+ }),
43325
+ validate: true,
43326
+ global
43327
+ });
43328
+ }
43329
+ toRulesyncPermissions() {
43330
+ const permissions = isRecord$1(this.json["permissions"]) ? this.json[CRUSH_PERMISSIONS_KEY] : {};
43331
+ const options = isRecord$1(this.json["options"]) ? this.json[CRUSH_OPTIONS_KEY] : {};
43332
+ const rulesyncConfig = convertCrushListsToRulesync({
43333
+ allowed: isStringArray$2(permissions["allowed_tools"]) ? permissions[CRUSH_ALLOWED_TOOLS_KEY] : [],
43334
+ disabled: isStringArray$2(options["disabled_tools"]) ? options[CRUSH_DISABLED_TOOLS_KEY] : []
43335
+ });
43336
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
43337
+ }
43338
+ validate() {
43339
+ return {
43340
+ success: true,
43341
+ error: null
43342
+ };
43343
+ }
43344
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
43345
+ return new CrushPermissions({
43346
+ outputRoot,
43347
+ relativeDirPath,
43348
+ relativeFilePath,
43349
+ fileContent: "{}",
43350
+ validate: false,
43351
+ global
43352
+ });
43353
+ }
43354
+ };
43355
+ /**
43356
+ * The Crush tool names the canonical config manages, i.e. the names its
43357
+ * categories map to. Entries naming any other tool are the user's and survive
43358
+ * a generate. The all-tools `*` category names no Crush tool of its own.
43359
+ */
43360
+ function managedCrushToolNames(config) {
43361
+ return new Set(Object.keys(config.permission).filter((category) => category !== "*" && !isPrototypePollutionKey(category)).map((category) => toCrushToolName(category)));
43362
+ }
43363
+ /**
43364
+ * Reduce one category's rules to the single tool-wide action Crush can carry,
43365
+ * or undefined when nothing should be written for the tool: no catch-all rule,
43366
+ * a catch-all allow next to a narrower deny/ask (Crush cannot narrow an
43367
+ * allowed tool, so it keeps prompting instead), or a deny on an MCP tool
43368
+ * (`options.disabled_tools` only covers built-ins).
43369
+ */
43370
+ function resolveCategoryAction({ category, toolName, rules, logger }) {
43371
+ let catchAllAction;
43372
+ let restricted = false;
43373
+ for (const [rawPattern, action] of Object.entries(rules)) {
43374
+ if (isPrototypePollutionKey(rawPattern)) continue;
43375
+ const pattern = normalizeCatchAllPattern(rawPattern);
43376
+ if (pattern === CATCH_ALL_PATTERN$4) {
43377
+ catchAllAction = action;
43378
+ continue;
43379
+ }
43380
+ logger?.warn(`Crush permissions are tool-wide (no argument patterns), so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
43381
+ if (action !== "allow") restricted = true;
43382
+ }
43383
+ if (catchAllAction === "allow" && restricted) {
43384
+ 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}".`);
43385
+ return;
43386
+ }
43387
+ if (catchAllAction === "deny" && isMcpToolName(toolName)) {
43388
+ 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.`);
43389
+ return;
43390
+ }
43391
+ return catchAllAction;
43392
+ }
43393
+ /**
43394
+ * Convert a rulesync permissions config into Crush's two tool lists.
43395
+ */
43396
+ function convertRulesyncToCrushLists({ config, logger }) {
43397
+ const lists = {
43398
+ allowed: [],
43399
+ disabled: []
43400
+ };
43401
+ const actionByTool = /* @__PURE__ */ new Map();
43402
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
43403
+ if (isPrototypePollutionKey(category)) continue;
43404
+ if (category === "*") {
43405
+ for (const [rawPattern, action] of Object.entries(rules)) {
43406
+ if (isPrototypePollutionKey(rawPattern)) continue;
43407
+ logger?.warn(`Crush has no allow-all or disable-all tool list, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(rawPattern)}) was skipped.`);
43408
+ }
43409
+ continue;
43410
+ }
43411
+ const toolName = toCrushToolName(category);
43412
+ const catchAllAction = resolveCategoryAction({
43413
+ category,
43414
+ toolName,
43415
+ rules,
43416
+ logger
43417
+ });
43418
+ if (catchAllAction === void 0) continue;
43419
+ const previous = actionByTool.get(toolName);
43420
+ 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.`);
43421
+ actionByTool.set(toolName, catchAllAction);
43422
+ if (catchAllAction === "allow") lists.allowed.push(toolName);
43423
+ else if (catchAllAction === "deny") lists.disabled.push(toolName);
43424
+ }
43425
+ return {
43426
+ allowed: uniq$1(lists.allowed),
43427
+ disabled: uniq$1(lists.disabled)
43428
+ };
43429
+ }
43430
+ /**
43431
+ * Convert Crush's two tool lists back into a rulesync config. A `tool:action`
43432
+ * entry scopes an allow to one action of a tool, which the canonical model
43433
+ * cannot express, so it is left out rather than widened to the whole tool.
43434
+ */
43435
+ function convertCrushListsToRulesync({ allowed, disabled }) {
43436
+ const permission = {};
43437
+ const bucketFor = (category) => {
43438
+ const own = lookupOwn({
43439
+ record: permission,
43440
+ key: category
43441
+ });
43442
+ if (own !== void 0) return own;
43443
+ const created = {};
43444
+ permission[category] = created;
43445
+ return created;
43446
+ };
43447
+ for (const entry of disabled) {
43448
+ if (entry === "" || isPrototypePollutionKey(entry)) continue;
43449
+ bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] = "deny";
43450
+ }
43451
+ for (const entry of allowed) {
43452
+ if (entry === "" || entry.includes(":") || isPrototypePollutionKey(entry)) continue;
43453
+ bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] ??= "allow";
43454
+ }
43455
+ return { permission };
43456
+ }
43457
+ //#endregion
42509
43458
  //#region src/features/permissions/cursor-permissions.ts
42510
43459
  /**
42511
43460
  * Mapping from rulesync canonical tool category names (lowercase) to Cursor CLI
@@ -43660,6 +44609,40 @@ function convertDeepagentsToRulesyncPermissions({ allowList }) {
43660
44609
  return Object.keys(bash).length > 0 ? { permission: { bash } } : { permission: {} };
43661
44610
  }
43662
44611
  //#endregion
44612
+ //#region src/features/permissions/single-action-collapse.ts
44613
+ /**
44614
+ * Strictness order shared by the tools whose permission model evaluates
44615
+ * `deny > ask > allow`. Used wherever several canonical rules have to fold
44616
+ * into one entry, so the strictest action always wins.
44617
+ */
44618
+ const PERMISSION_ACTION_PRIORITY = {
44619
+ allow: 0,
44620
+ ask: 1,
44621
+ deny: 2
44622
+ };
44623
+ /**
44624
+ * Collapse a pattern map to the single action a tool can hold for a scope
44625
+ * that has no pattern matcher, using deny > ask > allow precedence.
44626
+ *
44627
+ * A map without a catch-all grants nothing to unmatched inputs, so an
44628
+ * implicit `ask` joins the candidates and a narrow allowlist can never widen
44629
+ * into a blanket allow. Returns `undefined` for an empty map; each caller
44630
+ * decides what an empty map means for its tool (e.g. emit nothing, or fall
44631
+ * back to `deny` when the tool's own default is allow).
44632
+ */
44633
+ function collapseRulesToSingleAction({ rules }) {
44634
+ const actions = Object.values(rules);
44635
+ if (actions.length === 0) return;
44636
+ return (Object.hasOwn(rules, "*") ? actions : [...actions, "ask"]).reduce((current, candidate) => PERMISSION_ACTION_PRIORITY[candidate] > PERMISSION_ACTION_PRIORITY[current] ? candidate : current);
44637
+ }
44638
+ /**
44639
+ * Whether a pattern map carries anything beyond the `*` catch-all, i.e.
44640
+ * whether collapsing it to a single action loses information worth a warning.
44641
+ */
44642
+ function hasPatternSpecificRules(rules) {
44643
+ return Object.keys(rules).some((pattern) => pattern !== "*");
44644
+ }
44645
+ //#endregion
43663
44646
  //#region src/features/permissions/devin-permissions.ts
43664
44647
  /**
43665
44648
  * Mapping from rulesync canonical tool category names to Devin Local permission
@@ -43669,17 +44652,21 @@ function convertDeepagentsToRulesyncPermissions({ allowList }) {
43669
44652
  * `Write(glob)`, `Exec(prefix)`, and `Fetch(pattern)` — plus MCP tool patterns
43670
44653
  * (`mcp__server__tool`). The canonical `edit` and `write` categories both map
43671
44654
  * onto Devin's single `Write` scope; on import `Write` maps back to `write`, so
43672
- * `edit` rules round-trip as `write` (a lossy but documented collapse). Unknown
44655
+ * `edit` rules round-trip as `write` (a lossy but documented collapse). The
44656
+ * canonical `websearch` category maps onto the bare `web_search` tool name,
44657
+ * accepted in the permission lists since CLI v3000.10.21 (2026-09-10). Unknown
43673
44658
  * names (e.g. `mcp__github__list_issues`) pass through verbatim.
43674
44659
  *
43675
44660
  * @see https://docs.devin.ai/cli/reference/permissions
44661
+ * @see https://docs.devin.ai/cli/changelog/stable — v3000.10.21, `web_search`
43676
44662
  */
43677
44663
  const CANONICAL_TO_DEVIN_SCOPE = {
43678
44664
  read: "Read",
43679
44665
  write: "Write",
43680
44666
  edit: "Write",
43681
44667
  bash: "Exec",
43682
- webfetch: "Fetch"
44668
+ webfetch: "Fetch",
44669
+ websearch: "web_search"
43683
44670
  };
43684
44671
  /**
43685
44672
  * Reverse mapping from Devin scope matchers to rulesync canonical names.
@@ -43688,8 +44675,15 @@ const DEVIN_SCOPE_TO_CANONICAL = {
43688
44675
  Read: "read",
43689
44676
  Write: "write",
43690
44677
  Exec: "bash",
43691
- Fetch: "webfetch"
44678
+ Fetch: "webfetch",
44679
+ web_search: "websearch"
43692
44680
  };
44681
+ /**
44682
+ * Devin scopes that exist only as a bare tool name: there is no
44683
+ * `web_search(pattern)` matcher, so a pattern-specific rule under the canonical
44684
+ * category cannot be expressed and is collapsed to one action instead.
44685
+ */
44686
+ const DEVIN_BARE_ONLY_SCOPES = /* @__PURE__ */ new Set(["web_search"]);
43693
44687
  function toDevinScope(canonical) {
43694
44688
  return CANONICAL_TO_DEVIN_SCOPE[canonical] ?? canonical;
43695
44689
  }
@@ -43874,7 +44868,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
43874
44868
  throw new Error(`Failed to parse existing Devin config at ${filePath}: ${formatError(error)}`, { cause: error });
43875
44869
  }
43876
44870
  const config = rulesyncPermissions.getJson();
43877
- const { allow, ask, deny } = convertRulesyncToDevinPermissions(config);
44871
+ const { allow, ask, deny } = convertRulesyncToDevinPermissions({
44872
+ config,
44873
+ logger
44874
+ });
43878
44875
  const managedScopes = new Set(Object.keys(config.permission).map((category) => toDevinScope(category)));
43879
44876
  const existingPermissions = isRecord$1(settings.permissions) ? settings.permissions : {};
43880
44877
  const preserve = (entries) => (entries ?? []).filter((entry) => !managedScopes.has(parseDevinPermissionEntry(entry).scope));
@@ -43978,26 +44975,50 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
43978
44975
  }
43979
44976
  };
43980
44977
  /**
44978
+ * Collapse the pattern rules of a bare-only scope to the single action Devin
44979
+ * can hold for it, using deny > ask > allow precedence. A map without a
44980
+ * catch-all grants nothing to unmatched inputs, so an implicit `ask` joins the
44981
+ * candidates and a narrow allowlist can never widen into a blanket allow.
44982
+ * Returns `undefined` for an empty map: like any other empty category it
44983
+ * emits nothing, which leaves Devin's own auto-approve default in place.
44984
+ */
44985
+ function collapseBareOnlyScopeRules({ category, scope, rules, logger }) {
44986
+ const action = collapseRulesToSingleAction({ rules });
44987
+ if (action === void 0) return;
44988
+ 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.`);
44989
+ return action;
44990
+ }
44991
+ /**
43981
44992
  * Convert rulesync permissions config to Devin allow/ask/deny arrays.
43982
44993
  */
43983
- function convertRulesyncToDevinPermissions(config) {
44994
+ function convertRulesyncToDevinPermissions({ config, logger }) {
43984
44995
  const allow = [];
43985
44996
  const ask = [];
43986
44997
  const deny = [];
44998
+ const push = (entry, action) => {
44999
+ switch (action) {
45000
+ case "allow":
45001
+ allow.push(entry);
45002
+ break;
45003
+ case "ask":
45004
+ ask.push(entry);
45005
+ break;
45006
+ case "deny": deny.push(entry);
45007
+ }
45008
+ };
43987
45009
  for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
43988
45010
  const scope = toDevinScope(category);
43989
- for (const [pattern, action] of Object.entries(rules)) {
43990
- const entry = buildDevinPermissionEntry(scope, pattern);
43991
- switch (action) {
43992
- case "allow":
43993
- allow.push(entry);
43994
- break;
43995
- case "ask":
43996
- ask.push(entry);
43997
- break;
43998
- case "deny": deny.push(entry);
43999
- }
45011
+ if (DEVIN_BARE_ONLY_SCOPES.has(scope)) {
45012
+ const action = collapseBareOnlyScopeRules({
45013
+ category,
45014
+ scope,
45015
+ rules,
45016
+ logger
45017
+ });
45018
+ if (action !== void 0) push(scope, action);
45019
+ continue;
44000
45020
  }
45021
+ for (const [pattern, action] of Object.entries(rules)) push(buildDevinPermissionEntry(scope, pattern), action);
44001
45022
  }
44002
45023
  return {
44003
45024
  allow,
@@ -46243,20 +47264,14 @@ const OPENCODE_ACTION_ONLY_PERMISSION_KEYS = /* @__PURE__ */ new Set([
46243
47264
  "question",
46244
47265
  "doom_loop"
46245
47266
  ]);
46246
- const PERMISSION_ACTION_PRIORITY = {
46247
- allow: 0,
46248
- ask: 1,
46249
- deny: 2
46250
- };
46251
47267
  function toOpencodePermission({ category, value, logger }) {
46252
47268
  if (typeof value === "string" || !OPENCODE_ACTION_ONLY_PERMISSION_KEYS.has(category)) return value;
46253
- const actions = Object.values(value);
46254
- if (actions.length === 0) {
47269
+ const action = collapseRulesToSingleAction({ rules: value });
47270
+ if (action === void 0) {
46255
47271
  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.`);
46256
47272
  return "deny";
46257
47273
  }
46258
- const action = (Object.hasOwn(value, "*") ? actions : [...actions, "ask"]).reduce((current, candidate) => PERMISSION_ACTION_PRIORITY[candidate] > PERMISSION_ACTION_PRIORITY[current] ? candidate : current);
46259
- 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.`);
47274
+ 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.`);
46260
47275
  return action;
46261
47276
  }
46262
47277
  /**
@@ -46634,6 +47649,7 @@ const QWEN_OVERRIDE_TOOLS_KEYS = [
46634
47649
  "visible",
46635
47650
  "eager",
46636
47651
  "listDirectory",
47652
+ "todoWrite",
46637
47653
  "workflowsEnabled"
46638
47654
  ];
46639
47655
  const QWEN_OVERRIDE_SECURITY_KEYS = [
@@ -46739,6 +47755,11 @@ const QWEN_OVERRIDE_GROUPS = [{
46739
47755
  rule: "global-machine-wide",
46740
47756
  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.`,
46741
47757
  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."
47758
+ },
47759
+ todoWrite: {
47760
+ rule: "global-machine-wide",
47761
+ 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.`,
47762
+ 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."
46742
47763
  }
46743
47764
  }
46744
47765
  }, {
@@ -48545,9 +49566,25 @@ function widenToPrefixGlob(pattern) {
48545
49566
  function toShellEntry(prefix) {
48546
49567
  return prefix === "" ? SHELL_TOOL_NAME : `${SHELL_TOOL_NAME}(${prefix})`;
48547
49568
  }
48548
- /** The `bash` pattern a `run_shell_command(<prefix>)` entry stands for. */
49569
+ /**
49570
+ * The `bash` pattern a `run_shell_command(<prefix>)` entry stands for. A
49571
+ * prefix Tabnine matches literally, so one an author spelled glob-like
49572
+ * (`git *`, `*`) is read as the glob it looks like rather than doubled into
49573
+ * `git * *`, which no comparison or warning could make sense of.
49574
+ */
48549
49575
  function toShellPattern(prefix) {
48550
- return prefix === void 0 || prefix === "" ? "*" : `${prefix} *`;
49576
+ if (prefix === void 0 || prefix === "" || prefix === "*") return "*";
49577
+ return prefix.endsWith(" *") ? prefix : `${prefix} *`;
49578
+ }
49579
+ /**
49580
+ * Whether {@link toShellPattern} read the prefix as a glob: such an entry
49581
+ * would never fire on Tabnine as spelled (the prefix is matched literally, so
49582
+ * `run_shell_command(git *)` only matches a command line starting with the
49583
+ * two characters `git *`), and rulesync writes it back as the prefix the glob
49584
+ * denotes — a wider entry that the author is told about.
49585
+ */
49586
+ function isGlobSpelledPrefix(prefix) {
49587
+ return prefix !== void 0 && (prefix === "*" || prefix.endsWith(" *"));
48551
49588
  }
48552
49589
  /**
48553
49590
  * Split a `tools.allowed`/`tools.exclude` entry into its tool name and the
@@ -48673,7 +49710,7 @@ function collectTrustAffectingOverrideEntries({ tools, general }) {
48673
49710
  const allowed = isStringArray$2(tools[ALLOWED_KEY]) ? tools[ALLOWED_KEY] : [];
48674
49711
  if (allowed.length > 0) entries.push({
48675
49712
  label: `tools.allowed (${allowed.map(quoteValueForWarning).join(", ")})`,
48676
- reason: "auto-approves what it names as the override spells it; a non-shell entry has no canonical rule to be checked against"
49713
+ 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"
48677
49714
  });
48678
49715
  if (Object.hasOwn(tools, SANDBOX_KEY) && isNotTrue(tools[SANDBOX_KEY])) entries.push({
48679
49716
  label: `tools.sandbox = ${quoteValueForWarning(tools[SANDBOX_KEY])}`,
@@ -48784,6 +49821,54 @@ function buildToolLists({ permission, overrideShellAllowPatterns, logger }) {
48784
49821
  };
48785
49822
  }
48786
49823
  /**
49824
+ * Names the glob-spelled `run_shell_command(...)` override entries that were
49825
+ * written as the prefix their glob denotes. Said only for the entries that
49826
+ * made it into the list: one that a deny or ask overlaps is announced as
49827
+ * withheld by the comparison instead, and one whose glob names no prefix at
49828
+ * all is skipped by it.
49829
+ */
49830
+ function warnAboutGlobSpelledOverrideEntries({ entries, allowed, logger }) {
49831
+ for (const { entry, pattern } of entries) {
49832
+ const prefix = toShellPrefix(pattern);
49833
+ const written = prefix === void 0 ? void 0 : toShellEntry(prefix);
49834
+ if (written === void 0 || !allowed.includes(written)) continue;
49835
+ 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.`);
49836
+ }
49837
+ }
49838
+ /**
49839
+ * A verbatim override allow naming a tool the canonical block denies is
49840
+ * withheld the way a shadowed `bash` allow is: Tabnine never loads an excluded
49841
+ * tool whatever `allowed` says, so the entry would only ever contradict the
49842
+ * deny it sits beside — and an override is not a way around a canonical rule.
49843
+ * Returns the entries that may be written.
49844
+ */
49845
+ function withholdDeniedOverrideAllowed({ overrideVerbatimAllowed, exclude, logger }) {
49846
+ const denied = overrideVerbatimAllowed.filter((entry) => {
49847
+ const toolName = parseTabnineEntry(entry)?.toolName;
49848
+ return toolName !== void 0 && exclude.includes(toolName);
49849
+ });
49850
+ 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.`);
49851
+ return overrideVerbatimAllowed.filter((entry) => !denied.includes(entry));
49852
+ }
49853
+ /**
49854
+ * The entries of a managed tool that the canonical block did not re-derive are
49855
+ * named rather than removed silently. Dropping an exclude loosens the policy,
49856
+ * so it is a warning; dropping an allow only brings back Tabnine's confirmation
49857
+ * prompt, so it is said at info level for the author who wonders where a
49858
+ * hand-written entry went.
49859
+ */
49860
+ function reportDroppedManagedEntries({ existingTools, allowedList, excludeList, logger }) {
49861
+ const dropped = (key, list) => uniq(isStringArray$2(existingTools?.[key]) ? existingTools[key] : []).filter((entry) => {
49862
+ if (list.includes(entry)) return false;
49863
+ const toolName = parseTabnineEntry(entry)?.toolName;
49864
+ return toolName === void 0 || !list.includes(toolName);
49865
+ });
49866
+ const droppedExclude = dropped(EXCLUDE_KEY, excludeList);
49867
+ 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.`);
49868
+ const droppedAllowed = dropped(ALLOWED_KEY, allowedList);
49869
+ 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.`);
49870
+ }
49871
+ /**
48787
49872
  * Permissions generator for the Tabnine CLI.
48788
49873
  *
48789
49874
  * Tabnine CLI keeps its tool policy in `.tabnine/agent/settings.json` (project)
@@ -48849,7 +49934,10 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48849
49934
  const config = rulesyncPermissions.getJson();
48850
49935
  const override = isRecord$1(config.tabnine) ? config.tabnine : {};
48851
49936
  const { tools: overrideTools, general: overrideGeneral, refused: refusedPaths } = stripRefusedOverridePaths(override);
48852
- 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.`);
49937
+ if (refusedPaths.length > 0) {
49938
+ const one = refusedPaths.length === 1;
49939
+ 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"}.`);
49940
+ }
48853
49941
  const overrideList = (key) => {
48854
49942
  const value = overrideTools[key];
48855
49943
  if (value === void 0 || isStringArray$2(value)) return value ?? [];
@@ -48858,10 +49946,17 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48858
49946
  };
48859
49947
  const overrideShellAllowPatterns = [];
48860
49948
  const overrideVerbatimAllowed = [];
49949
+ const globSpelledOverrideEntries = [];
48861
49950
  for (const entry of overrideList(ALLOWED_KEY)) {
48862
49951
  const parsed = parseTabnineEntry(entry);
48863
- if (parsed?.toolName === SHELL_TOOL_NAME) overrideShellAllowPatterns.push(toShellPattern(parsed.prefix));
48864
- else overrideVerbatimAllowed.push(entry);
49952
+ if (parsed?.toolName === SHELL_TOOL_NAME) {
49953
+ const pattern = toShellPattern(parsed.prefix);
49954
+ if (isGlobSpelledPrefix(parsed.prefix)) globSpelledOverrideEntries.push({
49955
+ entry,
49956
+ pattern
49957
+ });
49958
+ overrideShellAllowPatterns.push(pattern);
49959
+ } else overrideVerbatimAllowed.push(entry);
48865
49960
  }
48866
49961
  const overrideExclude = overrideList(EXCLUDE_KEY);
48867
49962
  const { allowed, exclude } = buildToolLists({
@@ -48869,6 +49964,16 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48869
49964
  overrideShellAllowPatterns,
48870
49965
  logger
48871
49966
  });
49967
+ warnAboutGlobSpelledOverrideEntries({
49968
+ entries: globSpelledOverrideEntries,
49969
+ allowed,
49970
+ logger
49971
+ });
49972
+ const writableOverrideAllowed = withholdDeniedOverrideAllowed({
49973
+ overrideVerbatimAllowed,
49974
+ exclude,
49975
+ logger
49976
+ });
48872
49977
  const ignoredOverrideKeys = Object.keys(override).filter((key) => key !== TOOLS_KEY && key !== GENERAL_KEY);
48873
49978
  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.`);
48874
49979
  const existingTools = this.existingToolsGroup(existingContent);
@@ -48879,7 +49984,7 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48879
49984
  });
48880
49985
  const allowedList = uniq([
48881
49986
  ...allowed,
48882
- ...overrideVerbatimAllowed,
49987
+ ...writableOverrideAllowed,
48883
49988
  ...preservedEntries(ALLOWED_KEY)
48884
49989
  ]);
48885
49990
  const excludeList = uniq([
@@ -48887,12 +49992,12 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48887
49992
  ...overrideExclude,
48888
49993
  ...preservedEntries(EXCLUDE_KEY)
48889
49994
  ]);
48890
- const droppedExcludeEntries = uniq(isStringArray$2(existingTools?.[EXCLUDE_KEY]) ? existingTools[EXCLUDE_KEY] : []).filter((entry) => {
48891
- if (excludeList.includes(entry)) return false;
48892
- const toolName = parseTabnineEntry(entry)?.toolName;
48893
- return toolName === void 0 || !excludeList.includes(toolName);
49995
+ reportDroppedManagedEntries({
49996
+ existingTools,
49997
+ allowedList,
49998
+ excludeList,
49999
+ logger
48894
50000
  });
48895
- 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.`);
48896
50001
  const tools = {
48897
50002
  ...overrideTools,
48898
50003
  [ALLOWED_KEY]: allowedList.length > 0 ? allowedList : void 0,
@@ -48906,7 +50011,7 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48906
50011
  entries: collectTrustAffectingOverrideEntries({
48907
50012
  tools: {
48908
50013
  ...overrideTools,
48909
- [ALLOWED_KEY]: overrideVerbatimAllowed
50014
+ [ALLOWED_KEY]: writableOverrideAllowed
48910
50015
  },
48911
50016
  general: overrideGeneral
48912
50017
  }),
@@ -52263,6 +53368,14 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
52263
53368
  supportsImport: true
52264
53369
  }
52265
53370
  }],
53371
+ ["crush", {
53372
+ class: CrushPermissions,
53373
+ meta: {
53374
+ supportsProject: true,
53375
+ supportsGlobal: true,
53376
+ supportsImport: true
53377
+ }
53378
+ }],
52266
53379
  ["cursor", {
52267
53380
  class: CursorPermissions,
52268
53381
  meta: {
@@ -58284,6 +59397,183 @@ var PiSkill = class PiSkill extends ToolSkill {
58284
59397
  }
58285
59398
  };
58286
59399
  //#endregion
59400
+ //#region src/constants/pool-paths.ts
59401
+ /**
59402
+ * Pool (Poolside's coding agent CLI) configuration-layout conventions.
59403
+ *
59404
+ * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
59405
+ * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
59406
+ * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
59407
+ * path), the project-root `AGENTS.md`, and nested per-directory
59408
+ * `AGENTS.md` files from the repository root down through the working
59409
+ * directory, deeper files taking precedence. It skips ignored directories
59410
+ * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
59411
+ *
59412
+ * Skills follow the Agent Skills format (`<name>/SKILL.md` bundles). Pool
59413
+ * scans `.poolside/skills/` (project) and `~/.config/poolside/skills/`
59414
+ * (global) plus the shared `.agents/skills/` / `~/.agents/skills/` roots and
59415
+ * the skill directories of other Agent Skills tools; rulesync writes only the
59416
+ * Pool-specific roots and leaves the shared ones to their own targets.
59417
+ *
59418
+ * @see https://docs.poolside.ai/agent-instructions
59419
+ * @see https://docs.poolside.ai/skills
59420
+ * @see https://github.com/poolsideai/pool
59421
+ */
59422
+ /** Project-scoped `.poolside/` directory at the project root. */
59423
+ const POOL_DIR = ".poolside";
59424
+ /** Global config directory for Pool, relative to the home directory. */
59425
+ const POOL_GLOBAL_DIR = join(".config", "poolside");
59426
+ /** Project skills root, relative to the project root. */
59427
+ const POOL_SKILLS_DIR_PATH = join(POOL_DIR, "skills");
59428
+ /** Global skills root, relative to the home directory. */
59429
+ const POOL_GLOBAL_SKILLS_DIR_PATH = join(POOL_GLOBAL_DIR, "skills");
59430
+ //#endregion
59431
+ //#region src/features/skills/pool-skill.ts
59432
+ const PoolSkillFrontmatterSchema = z.looseObject({
59433
+ name: z.string(),
59434
+ description: z.string()
59435
+ });
59436
+ /**
59437
+ * Represents a Pool (Poolside) skill directory.
59438
+ *
59439
+ * Pool discovers Agent Skills (`<name>/SKILL.md` bundles, optionally with
59440
+ * supporting files) from `.poolside/skills/` (project) and
59441
+ * `~/.config/poolside/skills/` (global). It also scans the shared
59442
+ * `.agents/skills/` / `~/.agents/skills/` roots and the skill directories of
59443
+ * other Agent Skills tools; those belong to their own targets, so this class
59444
+ * writes only the two Pool-specific roots and a skill is written exactly once.
59445
+ * Pool requires the directory name to equal the frontmatter `name`, otherwise
59446
+ * it skips the skill. Unlike adapters that reject such a skill outright, this
59447
+ * one warns and still writes it: the directory name is the canonical identity
59448
+ * shared with every other target, and Pool merely skips the skill rather than
59449
+ * failing, so a hard error here would block the other targets for a
59450
+ * Pool-only concern. Import is lenient for the same reason.
59451
+ * @see https://docs.poolside.ai/skills
59452
+ */
59453
+ var PoolSkill = class PoolSkill extends ToolSkill {
59454
+ constructor({ outputRoot = process.cwd(), relativeDirPath = POOL_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
59455
+ super({
59456
+ outputRoot,
59457
+ relativeDirPath,
59458
+ dirName,
59459
+ mainFile: {
59460
+ name: SKILL_FILE_NAME,
59461
+ body,
59462
+ frontmatter: { ...frontmatter }
59463
+ },
59464
+ otherFiles,
59465
+ global
59466
+ });
59467
+ if (validate) {
59468
+ const result = this.validate();
59469
+ if (!result.success) throw result.error;
59470
+ }
59471
+ }
59472
+ static getSettablePaths({ global = false } = {}) {
59473
+ return { relativeDirPath: global ? POOL_GLOBAL_SKILLS_DIR_PATH : POOL_SKILLS_DIR_PATH };
59474
+ }
59475
+ getFrontmatter() {
59476
+ return PoolSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
59477
+ }
59478
+ getBody() {
59479
+ return this.mainFile?.body ?? "";
59480
+ }
59481
+ validate() {
59482
+ if (!this.mainFile) return {
59483
+ success: false,
59484
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`)
59485
+ };
59486
+ const result = PoolSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter);
59487
+ if (!result.success) return {
59488
+ success: false,
59489
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`)
59490
+ };
59491
+ return {
59492
+ success: true,
59493
+ error: null
59494
+ };
59495
+ }
59496
+ toRulesyncSkill() {
59497
+ const frontmatter = this.getFrontmatter();
59498
+ const rulesyncFrontmatter = {
59499
+ name: frontmatter.name,
59500
+ description: frontmatter.description,
59501
+ targets: ["*"]
59502
+ };
59503
+ return new RulesyncSkill({
59504
+ outputRoot: this.outputRoot,
59505
+ relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
59506
+ dirName: this.getDirName(),
59507
+ frontmatter: rulesyncFrontmatter,
59508
+ body: this.getBody(),
59509
+ otherFiles: this.getOtherFiles(),
59510
+ validate: true,
59511
+ global: this.global
59512
+ });
59513
+ }
59514
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false, logger }) {
59515
+ const settablePaths = PoolSkill.getSettablePaths({ global });
59516
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
59517
+ const dirName = rulesyncSkill.getDirName();
59518
+ const poolFrontmatter = {
59519
+ name: rulesyncFrontmatter.name,
59520
+ description: rulesyncFrontmatter.description
59521
+ };
59522
+ 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\``);
59523
+ return new PoolSkill({
59524
+ outputRoot,
59525
+ relativeDirPath: settablePaths.relativeDirPath,
59526
+ dirName,
59527
+ frontmatter: poolFrontmatter,
59528
+ body: rulesyncSkill.getBody(),
59529
+ otherFiles: rulesyncSkill.getOtherFiles(),
59530
+ validate,
59531
+ global
59532
+ });
59533
+ }
59534
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
59535
+ const targets = rulesyncSkill.getFrontmatter().targets;
59536
+ return targets.includes("*") || targets.includes("pool");
59537
+ }
59538
+ static async fromDir(params) {
59539
+ const loaded = await this.loadSkillDirContent({
59540
+ ...params,
59541
+ getSettablePaths: PoolSkill.getSettablePaths
59542
+ });
59543
+ const result = PoolSkillFrontmatterSchema.safeParse(loaded.frontmatter);
59544
+ if (!result.success) {
59545
+ const skillDirPath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName);
59546
+ throw new Error(`Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`);
59547
+ }
59548
+ return new PoolSkill({
59549
+ outputRoot: loaded.outputRoot,
59550
+ relativeDirPath: loaded.relativeDirPath,
59551
+ dirName: loaded.dirName,
59552
+ frontmatter: result.data,
59553
+ body: loaded.body,
59554
+ otherFiles: loaded.otherFiles,
59555
+ validate: true,
59556
+ global: loaded.global
59557
+ });
59558
+ }
59559
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, dirName, global = false }) {
59560
+ const settablePaths = PoolSkill.getSettablePaths({ global });
59561
+ return new PoolSkill({
59562
+ outputRoot,
59563
+ relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath,
59564
+ dirName,
59565
+ frontmatter: {
59566
+ name: "",
59567
+ description: ""
59568
+ },
59569
+ body: "",
59570
+ otherFiles: [],
59571
+ validate: false,
59572
+ global
59573
+ });
59574
+ }
59575
+ };
59576
+ //#endregion
58287
59577
  //#region src/features/skills/qwencode-skill.ts
58288
59578
  const QwencodeSkillFrontmatterSchema = z.looseObject({
58289
59579
  name: z.string(),
@@ -60170,6 +61460,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
60170
61460
  supportsGlobal: true
60171
61461
  }
60172
61462
  }],
61463
+ ["pool", {
61464
+ class: PoolSkill,
61465
+ meta: {
61466
+ supportsProject: true,
61467
+ supportsSimulated: false,
61468
+ supportsGlobal: true
61469
+ }
61470
+ }],
60173
61471
  ["qwencode", {
60174
61472
  class: QwencodeSkill,
60175
61473
  meta: {
@@ -65004,7 +66302,7 @@ var RooSubagent = class extends ToolSubagent {
65004
66302
  //#region src/features/subagents/tabnine-subagent.ts
65005
66303
  const TabnineSubagentFrontmatterSchema = z.looseObject({
65006
66304
  name: z.string(),
65007
- description: z.optional(z.string()),
66305
+ description: z.string(),
65008
66306
  /** "local" (default) runs inside the CLI; "remote" delegates to a Tabnine cloud agent. */
65009
66307
  kind: z.optional(z.string()),
65010
66308
  /** Built-in tool names the subagent may use; omitted = every tool. */
@@ -65063,14 +66361,23 @@ var TabnineSubagent = class TabnineSubagent extends ToolSubagent {
65063
66361
  validate: true
65064
66362
  });
65065
66363
  }
65066
- static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
66364
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false, logger }) {
65067
66365
  const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
65068
66366
  const tabnineSection = rulesyncFrontmatter.tabnine ?? {};
65069
- const tabnineSubagentFrontmatter = {
66367
+ const merged = {
65070
66368
  name: rulesyncFrontmatter.name,
65071
66369
  description: rulesyncFrontmatter.description,
65072
66370
  ...tabnineSection
65073
66371
  };
66372
+ let description = merged.description;
66373
+ if (description === void 0 || description === "") {
66374
+ description = rulesyncFrontmatter.name ? `${rulesyncFrontmatter.name} subagent` : "subagent";
66375
+ logger?.warn(`Tabnine CLI subagent ${rulesyncSubagent.getRelativeFilePath()} has no description, which Tabnine requires; wrote ${JSON.stringify(description)} as a placeholder.`);
66376
+ }
66377
+ const tabnineSubagentFrontmatter = {
66378
+ ...merged,
66379
+ description
66380
+ };
65074
66381
  const body = rulesyncSubagent.getBody();
65075
66382
  const fileContent = stringifyFrontmatter(body, tabnineSubagentFrontmatter, { avoidBlockScalars: true });
65076
66383
  const paths = this.getSettablePaths({ global });
@@ -71333,24 +72640,6 @@ var PiRule = class PiRule extends ToolRule {
71333
72640
  }
71334
72641
  };
71335
72642
  //#endregion
71336
- //#region src/constants/pool-paths.ts
71337
- /**
71338
- * Pool (Poolside's coding agent CLI) configuration-layout conventions.
71339
- *
71340
- * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
71341
- * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
71342
- * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
71343
- * path), the project-root `AGENTS.md`, and nested per-directory
71344
- * `AGENTS.md` files from the repository root down through the working
71345
- * directory, deeper files taking precedence. It skips ignored directories
71346
- * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
71347
- *
71348
- * @see https://docs.poolside.ai/agent-instructions
71349
- * @see https://github.com/poolsideai/pool
71350
- */
71351
- /** Global config directory for Pool, relative to the home directory. */
71352
- const POOL_GLOBAL_DIR = join(".config", "poolside");
71353
- //#endregion
71354
72643
  //#region src/features/rules/pool-rule.ts
71355
72644
  var PoolRule = class PoolRule extends ToolRule {
71356
72645
  static getSettablePaths({ global = false } = {}) {
@@ -76417,6 +77706,6 @@ async function importChecksCore(params) {
76417
77706
  return writtenCount;
76418
77707
  }
76419
77708
  //#endregion
76420
- 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 };
77709
+ 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 };
76421
77710
 
76422
- //# sourceMappingURL=import-D5n_V39C.js.map
77711
+ //# sourceMappingURL=import-C6R-lXFF.js.map