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.
@@ -444,36 +444,36 @@ const isFeatureValueEnabled = (value) => {
444
444
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
445
445
  //#endregion
446
446
  //#region src/constants/rulesync-paths.ts
447
- const { join: join$340 } = node_path.posix;
447
+ const { join: join$342 } = node_path.posix;
448
448
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
449
449
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
450
450
  const RULESYNC_USER_CONFIG_DIR_NAME = "rulesync";
451
451
  const RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME = ".config";
452
452
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
453
453
  const RULES_FEATURE_SUBDIR = "rules";
454
- const CURATED_RULES_FEATURE_SUBDIR = join$340(RULES_FEATURE_SUBDIR, ".curated");
454
+ const CURATED_RULES_FEATURE_SUBDIR = join$342(RULES_FEATURE_SUBDIR, ".curated");
455
455
  const COMMANDS_FEATURE_SUBDIR = "commands";
456
456
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
457
457
  const CHECKS_FEATURE_SUBDIR = "checks";
458
458
  const SKILLS_FEATURE_SUBDIR = "skills";
459
- const CURATED_SKILLS_FEATURE_SUBDIR = join$340(SKILLS_FEATURE_SUBDIR, ".curated");
460
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
461
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
462
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
463
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
464
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
465
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
466
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
467
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
468
- join$340(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
469
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
470
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
459
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$342(SKILLS_FEATURE_SUBDIR, ".curated");
460
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
461
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
462
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
463
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
464
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
465
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
466
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
467
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
468
+ join$342(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
469
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
470
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
471
471
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
472
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
472
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
473
473
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
474
474
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
475
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
476
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$340(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
475
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
476
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$342(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
477
477
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
478
478
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
479
479
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -583,6 +583,7 @@ const mcpProcessorToolTargetTuple = [
583
583
  "copilot",
584
584
  "copilotcli",
585
585
  "cortexcode",
586
+ "crush",
586
587
  "cursor",
587
588
  "deepagents",
588
589
  "factorydroid",
@@ -719,6 +720,7 @@ const skillsProcessorToolTargetTuple = [
719
720
  "musecode",
720
721
  "opencode",
721
722
  "pi",
723
+ "pool",
722
724
  "qwencode",
723
725
  "reasonix",
724
726
  "replit",
@@ -749,6 +751,7 @@ const hooksProcessorToolTargetTuple = [
749
751
  "copilot",
750
752
  "copilotcli",
751
753
  "cortexcode",
754
+ "crush",
752
755
  "opencode",
753
756
  "pi",
754
757
  "factorydroid",
@@ -781,6 +784,7 @@ const permissionsProcessorToolTargetTuple = [
781
784
  "continue",
782
785
  "copilot",
783
786
  "copilotcli",
787
+ "crush",
784
788
  "cursor",
785
789
  "deepagents",
786
790
  "devin",
@@ -3308,6 +3312,9 @@ const SHARED_USER_MANAGED_CONFIG_PATHS = [
3308
3312
  "reasonix.toml",
3309
3313
  ".vscode/settings.json",
3310
3314
  ".zed/settings.json",
3315
+ "crush.json",
3316
+ ".crush.json",
3317
+ ".config/crush/crush.json",
3311
3318
  "kilo.json",
3312
3319
  "kilo.jsonc",
3313
3320
  "opencode.json",
@@ -3512,6 +3519,27 @@ function omitPrototypePollutionKeys(record) {
3512
3519
  }
3513
3520
  return sanitized;
3514
3521
  }
3522
+ /**
3523
+ * Like {@link omitPrototypePollutionKeys}, but recursive: every plain object
3524
+ * reachable through the value (directly, or through an array) is copied with
3525
+ * its prototype-pollution keys dropped. Scalars and arrays of scalars come back
3526
+ * as they are.
3527
+ *
3528
+ * Use when passing an unknown, user-supplied value through unchanged — an MCP
3529
+ * server field a tool documents that rulesync does not model — so a
3530
+ * `__proto__` key nested anywhere inside it cannot ride into the generated
3531
+ * config, where the consuming tool may merge the object without the same care.
3532
+ */
3533
+ function omitPrototypePollutionKeysDeep(value) {
3534
+ if (Array.isArray(value)) return value.map((entry) => omitPrototypePollutionKeysDeep(entry));
3535
+ if (value === null || typeof value !== "object") return value;
3536
+ const sanitized = {};
3537
+ for (const [key, entry] of Object.entries(value)) {
3538
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
3539
+ sanitized[key] = omitPrototypePollutionKeysDeep(entry);
3540
+ }
3541
+ return sanitized;
3542
+ }
3515
3543
  //#endregion
3516
3544
  //#region src/utils/type-guards.ts
3517
3545
  /**
@@ -4579,6 +4607,14 @@ const VIBE_HOOK_EVENTS = [
4579
4607
  "stop"
4580
4608
  ];
4581
4609
  /**
4610
+ * Hook events supported by Crush.
4611
+ *
4612
+ * Crush's `hooks` config block currently fires a single event, `PreToolUse`
4613
+ * (Claude Code-compatible payload; `matcher` is a regex on the tool name).
4614
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
4615
+ */
4616
+ const CRUSH_HOOK_EVENTS = ["preToolUse"];
4617
+ /**
4582
4618
  * Hook events supported by JetBrains Junie CLI.
4583
4619
  *
4584
4620
  * Junie CLI exposes seven lifecycle events under the `"hooks"` key of
@@ -5121,6 +5157,7 @@ const HooksConfigSchema = zod_mini.z.looseObject({
5121
5157
  grokcli: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5122
5158
  "kimi-code": zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5123
5159
  zcode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5160
+ crush: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5124
5161
  bob: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5125
5162
  cortexcode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5126
5163
  continue: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
@@ -5618,6 +5655,13 @@ const VIBE_TO_CANONICAL_EVENT_NAMES = {
5618
5655
  post_agent_turn: "stop"
5619
5656
  };
5620
5657
  /**
5658
+ * Canonical -> Crush event names. Crush spells its events the Claude Code
5659
+ * way (`PreToolUse`).
5660
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
5661
+ */
5662
+ const CANONICAL_TO_CRUSH_EVENT_NAMES = { preToolUse: "PreToolUse" };
5663
+ const CRUSH_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CRUSH_EVENT_NAMES).map(([k, v]) => [v, k]));
5664
+ /**
5621
5665
  * Map canonical camelCase event names to Qwen Code PascalCase.
5622
5666
  *
5623
5667
  * Qwen Code reuses the same Claude-style PascalCase event names for the events
@@ -6057,6 +6101,11 @@ const McpServerSchema = zod_mini.z.looseObject({
6057
6101
  kiroAutoBlock: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6058
6102
  musecodeMode: zod_mini.z.optional(zod_mini.z.enum(["required", "optional"])),
6059
6103
  rovodevEnableInstructions: zod_mini.z.optional(zod_mini.z.boolean()),
6104
+ crushOauth: zod_mini.z.optional(zod_mini.z.boolean()),
6105
+ crushOauthClientId: zod_mini.z.optional(zod_mini.z.string()),
6106
+ crushOauthClientSecret: zod_mini.z.optional(zod_mini.z.string()),
6107
+ crushOauthCallbackPort: zod_mini.z.optional(zod_mini.z.int()),
6108
+ crushSessionless: zod_mini.z.optional(zod_mini.z.boolean()),
6060
6109
  headers: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.string())),
6061
6110
  /**
6062
6111
  * The canonical per-server tool allowlist.
@@ -6133,6 +6182,8 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
6133
6182
  continue: zod_mini.z.optional(toolScopedMcpSchema),
6134
6183
  copilot: zod_mini.z.optional(toolScopedMcpSchema),
6135
6184
  copilotcli: zod_mini.z.optional(toolScopedMcpSchema),
6185
+ cortexcode: zod_mini.z.optional(toolScopedMcpSchema),
6186
+ crush: zod_mini.z.optional(toolScopedMcpSchema),
6136
6187
  cursor: zod_mini.z.optional(toolScopedMcpSchema),
6137
6188
  deepagents: zod_mini.z.optional(toolScopedMcpSchema),
6138
6189
  devin: zod_mini.z.optional(toolScopedMcpSchema),
@@ -6149,7 +6200,6 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
6149
6200
  reasonix: zod_mini.z.optional(toolScopedMcpSchema),
6150
6201
  roo: zod_mini.z.optional(toolScopedMcpSchema),
6151
6202
  rovodev: zod_mini.z.optional(toolScopedMcpSchema),
6152
- cortexcode: zod_mini.z.optional(toolScopedMcpSchema),
6153
6203
  tabnine: zod_mini.z.optional(toolScopedMcpSchema),
6154
6204
  takt: zod_mini.z.optional(toolScopedMcpSchema),
6155
6205
  vibe: zod_mini.z.optional(toolScopedMcpSchema),
@@ -6481,6 +6531,15 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
6481
6531
  "musecodeMode",
6482
6532
  "rovodevEnableInstructions",
6483
6533
  "enable_instructions",
6534
+ "crushOauth",
6535
+ "crushOauthClientId",
6536
+ "crushOauthClientSecret",
6537
+ "crushOauthCallbackPort",
6538
+ "crushSessionless",
6539
+ "oauth_client_id",
6540
+ "oauth_client_secret",
6541
+ "oauth_callback_port",
6542
+ "sessionless",
6484
6543
  "enabled"
6485
6544
  ])];
6486
6545
  }));
@@ -6929,7 +6988,7 @@ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
6929
6988
  * autonomy/sandbox controls with no canonical permission category — under
6930
6989
  * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
6931
6990
  * `sandbox`, `sandboxImage`, `disabled`, `visible`, `eager`, `listDirectory`,
6932
- * `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
6991
+ * `todoWrite`, `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
6933
6992
  * `allowPrivateNetworkHooks`, `allowedInsecureVoiceBaseUrls`). Qwen Code strips
6934
6993
  * `tools.workflowsEnabled`, `security.allowPrivateNetworkHooks` and
6935
6994
  * `security.allowedInsecureVoiceBaseUrls` out of workspace settings, so generate
@@ -6942,7 +7001,7 @@ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
6942
7001
  * changes what the file said is reported in either scope, naming what that key
6943
7002
  * decides there — the autonomy and containment
6944
7003
  * controls (`approvalMode`, `autoAccept`, `sandbox`, `sandboxImage`), the
6945
- * registry controls (`disabled`, `visible`, `listDirectory`, and `eager`, which
7004
+ * registry controls (`disabled`, `visible`, `listDirectory`, `todoWrite`, and `eager`, which
6946
7005
  * demotes an omitted tool to deferred rather than removing it, replacing rather
6947
7006
  * than merging with the list a higher scope set), the Auto Mode
6948
7007
  * classifier config, and, because these groups are loose objects, any key
@@ -6983,6 +7042,14 @@ const QwencodePermissionsOverrideSchema = zod_mini.z.looseObject({
6983
7042
  * `looseObject` (verbatim passthrough). Both project and global scope are
6984
7043
  * supported.
6985
7044
  *
7045
+ * Only `tools` and `general` are written; any other key is ignored with a
7046
+ * warning. Within them, the paths Tabnine CLI runs as a command or sends its
7047
+ * traffic to — `tools.discoveryCommand`, `tools.callCommand`,
7048
+ * `tools.shell.pager`, `tools.sandbox.command`, `general.tabnineHost` and
7049
+ * `general.preferredEditor` — are refused with a warning rather than written,
7050
+ * so a fetched permissions file cannot point the CLI at an executable or a
7051
+ * server of its choosing; set those by hand in `settings.json`.
7052
+ *
6986
7053
  * @example
6987
7054
  * { "tools": { "core": ["read_file", "run_shell_command(git)"] },
6988
7055
  * "general": { "defaultApprovalMode": "plan" } }
@@ -7767,6 +7834,7 @@ const PermissionsConfigSchema = zod_mini.z.looseObject({
7767
7834
  continue: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7768
7835
  copilot: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7769
7836
  copilotcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7837
+ crush: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7770
7838
  goose: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7771
7839
  grokcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7772
7840
  "kimi-code": zod_mini.z.optional(KimiCodePermissionsOverrideSchema),
@@ -12792,6 +12860,26 @@ const ZCODE_USER_CONFIG_DECLARATION = {
12792
12860
  }
12793
12861
  };
12794
12862
  /**
12863
+ * Crush's JSON config, shared by the project (`crush.json` / `.crush.json`)
12864
+ * and global (`~/.config/crush/crush.json`) spellings so a policy edit lands
12865
+ * on both. See the `SHARED_CONFIG_OWNERSHIP` entries for the key rationale.
12866
+ */
12867
+ const CRUSH_CONFIG_DECLARATION = {
12868
+ format: "json",
12869
+ invalidRootPolicy: "error",
12870
+ features: {
12871
+ mcp: {
12872
+ kind: "replace-owned-keys",
12873
+ ownedKeys: ["mcp"]
12874
+ },
12875
+ hooks: {
12876
+ kind: "replace-owned-keys",
12877
+ ownedKeys: ["hooks"]
12878
+ },
12879
+ permissions: { kind: "deep-merge" }
12880
+ }
12881
+ };
12882
+ /**
12795
12883
  * What the two Claude Code settings files have in common: both are plain JSON,
12796
12884
  * and both validate against the one published schema, which the gateway
12797
12885
  * points every file it writes at. `.claude/settings.local.json` is the same
@@ -13052,6 +13140,8 @@ const SHARED_CONFIG_OWNERSHIP = {
13052
13140
  },
13053
13141
  ".zcode/config.json": ZCODE_WORKSPACE_CONFIG_DECLARATION,
13054
13142
  ".zcode/cli/config.json": ZCODE_USER_CONFIG_DECLARATION,
13143
+ "crush.json": CRUSH_CONFIG_DECLARATION,
13144
+ ".config/crush/crush.json": CRUSH_CONFIG_DECLARATION,
13055
13145
  ".bob/settings.json": {
13056
13146
  format: "json",
13057
13147
  invalidRootPolicy: "error",
@@ -18901,15 +18991,12 @@ var TabnineCommand = class TabnineCommand extends ToolCommand {
18901
18991
  return this.body;
18902
18992
  }
18903
18993
  getFrontmatter() {
18904
- return {
18905
- description: this.frontmatter.description,
18906
- prompt: this.frontmatter.prompt
18907
- };
18994
+ return this.frontmatter;
18908
18995
  }
18909
18996
  toRulesyncCommand() {
18910
18997
  const { description, prompt: _prompt, ...restFields } = this.frontmatter;
18911
18998
  const rulesyncFrontmatter = {
18912
- targets: ["tabnine"],
18999
+ targets: ["*"],
18913
19000
  description,
18914
19001
  ...Object.keys(restFields).length > 0 && { tabnine: restFields }
18915
19002
  };
@@ -20872,7 +20959,20 @@ function stableJson(value) {
20872
20959
  */
20873
20960
  function isSafeStringRecord(value) {
20874
20961
  if (!isPlainObject$1(value) || !isStringRecord(value)) return false;
20875
- return Object.entries(value).every(([key, entry]) => key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || entry.includes(char)));
20962
+ return Object.entries(value).every(([key, entry]) => isSafeEnvEntry({
20963
+ key,
20964
+ value: entry
20965
+ }));
20966
+ }
20967
+ /**
20968
+ * Whether one `env` entry survives the rule {@link isSafeStringRecord} applies
20969
+ * to the whole map. Exported for the adapters that hand a tool an environment
20970
+ * block outside the shared converter (Tabnine, whose hook shape it does not
20971
+ * express) so they drop the same entries this one does.
20972
+ */
20973
+ function isSafeEnvEntry(entry) {
20974
+ const { key, value } = entry;
20975
+ return typeof value === "string" && key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || value.includes(char));
20876
20976
  }
20877
20977
  /**
20878
20978
  * Control characters cannot ride from an existing tool config into a canonical
@@ -21763,135 +21863,6 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
21763
21863
  }
21764
21864
  };
21765
21865
  //#endregion
21766
- //#region src/features/hooks/bob-hooks.ts
21767
- const BOB_CONVERTER_CONFIG = {
21768
- supportedEvents: BOB_HOOK_EVENTS,
21769
- canonicalToToolEventNames: CANONICAL_TO_BOB_EVENT_NAMES,
21770
- toolToCanonicalEventNames: BOB_TO_CANONICAL_EVENT_NAMES,
21771
- projectDirVar: "",
21772
- noMatcherEvents: /* @__PURE__ */ new Set([
21773
- "sessionStart",
21774
- "beforeSubmitPrompt",
21775
- "stop"
21776
- ]),
21777
- supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
21778
- wildcardMatcherMeansAll: true
21779
- };
21780
- /**
21781
- * Single spelling of the settings.json codec/policy: fail closed on an
21782
- * unparseable root rather than replacing the user's Bob settings with
21783
- * generated output.
21784
- */
21785
- function parseBobSettings(fileContent, filePath) {
21786
- return parseSharedConfig({
21787
- format: "json",
21788
- fileContent,
21789
- filePath,
21790
- invalidRootPolicy: "error"
21791
- });
21792
- }
21793
- /**
21794
- * IBM Bob lifecycle hooks.
21795
- *
21796
- * Hooks live under the top-level `hooks` key of Bob's settings file —
21797
- * `<project>/.bob/settings.json` (project scope) and
21798
- * `~/.bob/settings/settings.json` (user scope) — in the Claude-Code shape:
21799
- * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command",
21800
- * "command": "...", "timeout"?: <seconds> }] }] }`. The file also holds
21801
- * settings rulesync does not own, so generation merges the `hooks` key into it
21802
- * (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting the file.
21803
- *
21804
- * @see https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks
21805
- */
21806
- var BobHooks = class BobHooks extends ToolHooks {
21807
- constructor(params) {
21808
- super({
21809
- ...params,
21810
- fileContent: params.fileContent ?? "{}"
21811
- });
21812
- }
21813
- isDeletable() {
21814
- return false;
21815
- }
21816
- static getSettablePaths({ global = false } = {}) {
21817
- return {
21818
- relativeDirPath: global ? BOB_GLOBAL_SETTINGS_DIR_PATH : BOB_DIR,
21819
- relativeFilePath: BOB_SETTINGS_FILE_NAME
21820
- };
21821
- }
21822
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
21823
- const paths = BobHooks.getSettablePaths({ global });
21824
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
21825
- return new BobHooks({
21826
- outputRoot,
21827
- relativeDirPath: paths.relativeDirPath,
21828
- relativeFilePath: paths.relativeFilePath,
21829
- fileContent,
21830
- validate
21831
- });
21832
- }
21833
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
21834
- const paths = BobHooks.getSettablePaths({ global });
21835
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
21836
- const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
21837
- const config = rulesyncHooks.getJson();
21838
- const hooks = canonicalToToolHooks({
21839
- config,
21840
- toolOverrideHooks: config.bob?.hooks,
21841
- converterConfig: BOB_CONVERTER_CONFIG,
21842
- logger
21843
- });
21844
- const fileContent = applySharedConfigPatch({
21845
- fileKey: sharedConfigFileKey(paths),
21846
- feature: "hooks",
21847
- existingContent,
21848
- patch: { hooks },
21849
- filePath,
21850
- logger
21851
- });
21852
- return new BobHooks({
21853
- outputRoot,
21854
- relativeDirPath: paths.relativeDirPath,
21855
- relativeFilePath: paths.relativeFilePath,
21856
- fileContent,
21857
- validate
21858
- });
21859
- }
21860
- toRulesyncHooks({ logger } = {}) {
21861
- const configPath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
21862
- let settings;
21863
- try {
21864
- settings = parseBobSettings(this.getFileContent(), configPath);
21865
- } catch (error) {
21866
- throw new Error(`Failed to parse Bob hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
21867
- }
21868
- const hooks = toolHooksToCanonical({
21869
- logger,
21870
- hooks: settings.hooks,
21871
- converterConfig: BOB_CONVERTER_CONFIG
21872
- });
21873
- return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
21874
- hooks,
21875
- overrideKey: "bob"
21876
- }), null, 2) });
21877
- }
21878
- validate() {
21879
- return {
21880
- success: true,
21881
- error: null
21882
- };
21883
- }
21884
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
21885
- return new BobHooks({
21886
- outputRoot,
21887
- relativeDirPath,
21888
- relativeFilePath,
21889
- fileContent: JSON.stringify({ hooks: {} }, null, 2),
21890
- validate: false
21891
- });
21892
- }
21893
- };
21894
- //#endregion
21895
21866
  //#region src/features/hooks/preserve-unowned-hook-commands.ts
21896
21867
  /**
21897
21868
  * Read the `hooks` value from a destination JSON file.
@@ -22178,75 +22149,22 @@ function warnSkip({ logger, event, expected }) {
22178
22149
  logger?.warn(`Skipping existing hook entry on ${event}: expected ${expected}`);
22179
22150
  }
22180
22151
  //#endregion
22181
- //#region src/features/hooks/claudecode-hooks.ts
22182
- const CLAUDE_CONVERTER_CONFIG = {
22183
- supportedEvents: CLAUDE_HOOK_EVENTS,
22184
- canonicalToToolEventNames: CANONICAL_TO_CLAUDE_EVENT_NAMES,
22185
- toolToCanonicalEventNames: CLAUDE_TO_CANONICAL_EVENT_NAMES,
22186
- projectDirVar: "$CLAUDE_PROJECT_DIR",
22187
- prefixDotRelativeCommandsOnly: true,
22188
- noMatcherEvents: /* @__PURE__ */ new Set([
22189
- "worktreeCreate",
22190
- "worktreeRemove",
22191
- "messageDisplay",
22192
- "postToolBatch",
22193
- "taskCreated",
22194
- "taskCompleted",
22195
- "teammateIdle",
22196
- "cwdChanged",
22197
- "beforeSubmitPrompt",
22198
- "stop"
22199
- ]),
22200
- supportedHookTypes: /* @__PURE__ */ new Set([
22201
- "command",
22202
- "prompt",
22203
- "http",
22204
- "mcp_tool",
22205
- "agent"
22206
- ]),
22207
- emitsPromptModel: true,
22208
- stringPassthroughFields: [
22209
- {
22210
- canonical: "if",
22211
- tool: "if"
22212
- },
22213
- {
22214
- canonical: "statusMessage",
22215
- tool: "statusMessage"
22216
- },
22217
- {
22218
- canonical: "shell",
22219
- tool: "shell",
22220
- commandOnly: true
22221
- }
22222
- ],
22223
- booleanPassthroughFields: [
22224
- {
22225
- canonical: "once",
22226
- tool: "once"
22227
- },
22228
- {
22229
- canonical: "async",
22230
- tool: "async",
22231
- commandOnly: true
22232
- },
22233
- {
22234
- canonical: "asyncRewake",
22235
- tool: "asyncRewake",
22236
- commandOnly: true
22237
- },
22238
- {
22239
- canonical: "continueOnBlock",
22240
- tool: "continueOnBlock"
22241
- }
22242
- ],
22243
- arrayPassthroughFields: [{
22244
- canonical: "args",
22245
- tool: "args",
22246
- commandOnly: true
22247
- }]
22248
- };
22249
- var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22152
+ //#region src/features/hooks/settings-json-hooks.ts
22153
+ /**
22154
+ * Shared implementation for the tools whose hooks live under the top-level
22155
+ * `hooks` key of a JSON settings file in Claude Code's shape —
22156
+ * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command", ... }] }] }`
22157
+ * — and whose file also holds settings rulesync does not own, so generation
22158
+ * merges the `hooks` key into it (see `SHARED_CONFIG_OWNERSHIP`) instead of
22159
+ * overwriting it, and `--delete` never removes the file wholesale.
22160
+ *
22161
+ * A concrete adapter supplies its {@link SettingsJsonHooksSpec} and its
22162
+ * settable paths; the event mapping, the matcher rules and the per-hook
22163
+ * fields the tool documents are all expressed in the spec's converter config.
22164
+ * An adapter that also supports preserving unowned hooks (Claude Code) opts in
22165
+ * through {@link ToolHooks.supportsPreserveUnowned}.
22166
+ */
22167
+ var SettingsJsonHooks = class extends ToolHooks {
22250
22168
  constructor(params) {
22251
22169
  super({
22252
22170
  ...params,
@@ -22256,24 +22174,42 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22256
22174
  isDeletable() {
22257
22175
  return false;
22258
22176
  }
22177
+ /** The tool-specific half of the adapter. Every concrete class overrides this. */
22178
+ static getSpec() {
22179
+ throw new Error(`${this.name} does not define getSpec()`);
22180
+ }
22259
22181
  /**
22260
- * The converter config used for both directions. Exposed as a static hook so
22261
- * plugin-scoped subclasses can swap tool-specific details (e.g. the project
22262
- * directory variable) without duplicating the rest of the config.
22182
+ * The converter config used for both directions. A separate hook so a
22183
+ * subclass can swap tool-specific details (e.g. the project directory
22184
+ * variable) without redefining the whole spec.
22263
22185
  */
22264
22186
  static getConverterConfig() {
22265
- return CLAUDE_CONVERTER_CONFIG;
22187
+ return this.getSpec().converterConfig;
22266
22188
  }
22267
22189
  static getSettablePaths(_options = {}) {
22268
- return {
22269
- relativeDirPath: CLAUDECODE_DIR,
22270
- relativeFilePath: CLAUDECODE_SETTINGS_FILE_NAME
22271
- };
22190
+ throw new Error(`${this.name} does not define getSettablePaths()`);
22191
+ }
22192
+ /**
22193
+ * The `SHARED_CONFIG_OWNERSHIP` key the settings file is merged under. By
22194
+ * default it is derived from the settable paths; an adapter whose file is
22195
+ * declared under another tool's key (the Claude Code plugin bundle) overrides
22196
+ * it.
22197
+ */
22198
+ static getSharedFileKey(paths) {
22199
+ return sharedConfigFileKey(paths);
22200
+ }
22201
+ /**
22202
+ * `new this(params)` for the concrete adapter the static method was called
22203
+ * on; the cast is what an abstract class needs to be constructed through
22204
+ * `this`, and lives in one place.
22205
+ */
22206
+ static instantiate(params) {
22207
+ return new this(params);
22272
22208
  }
22273
22209
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
22274
22210
  const paths = this.getSettablePaths({ global });
22275
22211
  const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
22276
- return new this({
22212
+ return this.instantiate({
22277
22213
  outputRoot,
22278
22214
  relativeDirPath: paths.relativeDirPath,
22279
22215
  relativeFilePath: paths.relativeFilePath,
@@ -22281,40 +22217,41 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22281
22217
  validate
22282
22218
  });
22283
22219
  }
22284
- static supportsPreserveUnowned() {
22285
- return true;
22286
- }
22287
22220
  static async getAuxiliaryFiles({ toolHooks } = {}) {
22288
- return toolHooks instanceof ClaudecodeHooks ? toolHooks.getOwnershipLockFiles() : [];
22221
+ return toolHooks instanceof this ? toolHooks.getOwnershipLockFiles() : [];
22289
22222
  }
22290
22223
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, preserveUnowned = false, logger }) {
22291
22224
  const paths = this.getSettablePaths({ global });
22292
22225
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22293
22226
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
22294
22227
  const config = rulesyncHooks.getJson();
22295
- const claudeHooks = canonicalToToolHooks({
22228
+ const generatedHooks = canonicalToToolHooks({
22296
22229
  config,
22297
- toolOverrideHooks: config.claudecode?.hooks,
22230
+ toolOverrideHooks: overrideHooksOf({
22231
+ config,
22232
+ overrideKey: this.getSpec().overrideKey
22233
+ }),
22298
22234
  converterConfig: this.getConverterConfig(),
22299
22235
  logger
22300
22236
  });
22301
22237
  const preserving = preserveUnowned && this.supportsPreserveUnowned();
22302
22238
  const merged = mergeGeneratedHookLists({
22303
22239
  existingContent,
22304
- generatedHooks: claudeHooks,
22240
+ generatedHooks,
22305
22241
  shape: "matcher-groups",
22306
22242
  preserveUnowned: preserving,
22307
22243
  previouslyOwned: preserving ? parseHooksOwnershipLock(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, HOOKS_OWNERSHIP_LOCK_FILE_NAME))) : void 0,
22308
22244
  logger
22309
22245
  });
22310
22246
  const fileContent = applySharedConfigPatch({
22311
- fileKey: CLAUDE_SETTINGS_SHARED_FILE_KEY,
22247
+ fileKey: this.getSharedFileKey(paths),
22312
22248
  feature: "hooks",
22313
22249
  existingContent,
22314
22250
  patch: { hooks: merged.hooks },
22315
- filePath
22251
+ filePath,
22252
+ logger
22316
22253
  });
22317
- return new this({
22254
+ return this.instantiate({
22318
22255
  outputRoot,
22319
22256
  relativeDirPath: paths.relativeDirPath,
22320
22257
  relativeFilePath: paths.relativeFilePath,
@@ -22324,20 +22261,29 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22324
22261
  });
22325
22262
  }
22326
22263
  toRulesyncHooks({ logger } = {}) {
22264
+ const ctor = this.constructor;
22265
+ const spec = ctor.getSpec();
22266
+ const configPath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
22327
22267
  let settings;
22328
22268
  try {
22329
- settings = JSON.parse(this.getFileContent());
22269
+ settings = parseSharedConfig({
22270
+ format: "json",
22271
+ fileContent: this.getFileContent(),
22272
+ filePath: configPath,
22273
+ invalidRootPolicy: "error"
22274
+ });
22330
22275
  } catch (error) {
22331
- throw new Error(`Failed to parse Claude hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
22276
+ const reason = error instanceof Error && error.cause instanceof Error ? error.cause : error;
22277
+ throw new Error(`Failed to parse ${spec.displayName} hooks content in ${configPath}: ${formatError(reason)}`, { cause: error });
22332
22278
  }
22333
22279
  const hooks = toolHooksToCanonical({
22280
+ logger,
22334
22281
  hooks: settings.hooks,
22335
- converterConfig: this.constructor.getConverterConfig(),
22336
- logger
22282
+ converterConfig: ctor.getConverterConfig()
22337
22283
  });
22338
22284
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
22339
22285
  hooks,
22340
- overrideKey: "claudecode"
22286
+ overrideKey: spec.overrideKey
22341
22287
  }), null, 2) });
22342
22288
  }
22343
22289
  validate() {
@@ -22347,7 +22293,7 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22347
22293
  };
22348
22294
  }
22349
22295
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
22350
- return new this({
22296
+ return this.instantiate({
22351
22297
  outputRoot,
22352
22298
  relativeDirPath,
22353
22299
  relativeFilePath,
@@ -22356,6 +22302,158 @@ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
22356
22302
  });
22357
22303
  }
22358
22304
  };
22305
+ /**
22306
+ * The `hooks` map of a tool's override block (`config.<overrideKey>.hooks`),
22307
+ * or `undefined` when the block states none. The schema keeps every override
22308
+ * block loose, so the lookup goes through the record form.
22309
+ */
22310
+ function overrideHooksOf({ config, overrideKey }) {
22311
+ const override = lookupOwn({
22312
+ record: config,
22313
+ key: overrideKey
22314
+ });
22315
+ if (!isPlainObject$1(override)) return;
22316
+ const hooks = override.hooks;
22317
+ return isPlainObject$1(hooks) ? hooks : void 0;
22318
+ }
22319
+ //#endregion
22320
+ //#region src/features/hooks/bob-hooks.ts
22321
+ const BOB_SPEC = {
22322
+ displayName: "Bob",
22323
+ overrideKey: "bob",
22324
+ converterConfig: {
22325
+ supportedEvents: BOB_HOOK_EVENTS,
22326
+ canonicalToToolEventNames: CANONICAL_TO_BOB_EVENT_NAMES,
22327
+ toolToCanonicalEventNames: BOB_TO_CANONICAL_EVENT_NAMES,
22328
+ projectDirVar: "",
22329
+ noMatcherEvents: /* @__PURE__ */ new Set([
22330
+ "sessionStart",
22331
+ "beforeSubmitPrompt",
22332
+ "stop"
22333
+ ]),
22334
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
22335
+ wildcardMatcherMeansAll: true
22336
+ }
22337
+ };
22338
+ /**
22339
+ * IBM Bob lifecycle hooks.
22340
+ *
22341
+ * Hooks live under the top-level `hooks` key of Bob's settings file —
22342
+ * `<project>/.bob/settings.json` (project scope) and
22343
+ * `~/.bob/settings/settings.json` (user scope) — in the Claude-Code shape:
22344
+ * `{ "<Event>": [{ "matcher"?: "<regex>", "hooks": [{ "type": "command",
22345
+ * "command": "...", "timeout"?: <seconds> }] }] }`. The file also holds
22346
+ * settings rulesync does not own, so generation merges the `hooks` key into it
22347
+ * (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting the file.
22348
+ *
22349
+ * @see https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks
22350
+ */
22351
+ var BobHooks = class extends SettingsJsonHooks {
22352
+ static getSpec() {
22353
+ return BOB_SPEC;
22354
+ }
22355
+ static getSettablePaths({ global = false } = {}) {
22356
+ return {
22357
+ relativeDirPath: global ? BOB_GLOBAL_SETTINGS_DIR_PATH : BOB_DIR,
22358
+ relativeFilePath: BOB_SETTINGS_FILE_NAME
22359
+ };
22360
+ }
22361
+ };
22362
+ //#endregion
22363
+ //#region src/features/hooks/claudecode-hooks.ts
22364
+ const CLAUDE_SPEC = {
22365
+ displayName: "Claude",
22366
+ overrideKey: "claudecode",
22367
+ converterConfig: {
22368
+ supportedEvents: CLAUDE_HOOK_EVENTS,
22369
+ canonicalToToolEventNames: CANONICAL_TO_CLAUDE_EVENT_NAMES,
22370
+ toolToCanonicalEventNames: CLAUDE_TO_CANONICAL_EVENT_NAMES,
22371
+ projectDirVar: "$CLAUDE_PROJECT_DIR",
22372
+ prefixDotRelativeCommandsOnly: true,
22373
+ noMatcherEvents: /* @__PURE__ */ new Set([
22374
+ "worktreeCreate",
22375
+ "worktreeRemove",
22376
+ "messageDisplay",
22377
+ "postToolBatch",
22378
+ "taskCreated",
22379
+ "taskCompleted",
22380
+ "teammateIdle",
22381
+ "cwdChanged",
22382
+ "beforeSubmitPrompt",
22383
+ "stop"
22384
+ ]),
22385
+ supportedHookTypes: /* @__PURE__ */ new Set([
22386
+ "command",
22387
+ "prompt",
22388
+ "http",
22389
+ "mcp_tool",
22390
+ "agent"
22391
+ ]),
22392
+ emitsPromptModel: true,
22393
+ stringPassthroughFields: [
22394
+ {
22395
+ canonical: "if",
22396
+ tool: "if"
22397
+ },
22398
+ {
22399
+ canonical: "statusMessage",
22400
+ tool: "statusMessage"
22401
+ },
22402
+ {
22403
+ canonical: "shell",
22404
+ tool: "shell",
22405
+ commandOnly: true
22406
+ }
22407
+ ],
22408
+ booleanPassthroughFields: [
22409
+ {
22410
+ canonical: "once",
22411
+ tool: "once"
22412
+ },
22413
+ {
22414
+ canonical: "async",
22415
+ tool: "async",
22416
+ commandOnly: true
22417
+ },
22418
+ {
22419
+ canonical: "asyncRewake",
22420
+ tool: "asyncRewake",
22421
+ commandOnly: true
22422
+ },
22423
+ {
22424
+ canonical: "continueOnBlock",
22425
+ tool: "continueOnBlock"
22426
+ }
22427
+ ],
22428
+ arrayPassthroughFields: [{
22429
+ canonical: "args",
22430
+ tool: "args",
22431
+ commandOnly: true
22432
+ }]
22433
+ }
22434
+ };
22435
+ var ClaudecodeHooks = class extends SettingsJsonHooks {
22436
+ static getSpec() {
22437
+ return CLAUDE_SPEC;
22438
+ }
22439
+ static getSettablePaths(_options = {}) {
22440
+ return {
22441
+ relativeDirPath: CLAUDECODE_DIR,
22442
+ relativeFilePath: CLAUDECODE_SETTINGS_FILE_NAME
22443
+ };
22444
+ }
22445
+ /**
22446
+ * Every Claude-shaped settings file rulesync writes for Claude Code — the
22447
+ * project and user `settings.json` and the plugin bundle's `hooks.json` — is
22448
+ * declared under the one Claude key in `SHARED_CONFIG_OWNERSHIP`.
22449
+ */
22450
+ static getSharedFileKey(_paths) {
22451
+ return CLAUDE_SETTINGS_SHARED_FILE_KEY;
22452
+ }
22453
+ static supportsPreserveUnowned() {
22454
+ return true;
22455
+ }
22456
+ };
22359
22457
  //#endregion
22360
22458
  //#region src/features/hooks/claudecode-plugin-hooks.ts
22361
22459
  var ClaudecodePluginHooks = class extends ClaudecodeHooks {
@@ -22883,54 +22981,45 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
22883
22981
  };
22884
22982
  //#endregion
22885
22983
  //#region src/features/hooks/continue-hooks.ts
22886
- const CONTINUE_CONVERTER_CONFIG = {
22887
- supportedEvents: CONTINUE_HOOK_EVENTS,
22888
- canonicalToToolEventNames: CANONICAL_TO_CONTINUE_EVENT_NAMES,
22889
- toolToCanonicalEventNames: CONTINUE_TO_CANONICAL_EVENT_NAMES,
22890
- projectDirVar: "$CONTINUE_PROJECT_DIR",
22891
- prefixDotRelativeCommandsOnly: true,
22892
- noMatcherEvents: /* @__PURE__ */ new Set([
22893
- "beforeSubmitPrompt",
22894
- "stop",
22895
- "teammateIdle",
22896
- "taskCompleted",
22897
- "worktreeCreate",
22898
- "worktreeRemove"
22899
- ]),
22900
- supportedHookTypes: /* @__PURE__ */ new Set([
22901
- "command",
22902
- "prompt",
22903
- "http",
22904
- "agent"
22905
- ]),
22906
- emitsPromptModel: true,
22907
- stringPassthroughFields: [{
22908
- canonical: "statusMessage",
22909
- tool: "statusMessage"
22910
- }],
22911
- booleanPassthroughFields: [{
22912
- canonical: "once",
22913
- tool: "once"
22914
- }, {
22915
- canonical: "async",
22916
- tool: "async",
22917
- commandOnly: true
22918
- }]
22984
+ const CONTINUE_SPEC = {
22985
+ displayName: "Continue",
22986
+ overrideKey: "continue",
22987
+ converterConfig: {
22988
+ supportedEvents: CONTINUE_HOOK_EVENTS,
22989
+ canonicalToToolEventNames: CANONICAL_TO_CONTINUE_EVENT_NAMES,
22990
+ toolToCanonicalEventNames: CONTINUE_TO_CANONICAL_EVENT_NAMES,
22991
+ projectDirVar: "$CONTINUE_PROJECT_DIR",
22992
+ prefixDotRelativeCommandsOnly: true,
22993
+ noMatcherEvents: /* @__PURE__ */ new Set([
22994
+ "beforeSubmitPrompt",
22995
+ "stop",
22996
+ "teammateIdle",
22997
+ "taskCompleted",
22998
+ "worktreeCreate",
22999
+ "worktreeRemove"
23000
+ ]),
23001
+ supportedHookTypes: /* @__PURE__ */ new Set([
23002
+ "command",
23003
+ "prompt",
23004
+ "http",
23005
+ "agent"
23006
+ ]),
23007
+ emitsPromptModel: true,
23008
+ stringPassthroughFields: [{
23009
+ canonical: "statusMessage",
23010
+ tool: "statusMessage"
23011
+ }],
23012
+ booleanPassthroughFields: [{
23013
+ canonical: "once",
23014
+ tool: "once"
23015
+ }, {
23016
+ canonical: "async",
23017
+ tool: "async",
23018
+ commandOnly: true
23019
+ }]
23020
+ }
22919
23021
  };
22920
23022
  /**
22921
- * Single spelling of the settings/hooks codec/policy: fail closed on an
22922
- * unparseable root rather than replacing the user's Continue settings with
22923
- * generated output.
22924
- */
22925
- function parseContinueSettings(fileContent, filePath) {
22926
- return parseSharedConfig({
22927
- format: "json",
22928
- fileContent,
22929
- filePath,
22930
- invalidRootPolicy: "error"
22931
- });
22932
- }
22933
- /**
22934
23023
  * Continue CLI hooks.
22935
23024
  *
22936
23025
  * Hooks live under the top-level `hooks` key of `<project>/.continue/settings.json`
@@ -22946,15 +23035,9 @@ function parseContinueSettings(fileContent, filePath) {
22946
23035
  * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/hookConfig.ts
22947
23036
  * @see https://github.com/continuedev/continue/blob/main/extensions/cli/src/hooks/types.ts
22948
23037
  */
22949
- var ContinueHooks = class ContinueHooks extends ToolHooks {
22950
- constructor(params) {
22951
- super({
22952
- ...params,
22953
- fileContent: params.fileContent ?? "{}"
22954
- });
22955
- }
22956
- isDeletable() {
22957
- return false;
23038
+ var ContinueHooks = class extends SettingsJsonHooks {
23039
+ static getSpec() {
23040
+ return CONTINUE_SPEC;
22958
23041
  }
22959
23042
  static getSettablePaths(_options = {}) {
22960
23043
  return {
@@ -22962,77 +23045,6 @@ var ContinueHooks = class ContinueHooks extends ToolHooks {
22962
23045
  relativeFilePath: CONTINUE_SETTINGS_FILE_NAME
22963
23046
  };
22964
23047
  }
22965
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
22966
- const paths = ContinueHooks.getSettablePaths({ global });
22967
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
22968
- return new ContinueHooks({
22969
- outputRoot,
22970
- relativeDirPath: paths.relativeDirPath,
22971
- relativeFilePath: paths.relativeFilePath,
22972
- fileContent,
22973
- validate
22974
- });
22975
- }
22976
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
22977
- const paths = ContinueHooks.getSettablePaths({ global });
22978
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22979
- const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
22980
- const config = rulesyncHooks.getJson();
22981
- const hooks = canonicalToToolHooks({
22982
- config,
22983
- toolOverrideHooks: config.continue?.hooks,
22984
- converterConfig: CONTINUE_CONVERTER_CONFIG,
22985
- logger
22986
- });
22987
- const fileContent = applySharedConfigPatch({
22988
- fileKey: sharedConfigFileKey(paths),
22989
- feature: "hooks",
22990
- existingContent,
22991
- patch: { hooks },
22992
- filePath,
22993
- logger
22994
- });
22995
- return new ContinueHooks({
22996
- outputRoot,
22997
- relativeDirPath: paths.relativeDirPath,
22998
- relativeFilePath: paths.relativeFilePath,
22999
- fileContent,
23000
- validate
23001
- });
23002
- }
23003
- toRulesyncHooks({ logger } = {}) {
23004
- const configPath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
23005
- let settings;
23006
- try {
23007
- settings = parseContinueSettings(this.getFileContent(), configPath);
23008
- } catch (error) {
23009
- throw new Error(`Failed to parse Continue hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
23010
- }
23011
- const hooks = toolHooksToCanonical({
23012
- logger,
23013
- hooks: settings.hooks,
23014
- converterConfig: CONTINUE_CONVERTER_CONFIG
23015
- });
23016
- return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
23017
- hooks,
23018
- overrideKey: "continue"
23019
- }), null, 2) });
23020
- }
23021
- validate() {
23022
- return {
23023
- success: true,
23024
- error: null
23025
- };
23026
- }
23027
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
23028
- return new ContinueHooks({
23029
- outputRoot,
23030
- relativeDirPath,
23031
- relativeFilePath,
23032
- fileContent: JSON.stringify({ hooks: {} }, null, 2),
23033
- validate: false
23034
- });
23035
- }
23036
23048
  };
23037
23049
  //#endregion
23038
23050
  //#region src/features/hooks/copilot-hooks.ts
@@ -23623,33 +23635,24 @@ const CORTEXCODE_SKILLS_DIR_PATH = (0, node_path.join)(CORTEXCODE_DIR, "skills")
23623
23635
  const CORTEXCODE_GLOBAL_SKILLS_DIR_PATH = (0, node_path.join)(CORTEXCODE_GLOBAL_DIR_PATH, "skills");
23624
23636
  //#endregion
23625
23637
  //#region src/features/hooks/cortexcode-hooks.ts
23626
- const CORTEXCODE_CONVERTER_CONFIG = {
23627
- supportedEvents: CORTEXCODE_HOOK_EVENTS,
23628
- canonicalToToolEventNames: CANONICAL_TO_CORTEXCODE_EVENT_NAMES,
23629
- toolToCanonicalEventNames: CORTEXCODE_TO_CANONICAL_EVENT_NAMES,
23630
- projectDirVar: "$CORTEX_PROJECT_DIR",
23631
- prefixDotRelativeCommandsOnly: true,
23632
- noMatcherEvents: /* @__PURE__ */ new Set(["beforeSubmitPrompt", "stop"]),
23633
- supportedHookTypes: /* @__PURE__ */ new Set(["command", "prompt"]),
23634
- booleanPassthroughFields: [{
23635
- canonical: "enabled",
23636
- tool: "enabled"
23637
- }]
23638
+ const CORTEXCODE_SPEC = {
23639
+ displayName: "Cortex Code",
23640
+ overrideKey: "cortexcode",
23641
+ converterConfig: {
23642
+ supportedEvents: CORTEXCODE_HOOK_EVENTS,
23643
+ canonicalToToolEventNames: CANONICAL_TO_CORTEXCODE_EVENT_NAMES,
23644
+ toolToCanonicalEventNames: CORTEXCODE_TO_CANONICAL_EVENT_NAMES,
23645
+ projectDirVar: "$CORTEX_PROJECT_DIR",
23646
+ prefixDotRelativeCommandsOnly: true,
23647
+ noMatcherEvents: /* @__PURE__ */ new Set(["beforeSubmitPrompt", "stop"]),
23648
+ supportedHookTypes: /* @__PURE__ */ new Set(["command", "prompt"]),
23649
+ booleanPassthroughFields: [{
23650
+ canonical: "enabled",
23651
+ tool: "enabled"
23652
+ }]
23653
+ }
23638
23654
  };
23639
23655
  /**
23640
- * Single spelling of the settings/hooks codec/policy: fail closed on an
23641
- * unparseable root rather than replacing the user's Cortex Code settings with
23642
- * generated output.
23643
- */
23644
- function parseCortexcodeSettings(fileContent, filePath) {
23645
- return parseSharedConfig({
23646
- format: "json",
23647
- fileContent,
23648
- filePath,
23649
- invalidRootPolicy: "error"
23650
- });
23651
- }
23652
- /**
23653
23656
  * Snowflake Cortex Code hooks.
23654
23657
  *
23655
23658
  * Hooks live under the top-level `hooks` key of `<project>/.cortex/settings.json`
@@ -23658,20 +23661,16 @@ function parseCortexcodeSettings(fileContent, filePath) {
23658
23661
  * "<regex>", "hooks": [{ "type": "command" | "prompt", ..., "timeout"?:
23659
23662
  * <seconds>, "enabled"?: <boolean> }] }] }`. The project file also holds
23660
23663
  * settings rulesync does not own, so generation merges the `hooks` key into
23661
- * either file (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it.
23664
+ * either file (see `SHARED_CONFIG_OWNERSHIP`) instead of overwriting it, and
23665
+ * neither file is removed wholesale (hooks.json sits in the CLI-owned
23666
+ * `~/.snowflake/cortex/` tree).
23662
23667
  *
23663
23668
  * @see https://docs.snowflake.com/en/user-guide/cortex-code/extensibility
23664
23669
  * @see https://docs.snowflake.com/en/user-guide/cortex-code/settings
23665
23670
  */
23666
- var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23667
- constructor(params) {
23668
- super({
23669
- ...params,
23670
- fileContent: params.fileContent ?? "{}"
23671
- });
23672
- }
23673
- isDeletable() {
23674
- return false;
23671
+ var CortexcodeHooks = class extends SettingsJsonHooks {
23672
+ static getSpec() {
23673
+ return CORTEXCODE_SPEC;
23675
23674
  }
23676
23675
  static getSettablePaths({ global = false } = {}) {
23677
23676
  return global ? {
@@ -23682,60 +23681,338 @@ var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23682
23681
  relativeFilePath: CORTEXCODE_SETTINGS_FILE_NAME
23683
23682
  };
23684
23683
  }
23684
+ };
23685
+ //#endregion
23686
+ //#region src/constants/crush-paths.ts
23687
+ const CRUSH_RULE_FILE_NAME = "CRUSH.md";
23688
+ const CRUSH_GLOBAL_DIR = (0, node_path.join)(".config", "crush");
23689
+ const CRUSH_LOCAL_RULE_FILE_NAME = "CRUSH.local.md";
23690
+ const CRUSH_IGNORE_FILE_NAME = ".crushignore";
23691
+ const CRUSH_SKILLS_PROJECT_DIR = (0, node_path.join)(".crush", "skills");
23692
+ const CRUSH_SKILLS_GLOBAL_DIR = (0, node_path.join)(CRUSH_GLOBAL_DIR, "skills");
23693
+ const CRUSH_CONFIG_FILE_NAME = "crush.json";
23694
+ const CRUSH_HIDDEN_CONFIG_FILE_NAME = ".crush.json";
23695
+ const CRUSH_PERMISSIONS_KEY = "permissions";
23696
+ const CRUSH_ALLOWED_TOOLS_KEY = "allowed_tools";
23697
+ const CRUSH_OPTIONS_KEY = "options";
23698
+ const CRUSH_DISABLED_TOOLS_KEY = "disabled_tools";
23699
+ const CRUSH_HOOKS_KEY = "hooks";
23700
+ //#endregion
23701
+ //#region src/features/crush-config.ts
23702
+ /**
23703
+ * Where Crush's JSON config lives for a scope, before the project-scope twin
23704
+ * is resolved: `<project>/crush.json` or `~/.config/crush/crush.json`. This is
23705
+ * also the key every adapter reports through `getSettablePaths`, so the
23706
+ * shared-config ownership declaration, the gitignore derivation and the
23707
+ * shared-write ordering all see one file per scope.
23708
+ */
23709
+ function getCrushConfigSettablePaths({ global = false } = {}) {
23710
+ return {
23711
+ relativeDirPath: global ? CRUSH_GLOBAL_DIR : ".",
23712
+ relativeFilePath: CRUSH_CONFIG_FILE_NAME
23713
+ };
23714
+ }
23715
+ /**
23716
+ * Resolves which Crush config file an adapter should read and write.
23717
+ *
23718
+ * At project scope Crush discovers both `.crush.json` and `crush.json` in the
23719
+ * working directory and merges every file it finds with `.crush.json` on top
23720
+ * (objects merge recursively, lists are concatenated, scalars are overridden).
23721
+ * Neither file therefore hides the other: an entry left in `crush.json` stays
23722
+ * in effect next to whatever `.crush.json` says. An existing `.crush.json` is
23723
+ * preferred because the user chose that spelling; otherwise `crush.json` is
23724
+ * used (and created when neither exists). The global scope has a single
23725
+ * spelling.
23726
+ *
23727
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
23728
+ */
23729
+ async function resolveCrushConfigFile({ outputRoot, global = false }) {
23730
+ const paths = getCrushConfigSettablePaths({ global });
23731
+ const configDir = (0, node_path.join)(outputRoot, paths.relativeDirPath);
23732
+ const filePath = (0, node_path.join)(configDir, paths.relativeFilePath);
23733
+ const fileContent = await readFileContentOrNull(filePath);
23734
+ if (!global) {
23735
+ const hiddenPath = (0, node_path.join)(configDir, CRUSH_HIDDEN_CONFIG_FILE_NAME);
23736
+ const hiddenContent = await readFileContentOrNull(hiddenPath);
23737
+ if (hiddenContent !== null) return {
23738
+ relativeDirPath: paths.relativeDirPath,
23739
+ relativeFilePath: CRUSH_HIDDEN_CONFIG_FILE_NAME,
23740
+ filePath: hiddenPath,
23741
+ fileContent: hiddenContent,
23742
+ ...fileContent === null ? {} : { twin: {
23743
+ filePath,
23744
+ fileContent
23745
+ } }
23746
+ };
23747
+ }
23748
+ return {
23749
+ relativeDirPath: paths.relativeDirPath,
23750
+ relativeFilePath: paths.relativeFilePath,
23751
+ filePath,
23752
+ fileContent
23753
+ };
23754
+ }
23755
+ /**
23756
+ * Single spelling of the crush.json codec/policy, matching the
23757
+ * `SHARED_CONFIG_OWNERSHIP` declaration for both scopes: fail closed on an
23758
+ * unparseable root rather than replacing the user's Crush config with
23759
+ * generated output.
23760
+ */
23761
+ function parseCrushConfig(fileContent, filePath) {
23762
+ return parseSharedConfig({
23763
+ format: "json",
23764
+ fileContent,
23765
+ filePath,
23766
+ invalidRootPolicy: "error"
23767
+ });
23768
+ }
23769
+ /**
23770
+ * Combine two Crush config documents the way Crush itself does
23771
+ * (`github.com/qjebbs/go-jsons`): objects merge recursively, arrays are
23772
+ * concatenated with `base` first, and any other value from `override` wins.
23773
+ */
23774
+ function mergeCrushConfigs({ base, override }) {
23775
+ const merged = { ...base };
23776
+ for (const [key, value] of Object.entries(override)) {
23777
+ if (isPrototypePollutionKey(key)) continue;
23778
+ const current = merged[key];
23779
+ if (isRecord$1(current) && isRecord$1(value)) merged[key] = mergeCrushConfigs({
23780
+ base: current,
23781
+ override: value
23782
+ });
23783
+ else if (Array.isArray(current) && Array.isArray(value)) merged[key] = [...current, ...value];
23784
+ else merged[key] = value;
23785
+ }
23786
+ return merged;
23787
+ }
23788
+ /**
23789
+ * The content an import should read for a resolved location: the chosen file
23790
+ * merged over its twin when both exist, so a server, hook or tool entry that
23791
+ * only the lower-priority `crush.json` declares is imported too — Crush sees
23792
+ * it, so rulesync should as well.
23793
+ */
23794
+ function crushConfigImportContent(location) {
23795
+ const own = location.fileContent ?? "{}";
23796
+ if (location.twin === void 0) return own;
23797
+ const merged = mergeCrushConfigs({
23798
+ base: parseCrushConfig(location.twin.fileContent, location.twin.filePath),
23799
+ override: parseCrushConfig(own, location.filePath)
23800
+ });
23801
+ return JSON.stringify(merged, null, 2);
23802
+ }
23803
+ function lookupPath(root, path) {
23804
+ let current = root;
23805
+ for (const segment of path) {
23806
+ if (!isRecord$1(current) || !Object.hasOwn(current, segment)) return;
23807
+ current = current[segment];
23808
+ }
23809
+ return current;
23810
+ }
23811
+ function isNonEmptyValue(value) {
23812
+ if (Array.isArray(value)) return value.length > 0;
23813
+ if (isRecord$1(value)) return Object.keys(value).length > 0;
23814
+ return value !== void 0;
23815
+ }
23816
+ /**
23817
+ * Warn when the twin Crush merges beneath the written file still carries a
23818
+ * value at one of the paths this feature owns. Rulesync only rewrites the
23819
+ * chosen file, and because Crush concatenates lists and merges objects across
23820
+ * the pair, such an entry — typically one an earlier generate wrote to
23821
+ * `crush.json` before the user added a `.crush.json` — stays in effect until
23822
+ * removed by hand, and cannot be retracted from `.rulesync/`.
23823
+ */
23824
+ function warnCrushTwinLeftovers({ location, ownedPaths, logger }) {
23825
+ if (location.twin === void 0) return;
23826
+ let twin;
23827
+ try {
23828
+ twin = parseCrushConfig(location.twin.fileContent, location.twin.filePath);
23829
+ } catch {
23830
+ return;
23831
+ }
23832
+ const leftovers = ownedPaths.filter((path) => isNonEmptyValue(lookupPath(twin, path))).map((path) => `"${path.join(".")}"`);
23833
+ if (leftovers.length === 0) return;
23834
+ 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.`);
23835
+ }
23836
+ //#endregion
23837
+ //#region src/features/hooks/crush-hooks.ts
23838
+ const SUPPORTED_CRUSH_EVENTS = new Set(CRUSH_HOOK_EVENTS);
23839
+ /**
23840
+ * Crush matches an event key case-insensitively and ignores underscores
23841
+ * (`PreToolUse`, `pre_tool_use` and `PRE_TOOL_USE` all work), so an import
23842
+ * looks the canonical name up by that normalized spelling.
23843
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
23844
+ */
23845
+ const NORMALIZED_CRUSH_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CRUSH_TO_CANONICAL_EVENT_NAMES).map(([crushEvent, canonical]) => [normalizeCrushEventName(crushEvent), canonical]));
23846
+ function normalizeCrushEventName(event) {
23847
+ return event.replaceAll("_", "").toLowerCase();
23848
+ }
23849
+ /**
23850
+ * Build the `hooks` block of `crush.json` from a canonical hooks config.
23851
+ * Crush keys a flat array of `{name, matcher, command, timeout}` entries by
23852
+ * event name; `matcher` is a regex tested against the (lower-case) Crush tool
23853
+ * name and `timeout` is in seconds. Only `type: "command"` canonical hooks are
23854
+ * emitted, since a Crush hook is a shell command. A shared canonical event
23855
+ * Crush does not fire is skipped (the HooksProcessor reports it), while an
23856
+ * event under the `crush.hooks` override — such as one an import filed there
23857
+ * — is written verbatim so it round-trips.
23858
+ */
23859
+ function canonicalToCrushHooks({ config, toolOverride, logger }) {
23860
+ const sharedHooks = {};
23861
+ for (const [event, defs] of Object.entries(config.hooks)) if (SUPPORTED_CRUSH_EVENTS.has(event)) sharedHooks[event] = defs;
23862
+ const effective = {
23863
+ ...sharedHooks,
23864
+ ...toolOverride
23865
+ };
23866
+ const hooks = {};
23867
+ for (const [event, defs] of Object.entries(effective)) {
23868
+ if (isPrototypePollutionKey(event)) continue;
23869
+ const crushEvent = lookupOwn({
23870
+ record: CANONICAL_TO_CRUSH_EVENT_NAMES,
23871
+ key: event
23872
+ }) ?? event;
23873
+ const entries = [];
23874
+ for (const def of defs) {
23875
+ const entry = canonicalDefToCrushEntry({
23876
+ def,
23877
+ event,
23878
+ logger
23879
+ });
23880
+ if (entry !== null) entries.push(entry);
23881
+ }
23882
+ if (entries.length > 0) hooks[crushEvent] = entries;
23883
+ }
23884
+ return hooks;
23885
+ }
23886
+ /** Convert one canonical hook definition to a Crush entry, or null to skip. */
23887
+ function canonicalDefToCrushEntry({ def, event, logger }) {
23888
+ if ((def.type ?? "command") !== "command") return null;
23889
+ if (typeof def.command !== "string" || def.command === "") {
23890
+ logger?.warn(`Crush hook ${quoteValueForWarning(def.name ?? "")} under "${event}" has no "command", which Crush would discard, so it was skipped.`);
23891
+ return null;
23892
+ }
23893
+ const entry = { command: def.command };
23894
+ if (typeof def.name === "string" && def.name !== "") entry.name = def.name;
23895
+ if (typeof def.matcher === "string" && def.matcher !== "" && def.matcher !== "*") entry.matcher = def.matcher;
23896
+ if (typeof def.timeout === "number") entry.timeout = Math.ceil(def.timeout);
23897
+ return entry;
23898
+ }
23899
+ /** Convert one raw hook entry to a canonical definition, or null to skip. */
23900
+ function crushEntryToCanonicalDef(raw) {
23901
+ if (!isRecord$1(raw) || typeof raw.command !== "string") return null;
23902
+ const def = {
23903
+ type: "command",
23904
+ command: raw.command
23905
+ };
23906
+ if (typeof raw.name === "string" && raw.name !== "") def.name = raw.name;
23907
+ if (typeof raw.matcher === "string" && raw.matcher !== "") def.matcher = raw.matcher;
23908
+ if (typeof raw.timeout === "number") def.timeout = raw.timeout;
23909
+ return def;
23910
+ }
23911
+ /**
23912
+ * Reverse {@link canonicalToCrushHooks}: parse the `hooks` block back into a
23913
+ * canonical event → definition[] record. An event Crush does not document is
23914
+ * carried under its own name so `buildImportedHooksConfig` files it under the
23915
+ * `crush.hooks` override.
23916
+ */
23917
+ function crushHooksToCanonical(hooksBlock) {
23918
+ const canonical = {};
23919
+ if (!isRecord$1(hooksBlock)) return canonical;
23920
+ for (const [crushEvent, rawEntries] of Object.entries(hooksBlock)) {
23921
+ if (isPrototypePollutionKey(crushEvent) || !Array.isArray(rawEntries)) continue;
23922
+ const canonicalEvent = lookupOwn({
23923
+ record: NORMALIZED_CRUSH_TO_CANONICAL_EVENT_NAMES,
23924
+ key: normalizeCrushEventName(crushEvent)
23925
+ }) ?? crushEvent;
23926
+ const defs = rawEntries.map((raw) => crushEntryToCanonicalDef(raw)).filter((def) => def !== null);
23927
+ if (defs.length === 0) continue;
23928
+ canonical[canonicalEvent] = [...lookupOwn({
23929
+ record: canonical,
23930
+ key: canonicalEvent
23931
+ }) ?? [], ...defs];
23932
+ }
23933
+ return canonical;
23934
+ }
23935
+ /**
23936
+ * Crush hooks.
23937
+ *
23938
+ * Crush reads hooks from the `hooks` key of its JSON config —
23939
+ * `<project>/crush.json` (or an existing `.crush.json`; Crush merges the pair,
23940
+ * lists concatenated) at project scope and `~/.config/crush/crush.json` at
23941
+ * user scope — as
23942
+ * `hooks.<Event>: [{name?, matcher?, command, timeout?}]`. Only `PreToolUse`
23943
+ * fires today. The `hooks` key is owned outright; every other top-level key
23944
+ * of the file is preserved and the file is never deleted.
23945
+ *
23946
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md
23947
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
23948
+ */
23949
+ var CrushHooks = class CrushHooks extends ToolHooks {
23950
+ json;
23951
+ constructor(params) {
23952
+ super(params);
23953
+ this.json = parseCrushConfig(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
23954
+ }
23955
+ getJson() {
23956
+ return this.json;
23957
+ }
23958
+ isDeletable() {
23959
+ return false;
23960
+ }
23961
+ static getSettablePaths({ global = false } = {}) {
23962
+ return getCrushConfigSettablePaths({ global });
23963
+ }
23685
23964
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
23686
- const paths = CortexcodeHooks.getSettablePaths({ global });
23687
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"hooks\":{}}";
23688
- return new CortexcodeHooks({
23965
+ const location = await resolveCrushConfigFile({
23689
23966
  outputRoot,
23690
- relativeDirPath: paths.relativeDirPath,
23691
- relativeFilePath: paths.relativeFilePath,
23692
- fileContent,
23693
- validate
23967
+ global
23968
+ });
23969
+ return new CrushHooks({
23970
+ outputRoot,
23971
+ relativeDirPath: location.relativeDirPath,
23972
+ relativeFilePath: location.relativeFilePath,
23973
+ fileContent: crushConfigImportContent(location),
23974
+ validate,
23975
+ global
23694
23976
  });
23695
23977
  }
23696
23978
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
23697
- const paths = CortexcodeHooks.getSettablePaths({ global });
23698
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
23699
- const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
23700
- const config = rulesyncHooks.getJson();
23701
- const hooks = canonicalToToolHooks({
23702
- config,
23703
- toolOverrideHooks: config.cortexcode?.hooks,
23704
- converterConfig: CORTEXCODE_CONVERTER_CONFIG,
23979
+ const location = await resolveCrushConfigFile({
23980
+ outputRoot,
23981
+ global
23982
+ });
23983
+ const existingContent = location.fileContent ?? "";
23984
+ warnCrushTwinLeftovers({
23985
+ location,
23986
+ ownedPaths: [[CRUSH_HOOKS_KEY]],
23705
23987
  logger
23706
23988
  });
23707
- const fileContent = applySharedConfigPatch({
23708
- fileKey: sharedConfigFileKey(paths),
23709
- feature: "hooks",
23710
- existingContent,
23711
- patch: { hooks },
23712
- filePath,
23989
+ const config = rulesyncHooks.getJson();
23990
+ const hooks = canonicalToCrushHooks({
23991
+ config,
23992
+ toolOverride: config.crush?.hooks,
23713
23993
  logger
23714
23994
  });
23715
- return new CortexcodeHooks({
23995
+ return new CrushHooks({
23716
23996
  outputRoot,
23717
- relativeDirPath: paths.relativeDirPath,
23718
- relativeFilePath: paths.relativeFilePath,
23719
- fileContent,
23720
- validate
23997
+ relativeDirPath: location.relativeDirPath,
23998
+ relativeFilePath: location.relativeFilePath,
23999
+ fileContent: applySharedConfigPatch({
24000
+ fileKey: sharedConfigFileKey(this.getSettablePaths({ global })),
24001
+ feature: "hooks",
24002
+ existingContent,
24003
+ patch: { [CRUSH_HOOKS_KEY]: hooks },
24004
+ filePath: location.filePath,
24005
+ logger
24006
+ }),
24007
+ validate,
24008
+ global
23721
24009
  });
23722
24010
  }
23723
- toRulesyncHooks({ logger } = {}) {
23724
- const configPath = (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath());
23725
- let settings;
23726
- try {
23727
- settings = parseCortexcodeSettings(this.getFileContent(), configPath);
23728
- } catch (error) {
23729
- throw new Error(`Failed to parse Cortex Code hooks content in ${configPath}: ${formatError(error)}`, { cause: error });
23730
- }
23731
- const hooks = toolHooksToCanonical({
23732
- logger,
23733
- hooks: settings.hooks,
23734
- converterConfig: CORTEXCODE_CONVERTER_CONFIG
23735
- });
24011
+ toRulesyncHooks() {
24012
+ const hooks = crushHooksToCanonical(this.json[CRUSH_HOOKS_KEY]);
23736
24013
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
23737
24014
  hooks,
23738
- overrideKey: "cortexcode"
24015
+ overrideKey: "crush"
23739
24016
  }), null, 2) });
23740
24017
  }
23741
24018
  validate() {
@@ -23744,13 +24021,14 @@ var CortexcodeHooks = class CortexcodeHooks extends ToolHooks {
23744
24021
  error: null
23745
24022
  };
23746
24023
  }
23747
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
23748
- return new CortexcodeHooks({
24024
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
24025
+ return new CrushHooks({
23749
24026
  outputRoot,
23750
24027
  relativeDirPath,
23751
24028
  relativeFilePath,
23752
- fileContent: JSON.stringify({ hooks: {} }, null, 2),
23753
- validate: false
24029
+ fileContent: JSON.stringify({ [CRUSH_HOOKS_KEY]: {} }, null, 2),
24030
+ validate: false,
24031
+ global
23754
24032
  });
23755
24033
  }
23756
24034
  };
@@ -26984,19 +27262,22 @@ var ReasonixHooks = class ReasonixHooks extends ToolHooks {
26984
27262
  //#endregion
26985
27263
  //#region src/features/hooks/tabnine-hooks.ts
26986
27264
  /**
26987
- * Environment block safe to hand Tabnine for a hook. A tool rebuilds each
26988
- * entry into `KEY=VALUE` for the spawned process, so a key holding `=`, a
26989
- * control character or nothing at all names a different variable than it
26990
- * appears to; such entries are dropped in both directions (with a warning on
26991
- * export, where the value came from an authored `.rulesync/hooks.*`).
27265
+ * Environment block safe to hand Tabnine for a hook. The shared converter
27266
+ * refuses a whole `env` map holding one bad entry; Tabnine's shape is authored
27267
+ * outside it, so the same per-entry rule (`isSafeEnvEntry`: a key holding `=`,
27268
+ * a control character or nothing at all names a different variable than it
27269
+ * appears to) is applied entry by entry and the bad ones are dropped in both
27270
+ * directions, with a warning on export, where the value came from an authored
27271
+ * `.rulesync/hooks.*`.
26992
27272
  */
26993
27273
  function sanitizeEnv({ env, warn }) {
26994
27274
  if (env === null || env === void 0 || typeof env !== "object" || Array.isArray(env)) return;
26995
27275
  const result = {};
26996
27276
  for (const [key, value] of Object.entries(env)) {
26997
- const unsafeKey = key === "" || key.includes("=") || CONTROL_CHARS.some((char) => key.includes(char));
26998
- const unsafeValue = typeof value !== "string" || CONTROL_CHARS.some((char) => value.includes(char));
26999
- if (unsafeKey || unsafeValue) {
27277
+ if (!isSafeEnvEntry({
27278
+ key,
27279
+ value
27280
+ })) {
27000
27281
  warn?.(`Tabnine CLI hook env entry ${JSON.stringify(key)} is not a safe KEY=VALUE pair; skipping it.`);
27001
27282
  continue;
27002
27283
  }
@@ -27097,7 +27378,7 @@ const TabnineMatcherEntrySchema = zod_mini.z.looseObject({
27097
27378
  });
27098
27379
  function tabnineMatcherEntryToCanonical(entry) {
27099
27380
  const sequential = entry.sequential === true;
27100
- const matcher = entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "" ? entry.matcher : void 0;
27381
+ const matcher = entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "" && entry.matcher !== ".*" ? entry.matcher : void 0;
27101
27382
  const defs = [];
27102
27383
  for (const hook of entry.hooks ?? []) {
27103
27384
  if (hook.type !== "command" || typeof hook.command !== "string" || hook.command === "") continue;
@@ -28139,6 +28420,18 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28139
28420
  supportedHookTypes: ["command", "http"],
28140
28421
  supportsMatcher: true
28141
28422
  }],
28423
+ ["crush", {
28424
+ class: CrushHooks,
28425
+ meta: {
28426
+ supportsProject: true,
28427
+ supportsGlobal: true,
28428
+ supportsImport: true
28429
+ },
28430
+ supportedEvents: CRUSH_HOOK_EVENTS,
28431
+ supportedHookTypes: ["command"],
28432
+ supportsMatcher: true,
28433
+ passthroughOverrideEvents: true
28434
+ }],
28142
28435
  ["zcode", {
28143
28436
  class: ZcodeHooks,
28144
28437
  meta: {
@@ -28820,14 +29113,6 @@ var ContinueIgnore = class ContinueIgnore extends ToolIgnore {
28820
29113
  }
28821
29114
  };
28822
29115
  //#endregion
28823
- //#region src/constants/crush-paths.ts
28824
- const CRUSH_RULE_FILE_NAME = "CRUSH.md";
28825
- const CRUSH_GLOBAL_DIR = (0, node_path.join)(".config", "crush");
28826
- const CRUSH_LOCAL_RULE_FILE_NAME = "CRUSH.local.md";
28827
- const CRUSH_IGNORE_FILE_NAME = ".crushignore";
28828
- const CRUSH_SKILLS_PROJECT_DIR = (0, node_path.join)(".crush", "skills");
28829
- const CRUSH_SKILLS_GLOBAL_DIR = (0, node_path.join)(CRUSH_GLOBAL_DIR, "skills");
28830
- //#endregion
28831
29116
  //#region src/features/ignore/crush-ignore.ts
28832
29117
  /**
28833
29118
  * Ignore generator for Crush.
@@ -30714,13 +30999,17 @@ function isRemoteMcpServer(serverConfig) {
30714
30999
  function resolveRemoteMcpUrl(serverConfig) {
30715
31000
  return serverConfig.url || serverConfig.httpUrl || void 0;
30716
31001
  }
30717
- /** The `command` array a `local` server is spawned with, `args` merged in. */
31002
+ /**
31003
+ * The `command` array a `local` server is spawned with, `args` merged in. An
31004
+ * entry carrying `args` but no `command` comes back empty rather than with
31005
+ * `args[0]` promoted to its program: every adapter writes element 0 back out
31006
+ * as the executable (or the whole array as the command line), so the caller
31007
+ * skips such an entry the same way as one with nothing to spawn at all.
31008
+ */
30718
31009
  function resolveLocalMcpCommand(serverConfig) {
30719
- const commandArray = [];
30720
- if (serverConfig.command) {
30721
- if (Array.isArray(serverConfig.command)) commandArray.push(...serverConfig.command);
30722
- else commandArray.push(serverConfig.command);
30723
- }
31010
+ const { command } = serverConfig;
31011
+ const commandArray = Array.isArray(command) ? [...command] : command ? [command] : [];
31012
+ if (commandArray.length === 0) return [];
30724
31013
  if (serverConfig.args) commandArray.push(...serverConfig.args);
30725
31014
  return commandArray;
30726
31015
  }
@@ -30730,9 +31019,13 @@ function resolveLocalMcpCommand(serverConfig) {
30730
31019
  * spells the transport out. Writing `{type: "local", command: []}` for it would
30731
31020
  * give the tool a server it cannot start and the importer a file it cannot read
30732
31021
  * back, so it is skipped out loud instead.
31022
+ *
31023
+ * The server name comes straight from the user's config, so it is quoted
31024
+ * through `quoteValueForWarning` rather than interpolated: a crafted name could
31025
+ * otherwise carry a line break or control characters into the log.
30733
31026
  */
30734
31027
  function warnAndSkipMcpServer({ toolName, serverName, reason, logger }) {
30735
- logger?.warn(`${toolName} MCP: skipping "${serverName}" because it declares ${reason}.`);
31028
+ logger?.warn(`${toolName} MCP: skipping ${quoteValueForWarning(serverName)} because it declares ${reason}.`);
30736
31029
  return null;
30737
31030
  }
30738
31031
  /**
@@ -30820,8 +31113,10 @@ function asBobRemoteType(stated, url) {
30820
31113
  * streamable HTTP server carries `type: "streamable-http"` and `url`, and an
30821
31114
  * SSE server carries a bare `url`. The canonical `transport` alias and the
30822
31115
  * Claude-style `httpUrl` alias are folded into `type`/`url`; `env`, `cwd`,
30823
- * `headers`, `timeout`, `alwaysAllow` and `disabled` pass through unchanged,
30824
- * as Bob documents all of them.
31116
+ * `headers`, `timeout`, `alwaysAllow` and `disabled` pass through, as Bob
31117
+ * documents all of them, with prototype-pollution keys dropped at every
31118
+ * nesting level (Bob spreads `env` and `headers` into the process environment
31119
+ * and the HTTP requests).
30825
31120
  *
30826
31121
  * Bob Shell documents the same file with an `httpURL` key instead of
30827
31122
  * `type` + `url` for streamable HTTP. rulesync writes the IDE spelling (the two
@@ -30889,7 +31184,7 @@ function convertToBobFormat(mcpServers, logger) {
30889
31184
  }
30890
31185
  for (const [key, value] of Object.entries(rest)) {
30891
31186
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
30892
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
31187
+ converted[key] = omitPrototypePollutionKeysDeep(value);
30893
31188
  }
30894
31189
  result[serverName] = converted;
30895
31190
  }
@@ -30900,7 +31195,9 @@ function convertToBobFormat(mcpServers, logger) {
30900
31195
  * (`type: "streamable-http"` + `url`) is already canonical and passes through;
30901
31196
  * the Bob Shell spelling `httpURL` becomes `url` with `type: "http"`; a bare
30902
31197
  * `url` is an SSE server in Bob, so it gains `type: "sse"` to keep that reading
30903
- * on the next generate.
31198
+ * on the next generate. Every other field passes through with its
31199
+ * prototype-pollution keys dropped at every nesting level, mirroring the
31200
+ * generate side's handling of `env` / `headers`.
30904
31201
  */
30905
31202
  function convertFromBobFormat(mcpServers) {
30906
31203
  if (!isMcpServers(mcpServers)) return {};
@@ -30915,7 +31212,7 @@ function convertFromBobFormat(mcpServers) {
30915
31212
  if (typeof value === "string") httpURL = value;
30916
31213
  continue;
30917
31214
  }
30918
- converted[key] = value;
31215
+ converted[key] = omitPrototypePollutionKeysDeep(value);
30919
31216
  }
30920
31217
  if (httpURL !== void 0) {
30921
31218
  converted.url = httpURL;
@@ -31763,7 +32060,7 @@ function convertRemoteServer({ serverName, serverConfig, logger }) {
31763
32060
  return converted;
31764
32061
  }
31765
32062
  function convertStdioServer({ serverName, serverConfig, logger }) {
31766
- const [command, ...args] = (typeof serverConfig.command === "string" ? serverConfig.command !== "" : Array.isArray(serverConfig.command) && serverConfig.command.length > 0) ? resolveLocalMcpCommand(serverConfig) : [];
32063
+ const [command, ...args] = resolveLocalMcpCommand(serverConfig);
31767
32064
  if (!command) {
31768
32065
  warnAndSkipMcpServer({
31769
32066
  toolName: "Continue",
@@ -31779,7 +32076,7 @@ function convertStdioServer({ serverName, serverConfig, logger }) {
31779
32076
  };
31780
32077
  if (args.length > 0) converted.args = args;
31781
32078
  if (isRecord$1(serverConfig.env)) converted.env = omitPrototypePollutionKeys(serverConfig.env);
31782
- 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.`);
32079
+ 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.`);
31783
32080
  return converted;
31784
32081
  }
31785
32082
  /**
@@ -32321,7 +32618,7 @@ function convertToCortexcodeFormat(mcpServers, logger) {
32321
32618
  }
32322
32619
  for (const [key, value] of Object.entries(rest)) {
32323
32620
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
32324
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
32621
+ converted[key] = omitPrototypePollutionKeysDeep(value);
32325
32622
  }
32326
32623
  result[serverName] = converted;
32327
32624
  }
@@ -32330,14 +32627,15 @@ function convertToCortexcodeFormat(mcpServers, logger) {
32330
32627
  /**
32331
32628
  * Convert Cortex Code's server map back to the canonical shape. The
32332
32629
  * documented spelling (`type` + `command`/`url`) is already canonical, so
32333
- * entries pass through with only prototype-pollution keys dropped.
32630
+ * entries pass through with only prototype-pollution keys dropped — at every
32631
+ * nesting level, mirroring the generate side.
32334
32632
  */
32335
32633
  function convertFromCortexcodeFormat(mcpServers) {
32336
32634
  if (!isMcpServers(mcpServers)) return {};
32337
32635
  const result = {};
32338
32636
  for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
32339
32637
  if (PROTOTYPE_POLLUTION_KEYS.has(serverName) || !isRecord$1(serverConfig)) continue;
32340
- result[serverName] = omitPrototypePollutionKeys(serverConfig);
32638
+ result[serverName] = omitPrototypePollutionKeysDeep(serverConfig);
32341
32639
  }
32342
32640
  return result;
32343
32641
  }
@@ -32376,19 +32674,12 @@ var CortexcodeMcp = class CortexcodeMcp extends ToolMcp {
32376
32674
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32377
32675
  if (!global) throw new Error(CORTEXCODE_GLOBAL_ONLY_MESSAGE);
32378
32676
  const paths = this.getSettablePaths({ global });
32379
- const json = parseCortexcodeMcpConfig({
32380
- fileContent: await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}",
32381
- relativePath: (0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)
32382
- });
32383
- const newJson = {
32384
- ...json,
32385
- mcpServers: json.mcpServers ?? {}
32386
- };
32677
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}";
32387
32678
  return new CortexcodeMcp({
32388
32679
  outputRoot,
32389
32680
  relativeDirPath: paths.relativeDirPath,
32390
32681
  relativeFilePath: paths.relativeFilePath,
32391
- fileContent: JSON.stringify(newJson, null, 2),
32682
+ fileContent,
32392
32683
  validate,
32393
32684
  global
32394
32685
  });
@@ -32436,6 +32727,319 @@ var CortexcodeMcp = class CortexcodeMcp extends ToolMcp {
32436
32727
  }
32437
32728
  };
32438
32729
  //#endregion
32730
+ //#region src/features/mcp/crush-mcp.ts
32731
+ /**
32732
+ * Crush-only MCP fields with no canonical counterpart, authored under a
32733
+ * `crush`-prefixed key in `.rulesync/mcp.jsonc` and written under Crush's own
32734
+ * name: `oauth*` drive Crush's OAuth 2.1 flow for HTTP servers and
32735
+ * `sessionless` marks a server that issues no `Mcp-Session-Id`.
32736
+ * `RulesyncMcp.getMcpServers()` strips both spellings so the client secret
32737
+ * never reaches another tool's config; `fromRulesyncMcp` re-merges them from
32738
+ * the raw source JSON, honouring the raw Crush spelling as a fallback for an
32739
+ * entry copied straight out of a `crush.json` (the `experimental_environment`
32740
+ * precedent) — except `oauth`, whose canonical key is Claude Code's
32741
+ * `{ clientId }` object and which only `crushOauth` can set.
32742
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
32743
+ */
32744
+ const CRUSH_ONLY_KEYS = [
32745
+ {
32746
+ canonical: "crushOauth",
32747
+ crush: "oauth",
32748
+ accepts: (v) => typeof v === "boolean",
32749
+ rawFallback: false
32750
+ },
32751
+ {
32752
+ canonical: "crushOauthClientId",
32753
+ crush: "oauth_client_id",
32754
+ accepts: (v) => typeof v === "string",
32755
+ rawFallback: true
32756
+ },
32757
+ {
32758
+ canonical: "crushOauthClientSecret",
32759
+ crush: "oauth_client_secret",
32760
+ accepts: (v) => typeof v === "string",
32761
+ rawFallback: true
32762
+ },
32763
+ {
32764
+ canonical: "crushOauthCallbackPort",
32765
+ crush: "oauth_callback_port",
32766
+ accepts: (v) => Number.isInteger(v),
32767
+ rawFallback: true
32768
+ },
32769
+ {
32770
+ canonical: "crushSessionless",
32771
+ crush: "sessionless",
32772
+ accepts: (v) => typeof v === "boolean",
32773
+ rawFallback: true
32774
+ }
32775
+ ];
32776
+ /**
32777
+ * The Crush-only keys of one raw canonical server entry, keyed by their
32778
+ * canonical name, so `convertToCrushFormat` sees them after the shared map
32779
+ * stripped them.
32780
+ */
32781
+ function readCrushOnlyKeys(rawServer) {
32782
+ const result = {};
32783
+ if (!isRecord$1(rawServer)) return result;
32784
+ for (const { canonical, crush, accepts, rawFallback } of CRUSH_ONLY_KEYS) {
32785
+ const value = accepts(rawServer[canonical]) ? rawServer[canonical] : rawFallback && accepts(rawServer[crush]) ? rawServer[crush] : void 0;
32786
+ if (value !== void 0) result[canonical] = value;
32787
+ }
32788
+ return result;
32789
+ }
32790
+ function copyCrushOnlyKeys({ from, to }) {
32791
+ for (const { canonical, crush, accepts } of CRUSH_ONLY_KEYS) if (accepts(from[canonical])) to[crush] = from[canonical];
32792
+ }
32793
+ /**
32794
+ * The remote transports Crush's `MCPType` enum accepts, spelled the way it
32795
+ * reads them. `streamable-http` is the canonical rulesync name for what Crush
32796
+ * calls `http`; a WebSocket server has no Crush transport and is left to the
32797
+ * caller to reject.
32798
+ */
32799
+ function asCrushRemoteType(stated, url) {
32800
+ if (stated === "sse") return "sse";
32801
+ if (stated === "http" || stated === "streamable-http") return "http";
32802
+ if (stated === void 0) return /^wss?:\/\//i.test(url) ? void 0 : "http";
32803
+ }
32804
+ /**
32805
+ * The transport half of a remote server (`type`, `url`, `headers`), or null
32806
+ * when Crush could not reach it and the server was skipped with a warning.
32807
+ */
32808
+ function convertRemoteTransport({ name, config, logger }) {
32809
+ const url = resolveRemoteMcpUrl(config);
32810
+ if (!url) {
32811
+ warnAndSkipMcpServer({
32812
+ toolName: "Crush",
32813
+ serverName: name,
32814
+ reason: "a remote transport without a url",
32815
+ logger
32816
+ });
32817
+ return null;
32818
+ }
32819
+ const stated = config.type ?? config.transport;
32820
+ const type = asCrushRemoteType(stated, url);
32821
+ if (type === void 0) {
32822
+ warnAndSkipMcpServer({
32823
+ toolName: "Crush",
32824
+ serverName: name,
32825
+ 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)`,
32826
+ logger
32827
+ });
32828
+ return null;
32829
+ }
32830
+ const converted = {
32831
+ type,
32832
+ url
32833
+ };
32834
+ if (config.headers && Object.keys(config.headers).length > 0) converted.headers = config.headers;
32835
+ return converted;
32836
+ }
32837
+ /**
32838
+ * The transport half of a stdio server (`type`, `command`, `args`, `env`), or
32839
+ * null when it has no command and was skipped with a warning.
32840
+ */
32841
+ function convertStdioTransport({ name, config, logger }) {
32842
+ const [command, ...args] = resolveLocalMcpCommand(config);
32843
+ if (!command) {
32844
+ warnAndSkipMcpServer({
32845
+ toolName: "Crush",
32846
+ serverName: name,
32847
+ reason: "a stdio transport without a command",
32848
+ logger
32849
+ });
32850
+ return null;
32851
+ }
32852
+ const converted = {
32853
+ type: "stdio",
32854
+ command
32855
+ };
32856
+ if (args.length > 0) converted.args = args;
32857
+ if (config.env && Object.keys(config.env).length > 0) converted.env = config.env;
32858
+ return converted;
32859
+ }
32860
+ /**
32861
+ * Convert canonical rulesync servers to the `mcp.<name>` shape of crush.json.
32862
+ * `type` is required by Crush's schema, so it is always written: stdio servers
32863
+ * carry `type: "stdio"` with `command`/`args`/`env`, remote servers `http` or
32864
+ * `sse` with `url` and optional `headers`. `disabled`, the per-server
32865
+ * `disabledTools`/`enabledTools` filters (`disabled_tools`/`enabled_tools`)
32866
+ * and `timeout` (which Crush reads in seconds) map onto their Crush fields.
32867
+ *
32868
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
32869
+ */
32870
+ function convertToCrushFormat(mcpServers, logger) {
32871
+ const result = {};
32872
+ for (const [name, config] of Object.entries(mcpServers)) {
32873
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
32874
+ if (!isRecord$1(config)) continue;
32875
+ if (declaresNoTransport(config)) {
32876
+ warnAndSkipMcpServer({
32877
+ toolName: "Crush",
32878
+ serverName: name,
32879
+ reason: "no transport",
32880
+ logger
32881
+ });
32882
+ continue;
32883
+ }
32884
+ const converted = isRemoteMcpServer(config) ? convertRemoteTransport({
32885
+ name,
32886
+ config,
32887
+ logger
32888
+ }) : convertStdioTransport({
32889
+ name,
32890
+ config,
32891
+ logger
32892
+ });
32893
+ if (converted === null) continue;
32894
+ if (config.disabled === true) converted.disabled = true;
32895
+ if (config.disabledTools && config.disabledTools.length > 0) converted.disabled_tools = config.disabledTools;
32896
+ if (config.enabledTools && config.enabledTools.length > 0) converted.enabled_tools = config.enabledTools;
32897
+ if (typeof config.timeout === "number") converted.timeout = Math.ceil(config.timeout);
32898
+ copyCrushOnlyKeys({
32899
+ from: config,
32900
+ to: converted
32901
+ });
32902
+ result[name] = converted;
32903
+ }
32904
+ return result;
32905
+ }
32906
+ /**
32907
+ * Convert the `mcp.<name>` entries of crush.json back to canonical rulesync
32908
+ * servers. `stdio` is Crush's default `type`, so it is dropped (a canonical
32909
+ * server with a `command` is stdio already); `http` and `sse` are kept as the
32910
+ * canonical transport names; `disabled_tools`/`enabled_tools` become the
32911
+ * canonical `disabledTools`/`enabledTools`; the Crush-only OAuth and
32912
+ * `sessionless` fields are lifted into their `crush*` authoring keys (a value
32913
+ * of the wrong type is dropped, as Crush itself would refuse it), and any
32914
+ * other key is kept verbatim so an import preserves what a hand-authored
32915
+ * entry declared.
32916
+ */
32917
+ function convertFromCrushFormat(crushServers) {
32918
+ const result = {};
32919
+ for (const [name, config] of Object.entries(crushServers)) {
32920
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
32921
+ const converted = {};
32922
+ for (const [key, value] of Object.entries(config)) {
32923
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
32924
+ switch (key) {
32925
+ case "type":
32926
+ if (value === "http" || value === "sse") converted.type = value;
32927
+ break;
32928
+ case "disabled_tools":
32929
+ if (isStringArray$2(value)) converted.disabledTools = value;
32930
+ break;
32931
+ case "enabled_tools":
32932
+ if (isStringArray$2(value)) converted.enabledTools = value;
32933
+ break;
32934
+ case "oauth_token": break;
32935
+ default: {
32936
+ const crushOnly = CRUSH_ONLY_KEYS.find((entry) => entry.crush === key);
32937
+ if (crushOnly === void 0) converted[key] = value;
32938
+ else if (crushOnly.accepts(value)) converted[crushOnly.canonical] = value;
32939
+ }
32940
+ }
32941
+ }
32942
+ result[name] = converted;
32943
+ }
32944
+ return result;
32945
+ }
32946
+ /**
32947
+ * Crush MCP servers.
32948
+ *
32949
+ * Crush reads MCP servers from the `mcp` key of its JSON config:
32950
+ * `<project>/crush.json` (or an existing `.crush.json`; Crush merges the pair,
32951
+ * objects recursively) at project scope and `~/.config/crush/crush.json` at
32952
+ * user scope. The
32953
+ * `crushrc` Bash config that Crush now recommends compiles its `mcp add`
32954
+ * builtin into the same `mcp.<name>` entries and overrides the JSON key by
32955
+ * key, so a `crushrc` next to the generated file takes precedence. Every
32956
+ * other top-level key of the file is preserved; the file is never deleted.
32957
+ *
32958
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/config/README.md
32959
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
32960
+ */
32961
+ var CrushMcp = class CrushMcp extends ToolMcp {
32962
+ json;
32963
+ constructor(params) {
32964
+ super(params);
32965
+ this.json = parseCrushConfig(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
32966
+ }
32967
+ getJson() {
32968
+ return this.json;
32969
+ }
32970
+ isDeletable() {
32971
+ return false;
32972
+ }
32973
+ static getSettablePaths({ global = false } = {}) {
32974
+ return getCrushConfigSettablePaths({ global });
32975
+ }
32976
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32977
+ const location = await resolveCrushConfigFile({
32978
+ outputRoot,
32979
+ global
32980
+ });
32981
+ return new CrushMcp({
32982
+ outputRoot,
32983
+ relativeDirPath: location.relativeDirPath,
32984
+ relativeFilePath: location.relativeFilePath,
32985
+ fileContent: crushConfigImportContent(location),
32986
+ validate,
32987
+ global
32988
+ });
32989
+ }
32990
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
32991
+ const location = await resolveCrushConfigFile({
32992
+ outputRoot,
32993
+ global
32994
+ });
32995
+ const existingContent = location.fileContent ?? "";
32996
+ warnCrushTwinLeftovers({
32997
+ location,
32998
+ ownedPaths: [["mcp"]],
32999
+ logger
33000
+ });
33001
+ const converted = convertToCrushFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => [serverName, {
33002
+ ...serverConfig,
33003
+ ...readCrushOnlyKeys(rulesyncMcp.getRawMcpServer(serverName))
33004
+ }])), logger);
33005
+ return new CrushMcp({
33006
+ outputRoot,
33007
+ relativeDirPath: location.relativeDirPath,
33008
+ relativeFilePath: location.relativeFilePath,
33009
+ fileContent: applySharedConfigPatch({
33010
+ fileKey: sharedConfigFileKey(this.getSettablePaths({ global })),
33011
+ feature: "mcp",
33012
+ existingContent,
33013
+ patch: { ["mcp"]: converted },
33014
+ filePath: location.filePath,
33015
+ logger
33016
+ }),
33017
+ validate,
33018
+ global
33019
+ });
33020
+ }
33021
+ toRulesyncMcp() {
33022
+ const converted = convertFromCrushFormat(isRecord$1(this.json["mcp"]) ? this.json["mcp"] : {});
33023
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers: converted }, null, 2) });
33024
+ }
33025
+ validate() {
33026
+ return {
33027
+ success: true,
33028
+ error: null
33029
+ };
33030
+ }
33031
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
33032
+ return new CrushMcp({
33033
+ outputRoot,
33034
+ relativeDirPath,
33035
+ relativeFilePath,
33036
+ fileContent: JSON.stringify({ ["mcp"]: {} }, null, 2),
33037
+ validate: false,
33038
+ global
33039
+ });
33040
+ }
33041
+ };
33042
+ //#endregion
32439
33043
  //#region src/features/mcp/mcp-env-var-format.ts
