rulesync 16.6.0 → 16.7.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.
@@ -639,6 +639,13 @@ async function readFileBuffer(filepath) {
639
639
  return (0, node_fs_promises.readFile)(filepath);
640
640
  }
641
641
  /**
642
+ * Read file as a buffer if it exists, otherwise return null.
643
+ */
644
+ async function readFileBufferOrNull(filepath) {
645
+ if (await fileExists(filepath)) return readFileBuffer(filepath);
646
+ return null;
647
+ }
648
+ /**
642
649
  * Normalizes text to LF line endings and adds exactly one trailing newline.
643
650
  * Removes any existing trailing whitespace and appends a single newline.
644
651
  */
@@ -650,6 +657,10 @@ async function writeFileContent(filepath, content) {
650
657
  await ensureDir((0, node_path.dirname)(filepath));
651
658
  await (0, node_fs_promises.writeFile)(filepath, content, "utf-8");
652
659
  }
660
+ async function writeFileBuffer(filepath, buffer) {
661
+ await ensureDir((0, node_path.dirname)(filepath));
662
+ await (0, node_fs_promises.writeFile)(filepath, buffer);
663
+ }
653
664
  async function fileExists(filepath) {
654
665
  try {
655
666
  await (0, node_fs_promises.stat)(filepath);
@@ -2182,6 +2193,7 @@ const HookDefinitionSchema = zod_mini.z.looseObject({
2182
2193
  name: zod_mini.z.optional(safeString),
2183
2194
  description: zod_mini.z.optional(safeString),
2184
2195
  failClosed: zod_mini.z.optional(zod_mini.z.boolean()),
2196
+ commandRegex: zod_mini.z.optional(safeString),
2185
2197
  sequential: zod_mini.z.optional(zod_mini.z.boolean()),
2186
2198
  async: zod_mini.z.optional(zod_mini.z.boolean()),
2187
2199
  env: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), safeString)),
@@ -2198,6 +2210,7 @@ const HookDefinitionSchema = zod_mini.z.looseObject({
2198
2210
  metadata: zod_mini.z.optional(zod_mini.z.looseObject({})),
2199
2211
  if: zod_mini.z.optional(safeString),
2200
2212
  commandWindows: zod_mini.z.optional(safeString),
2213
+ additionalContextLimit: zod_mini.z.optional(zod_mini.z.int().check((0, zod_mini.nonnegative)())),
2201
2214
  asyncRewake: zod_mini.z.optional(zod_mini.z.boolean()),
2202
2215
  continueOnBlock: zod_mini.z.optional(zod_mini.z.boolean())
2203
2216
  });
@@ -4245,7 +4258,8 @@ const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
4245
4258
  */
4246
4259
  const FactorydroidPermissionsOverrideSchema = zod_mini.z.looseObject({
4247
4260
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
4248
- commandBlocklist: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
4261
+ commandBlocklist: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
4262
+ disabledSkills: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
4249
4263
  });
4250
4264
  /**
4251
4265
  * Tool-scoped override block for Warp. Warp's `[agents.profiles]` table exposes
@@ -4691,13 +4705,22 @@ const CodexBasePermissionProfileSchema = zod_mini.z.enum(CODEX_BASE_PERMISSION_P
4691
4705
  * `base_permission_profile` it is consumed by the profile builder, not
4692
4706
  * written as a top-level config key.
4693
4707
  *
4694
- * Two surfaces are deliberately NOT authorable here so the override can never
4695
- * clobber a feature-owned key: `mcp_servers.*` per-MCP gating is owned by the
4696
- * MCP feature (`codexcli-mcp.ts` already writes the `mcp_servers` tables in the
4697
- * same `config.toml`), and `permissions` / `default_permissions` are owned by
4698
- * the canonical model. Any such key placed in the override is skipped with a
4699
- * warning. Kept `looseObject` (verbatim passthrough) so future top-level Codex
4700
- * config keys can be authored without Rulesync modeling each one.
4708
+ * The keys written to `config.toml` are an **allowlist**, not verbatim
4709
+ * passthrough: only `CODEXCLI_OVERRIDE_KEYS`
4710
+ * (`src/constants/codexcli-paths.ts` `approval_policy`, `sandbox_mode`,
4711
+ * `sandbox_workspace_write`, `apps`, `approvals_reviewer`) are emitted, and
4712
+ * `computeCodexcliOverridePatch` skips anything else with a warning.
4713
+ * `base_permission_profile` and `git_write_rules` are consumed by the profile
4714
+ * builder rather than written, as described above, and `permission` is the
4715
+ * tool-scoped canonical block, which `RulesyncPermissions.forTarget` strips out
4716
+ * of the override before it ever reaches the patch. The allowlist is what keeps
4717
+ * the override from clobbering a feature-owned key: `mcp_servers.*` per-MCP
4718
+ * gating is owned by the MCP feature (`codexcli-mcp.ts` already writes the
4719
+ * `mcp_servers` tables in the same `config.toml`), and `permissions` /
4720
+ * `default_permissions` are owned by the canonical model. The schema itself is
4721
+ * `looseObject` so an unmodeled key parses (and is then reported rather than
4722
+ * rejected outright); supporting a new top-level Codex config key means adding
4723
+ * it to `CODEXCLI_OVERRIDE_KEYS`.
4701
4724
  *
4702
4725
  * @see https://developers.openai.com/codex/config-reference
4703
4726
  * @see https://developers.openai.com/codex/permissions
@@ -5172,7 +5195,11 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
5172
5195
  })),
5173
5196
  copilot: zod_mini.z.optional(zod_mini.z.looseObject({
5174
5197
  license: zod_mini.z.optional(zod_mini.z.string()),
5175
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
5198
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
5199
+ "argument-hint": zod_mini.z.optional(zod_mini.z.string()),
5200
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
5201
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
5202
+ context: zod_mini.z.optional(zod_mini.z.string())
5176
5203
  })),
5177
5204
  copilotcli: zod_mini.z.optional(zod_mini.z.looseObject({
5178
5205
  license: zod_mini.z.optional(zod_mini.z.string()),
@@ -5230,7 +5257,9 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
5230
5257
  })),
5231
5258
  factorydroid: zod_mini.z.optional(zod_mini.z.looseObject({
5232
5259
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
5233
- "user-invocable": zod_mini.z.optional(zod_mini.z.boolean())
5260
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
5261
+ enabled: zod_mini.z.optional(zod_mini.z.boolean()),
5262
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
5234
5263
  })),
5235
5264
  grokcli: zod_mini.z.optional(zod_mini.z.looseObject({
5236
5265
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
@@ -5443,8 +5472,8 @@ async function getLocalSkillDirNames(outputRoot) {
5443
5472
  * Resolve the effective `disable-model-invocation` value for a tool skill.
5444
5473
  *
5445
5474
  * The rulesync skill frontmatter exposes a root-level `disable-model-invocation`
5446
- * default that applies to every tool supporting the flag (claudecode, cursor,
5447
- * zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
5475
+ * default that applies to every tool supporting the flag (claudecode, copilot,
5476
+ * copilotcli, cursor, zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
5448
5477
  * default with a per-target value. A defined section value (including `false`)
5449
5478
  * always wins over the root default.
5450
5479
  *
@@ -5457,8 +5486,8 @@ function resolveDisableModelInvocation({ rootFrontmatter, section }) {
5457
5486
  * Resolve the effective `user-invocable` value for a tool skill.
5458
5487
  *
5459
5488
  * The rulesync skill frontmatter exposes a root-level `user-invocable` default
5460
- * that applies to every tool supporting the flag (claudecode, qwencode, vibe,
5461
- * grokcli, factorydroid). Each tool's own section may override that default with a
5489
+ * that applies to every tool supporting the flag (claudecode, copilot,
5490
+ * copilotcli, qwencode, vibe, grokcli, factorydroid). Each tool's own section may override that default with a
5462
5491
  * per-target value. A defined section value (including `false`) always wins
5463
5492
  * over the root default.
5464
5493
  *
@@ -5671,6 +5700,31 @@ function fileContentsEquivalent({ filePath, expected, existing }) {
5671
5700
  if (structured !== void 0) return structured;
5672
5701
  return addTrailingNewline(expected) === addTrailingNewline(existing);
5673
5702
  }
5703
+ /**
5704
+ * Whether an on-disk companion file is equivalent to the generated one.
5705
+ *
5706
+ * Companion files (everything beside a skill's `SKILL.md`) are written byte for
5707
+ * byte, so byte equality is the whole test for a user asset carried through
5708
+ * from the source directory: a CRLF fixture or a deliberately newline-less file
5709
+ * must compare equal to itself and unequal to a normalized copy, and a copy
5710
+ * that has drifted must be repaired rather than tolerated.
5711
+ *
5712
+ * A `composed` file is different — Rulesync builds it from frontmatter (Codex
5713
+ * CLI's `agents/openai.yaml`), so differing bytes fall back to the structured
5714
+ * comparison and a formatter re-indenting it is not reported as a change on
5715
+ * every generate. Only the structured verdict counts: there is deliberately no
5716
+ * text fallback, since trailing-whitespace-insensitive text equality is exactly
5717
+ * the normalization companion files no longer get.
5718
+ */
5719
+ function companionFileContentsEquivalent({ filePath, expected, existing, composed = false }) {
5720
+ if (existing === null) return false;
5721
+ if (existing.equals(expected)) return true;
5722
+ if (!composed) return false;
5723
+ const expectedText = expected.toString("utf-8");
5724
+ const existingText = existing.toString("utf-8");
5725
+ if (!Buffer.from(expectedText, "utf-8").equals(expected) || !Buffer.from(existingText, "utf-8").equals(existing)) return false;
5726
+ return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
5727
+ }
5674
5728
  //#endregion
5675
5729
  //#region src/types/feature-processor.ts
5676
5730
  var FeatureProcessor = class {
@@ -7071,6 +7125,14 @@ const SHARED_CONFIG_OWNERSHIP = {
7071
7125
  ownedKeys: ["mcp", "tools"]
7072
7126
  } }
7073
7127
  },