32440
33044
  /**
32441
33045
  * Canonical rulesync env var reference pattern: `${VAR}` (but not `${env:VAR}`,
@@ -36480,7 +37084,7 @@ function convertToTabnineFormat(mcpServers, logger) {
36480
37084
  }
36481
37085
  for (const [key, value] of Object.entries(rest)) {
36482
37086
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36483
- converted[key] = (key === "env" || key === "headers") && isRecord$1(value) ? omitPrototypePollutionKeys(value) : value;
37087
+ converted[key] = omitPrototypePollutionKeysDeep(value);
36484
37088
  }
36485
37089
  if (enabledTools !== void 0) converted.includeTools = enabledTools;
36486
37090
  if (disabledTools !== void 0) converted.excludeTools = disabledTools;
@@ -36491,7 +37095,9 @@ function convertToTabnineFormat(mcpServers, logger) {
36491
37095
  /**
36492
37096
  * Convert Tabnine's server map back to the canonical shape: `includeTools` /
36493
37097
  * `excludeTools` become `enabledTools` / `disabledTools`; `type` (`stdio`,
36494
- * `sse`, `http`) and every other documented field are already canonical.
37098
+ * `sse`, `http`) and every other documented field are already canonical and
37099
+ * pass through with their prototype-pollution keys dropped at every nesting
37100
+ * level, mirroring the generate side.
36495
37101
  */
36496
37102
  function convertFromTabnineFormat(mcpServers) {
36497
37103
  if (!isMcpServers(mcpServers)) return {};
@@ -36503,7 +37109,7 @@ function convertFromTabnineFormat(mcpServers) {
36503
37109
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36504
37110
  if (key === "includeTools") converted.enabledTools = value;
36505
37111
  else if (key === "excludeTools") converted.disabledTools = value;
36506
- else converted[key] = value;
37112
+ else converted[key] = omitPrototypePollutionKeysDeep(value);
36507
37113
  }
36508
37114
  result[serverName] = converted;
36509
37115
  }
@@ -37651,6 +38257,15 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
37651
38257
  supportsDisabledTools: false
37652
38258
  }
37653
38259
  }],
38260
+ ["crush", {
38261
+ class: CrushMcp,
38262
+ meta: {
38263
+ supportsProject: true,
38264
+ supportsGlobal: true,
38265
+ supportsEnabledTools: true,
38266
+ supportsDisabledTools: true
38267
+ }
38268
+ }],
37654
38269
  ["cursor", {
37655
38270
  class: CursorMcp,
37656
38271
  meta: {
@@ -39082,7 +39697,7 @@ const ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES = {
39082
39697
  function toAntigravityCliToolName(canonical) {
39083
39698
  return CANONICAL_TO_ANTIGRAVITY_CLI_TOOL_NAMES[canonical] ?? canonical;
39084
39699
  }
39085
- function toCanonicalToolName$7(cliName) {
39700
+ function toCanonicalToolName$8(cliName) {
39086
39701
  return ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES[cliName] ?? cliName;
39087
39702
  }
39088
39703
  /**
@@ -39298,7 +39913,7 @@ function convertAntigravityCliToRulesyncPermissions(params) {
39298
39913
  const processEntries = (entries, action) => {
39299
39914
  for (const entry of entries) {
39300
39915
  const { toolName, pattern } = parsePermissionEntry$1(entry);
39301
- const canonical = toCanonicalToolName$7(toolName);
39916
+ const canonical = toCanonicalToolName$8(toolName);
39302
39917
  if (!permission[canonical]) permission[canonical] = {};
39303
39918
  permission[canonical][pattern] = action;
39304
39919
  }
@@ -39555,11 +40170,34 @@ const CANONICAL_TO_AUGMENT_TOOL_NAMES = {
39555
40170
  websearch: "web-search"
39556
40171
  };
39557
40172
  const AUGMENT_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_AUGMENT_TOOL_NAMES).map(([k, v]) => [v, k]));
40173
+ const CURRENT_TO_LEGACY_AUGMENT_TOOL_NAMES = {
40174
+ terminal: "launch-process",
40175
+ read: "view",
40176
+ edit: "str-replace-editor",
40177
+ write: "save-file"
40178
+ };
39558
40179
  function toAugmentToolName(canonical) {
39559
- return CANONICAL_TO_AUGMENT_TOOL_NAMES[canonical] ?? canonical;
40180
+ return lookupOwn({
40181
+ record: CANONICAL_TO_AUGMENT_TOOL_NAMES,
40182
+ key: canonical
40183
+ }) ?? canonical;
39560
40184
  }
39561
- function toCanonicalToolName$6(augmentName) {
39562
- return AUGMENT_TO_CANONICAL_TOOL_NAMES[augmentName] ?? augmentName;
40185
+ /**
40186
+ * Resolve an existing entry's `toolName` to the legacy spelling rulesync emits, so that the
40187
+ * current alias and the legacy name are treated as the same managed tool.
40188
+ */
40189
+ function toLegacyAugmentToolName(augmentName) {
40190
+ return lookupOwn({
40191
+ record: CURRENT_TO_LEGACY_AUGMENT_TOOL_NAMES,
40192
+ key: augmentName
40193
+ }) ?? augmentName;
40194
+ }
40195
+ function toCanonicalToolName$7(augmentName) {
40196
+ const legacyName = toLegacyAugmentToolName(augmentName);
40197
+ return lookupOwn({
40198
+ record: AUGMENT_TO_CANONICAL_TOOL_NAMES,
40199
+ key: legacyName
40200
+ }) ?? legacyName;
39563
40201
  }
39564
40202
  function actionToAugmentType(action) {
39565
40203
  switch (action) {
@@ -39775,10 +40413,11 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
39775
40413
  const generatedKeys = new Set(generated.map((e) => `${e.toolName}|${e.shellInputRegex ?? ""}|${e.permission.type}`));
39776
40414
  const preservedBasicEntries = basicExistingEntries.filter((entry) => {
39777
40415
  if (entry.toolName === "*") return false;
39778
- if (!MANAGED_AUGMENT_TOOL_NAMES.has(entry.toolName)) return true;
40416
+ const legacyToolName = toLegacyAugmentToolName(entry.toolName);
40417
+ if (!MANAGED_AUGMENT_TOOL_NAMES.has(legacyToolName)) return true;
39779
40418
  if (entry.permission.type === "deny") {
39780
- const key = `${entry.toolName}|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
39781
- return !generatedKeys.has(key);
40419
+ const suffix = `|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
40420
+ return !generatedKeys.has(`${entry.toolName}${suffix}`) && !generatedKeys.has(`${legacyToolName}${suffix}`);
39782
40421
  }
39783
40422
  return false;
39784
40423
  });
@@ -40005,14 +40644,15 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
40005
40644
  if (isSpecialEntry(entry)) continue;
40006
40645
  const type = entry.permission.type;
40007
40646
  if (!isBasicAugmentType(type)) continue;
40008
- const canonical = toCanonicalToolName$6(entry.toolName);
40647
+ const legacyToolName = toLegacyAugmentToolName(entry.toolName);
40648
+ const canonical = toCanonicalToolName$7(entry.toolName);
40009
40649
  if (forbiddenMapKeys.has(canonical)) {
40010
40650
  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.`);
40011
40651
  continue;
40012
40652
  }
40013
40653
  const action = augmentTypeToAction(type);
40014
40654
  let pattern;
40015
- if (entry.toolName === "launch-process" && entry.shellInputRegex) {
40655
+ if (legacyToolName === "launch-process" && entry.shellInputRegex) {
40016
40656
  const regex = entry.shellInputRegex;
40017
40657
  if (isShellRegexRoundtrippable(regex)) pattern = shellRegexToGlob(regex);
40018
40658
  else if (action === "deny") {
@@ -40027,9 +40667,19 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
40027
40667
  logger?.warn(`AugmentCode permissions: skipping entry for tool '${entry.toolName}' because its pattern resolves to the reserved object key '${pattern}'.`);
40028
40668
  continue;
40029
40669
  }
40030
- if (!permission[canonical]) permission[canonical] = {};
40031
- const existing = permission[canonical][pattern];
40032
- if (existing === void 0 || actionPriority[action] > actionPriority[existing]) permission[canonical][pattern] = action;
40670
+ let bucket = lookupOwn({
40671
+ record: permission,
40672
+ key: canonical
40673
+ });
40674
+ if (bucket === void 0) {
40675
+ bucket = {};
40676
+ permission[canonical] = bucket;
40677
+ }
40678
+ const existing = lookupOwn({
40679
+ record: bucket,
40680
+ key: pattern
40681
+ });
40682
+ if (existing === void 0 || actionPriority[action] > actionPriority[existing]) bucket[pattern] = action;
40033
40683
  }
40034
40684
  return { permission };
40035
40685
  }
@@ -40240,7 +40890,7 @@ const CLAUDE_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONIC
40240
40890
  function toClaudeToolName(canonical) {
40241
40891
  return CANONICAL_TO_CLAUDE_TOOL_NAMES[canonical] ?? canonical;
40242
40892
  }
40243
- function toCanonicalToolName$5(claudeName) {
40893
+ function toCanonicalToolName$6(claudeName) {
40244
40894
  return CLAUDE_TO_CANONICAL_TOOL_NAMES[claudeName] ?? claudeName;
40245
40895
  }
40246
40896
  /**
@@ -41135,7 +41785,7 @@ function convertClaudeToRulesyncPermissions(params) {
41135
41785
  const processEntries = (entries, action) => {
41136
41786
  for (const entry of entries) {
41137
41787
  const { toolName, pattern } = parseClaudePermissionEntry(entry);
41138
- const canonical = toCanonicalToolName$5(toolName);
41788
+ const canonical = toCanonicalToolName$6(toolName);
41139
41789
  if (!permission[canonical]) permission[canonical] = {};
41140
41790
  permission[canonical][pattern] = action;
41141
41791
  }
@@ -41960,7 +42610,16 @@ function mapBashActionToDecision(action) {
41960
42610
  //#endregion
41961
42611
  //#region src/features/permissions/continue-permissions.ts
41962
42612
  const CONTINUE_GLOBAL_ONLY_MESSAGE = "Continue permissions are global-only; use --global to sync ~/.continue/permissions.yaml";
41963
- const CATCH_ALL_PATTERN$4 = "*";
42613
+ const CATCH_ALL_PATTERN$5 = "*";
42614
+ /**
42615
+ * A run of `*` (`**`, `***`) is the catch-all spelled another way: Continue
42616
+ * would match `Tool(**)` against every argument just as the bare tool name
42617
+ * does, so it is written — and, under the all-tools category, honored — as
42618
+ * the bare entry instead of being skipped as a pattern-specific rule.
42619
+ */
42620
+ function normalizeCatchAllPattern$1(pattern) {
42621
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$5 : pattern;
42622
+ }
41964
42623
  const CANONICAL_TO_CONTINUE_TOOL_NAMES = {
41965
42624
  bash: "Bash",
41966
42625
  read: "Read",
@@ -41988,7 +42647,7 @@ const CONTINUE_PATTERN_RE = /^([^(]+)(?:\(([^)]*)\))?$/;
41988
42647
  function toContinueToolName(canonical) {
41989
42648
  return Object.hasOwn(CANONICAL_TO_CONTINUE_TOOL_NAMES, canonical) ? CANONICAL_TO_CONTINUE_TOOL_NAMES[canonical] ?? canonical : canonical;
41990
42649
  }
41991
- function toCanonicalToolName$4(continueName) {
42650
+ function toCanonicalToolName$5(continueName) {
41992
42651
  return Object.hasOwn(CONTINUE_TO_CANONICAL_TOOL_NAMES, continueName) ? CONTINUE_TO_CANONICAL_TOOL_NAMES[continueName] ?? continueName : continueName;
41993
42652
  }
41994
42653
  /**
@@ -41996,7 +42655,7 @@ function toCanonicalToolName$4(continueName) {
41996
42655
  * `Tool(pattern)`.
41997
42656
  */
41998
42657
  function buildContinuePermissionEntry(toolName, pattern) {
41999
- return pattern === CATCH_ALL_PATTERN$4 ? toolName : `${toolName}(${pattern})`;
42658
+ return pattern === CATCH_ALL_PATTERN$5 ? toolName : `${toolName}(${pattern})`;
42000
42659
  }
42001
42660
  /**
42002
42661
  * Split a permissions.yaml entry into its tool name and argument pattern, or
@@ -42010,7 +42669,7 @@ function parseContinuePermissionEntry(entry) {
42010
42669
  if (toolName === "") return;
42011
42670
  return {
42012
42671
  toolName,
42013
- pattern: pattern === "" ? CATCH_ALL_PATTERN$4 : pattern
42672
+ pattern: pattern === "" ? CATCH_ALL_PATTERN$5 : pattern
42014
42673
  };
42015
42674
  }
42016
42675
  /**
@@ -42177,13 +42836,14 @@ function convertRulesyncToContinuePermissions({ config, logger }) {
42177
42836
  for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
42178
42837
  if (isPrototypePollutionKey(category)) continue;
42179
42838
  const toolName = category === "*" ? "*" : toContinueToolName(category);
42180
- for (const [pattern, action] of Object.entries(rules)) {
42181
- if (isPrototypePollutionKey(pattern)) continue;
42839
+ for (const [rawPattern, action] of Object.entries(rules)) {
42840
+ if (isPrototypePollutionKey(rawPattern)) continue;
42841
+ const pattern = normalizeCatchAllPattern$1(rawPattern);
42182
42842
  if (pattern.includes("(") || pattern.includes(")")) {
42183
42843
  logger?.warn(`Continue permissions.yaml cannot hold a parenthesis inside a pattern, so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
42184
42844
  continue;
42185
42845
  }
42186
- if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$4) {
42846
+ if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$5) {
42187
42847
  logger?.warn(`Continue permissions.yaml cannot scope a pattern to every tool, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
42188
42848
  continue;
42189
42849
  }
@@ -42208,7 +42868,7 @@ function convertContinuePermissionsToRulesync(lists) {
42208
42868
  const parsedEntry = parseContinuePermissionEntry(entry);
42209
42869
  if (parsedEntry === void 0) continue;
42210
42870
  if (isPrototypePollutionKey(parsedEntry.toolName) || isPrototypePollutionKey(parsedEntry.pattern)) continue;
42211
- const category = toCanonicalToolName$4(parsedEntry.toolName);
42871
+ const category = toCanonicalToolName$5(parsedEntry.toolName);
42212
42872
  permission[category] ??= {};
42213
42873
  permission[category][parsedEntry.pattern] ??= action;
42214
42874
  }
@@ -42531,6 +43191,295 @@ var CopilotcliPermissions = class CopilotcliPermissions extends ToolPermissions
42531
43191
  }
42532
43192
  };
42533
43193
  //#endregion
43194
+ //#region src/features/permissions/crush-permissions.ts
43195
+ const CATCH_ALL_PATTERN$4 = "*";
43196
+ /**
43197
+ * A run of `*` (`**`, `***`) is the catch-all spelled another way: Crush has no
43198
+ * argument patterns at all, so it is the only pattern a rule can carry and
43199
+ * still be written.
43200
+ */
43201
+ function normalizeCatchAllPattern(pattern) {
43202
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$4 : pattern;
43203
+ }
43204
+ const CANONICAL_TO_CRUSH_TOOL_NAMES = {
43205
+ bash: "bash",
43206
+ read: "view",
43207
+ edit: "edit",
43208
+ write: "write",
43209
+ grep: "grep",
43210
+ glob: "glob",
43211
+ webfetch: "fetch"
43212
+ };
43213
+ const CRUSH_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CRUSH_TOOL_NAMES).map(([k, v]) => [v, k]));
43214
+ const MCP_CANONICAL_PREFIX$4 = "mcp__";
43215
+ const MCP_CRUSH_PREFIX = "mcp_";
43216
+ function toCrushToolName(canonical) {
43217
+ if (Object.hasOwn(CANONICAL_TO_CRUSH_TOOL_NAMES, canonical)) return CANONICAL_TO_CRUSH_TOOL_NAMES[canonical] ?? canonical;
43218
+ if (canonical.startsWith(MCP_CANONICAL_PREFIX$4) && canonical.length > 5) return `${MCP_CRUSH_PREFIX}${canonical.slice(5).replaceAll("__", "_")}`;
43219
+ return canonical;
43220
+ }
43221
+ function toCanonicalToolName$4(crushName) {
43222
+ return Object.hasOwn(CRUSH_TO_CANONICAL_TOOL_NAMES, crushName) ? CRUSH_TO_CANONICAL_TOOL_NAMES[crushName] ?? crushName : crushName;
43223
+ }
43224
+ function isMcpToolName(crushName) {
43225
+ return crushName.startsWith(MCP_CRUSH_PREFIX) && crushName.length > 4;
43226
+ }
43227
+ function uniq$9(values) {
43228
+ return [...new Set(values)];
43229
+ }
43230
+ /**
43231
+ * Permissions adapter for Crush.
43232
+ *
43233
+ * Crush has no per-argument permission rules. Its JSON config carries two
43234
+ * tool-level lists instead:
43235
+ * - `permissions.allowed_tools`: tools (or `tool:action` pairs) that run
43236
+ * without a permission prompt; everything else prompts.
43237
+ * - `options.disabled_tools`: built-in tools removed from the agent
43238
+ * entirely.
43239
+ *
43240
+ * Mapping (rulesync canonical -> Crush), catch-all (`*`) rules only:
43241
+ * - `allow` -> an `allowed_tools` entry.
43242
+ * - `deny` -> a `disabled_tools` entry. Crush only filters its built-ins
43243
+ * through that list, so a deny for an MCP tool is reported and skipped
43244
+ * (disable the tool in the MCP server's `disabled_tools` instead).
43245
+ * - `ask` -> nothing (prompting is Crush's default).
43246
+ * - Tool name: `bash` -> `bash`, `read` -> `view`, `edit` -> `edit`,
43247
+ * `write` -> `write`, `grep` -> `grep`, `glob` -> `glob`,
43248
+ * `webfetch` -> `fetch`, `mcp__<server>__<tool>` -> `mcp_<server>_<tool>`;
43249
+ * any other category passes through verbatim.
43250
+ *
43251
+ * A pattern-specific rule cannot be expressed and is reported and skipped.
43252
+ * Because Crush cannot narrow an allowed tool, a category's catch-all `allow`
43253
+ * is not written while the same category carries a pattern-specific `deny` or
43254
+ * `ask`: the tool keeps prompting (fail closed) rather than being widened to
43255
+ * everything. The all-tools `*` category is mirrored onto `bash` by
43256
+ * `honorAllToolsOnBash` and otherwise skipped, since Crush has no allow-all
43257
+ * or disable-all list.
43258
+ *
43259
+ * `crush.json` is Crush's main config: entries naming a tool the canonical
43260
+ * config does not manage, and every `tool:action` entry (a form rulesync
43261
+ * never derives), are preserved verbatim; the managed tools' bare entries
43262
+ * are rebuilt, every other key of `permissions` / `options` and of the file is
43263
+ * kept, and the file is never deleted. Project scope writes `crush.json`, or
43264
+ * an existing `.crush.json`; Crush merges the pair (lists concatenated), so an
43265
+ * entry left in the other file stays in effect and is reported.
43266
+ *
43267
+ * @see https://github.com/charmbracelet/crush/blob/main/docs/config/README.md
43268
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/config.go
43269
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/permission/permission.go
43270
+ */
43271
+ var CrushPermissions = class CrushPermissions extends ToolPermissions {
43272
+ json;
43273
+ constructor(params) {
43274
+ super({
43275
+ ...params,
43276
+ fileContent: params.fileContent ?? ""
43277
+ });
43278
+ this.json = parseCrushConfig(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
43279
+ }
43280
+ getJson() {
43281
+ return this.json;
43282
+ }
43283
+ isDeletable() {
43284
+ return false;
43285
+ }
43286
+ /**
43287
+ * `crush.json` is Crush's file: rulesync merges into it when it exists but
43288
+ * does not create one that holds nothing of its own — an absent file and an
43289
+ * absent `allowed_tools` both mean "prompt for everything".
43290
+ */
43291
+ shouldSkipCreationWhenPayloadEmpty() {
43292
+ return true;
43293
+ }
43294
+ static getSettablePaths({ global = false } = {}) {
43295
+ return getCrushConfigSettablePaths({ global });
43296
+ }
43297
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
43298
+ const location = await resolveCrushConfigFile({
43299
+ outputRoot,
43300
+ global
43301
+ });
43302
+ return new CrushPermissions({
43303
+ outputRoot,
43304
+ relativeDirPath: location.relativeDirPath,
43305
+ relativeFilePath: location.relativeFilePath,
43306
+ fileContent: crushConfigImportContent(location),
43307
+ validate,
43308
+ global
43309
+ });
43310
+ }
43311
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
43312
+ const location = await resolveCrushConfigFile({
43313
+ outputRoot,
43314
+ global
43315
+ });
43316
+ const existingContent = location.fileContent ?? "";
43317
+ warnCrushTwinLeftovers({
43318
+ location,
43319
+ ownedPaths: [[CRUSH_PERMISSIONS_KEY, CRUSH_ALLOWED_TOOLS_KEY], [CRUSH_OPTIONS_KEY, CRUSH_DISABLED_TOOLS_KEY]],
43320
+ logger
43321
+ });
43322
+ const existing = parseCrushConfig(existingContent, location.filePath);
43323
+ const existingPermissions = isRecord$1(existing["permissions"]) ? existing[CRUSH_PERMISSIONS_KEY] : void 0;
43324
+ const existingOptions = isRecord$1(existing["options"]) ? existing[CRUSH_OPTIONS_KEY] : void 0;
43325
+ const config = rulesyncPermissions.getJson();
43326
+ const generated = convertRulesyncToCrushLists({
43327
+ config,
43328
+ logger
43329
+ });
43330
+ const managedToolNames = managedCrushToolNames(config);
43331
+ const preservedAllowed = (isStringArray$2(existingPermissions?.["allowed_tools"]) ? existingPermissions[CRUSH_ALLOWED_TOOLS_KEY] : []).filter((entry) => entry.includes(":") || !managedToolNames.has(entry));
43332
+ const preservedDisabled = (isStringArray$2(existingOptions?.["disabled_tools"]) ? existingOptions[CRUSH_DISABLED_TOOLS_KEY] : []).filter((entry) => !managedToolNames.has(entry));
43333
+ const allowedList = uniq$9([...preservedAllowed, ...generated.allowed]);
43334
+ const disabledList = uniq$9([...preservedDisabled, ...generated.disabled]);
43335
+ const patch = {};
43336
+ if (allowedList.length > 0 || existingPermissions !== void 0) patch[CRUSH_PERMISSIONS_KEY] = { [CRUSH_ALLOWED_TOOLS_KEY]: allowedList.length > 0 ? allowedList : void 0 };
43337
+ if (disabledList.length > 0 || existingOptions !== void 0) patch[CRUSH_OPTIONS_KEY] = { [CRUSH_DISABLED_TOOLS_KEY]: disabledList.length > 0 ? disabledList : void 0 };
43338
+ return new CrushPermissions({
43339
+ outputRoot,
43340
+ relativeDirPath: location.relativeDirPath,
43341
+ relativeFilePath: location.relativeFilePath,
43342
+ fileContent: applySharedConfigPatch({
43343
+ fileKey: sharedConfigFileKey(CrushPermissions.getSettablePaths({ global })),
43344
+ feature: "permissions",
43345
+ existingContent,
43346
+ patch,
43347
+ filePath: location.filePath,
43348
+ logger
43349
+ }),
43350
+ validate: true,
43351
+ global
43352
+ });
43353
+ }
43354
+ toRulesyncPermissions() {
43355
+ const permissions = isRecord$1(this.json["permissions"]) ? this.json[CRUSH_PERMISSIONS_KEY] : {};
43356
+ const options = isRecord$1(this.json["options"]) ? this.json[CRUSH_OPTIONS_KEY] : {};
43357
+ const rulesyncConfig = convertCrushListsToRulesync({
43358
+ allowed: isStringArray$2(permissions["allowed_tools"]) ? permissions[CRUSH_ALLOWED_TOOLS_KEY] : [],
43359
+ disabled: isStringArray$2(options["disabled_tools"]) ? options[CRUSH_DISABLED_TOOLS_KEY] : []
43360
+ });
43361
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
43362
+ }
43363
+ validate() {
43364
+ return {
43365
+ success: true,
43366
+ error: null
43367
+ };
43368
+ }
43369
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
43370
+ return new CrushPermissions({
43371
+ outputRoot,
43372
+ relativeDirPath,
43373
+ relativeFilePath,
43374
+ fileContent: "{}",
43375
+ validate: false,
43376
+ global
43377
+ });
43378
+ }
43379
+ };
43380
+ /**
43381
+ * The Crush tool names the canonical config manages, i.e. the names its
43382
+ * categories map to. Entries naming any other tool are the user's and survive
43383
+ * a generate. The all-tools `*` category names no Crush tool of its own.
43384
+ */
43385
+ function managedCrushToolNames(config) {
43386
+ return new Set(Object.keys(config.permission).filter((category) => category !== "*" && !isPrototypePollutionKey(category)).map((category) => toCrushToolName(category)));
43387
+ }
43388
+ /**
43389
+ * Reduce one category's rules to the single tool-wide action Crush can carry,
43390
+ * or undefined when nothing should be written for the tool: no catch-all rule,
43391
+ * a catch-all allow next to a narrower deny/ask (Crush cannot narrow an
43392
+ * allowed tool, so it keeps prompting instead), or a deny on an MCP tool
43393
+ * (`options.disabled_tools` only covers built-ins).
43394
+ */
43395
+ function resolveCategoryAction({ category, toolName, rules, logger }) {
43396
+ let catchAllAction;
43397
+ let restricted = false;
43398
+ for (const [rawPattern, action] of Object.entries(rules)) {
43399
+ if (isPrototypePollutionKey(rawPattern)) continue;
43400
+ const pattern = normalizeCatchAllPattern(rawPattern);
43401
+ if (pattern === CATCH_ALL_PATTERN$4) {
43402
+ catchAllAction = action;
43403
+ continue;
43404
+ }
43405
+ logger?.warn(`Crush permissions are tool-wide (no argument patterns), so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
43406
+ if (action !== "allow") restricted = true;
43407
+ }
43408
+ if (catchAllAction === "allow" && restricted) {
43409
+ 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}".`);
43410
+ return;
43411
+ }
43412
+ if (catchAllAction === "deny" && isMcpToolName(toolName)) {
43413
+ 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.`);
43414
+ return;
43415
+ }
43416
+ return catchAllAction;
43417
+ }
43418
+ /**
43419
+ * Convert a rulesync permissions config into Crush's two tool lists.
43420
+ */
43421
+ function convertRulesyncToCrushLists({ config, logger }) {
43422
+ const lists = {
43423
+ allowed: [],
43424
+ disabled: []
43425
+ };
43426
+ const actionByTool = /* @__PURE__ */ new Map();
43427
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
43428
+ if (isPrototypePollutionKey(category)) continue;
43429
+ if (category === "*") {
43430
+ for (const [rawPattern, action] of Object.entries(rules)) {
43431
+ if (isPrototypePollutionKey(rawPattern)) continue;
43432
+ logger?.warn(`Crush has no allow-all or disable-all tool list, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(rawPattern)}) was skipped.`);
43433
+ }
43434
+ continue;
43435
+ }
43436
+ const toolName = toCrushToolName(category);
43437
+ const catchAllAction = resolveCategoryAction({
43438
+ category,
43439
+ toolName,
43440
+ rules,
43441
+ logger
43442
+ });
43443
+ if (catchAllAction === void 0) continue;
43444
+ const previous = actionByTool.get(toolName);
43445
+ 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.`);
43446
+ actionByTool.set(toolName, catchAllAction);
43447
+ if (catchAllAction === "allow") lists.allowed.push(toolName);
43448
+ else if (catchAllAction === "deny") lists.disabled.push(toolName);
43449
+ }
43450
+ return {
43451
+ allowed: uniq$9(lists.allowed),
43452
+ disabled: uniq$9(lists.disabled)
43453
+ };
43454
+ }
43455
+ /**
43456
+ * Convert Crush's two tool lists back into a rulesync config. A `tool:action`
43457
+ * entry scopes an allow to one action of a tool, which the canonical model
43458
+ * cannot express, so it is left out rather than widened to the whole tool.
43459
+ */
43460
+ function convertCrushListsToRulesync({ allowed, disabled }) {
43461
+ const permission = {};
43462
+ const bucketFor = (category) => {
43463
+ const own = lookupOwn({
43464
+ record: permission,
43465
+ key: category
43466
+ });
43467
+ if (own !== void 0) return own;
43468
+ const created = {};
43469
+ permission[category] = created;
43470
+ return created;
43471
+ };
43472
+ for (const entry of disabled) {
43473
+ if (entry === "" || isPrototypePollutionKey(entry)) continue;
43474
+ bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] = "deny";
43475
+ }
43476
+ for (const entry of allowed) {
43477
+ if (entry === "" || entry.includes(":") || isPrototypePollutionKey(entry)) continue;
43478
+ bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] ??= "allow";
43479
+ }
43480
+ return { permission };
43481
+ }
43482
+ //#endregion
42534
43483
  //#region src/features/permissions/cursor-permissions.ts