7128
+ ".config/goose/config.yaml": {
7129
+ format: "yaml",
7130
+ invalidRootPolicy: "error",
7131
+ features: { mcp: {
7132
+ kind: "replace-owned-keys",
7133
+ ownedKeys: ["extensions"]
7134
+ } }
7135
+ },
7074
7136
  [CODEXCLI_CONFIG_SHARED_FILE_KEY]: {
7075
7137
  format: "toml",
7076
7138
  features: {
@@ -9309,9 +9371,10 @@ const FACTORYDROID_HOOKS_FILE_NAME = "hooks.json";
9309
9371
  //#region src/features/commands/factorydroid-command.ts
9310
9372
  const FactorydroidCommandFrontmatterSchema = zod_mini.z.looseObject({
9311
9373
  description: zod_mini.z.optional(zod_mini.z.string()),
9312
- "argument-hint": zod_mini.z.optional(zod_mini.z.string()),
9313
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
9374
+ "argument-hint": zod_mini.z.optional(zod_mini.z.string())
9314
9375
  });
9376
+ /** Not a Droid command surface; see the schema comment above. */
9377
+ const FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS = ["allowed-tools"];
9315
9378
  var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9316
9379
  frontmatter;
9317
9380
  body;
@@ -9338,6 +9401,7 @@ var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9338
9401
  }
9339
9402
  toRulesyncCommand() {
9340
9403
  const { description, ...restFields } = this.frontmatter;
9404
+ for (const field of FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS) delete restFields[field];
9341
9405
  const rulesyncFrontmatter = {
9342
9406
  targets: ["*"],
9343
9407
  description,
@@ -9361,6 +9425,7 @@ var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9361
9425
  description: rulesyncFrontmatter.description,
9362
9426
  ...factorydroidFields
9363
9427
  };
9428
+ for (const field of FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS) delete factorydroidFrontmatter[field];
9364
9429
  const body = rulesyncCommand.getBody();
9365
9430
  const paths = this.getSettablePaths({ global });
9366
9431
  return new FactorydroidCommand({
@@ -13072,16 +13137,28 @@ function buildEffectiveHooks$1({ config, toolOverrideHooks, supportedEvents }) {
13072
13137
  };
13073
13138
  }
13074
13139
  /**
13075
- * Group a list of hook definitions by their `matcher` (empty string when absent),
13076
- * preserving insertion order of both keys and grouped definitions.
13140
+ * Group a list of hook definitions by their `matcher` (empty string when
13141
+ * absent), preserving insertion order of both keys and grouped definitions.
13142
+ * Definitions that disagree on a `subdividesGroup` passthrough field are split
13143
+ * into separate groups, so a restricting field is never inherited by a hook
13144
+ * that did not ask for it.
13077
13145
  */
13078
- function groupDefinitionsByMatcher(definitions) {
13146
+ function groupDefinitionsByMatcher({ definitions, converterConfig }) {
13147
+ const subdividingFields = (converterConfig.groupPassthroughFields ?? []).filter(({ subdividesGroup }) => subdividesGroup);
13079
13148
  const byMatcher = /* @__PURE__ */ new Map();
13080
13149
  for (const def of definitions) {
13081
- const key = def.matcher ?? "";
13082
- const list = byMatcher.get(key);
13083
- if (list) list.push(def);
13084
- else byMatcher.set(key, [def]);
13150
+ const rawMatcher = def.matcher ?? "";
13151
+ const matcher = converterConfig.wildcardMatcherMeansAll && rawMatcher === "*" ? "" : rawMatcher;
13152
+ const key = [matcher, ...subdividingFields.map(({ canonical, valueType }) => {
13153
+ const value = def[canonical];
13154
+ return isGroupPassthroughValue(value, valueType) ? stableJson(value) : "";
13155
+ })].join("\0");
13156
+ const group = byMatcher.get(key);
13157
+ if (group) group.defs.push(def);
13158
+ else byMatcher.set(key, {
13159
+ matcher,
13160
+ defs: [def]
13161
+ });
13085
13162
  }
13086
13163
  return byMatcher;
13087
13164
  }
@@ -13126,6 +13203,24 @@ function importBooleanPassthroughFields({ h, converterConfig }) {
13126
13203
  return Object.fromEntries((converterConfig.booleanPassthroughFields ?? []).filter(({ tool }) => typeof h[tool] === "boolean").map(({ canonical, tool }) => [canonical, h[tool]]));
13127
13204
  }
13128
13205
  /**
13206
+ * Emit the configured number passthrough fields on the tool side, mapping each
13207
+ * canonical field name to its (possibly renamed) tool field name. Only finite
13208
+ * numbers are carried through.
13209
+ */
13210
+ function emitNumberPassthroughFields({ def, hookType, converterConfig }) {
13211
+ return Object.fromEntries((converterConfig.numberPassthroughFields ?? []).filter(({ canonical, commandOnly }) => {
13212
+ if (commandOnly === true && hookType !== "command") return false;
13213
+ return Number.isFinite(def[canonical]);
13214
+ }).map(({ canonical, tool }) => [tool, def[canonical]]));
13215
+ }
13216
+ /**
13217
+ * Import the configured number passthrough fields back into canonical fields,
13218
+ * reversing {@link emitNumberPassthroughFields}. Only finite numbers are read.
13219
+ */
13220
+ function importNumberPassthroughFields({ h, converterConfig }) {
13221
+ return Object.fromEntries((converterConfig.numberPassthroughFields ?? []).filter(({ tool }) => Number.isFinite(h[tool])).map(({ canonical, tool }) => [canonical, h[tool]]));
13222
+ }
13223
+ /**
13129
13224
  * Emit the configured string passthrough fields on the tool side, mapping each
13130
13225
  * canonical field name to its (possibly renamed) tool field name. Only non-empty
13131
13226
  * string values are carried through.
@@ -13163,17 +13258,45 @@ function importArrayPassthroughFields({ h, converterConfig, logger }) {
13163
13258
  return Object.fromEntries(fields.filter(({ tool }) => isSafeStringArray(h[tool])).map(({ canonical, tool }) => [canonical, h[tool]]));
13164
13259
  }
13165
13260
  /**
13261
+ * Emit the configured string-map passthrough fields on the tool side. Only maps
13262
+ * whose values are all control-character-free strings are carried through.
13263
+ */
13264
+ function emitRecordPassthroughFields({ def, hookType, converterConfig }) {
13265
+ return Object.fromEntries((converterConfig.recordPassthroughFields ?? []).filter(({ canonical, commandOnly }) => {
13266
+ if (commandOnly === true && hookType !== "command") return false;
13267
+ return isSafeStringRecord(def[canonical]);
13268
+ }).map(({ canonical, tool }) => [tool, def[canonical]]));
13269
+ }
13270
+ /**
13271
+ * Import the configured string-map passthrough fields, reversing
13272
+ * {@link emitRecordPassthroughFields}.
13273
+ */
13274
+ function importRecordPassthroughFields({ h, hookType, converterConfig, logger }) {
13275
+ const fields = (converterConfig.recordPassthroughFields ?? []).filter(({ commandOnly }) => commandOnly !== true || hookType === "command");
13276
+ for (const { tool } of fields) if (h[tool] !== void 0 && !isSafeStringRecord(h[tool])) logger?.warn(`Dropping "${tool}" while importing a hook: it must be a map of strings whose keys are non-empty and free of "=", and whose keys and values carry no newline, carriage return or NUL characters.`);
13277
+ return Object.fromEntries(fields.filter(({ tool }) => isSafeStringRecord(h[tool])).map(({ canonical, tool }) => [canonical, h[tool]]));
13278
+ }
13279
+ /**
13280
+ * Check a value against the shape its field documents. A string field also
13281
+ * rejects control characters, matching the canonical `safeString` so an
13282
+ * imported value cannot fail validation on the next generate.
13283
+ */
13284
+ function isGroupPassthroughValue(value, valueType = "object") {
13285
+ if (valueType === "string") return typeof value === "string" && !CONTROL_CHARS.some((char) => value.includes(char));
13286
+ return isPlainObject$1(value);
13287
+ }
13288
+ /**
13166
13289
  * Emit the configured group-level passthrough fields, taken from the first
13167
13290
  * definition of the group that carries one.
13168
13291
  */
13169
13292
  function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }) {
13170
13293
  const emitted = {};
13171
- for (const { canonical, tool } of converterConfig.groupPassthroughFields ?? []) {
13294
+ for (const { canonical, tool, valueType } of converterConfig.groupPassthroughFields ?? []) {
13172
13295
  const carried = defs.map((def) => def[canonical]);
13173
- const first = carried.find((value) => isPlainObject$1(value));
13296
+ const first = carried.find((value) => isGroupPassthroughValue(value, valueType));
13174
13297
  if (first === void 0) continue;
13175
13298
  const firstStable = stableJson(first);
13176
- const agrees = (value) => isPlainObject$1(value) && stableJson(value) === firstStable;
13299
+ const agrees = (value) => isGroupPassthroughValue(value, valueType) && stableJson(value) === firstStable;
13177
13300
  if (!carried.every(agrees)) logger?.warn(`"${tool}" belongs to the whole matcher group on "${eventName}" hooks, so every hook in this group gets ${JSON.stringify(first)} — including any that asked for something else, or for nothing.`);
13178
13301
  emitted[tool] = first;
13179
13302
  }
@@ -13185,7 +13308,7 @@ function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }
13185
13308
  */
13186
13309
  function importGroupPassthroughFields({ rawEntry, converterConfig }) {
13187
13310
  const entry = rawEntry;
13188
- return Object.fromEntries((converterConfig.groupPassthroughFields ?? []).filter(({ tool }) => isPlainObject$1(entry[tool])).map(({ canonical, tool }) => [canonical, entry[tool]]));
13311
+ return Object.fromEntries((converterConfig.groupPassthroughFields ?? []).filter(({ tool, valueType }) => isGroupPassthroughValue(entry[tool], valueType)).map(({ canonical, tool }) => [canonical, entry[tool]]));
13189
13312
  }
13190
13313
  /**
13191
13314
  * Emit the payload fields specific to a hook type — `url`/`headers`/
@@ -13231,6 +13354,11 @@ function buildToolHooks({ defs, converterConfig }) {
13231
13354
  hookType,
13232
13355
  converterConfig
13233
13356
  }),
13357
+ ...emitNumberPassthroughFields({
13358
+ def,
13359
+ hookType,
13360
+ converterConfig
13361
+ }),
13234
13362
  ...emitStringPassthroughFields({
13235
13363
  def,
13236
13364
  hookType,
@@ -13241,6 +13369,11 @@ function buildToolHooks({ defs, converterConfig }) {
13241
13369
  hookType,
13242
13370
  converterConfig
13243
13371
  }),
13372
+ ...emitRecordPassthroughFields({
13373
+ def,
13374
+ hookType,
13375
+ converterConfig
13376
+ }),
13244
13377
  type: hookType,
13245
13378
  ...command !== void 0 && command !== null && { command },
13246
13379
  ...def.timeout !== void 0 && def.timeout !== null && { timeout: def.timeout },
@@ -13271,10 +13404,13 @@ function canonicalToToolHooks({ config, toolOverrideHooks, converterConfig, logg
13271
13404
  const result = {};
13272
13405
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
13273
13406
  const toolEventName = converterConfig.canonicalToToolEventNames[eventName] ?? eventName;
13274
- const byMatcher = groupDefinitionsByMatcher(definitions);
13407
+ const byMatcher = groupDefinitionsByMatcher({
13408
+ definitions,
13409
+ converterConfig
13410
+ });
13275
13411
  const entries = [];
13276
13412
  const isNoMatcherEvent = converterConfig.noMatcherEvents?.has(eventName) ?? false;
13277
- for (const [matcherKey, defs] of byMatcher) {
13413
+ for (const { matcher: matcherKey, defs } of byMatcher.values()) {
13278
13414
  if (isNoMatcherEvent && matcherKey) logger?.warn(`matcher "${matcherKey}" on "${eventName}" hook will be ignored — this event does not support matchers`);
13279
13415
  const hooks = buildToolHooks({
13280
13416
  defs,
@@ -13354,9 +13490,27 @@ function isStringArray(value) {
13354
13490
  }
13355
13491
  /** Compare object values without letting key order decide the answer. */
13356
13492
  function stableJson(value) {
13493
+ if (typeof value === "string") return JSON.stringify(value);
13357
13494
  return JSON.stringify(Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b))));
13358
13495
  }