42535
43484
  /**
42536
43485
  * Mapping from rulesync canonical tool category names (lowercase) to Cursor CLI
@@ -43685,6 +44634,40 @@ function convertDeepagentsToRulesyncPermissions({ allowList }) {
43685
44634
  return Object.keys(bash).length > 0 ? { permission: { bash } } : { permission: {} };
43686
44635
  }
43687
44636
  //#endregion
44637
+ //#region src/features/permissions/single-action-collapse.ts
44638
+ /**
44639
+ * Strictness order shared by the tools whose permission model evaluates
44640
+ * `deny > ask > allow`. Used wherever several canonical rules have to fold
44641
+ * into one entry, so the strictest action always wins.
44642
+ */
44643
+ const PERMISSION_ACTION_PRIORITY = {
44644
+ allow: 0,
44645
+ ask: 1,
44646
+ deny: 2
44647
+ };
44648
+ /**
44649
+ * Collapse a pattern map to the single action a tool can hold for a scope
44650
+ * that has no pattern matcher, using deny > ask > allow precedence.
44651
+ *
44652
+ * A map without a catch-all grants nothing to unmatched inputs, so an
44653
+ * implicit `ask` joins the candidates and a narrow allowlist can never widen
44654
+ * into a blanket allow. Returns `undefined` for an empty map; each caller
44655
+ * decides what an empty map means for its tool (e.g. emit nothing, or fall
44656
+ * back to `deny` when the tool's own default is allow).
44657
+ */
44658
+ function collapseRulesToSingleAction({ rules }) {
44659
+ const actions = Object.values(rules);
44660
+ if (actions.length === 0) return;
44661
+ return (Object.hasOwn(rules, "*") ? actions : [...actions, "ask"]).reduce((current, candidate) => PERMISSION_ACTION_PRIORITY[candidate] > PERMISSION_ACTION_PRIORITY[current] ? candidate : current);
44662
+ }
44663
+ /**
44664
+ * Whether a pattern map carries anything beyond the `*` catch-all, i.e.
44665
+ * whether collapsing it to a single action loses information worth a warning.
44666
+ */
44667
+ function hasPatternSpecificRules(rules) {
44668
+ return Object.keys(rules).some((pattern) => pattern !== "*");
44669
+ }
44670
+ //#endregion
43688
44671
  //#region src/features/permissions/devin-permissions.ts
43689
44672
  /**
43690
44673
  * Mapping from rulesync canonical tool category names to Devin Local permission
@@ -43694,17 +44677,21 @@ function convertDeepagentsToRulesyncPermissions({ allowList }) {
43694
44677
  * `Write(glob)`, `Exec(prefix)`, and `Fetch(pattern)` — plus MCP tool patterns
43695
44678
  * (`mcp__server__tool`). The canonical `edit` and `write` categories both map
43696
44679
  * onto Devin's single `Write` scope; on import `Write` maps back to `write`, so
43697
- * `edit` rules round-trip as `write` (a lossy but documented collapse). Unknown
44680
+ * `edit` rules round-trip as `write` (a lossy but documented collapse). The
44681
+ * canonical `websearch` category maps onto the bare `web_search` tool name,
44682
+ * accepted in the permission lists since CLI v3000.10.21 (2026-09-10). Unknown
43698
44683
  * names (e.g. `mcp__github__list_issues`) pass through verbatim.
43699
44684
  *
43700
44685
  * @see https://docs.devin.ai/cli/reference/permissions
44686
+ * @see https://docs.devin.ai/cli/changelog/stable — v3000.10.21, `web_search`
43701
44687
  */
43702
44688
  const CANONICAL_TO_DEVIN_SCOPE = {
43703
44689
  read: "Read",
43704
44690
  write: "Write",
43705
44691
  edit: "Write",
43706
44692
  bash: "Exec",
43707
- webfetch: "Fetch"
44693
+ webfetch: "Fetch",
44694
+ websearch: "web_search"
43708
44695
  };