13359
13496
  /**
13497
+ * A string map safe to hand a tool as a hook's environment block. On top of
13498
+ * {@link isStringRecord} it rejects a non-plain object (a class instance is not
13499
+ * data) and applies the control-character rule to the values, as
13500
+ * {@link isSafeStringArray} does for `args`.
13501
+ *
13502
+ * The keys are checked more strictly than the values. A tool builds each entry
13503
+ * back into a `KEY=VALUE` string for the spawned process, so a key holding `=`
13504
+ * (or a control character, or nothing at all) names a different variable than
13505
+ * it appears to — `PATH=/tmp/evil` written as a key would set `PATH`. An
13506
+ * authored `.rulesync/hooks.*` can arrive via `rulesync fetch`, so that is not
13507
+ * a shape to pass along.
13508
+ */
13509
+ function isSafeStringRecord(value) {
13510
+ if (!isPlainObject$1(value) || !isStringRecord(value)) return false;
13511
+ return Object.entries(value).every(([key, entry]) => key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || entry.includes(char)));
13512
+ }
13513
+ /**
13360
13514
  * Control characters cannot ride from an existing tool config into a canonical
13361
13515
  * field the schema guards with `safeString`, or the next generate fails
13362
13516
  * validation on a file this import itself wrote — and the hooks feature is
@@ -13409,6 +13563,10 @@ function toolHookToCanonical({ h, rawEntry, converterConfig, logger }) {
13409
13563
  h,
13410
13564
  converterConfig
13411
13565
  }),
13566
+ ...importNumberPassthroughFields({
13567
+ h,
13568
+ converterConfig
13569
+ }),
13412
13570
  ...importStringPassthroughFields({
13413
13571
  h,
13414
13572
  converterConfig
@@ -13418,6 +13576,12 @@ function toolHookToCanonical({ h, rawEntry, converterConfig, logger }) {
13418
13576
  converterConfig,
13419
13577
  logger
13420
13578
  }),
13579
+ ...importRecordPassthroughFields({
13580
+ h,
13581
+ hookType,
13582
+ converterConfig,
13583
+ logger
13584
+ }),
13421
13585
  ...importGroupPassthroughFields({
13422
13586
  rawEntry,
13423
13587
  converterConfig
@@ -13935,6 +14099,14 @@ var ClaudecodeHooks = class extends ToolHooks {
13935
14099
  isDeletable() {
13936
14100
  return false;
13937
14101
  }
14102
+ /**
14103
+ * The converter config used for both directions. Exposed as a static hook so
14104
+ * plugin-scoped subclasses can swap tool-specific details (e.g. the project
14105
+ * directory variable) without duplicating the rest of the config.
14106
+ */
14107
+ static getConverterConfig() {
14108
+ return CLAUDE_CONVERTER_CONFIG;
14109
+ }
13938
14110
  static getSettablePaths(_options = {}) {
13939
14111
  return {
13940
14112
  relativeDirPath: CLAUDECODE_DIR,
@@ -13960,7 +14132,7 @@ var ClaudecodeHooks = class extends ToolHooks {
13960
14132
  const claudeHooks = canonicalToToolHooks({
13961
14133
  config,
13962
14134
  toolOverrideHooks: config.claudecode?.hooks,
13963
- converterConfig: CLAUDE_CONVERTER_CONFIG,
14135
+ converterConfig: this.getConverterConfig(),
13964
14136
  logger
13965
14137
  });
13966
14138
  const fileContent = applySharedConfigPatch({
@@ -13987,7 +14159,7 @@ var ClaudecodeHooks = class extends ToolHooks {
13987
14159
  }
13988
14160
  const hooks = toolHooksToCanonical({
13989
14161
  hooks: settings.hooks,
13990
- converterConfig: CLAUDE_CONVERTER_CONFIG
14162
+ converterConfig: this.constructor.getConverterConfig()
13991
14163
  });
13992
14164
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
13993
14165
  hooks,
@@ -14016,6 +14188,21 @@ var ClaudecodePluginHooks = class extends ClaudecodeHooks {
14016
14188
  isDeletable() {
14017
14189
  return true;
14018
14190
  }
14191
+ /**
14192
+ * Plugin hook scripts ship inside the plugin, so their commands must resolve
14193
+ * against the plugin install directory rather than the consumer's project
14194
+ * root. Upstream documents `"${CLAUDE_PLUGIN_ROOT}"/scripts/format-code.sh`;
14195
+ * `$CLAUDE_PROJECT_DIR` would expand to a path in the consumer's own repo,
14196
+ * where the bundled script does not exist.
14197
+ *
14198
+ * @see https://code.claude.com/docs/en/plugins-reference
14199
+ */
14200
+ static getConverterConfig() {
14201
+ return {
14202
+ ...super.getConverterConfig(),
14203
+ projectDirVar: "$CLAUDE_PLUGIN_ROOT"
14204
+ };
14205
+ }
14019
14206
  static getSettablePaths() {
14020
14207
  return {
14021
14208
  relativeDirPath: CLAUDECODE_PLUGIN_HOOKS_DIR,
@@ -14038,6 +14225,10 @@ const CODEXCLI_CONVERTER_CONFIG = {
14038
14225
  }, {
14039
14226
  canonical: "statusMessage",
14040
14227
  tool: "statusMessage"
14228
+ }],
14229
+ numberPassthroughFields: [{
14230
+ canonical: "additionalContextLimit",
14231
+ tool: "additionalContextLimit"
14041
14232
  }]
14042
14233
  };
14043
14234
  /**
@@ -15081,7 +15272,13 @@ const FACTORYDROID_CONVERTER_CONFIG = {
15081
15272
  toolToCanonicalEventNames: FACTORYDROID_TO_CANONICAL_EVENT_NAMES,
15082
15273
  projectDirVar: "$FACTORY_PROJECT_DIR",
15083
15274
  prefixDotRelativeCommandsOnly: true,
15084
- supportedHookTypes: /* @__PURE__ */ new Set(["command"])
15275
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
15276
+ groupPassthroughFields: [{
15277
+ canonical: "commandRegex",
15278
+ tool: "commandRegex",
15279
+ valueType: "string",
15280
+ subdividesGroup: true
15281
+ }]
15085
15282
  };
15086
15283
  var FactorydroidHooks = class FactorydroidHooks extends ToolHooks {
15087
15284
  constructor(params) {
@@ -15177,7 +15374,8 @@ const GOOSE_CONVERTER_CONFIG = {
15177
15374
  canonicalToToolEventNames: CANONICAL_TO_GOOSE_EVENT_NAMES,
15178
15375
  toolToCanonicalEventNames: GOOSE_TO_CANONICAL_EVENT_NAMES,
15179
15376
  projectDirVar: "",
15180
- supportedHookTypes: /* @__PURE__ */ new Set(["command"])
15377
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
15378
+ wildcardMatcherMeansAll: true
15181
15379
  };
15182
15380
  /**
15183
15381
  * Represents a Goose lifecycle hooks file.
@@ -15272,6 +15470,11 @@ const GROKCLI_CONVERTER_CONFIG = {
15272
15470
  toolToCanonicalEventNames: GROKCLI_TO_CANONICAL_EVENT_NAMES,
15273
15471
  projectDirVar: "",
15274
15472
  supportedHookTypes: /* @__PURE__ */ new Set(["command", "http"]),
15473
+ recordPassthroughFields: [{
15474
+ canonical: "env",
15475
+ tool: "env",
15476
+ commandOnly: true
15477
+ }],
15275
15478
  noMatcherEvents: /* @__PURE__ */ new Set([
15276
15479
  "sessionStart",
15277
15480
  "sessionEnd",
@@ -21222,6 +21425,27 @@ function resolveGooseType(config, url) {
21222
21425
  return canonicalTransport(config) === "builtin" ? "builtin" : "stdio";
21223
21426
  }
21224
21427
  /**
21428
+ * The Goose extension types that carry an MCP server. Goose also documents
21429
+ * `builtin`, `platform`, `frontend` and `inline_python` extensions, which have
21430
+ * no canonical MCP counterpart: they name capabilities Goose provides itself
21431
+ * rather than a server rulesync could describe.
21432
+ */
21433
+ const GOOSE_MCP_EXTENSION_TYPES = /* @__PURE__ */ new Set([
21434
+ "stdio",
21435
+ "streamable_http",
21436
+ "sse"
21437
+ ]);
21438
+ /**
21439
+ * Resolves the Goose extension type of an existing `extensions:` entry the way
21440
+ * Goose itself reads it: the declared `type`, or the shape of the entry when
21441
+ * the key is absent.
21442
+ */
21443
+ function existingExtensionType(ext) {
21444
+ if (typeof ext.type === "string") return ext.type;
21445
+ if (typeof ext.cmd === "string") return "stdio";
21446
+ if (typeof ext.uri === "string") return "streamable_http";
21447
+ }
21448
+ /**
21225
21449
  * Resolves the canonical timeout for a server (`timeout` or `networkTimeout`).
21226
21450
  */
21227
21451
  function resolveGooseTimeout(config) {
@@ -21247,15 +21471,20 @@ function applyGooseStdioFields(ext, config) {
21247
21471
  /**
21248
21472
  * Converts a single rulesync canonical MCP server into a Goose `extensions:` entry.
21249
21473
  */
21250
- function convertServerToGooseExtension(name, config) {
21474
+ function convertServerToGooseExtension(name, config, logger) {
21251
21475
  const url = resolveGooseUrl(config);
21252
21476
  const gooseType = resolveGooseType(config, url);
21253
21477
  const ext = {
21254
21478
  name,
21255
21479
  type: gooseType
21256
21480
  };
21257
- if (gooseType === "stdio") applyGooseStdioFields(ext, config);
21258
- else if (gooseType === "sse" || gooseType === "streamable_http") {
21481
+ if (gooseType === "stdio") {
21482
+ applyGooseStdioFields(ext, config);
21483
+ if (typeof ext.cmd !== "string" || ext.cmd === "") {
21484
+ warnWithFallback(logger, `Goose extension "${name}" has no command to run; skipping it rather than writing a stdio extension Goose cannot start to ~/.config/goose/config.yaml.`);
21485
+ return;
21486
+ }
21487
+ } else if (gooseType === "sse" || gooseType === "streamable_http") {
21259
21488
  if (url !== void 0) ext.uri = url;
21260
21489
  if (isPlainObject$1(config.headers)) ext.headers = omitPrototypePollutionKeys(config.headers);
21261
21490
  }
@@ -21270,13 +21499,41 @@ function convertServerToGooseExtension(name, config) {
21270
21499
  * Goose uses a non-standard schema: `name`, `type` (`stdio` | `streamable_http`
21271
21500
  * | `sse` | `builtin`), `cmd`/`args`/`envs` for stdio, `uri`/`headers` for
21272
21501
  * remote, plus `enabled` and `timeout`.
21273
- */
21274
- function convertToGooseFormat(mcpServers) {
21275
- const extensions = {};
21502
+ *
21503
+ * `extensions:` is co-owned: alongside the MCP servers rulesync manages it also
21504
+ * holds Goose's own `builtin`/`platform`/`frontend`/`inline_python` extensions
21505
+ * (`developer`, `memory`, ...), which have no canonical MCP representation.
21506
+ * Those entries are carried over from `existingExtensions` untouched — removing
21507
+ * `developer` alone costs the agent its shell and text-editor tools. Only an
21508
+ * entry rulesync can positively identify as an MCP server is rulesync's to
21509
+ * replace, so a server deleted from `.rulesync/.mcp.json` is retracted (with a
21510
+ * warning naming it) while an entry of an unrecognized shape or a future
21511
+ * extension type is left alone rather than assumed to be ours.
21512
+ */
21513
+ function convertToGooseFormat({ mcpServers, existingExtensions, logger }) {
21514
+ const generated = {};
21276
21515
  for (const [name, config] of Object.entries(mcpServers)) {
21277
21516
  if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
21278
- extensions[name] = convertServerToGooseExtension(name, config);
21517
+ const ext = convertServerToGooseExtension(name, config, logger);
21518
+ if (ext !== void 0) generated[name] = ext;
21279
21519
  }
21520
+ const extensions = {};
21521
+ const retracted = [];
21522
+ for (const [name, ext] of Object.entries(existingExtensions)) {
21523
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
21524
+ const type = isRecord(ext) ? existingExtensionType(ext) : void 0;
21525
+ if (type !== void 0 && GOOSE_MCP_EXTENSION_TYPES.has(type)) {
21526
+ if (!Object.hasOwn(generated, name)) retracted.push(name);
21527
+ continue;
21528
+ }
21529
+ if (!Object.hasOwn(generated, name)) {
21530
+ extensions[name] = ext;
21531
+ continue;
21532
+ }
21533
+ if (generated[name]?.type !== type) warnWithFallback(logger, `Goose extension "${name}" already exists in config.yaml as a non-MCP extension; the MCP server of the same name replaces it.`);
21534
+ }
21535
+ if (retracted.length > 0) warnWithFallback(logger, `Removing MCP extension(s) ${retracted.map((name) => `"${name}"`).join(", ")} from ~/.config/goose/config.yaml: they are not in the generated rulesync MCP config. Import them first if they were added with \`goose configure\`.`);
21536
+ Object.assign(extensions, generated);
21280
21537
  return extensions;
21281
21538
  }
21282
21539
  /**
@@ -21287,16 +21544,27 @@ function convertToGooseFormat(mcpServers) {
21287
21544
  * so both `url` and the Claude-specific `httpUrl` alias come back as `url`; and
21288
21545
  * the `streamable_http` type maps back to canonical `http`. These are the
21289
21546
  * canonical/preferred forms, so re-generating produces an equivalent config.
21547
+ *
21548
+ * Non-MCP extension types (`builtin`, `platform`, `frontend`, `inline_python`)
21549
+ * are skipped: they describe capabilities Goose provides itself, and importing
21550
+ * one would strip the type that makes it work — a `builtin` entry came back as
21551
+ * a `stdio` extension with no `cmd` that Goose cannot start. They stay in `config.yaml`,
21552
+ * which generation preserves.
21290
21553
  */
21291
21554
  function convertFromGooseFormat(extensions) {
21292
21555
  const result = {};
21556
+ const skipped = [];
21293
21557
  for (const [name, ext] of Object.entries(extensions)) {
21294
21558
  if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(ext)) continue;
21559
+ const type = existingExtensionType(ext);
21560
+ if (type === void 0 || !GOOSE_MCP_EXTENSION_TYPES.has(type)) {
21561
+ skipped.push(name);
21562
+ continue;
21563
+ }
21295
21564
  const server = {};
21296
- const type = typeof ext.type === "string" ? ext.type : void 0;
21297
21565
  if (type === "sse") server.type = "sse";
21298
21566
  else if (type === "streamable_http") server.type = "http";
21299
- else if (type === "stdio") server.type = "stdio";
21567
+ else server.type = "stdio";
21300
21568
  if (typeof ext.cmd === "string") server.command = ext.cmd;
21301
21569
  if (isStringArray$1(ext.args)) server.args = ext.args;
21302
21570
  if (isPlainObject$1(ext.envs)) server.env = omitPrototypePollutionKeys(ext.envs);
@@ -21306,6 +21574,7 @@ function convertFromGooseFormat(extensions) {
21306
21574
  if (typeof ext.timeout === "number") server.timeout = ext.timeout;
21307
21575
  result[name] = server;
21308
21576
  }
21577
+ if (skipped.length > 0) warnWithFallback(void 0, `Skipping ${skipped.length} non-MCP Goose extension(s) (${skipped.map((name) => `"${name}"`).join(", ")}): they describe capabilities Goose provides itself and have no rulesync representation.`);
21309
21578
  return result;
21310
21579
  }
21311
21580
  /**
@@ -21427,15 +21696,25 @@ var GooseMcp = class GooseMcp extends ToolMcp {
21427
21696
  global
21428
21697
  });
21429
21698
  }
21430
- const merged = {
21431
- ...parseGooseConfig(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "", paths.relativeDirPath, paths.relativeFilePath),
21432
- extensions: convertToGooseFormat(rulesyncMcp.getMcpServers())
21433
- };
21699
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
21700
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
21701
+ const config = parseGooseConfig(existingContent, paths.relativeDirPath, paths.relativeFilePath);
21702
+ const existingExtensions = isRecord(config.extensions) ? config.extensions : {};
21434
21703
  return new GooseMcp({
21435
21704
  outputRoot,
21436
21705
  relativeDirPath: paths.relativeDirPath,
21437
21706
  relativeFilePath: paths.relativeFilePath,
21438
- fileContent: (0, js_yaml.dump)(merged),
21707
+ fileContent: applySharedConfigPatch({
21708
+ fileKey: sharedConfigFileKey(paths),
21709
+ feature: "mcp",
21710
+ existingContent,
21711
+ patch: { extensions: convertToGooseFormat({
21712
+ mcpServers: rulesyncMcp.getMcpServers(),
21713
+ existingExtensions,
21714
+ logger
21715
+ }) },
21716
+ filePath
21717
+ }),
21439
21718
  validate,
21440
21719
  global
21441
21720
  });
@@ -28144,7 +28423,8 @@ const FACTORYDROID_OVERRIDE_KEYS = [
28144
28423
  "interactionMode",
28145
28424
  "extraKnownMarketplaces",
28146
28425
  "enabledPlugins",
28147
- "hooksDisabled"
28426
+ "hooksDisabled",
28427
+ "disabledSkills"
28148
28428
  ];
28149
28429
  /**
28150
28430
  * Permissions adapter for Factory Droid.
@@ -32295,6 +32575,30 @@ const CANONICAL_TO_ZED_TOOL_NAMES = {
32295
32575
  webfetch: "fetch",
32296
32576
  websearch: "search_web"
32297
32577
  };
32578
+ /**
32579
+ * Canonical categories whose Zed tool is not permission-gated. Zed's gated list
32580
+ * is `terminal`, `edit_file`, `write_file`, `delete_path`, `move_path`,
32581
+ * `copy_path`, `create_directory`, `fetch`, `search_web` and `skill`; the
32582
+ * read-only tools (`read_file`, `grep`, `find_path`, `list_directory`) sit in
32583
+ * Zed's own `EXCLUDED_TOOLS` and never call `decide_permission_from_settings`,
32584
+ * so a `tools.<name>` entry for one is config Zed never consults. Zed's real
32585
+ * read-denial surface is `private_files`, which the ignore feature owns.
32586
+ *
32587
+ * @see https://zed.dev/docs/ai/tool-permissions#supported-tools
32588
+ */
32589
+ const ZED_EXCLUDED_CANONICAL_CATEGORIES = /* @__PURE__ */ new Set([
32590
+ "read",
32591
+ "grep",
32592
+ "glob"
32593
+ ]);
32594
+ /** The Zed-side spellings of the same tools, for a category that names one directly. */
32595
+ const ZED_EXCLUDED_TOOL_NAMES = /* @__PURE__ */ new Set([
32596
+ "read_file",
32597
+ "grep",
32598
+ "find_path",
32599
+ "list_directory"
32600
+ ]);
32601
+ const isZedExcludedCategory = (category) => ZED_EXCLUDED_CANONICAL_CATEGORIES.has(category) || ZED_EXCLUDED_TOOL_NAMES.has(toZedToolName(category));
32298
32602
  const ZED_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_ZED_TOOL_NAMES).map(([k, v]) => [v, k]));
32299
32603
  function toZedToolName(canonical) {
32300
32604
  return CANONICAL_TO_ZED_TOOL_NAMES[canonical] ?? canonical;
@@ -32416,15 +32720,21 @@ var ZedPermissions = class ZedPermissions extends ToolPermissions {
32416
32720
  const existingTools = asRecord(toolPermissions.tools);
32417
32721
  let managedDefault;
32418
32722
  const managedTools = {};
32723
+ const excludedCategories = [];
32419
32724
  for (const [category, rules] of Object.entries(config.permission)) {
32420
32725
  if (category === "*") {
32421
32726
  for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") managedDefault = CANONICAL_TO_ZED_ACTION[action];
32422
32727
  else logger?.warn(`Zed permissions: dropping the "*" category rule for pattern "${pattern}" — Zed's global tool-permission default takes no patterns; scope the rule to a tool category instead.`);
32423
32728
  continue;
32424
32729
  }
32730
+ if (isZedExcludedCategory(category)) {
32731
+ if (Object.values(rules).some((action) => action === "deny" || action === "ask")) excludedCategories.push(category);
32732
+ continue;
32733
+ }
32425
32734
  const tool = buildZedToolPermission(rules);
32426
32735
  if (tool) managedTools[toZedToolName(category)] = tool;
32427
32736
  }
32737
+ if (excludedCategories.length > 0) logger?.warn(`Zed permissions: dropping the ${excludedCategories.map((category) => `"${category}"`).join(", ")} ${excludedCategories.length === 1 ? "category" : "categories"} — Zed does not gate its read-only tools, so the entries would never be consulted. Zed's read-denial surface is \`private_files\`, which the ignore feature writes from \`.rulesync/.aiignore\`.`);
32428
32738
  const managedToolNames = new Set(Object.keys(managedTools));
32429
32739
  if ("*" in config.permission) managedToolNames.add("*");
32430
32740
  const preservedTools = Object.fromEntries(Object.entries(existingTools).filter(([toolName]) => !managedToolNames.has(toolName)));
@@ -33216,18 +33526,16 @@ var DirFeatureProcessor = class {
33216
33526
  })) dirHasChanges = true;
33217
33527
  }
33218
33528
  const otherFiles = aiDir.getOtherFiles();
33219
- const otherFileContents = [];
33220
33529
  for (const file of otherFiles) {
33221
- const contentWithNewline = addTrailingNewline(file.fileBuffer.toString("utf-8"));
33222
- otherFileContents.push(contentWithNewline);
33223
- if (!dirHasChanges) {
33224
- const filePath = (0, node_path.join)(dirPath, file.relativeFilePathToDirPath);
33225
- if (!fileContentsEquivalent({
33226
- filePath,
33227
- expected: contentWithNewline,
33228
- existing: await readFileContentOrNull(filePath)
33229
- })) dirHasChanges = true;
33230
- }
33530
+ if (dirHasChanges) break;
33531
+ const filePath = (0, node_path.join)(dirPath, file.relativeFilePathToDirPath);
33532
+ const existingBuffer = await readFileBufferOrNull(filePath);
33533
+ if (!companionFileContentsEquivalent({
33534
+ filePath,
33535
+ expected: file.fileBuffer,
33536
+ existing: existingBuffer,
33537
+ composed: file.composed
33538
+ })) dirHasChanges = true;
33231
33539
  }
33232
33540
  if (!dirHasChanges) continue;
33233
33541
  const relativeDir = aiDir.getRelativePathFromCwd();
@@ -33247,11 +33555,8 @@ var DirFeatureProcessor = class {
33247
33555
  await writeFileContent((0, node_path.join)(dirPath, mainFile.name), mainFileContent);
33248
33556
  changedPaths.push((0, node_path.join)(relativeDir, mainFile.name));
33249
33557
  }
33250
- for (const [i, file] of otherFiles.entries()) {
33251
- const filePath = (0, node_path.join)(dirPath, file.relativeFilePathToDirPath);
33252
- const content = otherFileContents[i];
33253
- if (content === void 0) throw new Error(`Internal error: content for file ${file.relativeFilePathToDirPath} is undefined. This indicates a synchronization issue between otherFiles and otherFileContents arrays.`);
33254
- await writeFileContent(filePath, content);
33558
+ for (const file of otherFiles) {
33559
+ await writeFileBuffer((0, node_path.join)(dirPath, file.relativeFilePathToDirPath), file.fileBuffer);
33255
33560
  changedPaths.push((0, node_path.join)(relativeDir, file.relativeFilePathToDirPath));
33256
33561
  }
33257
33562
  }
@@ -33977,6 +34282,39 @@ function buildClaudecodeSkillFrontmatter({ rulesyncFrontmatter, resolvedDisableM
33977
34282
  return frontmatter;
33978
34283
  }
33979
34284
  /**
34285
+ * Escapes the glob metacharacters in a directory path so it matches literally.
34286
+ * A real directory name may contain them — `app/[slug]` in a Next.js tree is
34287
+ * the common case, and unescaped `[slug]` reads as a bracket expression that
34288
+ * matches a different subtree (or nothing at all).
34289
+ *
34290
+ * @see https://code.claude.com/docs/en/memory
34291
+ */
34292
+ function escapeGlobLiteral(dirPath) {
34293
+ return dirPath.replaceAll(/[\\*?[\]{}()!]/g, "\\$&");
34294
+ }
34295
+ /**
34296
+ * Claude Code scopes a nested skill by its location: a skill living in
34297
+ * `apps/web/.claude/skills/deploy` only activates while working under
34298
+ * `apps/web`. rulesync generates every imported skill into the project-root
34299
+ * `.claude/skills/`, so on import that location-based scoping has to be
34300
+ * re-expressed as an explicit `paths` glob — otherwise the round-trip silently
34301
+ * promotes a subtree skill to global activation.
34302
+ *
34303
+ * Returns the derived glob for a nested discovery root, or `undefined` for the
34304
+ * project-root `.claude/skills` (and for any root whose subtree cannot be
34305
+ * determined), where no scoping is implied.
34306
+ *
34307
+ * @see https://code.claude.com/docs/en/skills
34308
+ */
34309
+ function deriveNestedSkillPaths(relativeDirPath) {
34310
+ const posixDirPath = toPosixPath(relativeDirPath);
34311
+ const skillsDirSuffix = `/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`;
34312
+ if (!posixDirPath.endsWith(skillsDirSuffix)) return;
34313
+ const subtree = posixDirPath.slice(0, -skillsDirSuffix.length);
34314
+ if (subtree === "" || subtree === ".") return;
34315
+ return [`${escapeGlobLiteral(subtree)}/**`];
34316
+ }
34317
+ /**
33980
34318
  * Represents a Claude Code skill directory.
33981
34319
  * Unlike subagents and commands, skills are directories containing SKILL.md and other files.
33982
34320
  * Extends ToolSkill to inherit directory management and security features from AiDir.
@@ -34064,6 +34402,7 @@ var ClaudecodeSkill = class extends ToolSkill {
34064
34402
  }
34065
34403
  toRulesyncSkill() {
34066
34404
  const frontmatter = this.getFrontmatter();
34405
+ const resolvedPaths = frontmatter.paths !== void 0 ? frontmatter.paths : deriveNestedSkillPaths(this.relativeDirPath);
34067
34406
  const claudecodeSection = {
34068
34407
  ...frontmatter.when_to_use && { when_to_use: frontmatter.when_to_use },
34069
34408
  ...frontmatter["allowed-tools"] && { "allowed-tools": frontmatter["allowed-tools"] },
@@ -34080,7 +34419,7 @@ var ClaudecodeSkill = class extends ToolSkill {
34080
34419
  ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
34081
34420
  ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
34082
34421
  ...this.relativeDirPath === CLAUDECODE_SCHEDULED_TASKS_DIR_PATH && { "scheduled-task": true },
34083
- ...frontmatter.paths !== void 0 && { paths: frontmatter.paths }
34422
+ ...resolvedPaths !== void 0 && { paths: resolvedPaths }
34084
34423
  };
34085
34424
  const rulesyncFrontmatter = {
34086
34425
  name: frontmatter.name,
@@ -34478,7 +34817,8 @@ var CodexCliSkill = class CodexCliSkill extends ToolSkill {
34478
34817
  fileBuffer: Buffer.from((0, js_yaml.dump)(openaiObject, {
34479
34818
  lineWidth: -1,
34480
34819
  noRefs: true
34481
- }))
34820
+ })),
34821
+ composed: true
34482
34822
  }] : baseOtherFiles;
34483
34823
  return new CodexCliSkill({
34484
34824
  outputRoot,
@@ -34538,7 +34878,11 @@ const CopilotSkillFrontmatterSchema = zod_mini.z.looseObject({
34538
34878
  name: zod_mini.z.string(),
34539
34879
  description: zod_mini.z.string(),
34540
34880
  license: zod_mini.z.optional(zod_mini.z.string()),
34541
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
34881
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
34882
+ "argument-hint": zod_mini.z.optional(zod_mini.z.string()),
34883
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
34884
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
34885
+ context: zod_mini.z.optional(zod_mini.z.string())
34542
34886
  });
34543
34887
  /**
34544
34888
  * Represents a GitHub Copilot skill directory.
@@ -34596,7 +34940,11 @@ var CopilotSkill = class CopilotSkill extends ToolSkill {
34596
34940
  const frontmatter = this.getFrontmatter();
34597
34941
  const copilotSection = {
34598
34942
  ...frontmatter.license !== void 0 && { license: frontmatter.license },
34599
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
34943
+ ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] },
34944
+ ...frontmatter["argument-hint"] !== void 0 && { "argument-hint": frontmatter["argument-hint"] },
34945
+ ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
34946
+ ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
34947
+ ...frontmatter.context !== void 0 && { context: frontmatter.context }
34600
34948
  };
34601
34949
  const rulesyncFrontmatter = {
34602
34950
  name: frontmatter.name,
@@ -34618,11 +34966,24 @@ var CopilotSkill = class CopilotSkill extends ToolSkill {
34618
34966
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
34619
34967
  const settablePaths = CopilotSkill.getSettablePaths({ global });
34620
34968
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
34969
+ const copilotSection = rulesyncFrontmatter.copilot;
34970
+ const resolvedUserInvocable = resolveUserInvocable({
34971
+ rootFrontmatter: rulesyncFrontmatter,
34972
+ section: copilotSection
34973
+ });
34974
+ const resolvedDisableModelInvocation = resolveDisableModelInvocation({
34975
+ rootFrontmatter: rulesyncFrontmatter,
34976
+ section: copilotSection
34977
+ });
34621
34978
  const copilotFrontmatter = {
34622
34979
  name: rulesyncFrontmatter.name,
34623
34980
  description: rulesyncFrontmatter.description,
34624
- ...rulesyncFrontmatter.copilot?.license !== void 0 && { license: rulesyncFrontmatter.copilot.license },
34625
- ...rulesyncFrontmatter.copilot?.["allowed-tools"] !== void 0 && { "allowed-tools": rulesyncFrontmatter.copilot["allowed-tools"] }
34981
+ ...copilotSection?.license !== void 0 && { license: copilotSection.license },
34982
+ ...copilotSection?.["allowed-tools"] !== void 0 && { "allowed-tools": copilotSection["allowed-tools"] },
34983
+ ...copilotSection?.["argument-hint"] !== void 0 && { "argument-hint": copilotSection["argument-hint"] },
34984
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
34985
+ ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
34986
+ ...copilotSection?.context !== void 0 && { context: copilotSection.context }
34626
34987
  };
34627
34988
  return new CopilotSkill({
34628
34989
  outputRoot,
@@ -34769,14 +35130,23 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
34769
35130
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
34770
35131
  const settablePaths = CopilotcliSkill.getSettablePaths({ global });
34771
35132
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
35133
+ const copilotcliSection = rulesyncFrontmatter.copilotcli;
35134
+ const resolvedUserInvocable = resolveUserInvocable({
35135
+ rootFrontmatter: rulesyncFrontmatter,
35136
+ section: copilotcliSection
35137
+ });
35138
+ const resolvedDisableModelInvocation = resolveDisableModelInvocation({
35139
+ rootFrontmatter: rulesyncFrontmatter,
35140
+ section: copilotcliSection
35141
+ });
34772
35142
  const copilotcliFrontmatter = {
34773
35143
  name: rulesyncFrontmatter.name,
34774
35144
  description: rulesyncFrontmatter.description,
34775
- ...rulesyncFrontmatter.copilotcli?.license !== void 0 && { license: rulesyncFrontmatter.copilotcli.license },
34776
- ...rulesyncFrontmatter.copilotcli?.["allowed-tools"] !== void 0 && { "allowed-tools": rulesyncFrontmatter.copilotcli["allowed-tools"] },
34777
- ...rulesyncFrontmatter.copilotcli?.["argument-hint"] !== void 0 && { "argument-hint": rulesyncFrontmatter.copilotcli["argument-hint"] },
34778
- ...rulesyncFrontmatter.copilotcli?.["user-invocable"] !== void 0 && { "user-invocable": rulesyncFrontmatter.copilotcli["user-invocable"] },
34779
- ...rulesyncFrontmatter.copilotcli?.["disable-model-invocation"] !== void 0 && { "disable-model-invocation": rulesyncFrontmatter.copilotcli["disable-model-invocation"] }
35145
+ ...copilotcliSection?.license !== void 0 && { license: copilotcliSection.license },
35146
+ ...copilotcliSection?.["allowed-tools"] !== void 0 && { "allowed-tools": copilotcliSection["allowed-tools"] },
35147
+ ...copilotcliSection?.["argument-hint"] !== void 0 && { "argument-hint": copilotcliSection["argument-hint"] },
35148
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
35149
+ ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
34780
35150
  };
34781
35151
  return new CopilotcliSkill({
34782
35152
  outputRoot,
@@ -35334,7 +35704,9 @@ const FactorydroidSkillFrontmatterSchema = zod_mini.z.looseObject({
35334
35704
  name: zod_mini.z.string(),
35335
35705
  description: zod_mini.z.string(),
35336
35706
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
35337
- "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean())
35707
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
35708
+ enabled: zod_mini.z.optional(zod_mini.z.boolean()),
35709
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
35338
35710
  });
35339
35711
  /**
35340
35712
  * Represents a Factory Droid skill directory.
@@ -35390,7 +35762,9 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
35390
35762
  const frontmatter = this.getFrontmatter();
35391
35763
  const factorydroidBlock = {
35392
35764
  ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
35393
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] }
35765
+ ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
35766
+ ...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
35767
+ ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
35394
35768
  };
35395
35769
  const rulesyncFrontmatter = {
35396
35770
  name: frontmatter.name,
@@ -35412,19 +35786,22 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
35412
35786
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
35413
35787
  const settablePaths = FactorydroidSkill.getSettablePaths({ global });
35414
35788
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
35789
+ const factorydroidSection = rulesyncFrontmatter.factorydroid;
35415
35790
  const resolvedDisableModelInvocation = resolveDisableModelInvocation({
35416
35791
  rootFrontmatter: rulesyncFrontmatter,
35417
- section: rulesyncFrontmatter.factorydroid
35792
+ section: factorydroidSection
35418
35793
  });
35419
35794
  const resolvedUserInvocable = resolveUserInvocable({
35420
35795
  rootFrontmatter: rulesyncFrontmatter,
35421
- section: rulesyncFrontmatter.factorydroid
35796
+ section: factorydroidSection
35422
35797
  });
35423
35798
  const factorydroidFrontmatter = {
35424
35799
  name: rulesyncFrontmatter.name,
35425
35800
  description: rulesyncFrontmatter.description,
35426
35801
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
35427
- ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
35802
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
35803
+ ...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
35804
+ ...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
35428
35805
  };
35429
35806
  return new FactorydroidSkill({
35430
35807
  outputRoot,
@@ -39445,7 +39822,17 @@ var ClaudecodeSubagent = class ClaudecodeSubagent extends ToolSubagent {
39445
39822
  validate: true
39446
39823
  });
39447
39824
  }
39448
- static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
39825
+ /**
39826
+ * Last chance to adjust the tool frontmatter before it is written. The base
39827
+ * implementation only warns about names Claude Code rejects; plugin-scoped
39828
+ * subclasses extend it to drop fields Claude Code refuses to honor for
39829
+ * plugin-shipped agents.
39830
+ */
39831
+ static sanitizeFrontmatter({ frontmatter, relativeFilePath, logger }) {
39832
+ if (frontmatter.name.includes(":")) logger?.warn(`Claude Code will reject the subagent in ${relativeFilePath}: the name "${frontmatter.name}" contains ":", which is reserved for plugin namespacing.`);
39833
+ return frontmatter;
39834
+ }
39835
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false, logger }) {
39449
39836
  const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
39450
39837
  const claudecodeSection = this.filterToolSpecificSection(rulesyncFrontmatter.claudecode ?? {}, ["name", "description"]);
39451
39838
  const rawClaudecodeFrontmatter = {
@@ -39455,7 +39842,11 @@ var ClaudecodeSubagent = class ClaudecodeSubagent extends ToolSubagent {
39455
39842
  };
39456
39843
  const result = ClaudecodeSubagentFrontmatterSchema.safeParse(rawClaudecodeFrontmatter);
39457
39844
  if (!result.success) throw new Error(`Invalid claudecode subagent frontmatter in ${rulesyncSubagent.getRelativeFilePath()}: ${formatError(result.error)}`);
39458
- const claudecodeFrontmatter = result.data;
39845
+ const claudecodeFrontmatter = this.sanitizeFrontmatter({
39846
+ frontmatter: result.data,
39847
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
39848
+ logger
39849
+ });
39459
39850
  const body = rulesyncSubagent.getBody();
39460
39851
  const fileContent = stringifyFrontmatter(body, claudecodeFrontmatter);
39461
39852
  const paths = this.getSettablePaths({ global });
@@ -39524,6 +39915,21 @@ var ClaudecodeSubagent = class ClaudecodeSubagent extends ToolSubagent {
39524
39915
  };
39525
39916
  //#endregion
39526
39917
  //#region src/features/subagents/claudecode-plugin-subagent.ts
39918
+ /**
39919
+ * Claude Code refuses these for plugin-shipped agents "for security reasons",
39920
+ * so emitting them leaves the author believing the agent is constrained when it
39921
+ * is not. Only these three are dropped: the other fields upstream does not list
39922
+ * (e.g. `color`) are merely ignored, with no misleading security posture.
39923
+ *
39924
+ * @see https://code.claude.com/docs/en/plugins-reference
39925
+ */
39926
+ const PLUGIN_FORBIDDEN_FIELDS = [
39927
+ "hooks",
39928
+ "mcpServers",
39929
+ "permissionMode"
39930
+ ];
39931
+ /** The only `isolation` value plugin agents accept. */
39932
+ const PLUGIN_ISOLATION_VALUE = "worktree";
39527
39933
  var ClaudecodePluginSubagent = class extends ClaudecodeSubagent {
39528
39934
  static isTargetedByRulesyncSubagent(rulesyncSubagent) {
39529
39935
  const targets = rulesyncSubagent.getFrontmatter().targets;
@@ -39532,6 +39938,21 @@ var ClaudecodePluginSubagent = class extends ClaudecodeSubagent {
39532
39938
  static getSettablePaths() {
39533
39939
  return { relativeDirPath: CLAUDECODE_PLUGIN_AGENTS_DIR };
39534
39940
  }
39941
+ static sanitizeFrontmatter({ frontmatter, relativeFilePath, logger }) {
39942
+ const sanitized = { ...super.sanitizeFrontmatter({
39943
+ frontmatter,
39944
+ relativeFilePath,
39945
+ logger
39946
+ }) };
39947
+ const dropped = PLUGIN_FORBIDDEN_FIELDS.filter((field) => sanitized[field] !== void 0);
39948
+ for (const field of PLUGIN_FORBIDDEN_FIELDS) delete sanitized[field];
39949
+ if (dropped.length > 0) logger?.warn(`Dropping ${dropped.join(", ")} from claudecode-plugin subagent ${relativeFilePath}: Claude Code does not support these fields for plugin-shipped agents.`);
39950
+ if (sanitized.isolation !== void 0 && sanitized.isolation !== PLUGIN_ISOLATION_VALUE) {
39951
+ logger?.warn(`Dropping isolation "${sanitized.isolation}" from claudecode-plugin subagent ${relativeFilePath}: "${PLUGIN_ISOLATION_VALUE}" is the only value Claude Code accepts for plugin-shipped agents.`);
39952
+ delete sanitized.isolation;
39953
+ }
39954
+ return sanitized;
39955
+ }
39535
39956
  };
39536
39957
  //#endregion
39537
39958
  //#region src/features/subagents/cline-subagent.ts
@@ -43185,7 +43606,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
43185
43606
  outputRoot: this.outputRoot,
43186
43607
  relativeDirPath: RulesyncSubagent.getSettablePaths().relativeDirPath,
43187
43608
  rulesyncSubagent,
43188
- global: this.global
43609
+ global: this.global,
43610
+ logger: this.logger
43189
43611
  }));
43190
43612
  }
43191
43613
  async convertToolFilesToRulesyncFiles(toolFiles) {
@@ -45138,7 +45560,7 @@ var CodexcliRule = class CodexcliRule extends ToolRule {
45138
45560
  };
45139
45561
  //#endregion
45140
45562
  //#region src/features/rules/copilot-rule.ts
45141
- const CopilotRuleFrontmatterSchema = zod_mini.z.object({
45563
+ const CopilotRuleFrontmatterSchema = zod_mini.z.looseObject({
45142
45564
  description: zod_mini.z.optional(zod_mini.z.string()),
45143
45565
  applyTo: zod_mini.z.optional(zod_mini.z.string()),
45144
45566
  name: zod_mini.z.optional(zod_mini.z.string()),
@@ -45194,15 +45616,13 @@ var CopilotRule = class CopilotRule extends ToolRule {
45194
45616
  toRulesyncRule() {
45195
45617
  let globs;
45196
45618
  if (this.frontmatter.applyTo) globs = this.frontmatter.applyTo.split(",").map((g) => g.trim());
45619
+ const { description, applyTo: _applyTo, ...copilotFields } = this.frontmatter;
45197
45620
  const rulesyncFrontmatter = {
45198
45621
  targets: ["*"],
45199
45622
  root: this.isRoot(),
45200
- description: this.frontmatter.description,
45623
+ description,
45201
45624
  globs,
45202
- ...(this.frontmatter.excludeAgent || this.frontmatter.name) && { copilot: {
45203
- ...this.frontmatter.excludeAgent && { excludeAgent: this.frontmatter.excludeAgent },
45204
- ...this.frontmatter.name && { name: this.frontmatter.name }
45205
- } }
45625
+ ...Object.keys(copilotFields).length > 0 && { copilot: copilotFields }
45206
45626
  };
45207
45627
  const relativeFilePath = this.getRelativeFilePath().replace(/\.instructions\.md$/, ".md");
45208
45628
  return new RulesyncRule({
@@ -45219,10 +45639,9 @@ var CopilotRule = class CopilotRule extends ToolRule {
45219
45639
  const root = rulesyncFrontmatter.root;
45220
45640
  const paths = this.getSettablePaths({ global });
45221
45641
  const copilotFrontmatter = {
45642
+ ...rulesyncFrontmatter.copilot,
45222
45643
  description: rulesyncFrontmatter.description,
45223
- applyTo: rulesyncFrontmatter.globs?.length ? rulesyncFrontmatter.globs.join(",") : void 0,
45224
- excludeAgent: rulesyncFrontmatter.copilot?.excludeAgent,
45225
- name: rulesyncFrontmatter.copilot?.name
45644
+ applyTo: rulesyncFrontmatter.globs?.length ? rulesyncFrontmatter.globs.join(",") : void 0
45226
45645
  };
45227
45646
  const body = rulesyncRule.getBody();
45228
45647
  if (root) return new CopilotRule({
@@ -51621,6 +52040,12 @@ Object.defineProperty(exports, "warnOnConflictingFlags", {
51621
52040
  return warnOnConflictingFlags;
51622
52041
  }
51623
52042
  });
52043
+ Object.defineProperty(exports, "writeFileBuffer", {
52044
+ enumerable: true,
52045
+ get: function() {
52046
+ return writeFileBuffer;
52047
+ }
52048
+ });
51624
52049
  Object.defineProperty(exports, "writeFileContent", {
51625
52050
  enumerable: true,
51626
52051
  get: function() {