43709
44696
  /**
43710
44697
  * Reverse mapping from Devin scope matchers to rulesync canonical names.
@@ -43713,8 +44700,15 @@ const DEVIN_SCOPE_TO_CANONICAL = {
43713
44700
  Read: "read",
43714
44701
  Write: "write",
43715
44702
  Exec: "bash",
43716
- Fetch: "webfetch"
44703
+ Fetch: "webfetch",
44704
+ web_search: "websearch"
43717
44705
  };
44706
+ /**
44707
+ * Devin scopes that exist only as a bare tool name: there is no
44708
+ * `web_search(pattern)` matcher, so a pattern-specific rule under the canonical
44709
+ * category cannot be expressed and is collapsed to one action instead.
44710
+ */
44711
+ const DEVIN_BARE_ONLY_SCOPES = /* @__PURE__ */ new Set(["web_search"]);
43718
44712
  function toDevinScope(canonical) {
43719
44713
  return CANONICAL_TO_DEVIN_SCOPE[canonical] ?? canonical;
43720
44714
  }
@@ -43899,7 +44893,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
43899
44893
  throw new Error(`Failed to parse existing Devin config at ${filePath}: ${formatError(error)}`, { cause: error });
43900
44894
  }
43901
44895
  const config = rulesyncPermissions.getJson();
43902
- const { allow, ask, deny } = convertRulesyncToDevinPermissions(config);
44896
+ const { allow, ask, deny } = convertRulesyncToDevinPermissions({
44897
+ config,
44898
+ logger
44899
+ });
43903
44900
  const managedScopes = new Set(Object.keys(config.permission).map((category) => toDevinScope(category)));
43904
44901
  const existingPermissions = isRecord$1(settings.permissions) ? settings.permissions : {};
43905
44902
  const preserve = (entries) => (entries ?? []).filter((entry) => !managedScopes.has(parseDevinPermissionEntry(entry).scope));
@@ -44003,26 +45000,50 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
44003
45000
  }
44004
45001
  };
44005
45002
  /**
45003
+ * Collapse the pattern rules of a bare-only scope to the single action Devin
45004
+ * can hold for it, using deny > ask > allow precedence. A map without a
45005
+ * catch-all grants nothing to unmatched inputs, so an implicit `ask` joins the
45006
+ * candidates and a narrow allowlist can never widen into a blanket allow.
45007
+ * Returns `undefined` for an empty map: like any other empty category it
45008
+ * emits nothing, which leaves Devin's own auto-approve default in place.
45009
+ */
45010
+ function collapseBareOnlyScopeRules({ category, scope, rules, logger }) {
45011
+ const action = collapseRulesToSingleAction({ rules });
45012
+ if (action === void 0) return;
45013
+ 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.`);
45014
+ return action;
45015
+ }
45016
+ /**
44006
45017
  * Convert rulesync permissions config to Devin allow/ask/deny arrays.
44007
45018
  */
44008
- function convertRulesyncToDevinPermissions(config) {
45019
+ function convertRulesyncToDevinPermissions({ config, logger }) {
44009
45020
  const allow = [];
44010
45021
  const ask = [];
44011
45022
  const deny = [];
45023
+ const push = (entry, action) => {
45024
+ switch (action) {
45025
+ case "allow":
45026
+ allow.push(entry);
45027
+ break;
45028
+ case "ask":
45029
+ ask.push(entry);
45030
+ break;
45031
+ case "deny": deny.push(entry);
45032
+ }
45033
+ };
44012
45034
  for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
44013
45035
  const scope = toDevinScope(category);
44014
- for (const [pattern, action] of Object.entries(rules)) {
44015
- const entry = buildDevinPermissionEntry(scope, pattern);
44016
- switch (action) {
44017
- case "allow":
44018
- allow.push(entry);
44019
- break;
44020
- case "ask":
44021
- ask.push(entry);
44022
- break;
44023
- case "deny": deny.push(entry);
44024
- }
45036
+ if (DEVIN_BARE_ONLY_SCOPES.has(scope)) {
45037
+ const action = collapseBareOnlyScopeRules({
45038
+ category,
45039
+ scope,
45040
+ rules,
45041
+ logger
45042
+ });
45043
+ if (action !== void 0) push(scope, action);
45044
+ continue;
44025
45045
  }
45046
+ for (const [pattern, action] of Object.entries(rules)) push(buildDevinPermissionEntry(scope, pattern), action);
44026
45047
  }
44027
45048
  return {
44028
45049
  allow,
@@ -46268,20 +47289,14 @@ const OPENCODE_ACTION_ONLY_PERMISSION_KEYS = /* @__PURE__ */ new Set([
46268
47289
  "question",
46269
47290
  "doom_loop"
46270
47291
  ]);
46271
- const PERMISSION_ACTION_PRIORITY = {
46272
- allow: 0,
46273
- ask: 1,
46274
- deny: 2
46275
- };
46276
47292
  function toOpencodePermission({ category, value, logger }) {
46277
47293
  if (typeof value === "string" || !OPENCODE_ACTION_ONLY_PERMISSION_KEYS.has(category)) return value;
46278
- const actions = Object.values(value);
46279
- if (actions.length === 0) {
47294
+ const action = collapseRulesToSingleAction({ rules: value });
47295
+ if (action === void 0) {
46280
47296
  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.`);
46281
47297
  return "deny";
46282
47298
  }
46283
- const action = (Object.hasOwn(value, "*") ? actions : [...actions, "ask"]).reduce((current, candidate) => PERMISSION_ACTION_PRIORITY[candidate] > PERMISSION_ACTION_PRIORITY[current] ? candidate : current);
46284
- 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.`);
47299
+ 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.`);
46285
47300
  return action;
46286
47301
  }
46287
47302
  /**
@@ -46659,6 +47674,7 @@ const QWEN_OVERRIDE_TOOLS_KEYS = [
46659
47674
  "visible",
46660
47675
  "eager",
46661
47676
  "listDirectory",
47677
+ "todoWrite",
46662
47678
  "workflowsEnabled"
46663
47679
  ];
46664
47680
  const QWEN_OVERRIDE_SECURITY_KEYS = [
@@ -46764,6 +47780,11 @@ const QWEN_OVERRIDE_GROUPS = [{
46764
47780
  rule: "global-machine-wide",
46765
47781
  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.`,
46766
47782
  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."
47783
+ },
47784
+ todoWrite: {
47785
+ rule: "global-machine-wide",
47786
+ 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.`,
47787
+ 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."
46767
47788
  }
46768
47789
  }
46769
47790
  }, {
@@ -48570,9 +49591,25 @@ function widenToPrefixGlob(pattern) {
48570
49591
  function toShellEntry(prefix) {
48571
49592
  return prefix === "" ? SHELL_TOOL_NAME : `${SHELL_TOOL_NAME}(${prefix})`;
48572
49593
  }
48573
- /** The `bash` pattern a `run_shell_command(<prefix>)` entry stands for. */
49594
+ /**
49595
+ * The `bash` pattern a `run_shell_command(<prefix>)` entry stands for. A
49596
+ * prefix Tabnine matches literally, so one an author spelled glob-like
49597
+ * (`git *`, `*`) is read as the glob it looks like rather than doubled into
49598
+ * `git * *`, which no comparison or warning could make sense of.
49599
+ */
48574
49600
  function toShellPattern(prefix) {
48575
- return prefix === void 0 || prefix === "" ? "*" : `${prefix} *`;
49601
+ if (prefix === void 0 || prefix === "" || prefix === "*") return "*";
49602
+ return prefix.endsWith(" *") ? prefix : `${prefix} *`;
49603
+ }
49604
+ /**
49605
+ * Whether {@link toShellPattern} read the prefix as a glob: such an entry
49606
+ * would never fire on Tabnine as spelled (the prefix is matched literally, so
49607
+ * `run_shell_command(git *)` only matches a command line starting with the
49608
+ * two characters `git *`), and rulesync writes it back as the prefix the glob
49609
+ * denotes — a wider entry that the author is told about.
49610
+ */
49611
+ function isGlobSpelledPrefix(prefix) {
49612
+ return prefix !== void 0 && (prefix === "*" || prefix.endsWith(" *"));
48576
49613
  }
48577
49614
  /**
48578
49615
  * Split a `tools.allowed`/`tools.exclude` entry into its tool name and the
@@ -48698,7 +49735,7 @@ function collectTrustAffectingOverrideEntries({ tools, general }) {
48698
49735
  const allowed = isStringArray$2(tools[ALLOWED_KEY]) ? tools[ALLOWED_KEY] : [];
48699
49736
  if (allowed.length > 0) entries.push({
48700
49737
  label: `tools.allowed (${allowed.map(quoteValueForWarning).join(", ")})`,
48701
- reason: "auto-approves what it names as the override spells it; a non-shell entry has no canonical rule to be checked against"
49738
+ 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"
48702
49739
  });
48703
49740
  if (Object.hasOwn(tools, SANDBOX_KEY) && isNotTrue(tools[SANDBOX_KEY])) entries.push({
48704
49741
  label: `tools.sandbox = ${quoteValueForWarning(tools[SANDBOX_KEY])}`,
@@ -48809,6 +49846,54 @@ function buildToolLists({ permission, overrideShellAllowPatterns, logger }) {
48809
49846
  };
48810
49847
  }
48811
49848
  /**
49849
+ * Names the glob-spelled `run_shell_command(...)` override entries that were
49850
+ * written as the prefix their glob denotes. Said only for the entries that
49851
+ * made it into the list: one that a deny or ask overlaps is announced as
49852
+ * withheld by the comparison instead, and one whose glob names no prefix at
49853
+ * all is skipped by it.
49854
+ */
49855
+ function warnAboutGlobSpelledOverrideEntries({ entries, allowed, logger }) {
49856
+ for (const { entry, pattern } of entries) {
49857
+ const prefix = toShellPrefix(pattern);
49858
+ const written = prefix === void 0 ? void 0 : toShellEntry(prefix);
49859
+ if (written === void 0 || !allowed.includes(written)) continue;
49860
+ 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.`);
49861
+ }
49862
+ }
49863
+ /**
49864
+ * A verbatim override allow naming a tool the canonical block denies is
49865
+ * withheld the way a shadowed `bash` allow is: Tabnine never loads an excluded
49866
+ * tool whatever `allowed` says, so the entry would only ever contradict the
49867
+ * deny it sits beside — and an override is not a way around a canonical rule.
49868
+ * Returns the entries that may be written.
49869
+ */
49870
+ function withholdDeniedOverrideAllowed({ overrideVerbatimAllowed, exclude, logger }) {
49871
+ const denied = overrideVerbatimAllowed.filter((entry) => {
49872
+ const toolName = parseTabnineEntry(entry)?.toolName;
49873
+ return toolName !== void 0 && exclude.includes(toolName);
49874
+ });
49875
+ 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.`);
49876
+ return overrideVerbatimAllowed.filter((entry) => !denied.includes(entry));
49877
+ }
49878
+ /**
49879
+ * The entries of a managed tool that the canonical block did not re-derive are
49880
+ * named rather than removed silently. Dropping an exclude loosens the policy,
49881
+ * so it is a warning; dropping an allow only brings back Tabnine's confirmation
49882
+ * prompt, so it is said at info level for the author who wonders where a
49883
+ * hand-written entry went.
49884
+ */
49885
+ function reportDroppedManagedEntries({ existingTools, allowedList, excludeList, logger }) {
49886
+ const dropped = (key, list) => (0, es_toolkit.uniq)(isStringArray$2(existingTools?.[key]) ? existingTools[key] : []).filter((entry) => {
49887
+ if (list.includes(entry)) return false;
49888
+ const toolName = parseTabnineEntry(entry)?.toolName;
49889
+ return toolName === void 0 || !list.includes(toolName);
49890
+ });
49891
+ const droppedExclude = dropped(EXCLUDE_KEY, excludeList);
49892
+ 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.`);
49893
+ const droppedAllowed = dropped(ALLOWED_KEY, allowedList);
49894
+ 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.`);
49895
+ }
49896
+ /**
48812
49897
  * Permissions generator for the Tabnine CLI.
48813
49898
  *
48814
49899
  * Tabnine CLI keeps its tool policy in `.tabnine/agent/settings.json` (project)
@@ -48874,7 +49959,10 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48874
49959
  const config = rulesyncPermissions.getJson();
48875
49960
  const override = isRecord$1(config.tabnine) ? config.tabnine : {};
48876
49961
  const { tools: overrideTools, general: overrideGeneral, refused: refusedPaths } = stripRefusedOverridePaths(override);
48877
- if (refusedPaths.length > 0) warnWithFallback(logger, `Tabnine CLI permissions: refused to write ${refusedPaths.join(", ")} from the tabnine override; Tabnine CLI runs that value as a command or sends its traffic to it, and a permissions file (one that came from 'rulesync fetch' included) must not be able to point it at an executable or a server of its choosing. Set it by hand in ${(0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)} if you need it.`);
49962
+ if (refusedPaths.length > 0) {
49963
+ const one = refusedPaths.length === 1;
49964
+ warnWithFallback(logger, `Tabnine CLI permissions: refused to write ${refusedPaths.join(", ")} from the tabnine override; Tabnine CLI runs ${one ? "that value" : "those values"} as a command or sends its traffic there, and a permissions file (one that came from 'rulesync fetch' included) must not be able to point it at an executable or a server of its choosing. Set ${one ? "it" : "them"} by hand in ${(0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)} if you need ${one ? "it" : "them"}.`);
49965
+ }
48878
49966
  const overrideList = (key) => {
48879
49967
  const value = overrideTools[key];
48880
49968
  if (value === void 0 || isStringArray$2(value)) return value ?? [];
@@ -48883,10 +49971,17 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48883
49971
  };
48884
49972
  const overrideShellAllowPatterns = [];
48885
49973
  const overrideVerbatimAllowed = [];
49974
+ const globSpelledOverrideEntries = [];
48886
49975
  for (const entry of overrideList(ALLOWED_KEY)) {
48887
49976
  const parsed = parseTabnineEntry(entry);
48888
- if (parsed?.toolName === SHELL_TOOL_NAME) overrideShellAllowPatterns.push(toShellPattern(parsed.prefix));
48889
- else overrideVerbatimAllowed.push(entry);
49977
+ if (parsed?.toolName === SHELL_TOOL_NAME) {
49978
+ const pattern = toShellPattern(parsed.prefix);
49979
+ if (isGlobSpelledPrefix(parsed.prefix)) globSpelledOverrideEntries.push({
49980
+ entry,
49981
+ pattern
49982
+ });
49983
+ overrideShellAllowPatterns.push(pattern);
49984
+ } else overrideVerbatimAllowed.push(entry);
48890
49985
  }
48891
49986
  const overrideExclude = overrideList(EXCLUDE_KEY);
48892
49987
  const { allowed, exclude } = buildToolLists({
@@ -48894,6 +49989,16 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48894
49989
  overrideShellAllowPatterns,
48895
49990
  logger
48896
49991
  });
49992
+ warnAboutGlobSpelledOverrideEntries({
49993
+ entries: globSpelledOverrideEntries,
49994
+ allowed,
49995
+ logger
49996
+ });
49997
+ const writableOverrideAllowed = withholdDeniedOverrideAllowed({
49998
+ overrideVerbatimAllowed,
49999
+ exclude,
50000
+ logger
50001
+ });
48897
50002
  const ignoredOverrideKeys = Object.keys(override).filter((key) => key !== TOOLS_KEY && key !== GENERAL_KEY);
48898
50003
  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.`);
48899
50004
  const existingTools = this.existingToolsGroup(existingContent);
@@ -48904,7 +50009,7 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48904
50009
  });
48905
50010
  const allowedList = (0, es_toolkit.uniq)([
48906
50011
  ...allowed,
48907
- ...overrideVerbatimAllowed,
50012
+ ...writableOverrideAllowed,
48908
50013
  ...preservedEntries(ALLOWED_KEY)
48909
50014
  ]);
48910
50015
  const excludeList = (0, es_toolkit.uniq)([
@@ -48912,12 +50017,12 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48912
50017
  ...overrideExclude,
48913
50018
  ...preservedEntries(EXCLUDE_KEY)
48914
50019
  ]);
48915
- const droppedExcludeEntries = (0, es_toolkit.uniq)(isStringArray$2(existingTools?.[EXCLUDE_KEY]) ? existingTools[EXCLUDE_KEY] : []).filter((entry) => {
48916
- if (excludeList.includes(entry)) return false;
48917
- const toolName = parseTabnineEntry(entry)?.toolName;
48918
- return toolName === void 0 || !excludeList.includes(toolName);
50020
+ reportDroppedManagedEntries({
50021
+ existingTools,
50022
+ allowedList,
50023
+ excludeList,
50024
+ logger
48919
50025
  });
48920
- 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.`);
48921
50026
  const tools = {
48922
50027
  ...overrideTools,
48923
50028
  [ALLOWED_KEY]: allowedList.length > 0 ? allowedList : void 0,
@@ -48931,7 +50036,7 @@ var TabninePermissions = class TabninePermissions extends ToolPermissions {
48931
50036
  entries: collectTrustAffectingOverrideEntries({
48932
50037
  tools: {
48933
50038
  ...overrideTools,
48934
- [ALLOWED_KEY]: overrideVerbatimAllowed
50039
+ [ALLOWED_KEY]: writableOverrideAllowed
48935
50040
  },
48936
50041
  general: overrideGeneral
48937
50042
  }),
@@ -52288,6 +53393,14 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
52288
53393
  supportsImport: true
52289
53394
  }
52290
53395
  }],
53396
+ ["crush", {
53397
+ class: CrushPermissions,
53398
+ meta: {
53399
+ supportsProject: true,
53400
+ supportsGlobal: true,
53401
+ supportsImport: true
53402
+ }
53403
+ }],
52291
53404
  ["cursor", {
52292
53405
  class: CursorPermissions,
52293
53406
  meta: {
@@ -58309,6 +59422,183 @@ var PiSkill = class PiSkill extends ToolSkill {
58309
59422
  }
58310
59423
  };
58311
59424
  //#endregion
59425
+ //#region src/constants/pool-paths.ts
59426
+ /**
59427
+ * Pool (Poolside's coding agent CLI) configuration-layout conventions.
59428
+ *
59429
+ * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
59430
+ * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
59431
+ * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
59432
+ * path), the project-root `AGENTS.md`, and nested per-directory
59433
+ * `AGENTS.md` files from the repository root down through the working
59434
+ * directory, deeper files taking precedence. It skips ignored directories
59435
+ * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
59436
+ *
59437
+ * Skills follow the Agent Skills format (`<name>/SKILL.md` bundles). Pool
59438
+ * scans `.poolside/skills/` (project) and `~/.config/poolside/skills/`
59439
+ * (global) plus the shared `.agents/skills/` / `~/.agents/skills/` roots and
59440
+ * the skill directories of other Agent Skills tools; rulesync writes only the
59441
+ * Pool-specific roots and leaves the shared ones to their own targets.
59442
+ *
59443
+ * @see https://docs.poolside.ai/agent-instructions
59444
+ * @see https://docs.poolside.ai/skills
59445
+ * @see https://github.com/poolsideai/pool
59446
+ */
59447
+ /** Project-scoped `.poolside/` directory at the project root. */
59448
+ const POOL_DIR = ".poolside";
59449
+ /** Global config directory for Pool, relative to the home directory. */
59450
+ const POOL_GLOBAL_DIR = (0, node_path.join)(".config", "poolside");
59451
+ /** Project skills root, relative to the project root. */
59452
+ const POOL_SKILLS_DIR_PATH = (0, node_path.join)(POOL_DIR, "skills");
59453
+ /** Global skills root, relative to the home directory. */
59454
+ const POOL_GLOBAL_SKILLS_DIR_PATH = (0, node_path.join)(POOL_GLOBAL_DIR, "skills");
59455
+ //#endregion
59456
+ //#region src/features/skills/pool-skill.ts
59457
+ const PoolSkillFrontmatterSchema = zod_mini.z.looseObject({
59458
+ name: zod_mini.z.string(),
59459
+ description: zod_mini.z.string()
59460
+ });
59461
+ /**
59462
+ * Represents a Pool (Poolside) skill directory.
59463
+ *
59464
+ * Pool discovers Agent Skills (`<name>/SKILL.md` bundles, optionally with
59465
+ * supporting files) from `.poolside/skills/` (project) and
59466
+ * `~/.config/poolside/skills/` (global). It also scans the shared
59467
+ * `.agents/skills/` / `~/.agents/skills/` roots and the skill directories of
59468
+ * other Agent Skills tools; those belong to their own targets, so this class
59469
+ * writes only the two Pool-specific roots and a skill is written exactly once.
59470
+ * Pool requires the directory name to equal the frontmatter `name`, otherwise
59471
+ * it skips the skill. Unlike adapters that reject such a skill outright, this
59472
+ * one warns and still writes it: the directory name is the canonical identity
59473
+ * shared with every other target, and Pool merely skips the skill rather than
59474
+ * failing, so a hard error here would block the other targets for a
59475
+ * Pool-only concern. Import is lenient for the same reason.
59476
+ * @see https://docs.poolside.ai/skills
59477
+ */
59478
+ var PoolSkill = class PoolSkill extends ToolSkill {
59479
+ constructor({ outputRoot = process.cwd(), relativeDirPath = POOL_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
59480
+ super({
59481
+ outputRoot,
59482
+ relativeDirPath,
59483
+ dirName,
59484
+ mainFile: {
59485
+ name: SKILL_FILE_NAME,
59486
+ body,
59487
+ frontmatter: { ...frontmatter }
59488
+ },
59489
+ otherFiles,
59490
+ global
59491
+ });
59492
+ if (validate) {
59493
+ const result = this.validate();
59494
+ if (!result.success) throw result.error;
59495
+ }
59496
+ }
59497
+ static getSettablePaths({ global = false } = {}) {
59498
+ return { relativeDirPath: global ? POOL_GLOBAL_SKILLS_DIR_PATH : POOL_SKILLS_DIR_PATH };
59499
+ }
59500
+ getFrontmatter() {
59501
+ return PoolSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
59502
+ }
59503
+ getBody() {
59504
+ return this.mainFile?.body ?? "";
59505
+ }
59506
+ validate() {
59507
+ if (!this.mainFile) return {
59508
+ success: false,
59509
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`)
59510
+ };
59511
+ const result = PoolSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter);
59512
+ if (!result.success) return {
59513
+ success: false,
59514
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`)
59515
+ };
59516
+ return {
59517
+ success: true,
59518
+ error: null
59519
+ };
59520
+ }
59521
+ toRulesyncSkill() {
59522
+ const frontmatter = this.getFrontmatter();
59523
+ const rulesyncFrontmatter = {
59524
+ name: frontmatter.name,
59525
+ description: frontmatter.description,
59526
+ targets: ["*"]
59527
+ };
59528
+ return new RulesyncSkill({
59529
+ outputRoot: this.outputRoot,
59530
+ relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
59531
+ dirName: this.getDirName(),
59532
+ frontmatter: rulesyncFrontmatter,
59533
+ body: this.getBody(),
59534
+ otherFiles: this.getOtherFiles(),
59535
+ validate: true,
59536
+ global: this.global
59537
+ });
59538
+ }
59539
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false, logger }) {
59540
+ const settablePaths = PoolSkill.getSettablePaths({ global });
59541
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
59542
+ const dirName = rulesyncSkill.getDirName();
59543
+ const poolFrontmatter = {
59544
+ name: rulesyncFrontmatter.name,
59545
+ description: rulesyncFrontmatter.description
59546
+ };
59547
+ if (poolFrontmatter.name !== dirName) warnWithFallback(logger, `${stripControlCharacters(toPosixPath((0, node_path.join)(outputRoot, settablePaths.relativeDirPath, dirName, SKILL_FILE_NAME)))}: \`name\` ${quoteForLog(poolFrontmatter.name)} does not match its directory name ${quoteForLog(dirName)}; Pool only loads a skill whose directory name equals its \`name\``);
59548
+ return new PoolSkill({
59549
+ outputRoot,
59550
+ relativeDirPath: settablePaths.relativeDirPath,
59551
+ dirName,
59552
+ frontmatter: poolFrontmatter,
59553
+ body: rulesyncSkill.getBody(),
59554
+ otherFiles: rulesyncSkill.getOtherFiles(),
59555
+ validate,
59556
+ global
59557
+ });
59558
+ }
59559
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
59560
+ const targets = rulesyncSkill.getFrontmatter().targets;
59561
+ return targets.includes("*") || targets.includes("pool");
59562
+ }
59563
+ static async fromDir(params) {
59564
+ const loaded = await this.loadSkillDirContent({
59565
+ ...params,
59566
+ getSettablePaths: PoolSkill.getSettablePaths
59567
+ });
59568
+ const result = PoolSkillFrontmatterSchema.safeParse(loaded.frontmatter);
59569
+ if (!result.success) {
59570
+ const skillDirPath = (0, node_path.join)(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName);
59571
+ throw new Error(`Invalid frontmatter in ${(0, node_path.join)(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`);
59572
+ }
59573
+ return new PoolSkill({
59574
+ outputRoot: loaded.outputRoot,
59575
+ relativeDirPath: loaded.relativeDirPath,
59576
+ dirName: loaded.dirName,
59577
+ frontmatter: result.data,
59578
+ body: loaded.body,
59579
+ otherFiles: loaded.otherFiles,
59580
+ validate: true,
59581
+ global: loaded.global
59582
+ });
59583
+ }
59584
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, dirName, global = false }) {
59585
+ const settablePaths = PoolSkill.getSettablePaths({ global });
59586
+ return new PoolSkill({
59587
+ outputRoot,
59588
+ relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath,
59589
+ dirName,
59590
+ frontmatter: {
59591
+ name: "",
59592
+ description: ""
59593
+ },
59594
+ body: "",
59595
+ otherFiles: [],
59596
+ validate: false,
59597
+ global
59598
+ });
59599
+ }
59600
+ };
59601
+ //#endregion
58312
59602
  //#region src/features/skills/qwencode-skill.ts
58313
59603
  const QwencodeSkillFrontmatterSchema = zod_mini.z.looseObject({
58314
59604
  name: zod_mini.z.string(),
@@ -60195,6 +61485,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
60195
61485
  supportsGlobal: true
60196
61486
  }
60197
61487
  }],
61488
+ ["pool", {
61489
+ class: PoolSkill,
61490
+ meta: {
61491
+ supportsProject: true,
61492
+ supportsSimulated: false,
61493
+ supportsGlobal: true
61494
+ }
61495
+ }],
60198
61496
  ["qwencode", {
60199
61497
  class: QwencodeSkill,
60200
61498
  meta: {
@@ -65029,7 +66327,7 @@ var RooSubagent = class extends ToolSubagent {
65029
66327
  //#region src/features/subagents/tabnine-subagent.ts
65030
66328
  const TabnineSubagentFrontmatterSchema = zod_mini.z.looseObject({
65031
66329
  name: zod_mini.z.string(),
65032
- description: zod_mini.z.optional(zod_mini.z.string()),
66330
+ description: zod_mini.z.string(),
65033
66331
  /** "local" (default) runs inside the CLI; "remote" delegates to a Tabnine cloud agent. */
65034
66332
  kind: zod_mini.z.optional(zod_mini.z.string()),
65035
66333
  /** Built-in tool names the subagent may use; omitted = every tool. */
@@ -65088,14 +66386,23 @@ var TabnineSubagent = class TabnineSubagent extends ToolSubagent {
65088
66386
  validate: true
65089
66387
  });
65090
66388
  }
65091
- static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
66389
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false, logger }) {
65092
66390
  const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
65093
66391
  const tabnineSection = rulesyncFrontmatter.tabnine ?? {};
65094
- const tabnineSubagentFrontmatter = {
66392
+ const merged = {
65095
66393
  name: rulesyncFrontmatter.name,
65096
66394
  description: rulesyncFrontmatter.description,
65097
66395
  ...tabnineSection
65098
66396
  };
66397
+ let description = merged.description;
66398
+ if (description === void 0 || description === "") {
66399
+ description = rulesyncFrontmatter.name ? `${rulesyncFrontmatter.name} subagent` : "subagent";
66400
+ logger?.warn(`Tabnine CLI subagent ${rulesyncSubagent.getRelativeFilePath()} has no description, which Tabnine requires; wrote ${JSON.stringify(description)} as a placeholder.`);
66401
+ }
66402
+ const tabnineSubagentFrontmatter = {
66403
+ ...merged,
66404
+ description
66405
+ };
65099
66406
  const body = rulesyncSubagent.getBody();
65100
66407
  const fileContent = stringifyFrontmatter(body, tabnineSubagentFrontmatter, { avoidBlockScalars: true });
65101
66408
  const paths = this.getSettablePaths({ global });
@@ -71358,24 +72665,6 @@ var PiRule = class PiRule extends ToolRule {
71358
72665
  }
71359
72666
  };
71360
72667
  //#endregion
71361
- //#region src/constants/pool-paths.ts
71362
- /**
71363
- * Pool (Poolside's coding agent CLI) configuration-layout conventions.
71364
- *
71365
- * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
71366
- * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
71367
- * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
71368
- * path), the project-root `AGENTS.md`, and nested per-directory
71369
- * `AGENTS.md` files from the repository root down through the working
71370
- * directory, deeper files taking precedence. It skips ignored directories
71371
- * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
71372
- *
71373
- * @see https://docs.poolside.ai/agent-instructions
71374
- * @see https://github.com/poolsideai/pool
71375
- */
71376
- /** Global config directory for Pool, relative to the home directory. */
71377
- const POOL_GLOBAL_DIR = (0, node_path.join)(".config", "poolside");
71378
- //#endregion
71379
72668
  //#region src/features/rules/pool-rule.ts
71380
72669
  var PoolRule = class PoolRule extends ToolRule {
71381
72670
  static getSettablePaths({ global = false } = {}) {