rulesync 16.16.0 → 16.17.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.
@@ -1,6 +1,6 @@
1
1
  import { ZodError } from "zod";
2
2
  import { meta, minLength, nonnegative, optional, refine, z } from "zod/mini";
3
- import { chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
3
+ import { chmod, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
4
4
  import path, { basename, dirname, extname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
5
5
  import { parse, printParseErrorCode } from "jsonc-parser";
6
6
  import os from "node:os";
@@ -9,6 +9,7 @@ import { globbySync, isGitIgnoredSync } from "globby";
9
9
  import matter from "gray-matter";
10
10
  import { YAMLException, dump, load } from "js-yaml";
11
11
  import { omit } from "es-toolkit/object";
12
+ import { constants } from "node:fs";
12
13
  import { createHash } from "node:crypto";
13
14
  import { isDeepStrictEqual } from "node:util";
14
15
  import * as smolToml from "smol-toml";
@@ -470,9 +471,22 @@ function isEnvTest() {
470
471
  }
471
472
  //#endregion
472
473
  //#region src/utils/file.ts
474
+ /**
475
+ * Whether a relative path leads out of the root it is relative to. Matching
476
+ * whole segments matters: a directory really named `..cache` relatively
477
+ * resolves to `..cache/file`, which a prefix test would report as an escape.
478
+ */
473
479
  function pathEscapesRoot(relativePath) {
474
480
  return relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath);
475
481
  }
482
+ /** Whether a single path segment is a hidden (dot-prefixed) name. */
483
+ function isHiddenPathSegment(segment) {
484
+ return segment.startsWith(".") && segment !== "." && segment !== "..";
485
+ }
486
+ /** Split a path on both separators, so one predicate serves either platform. */
487
+ function splitPathSegments(filePath) {
488
+ return filePath.split(/[/\\]/);
489
+ }
476
490
  async function assertWritablePathInsideRoot(params) {
477
491
  const { rootPath, targetPath } = params;
478
492
  let existingPath = targetPath;
@@ -703,8 +717,45 @@ async function listDirectoryFiles(dir) {
703
717
  return [];
704
718
  }
705
719
  }
720
+ /** How many dot-prefixed segments a path has, used to prefer a named alias over a hidden one. */
721
+ function countHiddenSegments(filePath) {
722
+ return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
723
+ }
724
+ /**
725
+ * The real file a path denotes, posix-separated so it compares against the globby results
726
+ * that produce it. Two paths share an identity when they resolve to the very same file --
727
+ * a link beside its target, a link into a shared tree, or a cycle that walks back into an
728
+ * ancestor and yields the same file forty levels down.
729
+ */
730
+ async function realFileIdentity(filePath) {
731
+ try {
732
+ return toPosixPath(await realpath(filePath));
733
+ } catch {
734
+ return toPosixPath(filePath);
735
+ }
736
+ }
737
+ /**
738
+ * Pick the one path that represents a file among the paths that resolve to it.
739
+ *
740
+ * The path that walked through no link at all wins outright: it is already the real one,
741
+ * so it equals the file's identity. That keeps the real location of a file as the path
742
+ * callers see, rather than an alias that happens to sort first -- a directory link named
743
+ * `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a cycle must not replace
744
+ * `sub/note.md` with the same file reached back through the cycle.
745
+ * Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
746
+ * named one represents the entry rather than a hidden alias that a hidden-entry rule may
747
+ * then drop, taking the named path's content with it. `candidates` arrives in sorted
748
+ * order, so ties keep the first one deterministically.
749
+ */
750
+ function chooseRepresentative(candidates, identity) {
751
+ return candidates.reduce((best, candidate) => {
752
+ if (toPosixPath(best) === identity) return best;
753
+ if (toPosixPath(candidate) === identity) return candidate;
754
+ return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
755
+ });
756
+ }
706
757
  async function findFilesByGlobs(globs, options = {}) {
707
- const { type = "all", followSymbolicLinks = true, ignore } = options;
758
+ const { type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
708
759
  const globbyOptions = type === "file" ? {
709
760
  onlyFiles: true,
710
761
  onlyDirectories: false
@@ -719,23 +770,18 @@ async function findFilesByGlobs(globs, options = {}) {
719
770
  const results = globbySync(normalizedGlobs, {
720
771
  absolute: true,
721
772
  followSymbolicLinks,
773
+ dot,
722
774
  ...ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {},
723
775
  ...globbyOptions
724
776
  });
725
- const seenRealPaths = /* @__PURE__ */ new Set();
726
- const deduped = [];
777
+ const candidatesByFile = /* @__PURE__ */ new Map();
727
778
  for (const result of results.toSorted()) {
728
- let realResult;
729
- try {
730
- realResult = await realpath(result);
731
- } catch {
732
- realResult = result;
733
- }
734
- if (seenRealPaths.has(realResult)) continue;
735
- seenRealPaths.add(realResult);
736
- deduped.push(result);
779
+ const identity = await realFileIdentity(result);
780
+ const candidates = candidatesByFile.get(identity);
781
+ if (candidates === void 0) candidatesByFile.set(identity, [result]);
782
+ else candidates.push(result);
737
783
  }
738
- return deduped;
784
+ return [...candidatesByFile.entries()].map(([identity, candidates]) => chooseRepresentative(candidates, identity)).toSorted();
739
785
  }
740
786
  async function removeDirectory(dirPath) {
741
787
  if ([
@@ -894,6 +940,25 @@ var CLIError = class extends Error {
894
940
  }
895
941
  };
896
942
  //#endregion
943
+ //#region src/utils/warned-once.ts
944
+ /**
945
+ * The messages a once-per-run warning has already emitted in this process.
946
+ * This lives in its own module, free of imports, so the vitest setup file can
947
+ * clear it between tests without pulling `logger.js` into every test's module
948
+ * graph (which would defeat the module mocks some of those tests install).
949
+ */
950
+ const warnedOnceMessages = /* @__PURE__ */ new Set();
951
+ /** Whether `message` has not been emitted yet; records it when it has not. */
952
+ function claimWarnOnce(message) {
953
+ if (warnedOnceMessages.has(message)) return false;
954
+ warnedOnceMessages.add(message);
955
+ return true;
956
+ }
957
+ /** Forget which warnings were already emitted, so each test starts silent. */
958
+ function resetWarnedOnceMessages() {
959
+ warnedOnceMessages.clear();
960
+ }
961
+ //#endregion
897
962
  //#region src/utils/logger.ts
898
963
  /**
899
964
  * Base class for shared verbose/silent state and configuration logic
@@ -1046,6 +1111,17 @@ const fallbackLogger = new ConsoleLogger();
1046
1111
  function warnWithFallback(logger, message) {
1047
1112
  (logger ?? fallbackLogger).warn(message);
1048
1113
  }
1114
+ /**
1115
+ * Emit a warning at most once per run. A single `generate` reads the same source
1116
+ * file once per enabled tool target, so a warning that describes the source
1117
+ * rather than the target would otherwise be printed a dozen identical times.
1118
+ * Diagnostics that name the file they are about qualify; anything whose text
1119
+ * varies with what the user should do next does not.
1120
+ */
1121
+ function warnOnceWithFallback(logger, message) {
1122
+ if (!claimWarnOnce(message)) return;
1123
+ warnWithFallback(logger, message);
1124
+ }
1049
1125
  //#endregion
1050
1126
  //#region src/utils/validation.ts
1051
1127
  /**
@@ -1990,8 +2066,7 @@ var AiFile = class {
1990
2066
  const fullPath = path.join(this.outputRoot, this.relativeDirPath, this.relativeFilePath);
1991
2067
  const resolvedFull = resolve(fullPath);
1992
2068
  const resolvedBase = resolve(this.outputRoot);
1993
- const rel = relative(resolvedBase, resolvedFull);
1994
- if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
2069
+ if (pathEscapesRoot(relative(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
1995
2070
  return fullPath;
1996
2071
  }
1997
2072
  getFileContent() {
@@ -2053,6 +2128,27 @@ var RulesyncFile = class extends AiFile {
2053
2128
  }
2054
2129
  };
2055
2130
  //#endregion
2131
+ //#region src/utils/control-characters.ts
2132
+ /**
2133
+ * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
2134
+ * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
2135
+ * line and paragraph separators, and the plain LRM/RLM marks. A name or value
2136
+ * copied out of an untrusted config file, a fetched repository, or a tool's own
2137
+ * settings file must never reach the terminal with these intact: they let the
2138
+ * text forge log lines, reorder what is printed around them, or inject escape
2139
+ * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
2140
+ * neutral characters beside them, so they go too — a diagnostic line is not the
2141
+ * place to preserve the typography of a right-to-left name.
2142
+ */
2143
+ const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
2144
+ /**
2145
+ * Removes every control character from `text` so it is safe to splice into a
2146
+ * log line or other terminal output.
2147
+ */
2148
+ function stripControlCharacters(text) {
2149
+ return text.replace(CONTROL_CHARACTERS_PATTERN, "");
2150
+ }
2151
+ //#endregion
2056
2152
  //#region src/utils/type-guards.ts
2057
2153
  /**
2058
2154
  * Type guard to check if a value is a plain object (Record<string, unknown>).
@@ -2176,7 +2272,7 @@ function parseFrontmatter(content, filePath) {
2176
2272
  let body;
2177
2273
  let hasFrontmatter;
2178
2274
  try {
2179
- const result = matter(content);
2275
+ const result = matter(content, {});
2180
2276
  frontmatter = result.data;
2181
2277
  body = result.content;
2182
2278
  hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
@@ -2190,6 +2286,104 @@ function parseFrontmatter(content, filePath) {
2190
2286
  hasFrontmatter
2191
2287
  };
2192
2288
  }
2289
+ /**
2290
+ * A top-level `key: value` entry. Nested entries are left alone deliberately:
2291
+ * the repair below rewrites a value's meaning, and the failure it exists for —
2292
+ * an unquoted sentence with a colon in it — is a `description`, which is always
2293
+ * top-level.
2294
+ */
2295
+ const TOP_LEVEL_ENTRY_PATTERN = /^([A-Za-z_][\w.-]*):[^\S\r\n]+(\S.*)$/;
2296
+ /** A plain scalar that already starts as some other YAML construct. */
2297
+ const YAML_CONSTRUCT_PREFIX_PATTERN = /^["'|>&*![{#]/;
2298
+ /**
2299
+ * Cut a plain scalar at its inline comment — whitespace followed by `#`.
2300
+ *
2301
+ * This has to happen before anything else looks at the value, or a line such as
2302
+ * `allowed-tools: Read # TODO: add Bash later` reads as needing repair and comes
2303
+ * back quoted with the comment inside it, which for a list of tool permissions
2304
+ * would grant what the comment had disabled. Scanning by hand rather than with
2305
+ * `/\s+#.*$/`: that pattern is unanchored, so a long run of spaces with no `#`
2306
+ * after it backtracks from every starting position, and a value padded with a
2307
+ * megabyte of spaces takes minutes to reject. This is linear in the value.
2308
+ */
2309
+ function stripInlineComment(rawValue) {
2310
+ for (let index = 1; index < rawValue.length; index++) if (rawValue[index] === "#" && /\s/.test(rawValue[index - 1] ?? "")) return rawValue.slice(0, index).trimEnd();
2311
+ return rawValue.trimEnd();
2312
+ }
2313
+ function repairFrontmatterLine(line) {
2314
+ const unchanged = {
2315
+ line,
2316
+ droppedComment: false
2317
+ };
2318
+ const carriageReturn = line.endsWith("\r") ? "\r" : "";
2319
+ const bareLine = carriageReturn === "" ? line : line.slice(0, -1);
2320
+ const match = TOP_LEVEL_ENTRY_PATTERN.exec(bareLine);
2321
+ if (!match) return unchanged;
2322
+ const [, key = "", rawValue = ""] = match;
2323
+ const value = stripInlineComment(rawValue);
2324
+ if (value === "") return unchanged;
2325
+ if (!/:(?:\s|$)/.test(value)) return unchanged;
2326
+ if (YAML_CONSTRUCT_PREFIX_PATTERN.test(value)) return unchanged;
2327
+ return {
2328
+ line: `${key}: ${JSON.stringify(value)}${carriageReturn}`,
2329
+ droppedComment: value !== rawValue.trimEnd()
2330
+ };
2331
+ }
2332
+ /**
2333
+ * Quote the unquoted scalars that make a frontmatter block unparseable, or
2334
+ * return `undefined` when there is nothing to repair. Only the frontmatter
2335
+ * block is rewritten; the body is passed through untouched.
2336
+ */
2337
+ function repairMalformedFrontmatterYaml(content) {
2338
+ const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
2339
+ if (!opening) return;
2340
+ const blockStart = opening[0].length;
2341
+ const closing = /\r?\n---/.exec(content.slice(blockStart));
2342
+ if (!closing) return;
2343
+ const blockEnd = blockStart + closing.index;
2344
+ const block = content.slice(blockStart, blockEnd);
2345
+ const repairedLines = block.split("\n").map(repairFrontmatterLine);
2346
+ const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
2347
+ if (repairedBlock === block) return;
2348
+ return {
2349
+ content: content.slice(0, blockStart) + repairedBlock + content.slice(blockEnd),
2350
+ droppedComment: repairedLines.some(({ droppedComment }) => droppedComment)
2351
+ };
2352
+ }
2353
+ /**
2354
+ * Parse frontmatter, retrying once with unquoted colon-bearing values quoted.
2355
+ *
2356
+ * Files authored for another client routinely carry YAML that only that
2357
+ * client's parser accepts — `description: Use this skill when: the user asks
2358
+ * about PDFs` is the case the Agent Skills client guide names. Without a retry
2359
+ * such a file is not merely reported, it is dropped: the lenient skill import
2360
+ * catches the parse error and skips the whole skill. The retry is deliberately
2361
+ * narrow — one pass, top-level entries only, and the original error is what
2362
+ * surfaces if it does not help, so a genuinely broken file still fails with the
2363
+ * message that describes what is actually wrong with it. A file with no closing
2364
+ * `---`, or one whose opening fence carries a language tag, is not repaired at
2365
+ * all: neither is a frontmatter block gray-matter would have read.
2366
+ *
2367
+ * @see https://agentskills.io/client-implementation/adding-skills-support
2368
+ */
2369
+ function parseFrontmatterWithYamlRepair(content, filePath, options = {}) {
2370
+ try {
2371
+ return parseFrontmatter(content, filePath);
2372
+ } catch (error) {
2373
+ const repaired = repairMalformedFrontmatterYaml(content);
2374
+ if (repaired === void 0) throw error;
2375
+ let result;
2376
+ try {
2377
+ result = parseFrontmatter(repaired.content, filePath);
2378
+ } catch {
2379
+ throw error;
2380
+ }
2381
+ if (options.quiet === true) return result;
2382
+ const commentNote = repaired.droppedComment ? " Text following a space and `#` was read as a YAML comment and left out of the value." : "";
2383
+ warnOnceWithFallback(void 0, `Recovered malformed YAML frontmatter in ${filePath === void 0 ? "the input" : stripControlCharacters(toPosixPath(filePath))} by quoting values that contain a colon.${commentNote} Quote them in the file itself so other tools can read it too.`);
2384
+ return result;
2385
+ }
2386
+ }
2193
2387
  //#endregion
2194
2388
  //#region src/features/checks/rulesync-check.ts
2195
2389
  const RulesyncCheckFrontmatterSchema = z.looseObject({
@@ -5859,8 +6053,474 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5859
6053
  }
5860
6054
  };
5861
6055
  //#endregion
6056
+ //#region src/utils/concurrency.ts
6057
+ /**
6058
+ * Map over items with a bounded number of operations in flight.
6059
+ *
6060
+ * `Promise.all(items.map(...))` starts every operation at once, which is fine
6061
+ * for a handful of paths and not fine for a directory tree of unknown size: a
6062
+ * few thousand concurrent `realpath` calls queue on the libuv thread pool and
6063
+ * hold their closures alive while they wait. Results keep the input order.
6064
+ *
6065
+ * `withSemaphore` in `src/lib/github-utils.ts` bounds concurrency too, but it
6066
+ * wraps one call at a time: the caller still writes `Promise.all(items.map(…))`
6067
+ * around it, so every item's promise chain is allocated up front. This walks a
6068
+ * shared cursor with `limit` workers instead, so a list of unknown size costs
6069
+ * `limit` pending operations rather than one per item.
6070
+ */
6071
+ async function mapWithConcurrency({ items, limit, mapper }) {
6072
+ const results = Array.from({ length: items.length });
6073
+ let nextIndex = 0;
6074
+ const runWorker = async () => {
6075
+ while (nextIndex < items.length) {
6076
+ const index = nextIndex;
6077
+ nextIndex += 1;
6078
+ const item = items[index];
6079
+ if (item === void 0) continue;
6080
+ results[index] = await mapper(item);
6081
+ }
6082
+ };
6083
+ const workerCount = Math.max(1, Math.min(limit, items.length));
6084
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
6085
+ return results;
6086
+ }
6087
+ //#endregion
5862
6088
  //#region src/types/ai-dir.ts
5863
- var AiDir = class {
6089
+ /**
6090
+ * Directories that hold credentials. Excluding these protects something, so
6091
+ * their exclusion is reported rather than silent.
6092
+ */
6093
+ const NEVER_CARRIED_CREDENTIAL_DIR_NAMES = /* @__PURE__ */ new Set([
6094
+ ".ssh",
6095
+ ".aws",
6096
+ ".gnupg"
6097
+ ]);
6098
+ /**
6099
+ * Directories that are refused only when the path leaves the skill directory to
6100
+ * reach them. These are the per-application trees of a home directory, where
6101
+ * naming every credential file is a list always one release behind -- `gcloud`
6102
+ * alone writes `credentials.db` and `application_default_credentials.json`, and
6103
+ * `.config/anthropic/` holds an API key. A skill that ships a `.config/` of its
6104
+ * own still carries it: what is refused is a link that reaches the *user's*.
6105
+ *
6106
+ * A tool home is deliberately absent. `~/.claude/skills/` and `~/.codex/` hold
6107
+ * the global skills this feature exists to share, so refusing a link that
6108
+ * reaches one would refuse the ordinary case along with the bad one.
6109
+ */
6110
+ const NEVER_CARRIED_ESCAPING_DIR_NAMES = /* @__PURE__ */ new Set([
6111
+ ".config",
6112
+ ".local",
6113
+ ".azure",
6114
+ ".m2",
6115
+ ".terraform.d",
6116
+ ".docker",
6117
+ ".kube",
6118
+ "keychains"
6119
+ ]);
6120
+ /**
6121
+ * Whether an escaping real path passes through a directory only reachable by
6122
+ * escaping.
6123
+ *
6124
+ * Only the segments *past* the skill directory count. A global skill lives at
6125
+ * `~/.config/agents/skills/<name>` for Amp, Devin and Muse alike, so judging
6126
+ * the whole real path would refuse a link that never left the skills tree it
6127
+ * was already in -- the shared-directory case, reported as a credential.
6128
+ */
6129
+ function escapesIntoCredentialDir({ realDirPath, realFilePath }) {
6130
+ const normalize = (segment) => normalizePathSegment(segment.toLowerCase());
6131
+ const dirSegments = splitPathSegments(realDirPath).map(normalize);
6132
+ const fileSegments = splitPathSegments(realFilePath).map(normalize);
6133
+ let shared = 0;
6134
+ while (shared < dirSegments.length && shared < fileSegments.length && dirSegments[shared] === fileSegments[shared]) shared += 1;
6135
+ return (dirSegments.length - shared > 1 ? fileSegments : fileSegments.slice(shared)).some((segment) => NEVER_CARRIED_ESCAPING_DIR_NAMES.has(segment));
6136
+ }
6137
+ /**
6138
+ * Directories that are never part of a skill for the ordinary reasons: a
6139
+ * nested repository, or a build/cache tree. Leaving these out is what a user
6140
+ * expects, so it happens quietly.
6141
+ */
6142
+ const NEVER_CARRIED_NOISE_DIR_NAMES = /* @__PURE__ */ new Set([
6143
+ ".git",
6144
+ ".hg",
6145
+ ".svn",
6146
+ ".cache",
6147
+ ".venv",
6148
+ ".tox",
6149
+ ".mypy_cache",
6150
+ ".pytest_cache",
6151
+ ".ruff_cache",
6152
+ ".gradle",
6153
+ ".next",
6154
+ ".nuxt",
6155
+ ".turbo",
6156
+ ".parcel-cache",
6157
+ ".nyc_output",
6158
+ ".terraform"
6159
+ ]);
6160
+ /** Files that hold credentials. Compared lower-cased. */
6161
+ const NEVER_CARRIED_CREDENTIAL_FILE_NAMES = /* @__PURE__ */ new Set([
6162
+ ".npmrc",
6163
+ ".netrc",
6164
+ ".git-credentials",
6165
+ ".pgpass",
6166
+ ".pypirc",
6167
+ ".htpasswd",
6168
+ ".dockercfg",
6169
+ ".envrc"
6170
+ ]);
6171
+ /** Files that are local noise. Compared lower-cased. */
6172
+ const NEVER_CARRIED_NOISE_FILE_NAMES = /* @__PURE__ */ new Set([".ds_store"]);
6173
+ /** Credential files whose parent directory is otherwise ordinary content. */
6174
+ const NEVER_CARRIED_PATH_SUFFIXES = [
6175
+ ".docker/config.json",
6176
+ ".kube/config",
6177
+ ".config/gh/hosts.yml",
6178
+ ".config/gcloud/credentials.db",
6179
+ ".gem/credentials",
6180
+ "gcloud/application_default_credentials.json",
6181
+ ".codex/auth.json",
6182
+ ".gemini/oauth_creds.json"
6183
+ ];
6184
+ /** Whether a path ends in one of the credential files named above. */
6185
+ function endsWithNeverCarriedSuffix(filePath) {
6186
+ const posixPath = toPosixPath(filePath).toLowerCase();
6187
+ return NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`));
6188
+ }
6189
+ /**
6190
+ * `.env.<suffix>` spellings that are templates rather than real values.
6191
+ * Everything else matching `.env*` is treated as holding secrets, because
6192
+ * `.env.production` is no less sensitive than `.env` itself.
6193
+ */
6194
+ const ENV_TEMPLATE_SUFFIXES = /* @__PURE__ */ new Set([
6195
+ "example",
6196
+ "sample",
6197
+ "template",
6198
+ "dist",
6199
+ "defaults"
6200
+ ]);
6201
+ /**
6202
+ * Kernel pseudo-filesystems, matched against the resolved real path. A link
6203
+ * into one of these does not reach a file at all: `/proc/self/environ` reads
6204
+ * back the entire environment of the running process, API keys included, and
6205
+ * `stat` reports it as an ordinary file. Nothing a skill carries lives here.
6206
+ */
6207
+ const NEVER_CARRIED_REAL_PATH_ROOTS = [
6208
+ "/proc",
6209
+ "/sys",
6210
+ "/dev"
6211
+ ];
6212
+ /**
6213
+ * Whether a directory of this name is pruned during the walk, so it is never
6214
+ * descended into at all. Derived from the directory names above so the pruning
6215
+ * and the path check below cannot drift apart.
6216
+ */
6217
+ function isNeverCarriedDirName(dirName) {
6218
+ const normalized = normalizePathSegment(dirName.toLowerCase());
6219
+ return NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(normalized) || NEVER_CARRIED_NOISE_DIR_NAMES.has(normalized) || isCredentialFileName(normalized);
6220
+ }
6221
+ /**
6222
+ * Windows drops trailing dots and spaces from a name, so a file called `.env `
6223
+ * is written as `.env` once it lands in a tool directory there. Normalizing
6224
+ * before every comparison means the name is judged as what it becomes.
6225
+ */
6226
+ function normalizePathSegment(segment) {
6227
+ const normalized = segment.replace(/[\s.]+$/, "");
6228
+ return normalized === "" ? segment : normalized;
6229
+ }
6230
+ /**
6231
+ * Why a path reaches something that is never skill content, or `undefined`
6232
+ * when it does not.
6233
+ *
6234
+ * Carrying hidden entries means a secret sitting in a skill directory would be
6235
+ * copied into every enabled tool root, multiplying the places it can be
6236
+ * committed from, and a `.venv` would be copied file by file into each of them.
6237
+ * None of these names is ever skill content, so excluding them costs nothing.
6238
+ *
6239
+ * The check is applied to the resolved real path as well as the literal one:
6240
+ * the names are what makes an entry dangerous, and a symbolic link named
6241
+ * `vendor` pointing at `~/.aws` is exactly as dangerous as a directory called
6242
+ * `.aws`. Comparison is lower-cased because macOS and Windows resolve `.SSH`
6243
+ * and `.ssh` to the same file.
6244
+ */
6245
+ function classifyNeverCarried(relativePath) {
6246
+ const segments = toPosixPath(relativePath).toLowerCase().split("/").filter((segment) => segment !== "" && segment !== ".").map(normalizePathSegment);
6247
+ const fileName = segments.at(-1) ?? "";
6248
+ const posixPath = segments.join("/");
6249
+ if (segments.slice(0, -1).some((segment) => isCredentialFileName(segment))) return "credential";
6250
+ if (segments.some((segment) => NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(segment))) return "credential";
6251
+ if (segments.some((segment) => NEVER_CARRIED_NOISE_DIR_NAMES.has(segment))) return "noise";
6252
+ if (isCredentialFileName(fileName)) return "credential";
6253
+ if (NEVER_CARRIED_NOISE_FILE_NAMES.has(fileName)) return "noise";
6254
+ if (NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`))) return "credential";
6255
+ }
6256
+ /**
6257
+ * Whether a single path segment names a credential file, whether it is the file
6258
+ * itself or a directory somebody gave that name to.
6259
+ */
6260
+ function isCredentialFileName(segment) {
6261
+ const name = normalizePathSegment(segment.toLowerCase());
6262
+ if (NEVER_CARRIED_CREDENTIAL_FILE_NAMES.has(name)) return true;
6263
+ for (const base of [".env", ".envrc"]) {
6264
+ if (name === base) return true;
6265
+ if (name.startsWith(`${base}.`)) {
6266
+ const lastPiece = name.split(".").at(-1) ?? "";
6267
+ return !ENV_TEMPLATE_SUFFIXES.has(lastPiece);
6268
+ }
6269
+ }
6270
+ return false;
6271
+ }
6272
+ /** Whether a resolved real path points into a kernel pseudo-filesystem. */
6273
+ function isSystemPseudoPath(absolutePath) {
6274
+ const posixPath = toPosixPath(absolutePath);
6275
+ return NEVER_CARRIED_REAL_PATH_ROOTS.some((root) => posixPath === root || posixPath.startsWith(`${root}/`));
6276
+ }
6277
+ /** How many links a chain may be followed before it is treated as a loop. */
6278
+ const MAX_LINK_CHAIN_HOPS = 40;
6279
+ /**
6280
+ * Whether reaching a path goes through a kernel pseudo-filesystem, even when it
6281
+ * does not end in one.
6282
+ *
6283
+ * Asking `realpath` alone is not enough, and the entries it is not enough for
6284
+ * are the dangerous ones: `/proc/<pid>/fd/N`, `exe`, `cwd` and `root` are magic
6285
+ * links, so resolving them lands *outside* `/proc`, on whatever file the
6286
+ * process happens to hold open — a private key another program is reading right
6287
+ * now would come back as an ordinary path and be carried. Following the chain a
6288
+ * hop at a time, and checking each hop, is what sees the `/proc` in the middle.
6289
+ */
6290
+ async function resolvesThroughSystemPseudoPath(filePath) {
6291
+ let currentPath = resolve(filePath);
6292
+ let hops = 0;
6293
+ for (; hops < MAX_LINK_CHAIN_HOPS; hops++) {
6294
+ if (isSystemPseudoPath(currentPath)) return {
6295
+ throughPseudoPath: true,
6296
+ hops
6297
+ };
6298
+ try {
6299
+ if (isSystemPseudoPath(await realpath(dirname(currentPath)))) return {
6300
+ throughPseudoPath: true,
6301
+ hops
6302
+ };
6303
+ } catch {}
6304
+ let linkStats;
6305
+ try {
6306
+ linkStats = await lstat(currentPath);
6307
+ } catch {
6308
+ return {
6309
+ throughPseudoPath: false,
6310
+ hops
6311
+ };
6312
+ }
6313
+ if (!linkStats.isSymbolicLink()) break;
6314
+ let target;
6315
+ try {
6316
+ target = await readlink(currentPath);
6317
+ } catch {
6318
+ return {
6319
+ throughPseudoPath: false,
6320
+ hops
6321
+ };
6322
+ }
6323
+ currentPath = isAbsolute(target) ? target : resolve(dirname(currentPath), target);
6324
+ }
6325
+ return {
6326
+ throughPseudoPath: false,
6327
+ hops
6328
+ };
6329
+ }
6330
+ const MAX_CARRIED_FILES = 1e4;
6331
+ const MAX_CARRIED_DIRECTORIES = 1e4;
6332
+ /**
6333
+ * The bounds above limit what is *carried*; this one limits what is *looked at*.
6334
+ * A directory holding nothing but a few hundred thousand links to itself carries
6335
+ * no files and occupies no depth, and would still cost a `stat` apiece.
6336
+ */
6337
+ const MAX_CARRIED_ENTRIES_EXAMINED = 2e5;
6338
+ const MAX_CARRIED_BYTES = 104857600;
6339
+ /** How many `realpath` calls the carried-file filter keeps in flight. */
6340
+ const CARRIED_REALPATH_CONCURRENCY = 32;
6341
+ /** Sort directory entries by name so a walk of the same tree is reproducible. */
6342
+ function compareByName(left, right) {
6343
+ if (left.name === right.name) return 0;
6344
+ return left.name < right.name ? -1 : 1;
6345
+ }
6346
+ /**
6347
+ * Order the routes that cross the same number of symbolic links: the one with
6348
+ * the fewest hidden segments first, so a named alias represents a shared tree
6349
+ * rather than a hidden one that a hidden-entry rule then refuses, taking the
6350
+ * named route's content with it.
6351
+ */
6352
+ function comparePendingCarriedDirs(left, right) {
6353
+ if (left.hiddenSegments !== right.hiddenSegments) return left.hiddenSegments - right.hiddenSegments;
6354
+ if (left.depth !== right.depth) return left.depth - right.depth;
6355
+ if (left.dirPath === right.dirPath) return 0;
6356
+ return left.dirPath < right.dirPath ? -1 : 1;
6357
+ }
6358
+ /**
6359
+ * Collect the files under a carried directory, following symbolic links but
6360
+ * visiting each real directory exactly once, by its cheapest route.
6361
+ *
6362
+ * A glob walk cannot do this safely. Two links in one directory that both point
6363
+ * back at an ancestor double the paths walked per level, and the walker follows
6364
+ * them until the kernel's ELOOP limit (~40), so the path array alone exhausts
6365
+ * the heap long before anything reads a file — a depth bound only lowers the
6366
+ * exponent, while the base is whatever number of links the tree's author chose.
6367
+ * Remembering the real directories already visited removes the multiplication
6368
+ * itself: a cycle, and an alias for a directory already walked, both stop at the
6369
+ * entry that closes them.
6370
+ *
6371
+ * Which route represents a directory then matters, because the others are
6372
+ * dropped. The walk proceeds in rounds by the number of symbolic links crossed:
6373
+ * everything reachable without crossing one, then everything one link away, and
6374
+ * so on. A real location therefore always wins over an alias for it — at any
6375
+ * nesting depth, not just among siblings, which a depth-first walk could not
6376
+ * promise — and among aliases the named one wins over a hidden one.
6377
+ *
6378
+ * A broken link is skipped: it resolves to nothing to read.
6379
+ */
6380
+ async function walkCarriedFiles(dirPath, { skipHiddenRoutes = false } = {}) {
6381
+ const filePaths = [];
6382
+ const visitedRealDirPaths = /* @__PURE__ */ new Set();
6383
+ const truncations = /* @__PURE__ */ new Set();
6384
+ const unreadablePaths = /* @__PURE__ */ new Set();
6385
+ const pseudoPaths = /* @__PURE__ */ new Set();
6386
+ const depthStoppedRealDirPaths = /* @__PURE__ */ new Set();
6387
+ let deferredLinkedDirs = [];
6388
+ /** Whether the walk has hit a bound that ends it rather than one branch of it. */
6389
+ let examinedEntries = 0;
6390
+ const isFull = () => truncations.has("count") || truncations.has("directories") || truncations.has("entries");
6391
+ const addFile = (filePath) => {
6392
+ if (filePaths.length >= 1e4) {
6393
+ truncations.add("count");
6394
+ return;
6395
+ }
6396
+ filePaths.push(filePath);
6397
+ };
6398
+ /** Carry what a symbolic link names, or hold its directory for the next round. */
6399
+ const routeLinkedEntry = async (child, entryName) => {
6400
+ let targetStats;
6401
+ try {
6402
+ targetStats = await stat(child.dirPath);
6403
+ } catch {
6404
+ return;
6405
+ }
6406
+ if (targetStats.isFile()) {
6407
+ addFile(child.dirPath);
6408
+ return;
6409
+ }
6410
+ if (!targetStats.isDirectory() || isNeverCarriedDirName(entryName)) return;
6411
+ const { throughPseudoPath, hops } = await resolvesThroughSystemPseudoPath(child.dirPath);
6412
+ examinedEntries += hops;
6413
+ if (throughPseudoPath) {
6414
+ pseudoPaths.add(child.dirPath);
6415
+ return;
6416
+ }
6417
+ deferredLinkedDirs.push(child);
6418
+ };
6419
+ /** Walk one directory and everything below it that no symbolic link leads to. */
6420
+ const walkWithoutCrossingLinks = async (pending) => {
6421
+ if (isFull()) return;
6422
+ let realCurrentPath;
6423
+ try {
6424
+ realCurrentPath = await realpath(pending.dirPath);
6425
+ } catch {
6426
+ unreadablePaths.add(pending.dirPath);
6427
+ return;
6428
+ }
6429
+ if (visitedRealDirPaths.has(realCurrentPath)) return;
6430
+ if (isSystemPseudoPath(realCurrentPath)) {
6431
+ pseudoPaths.add(pending.dirPath);
6432
+ return;
6433
+ }
6434
+ if (pending.depth > 12) {
6435
+ depthStoppedRealDirPaths.add(realCurrentPath);
6436
+ return;
6437
+ }
6438
+ if (visitedRealDirPaths.size >= 1e4) {
6439
+ truncations.add("directories");
6440
+ return;
6441
+ }
6442
+ visitedRealDirPaths.add(realCurrentPath);
6443
+ let entries;
6444
+ try {
6445
+ entries = await readdir(pending.dirPath, { withFileTypes: true });
6446
+ } catch {
6447
+ unreadablePaths.add(pending.dirPath);
6448
+ return;
6449
+ }
6450
+ const realSubDirs = [];
6451
+ for (const entry of entries.toSorted(compareByName)) {
6452
+ if (isFull()) return;
6453
+ examinedEntries += 1;
6454
+ if (examinedEntries > 2e5) {
6455
+ truncations.add("entries");
6456
+ return;
6457
+ }
6458
+ if (skipHiddenRoutes && isHiddenPathSegment(entry.name)) continue;
6459
+ const entryPath = join(pending.dirPath, entry.name);
6460
+ const child = {
6461
+ dirPath: entryPath,
6462
+ depth: pending.depth + 1,
6463
+ hiddenSegments: pending.hiddenSegments + (isHiddenPathSegment(entry.name) ? 1 : 0)
6464
+ };
6465
+ if (entry.isFile()) {
6466
+ addFile(entryPath);
6467
+ continue;
6468
+ }
6469
+ if (entry.isDirectory()) {
6470
+ if (!isNeverCarriedDirName(entry.name)) realSubDirs.push(child);
6471
+ continue;
6472
+ }
6473
+ if (!entry.isSymbolicLink()) continue;
6474
+ await routeLinkedEntry(child, entry.name);
6475
+ }
6476
+ for (const realSubDir of realSubDirs) {
6477
+ if (isFull()) return;
6478
+ await walkWithoutCrossingLinks(realSubDir);
6479
+ }
6480
+ };
6481
+ let round = [{
6482
+ dirPath,
6483
+ depth: 0,
6484
+ hiddenSegments: 0
6485
+ }];
6486
+ while (round.length > 0 && !isFull()) {
6487
+ deferredLinkedDirs = [];
6488
+ for (const pending of round.toSorted(comparePendingCarriedDirs)) {
6489
+ if (isFull()) break;
6490
+ await walkWithoutCrossingLinks(pending);
6491
+ }
6492
+ round = deferredLinkedDirs;
6493
+ }
6494
+ for (const realDirPath of depthStoppedRealDirPaths) if (!visitedRealDirPaths.has(realDirPath)) {
6495
+ truncations.add("depth");
6496
+ break;
6497
+ }
6498
+ return {
6499
+ filePaths: filePaths.toSorted(),
6500
+ truncations,
6501
+ unreadablePaths: [...unreadablePaths],
6502
+ pseudoPaths: [...pseudoPaths]
6503
+ };
6504
+ }
6505
+ /** Render a set of refused or noteworthy paths for one warning line. */
6506
+ function formatReportedPaths(paths) {
6507
+ const sorted = [...paths].toSorted();
6508
+ const named = sorted.slice(0, 10).map((filePath) => stripControlCharacters(toPosixPath(filePath))).join(", ");
6509
+ const remaining = sorted.length - 10;
6510
+ return {
6511
+ count: sorted.length,
6512
+ list: `${named}${remaining > 0 ? `, and ${remaining} more` : ""}`
6513
+ };
6514
+ }
6515
+ /** "entry" or "entries", so the warnings below read as sentences. */
6516
+ function entryWord(count) {
6517
+ return count === 1 ? "entry" : "entries";
6518
+ }
6519
+ /** "resolves" or "resolve", to agree with the entry count it follows. */
6520
+ function resolveWord(count) {
6521
+ return count === 1 ? "resolves" : "resolve";
6522
+ }
6523
+ var AiDir = class AiDir {
5864
6524
  /**
5865
6525
  * @example "."
5866
6526
  */
@@ -5910,8 +6570,7 @@ var AiDir = class {
5910
6570
  const fullPath = path.join(this.outputRoot, this.relativeDirPath, this.dirName);
5911
6571
  const resolvedFull = resolve(fullPath);
5912
6572
  const resolvedBase = resolve(this.outputRoot);
5913
- const rel = relative(resolvedBase, resolvedFull);
5914
- if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
6573
+ if (pathEscapesRoot(relative(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
5915
6574
  return fullPath;
5916
6575
  }
5917
6576
  getMainFile() {
@@ -5937,9 +6596,227 @@ var AiDir = class {
5937
6596
  };
5938
6597
  }
5939
6598
  /**
6599
+ * A nested repository inside a carried directory is the one exclusion worth
6600
+ * reporting: unlike `.DS_Store`, it is there on purpose, and the tree it
6601
+ * points at is simply not reproduced on generate. Only the top level is
6602
+ * checked, which is where a submodule or a stray `git init` puts it, so the
6603
+ * check costs one stat per directory rather than a second walk. `fileExists`
6604
+ * is a bare `stat`, so it answers for a submodule pointer file and for a real
6605
+ * `.git` directory alike.
6606
+ */
6607
+ static async warnOnNestedGitDirectory(dirPath) {
6608
+ const gitEntryPath = join(dirPath, ".git");
6609
+ if (await fileExists(gitEntryPath)) warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(gitEntryPath))} with its directory: a nested repository is excluded, so the files it tracks are copied but its history is not.`);
6610
+ }
6611
+ /** Whether any segment of a relative path is dot-prefixed. */
6612
+ static hasHiddenSegment(relativePath) {
6613
+ return splitPathSegments(relativePath).some(isHiddenPathSegment);
6614
+ }
6615
+ /**
6616
+ * Whether the entry a path ends at is itself hidden, its ancestors aside.
6617
+ * A shared skill tree usually lives under a dot-directory, so an ancestor
6618
+ * says nothing about the file; its own name is what was chosen for it.
6619
+ */
6620
+ static hasHiddenName(relativePath) {
6621
+ return isHiddenPathSegment(splitPathSegments(relativePath).at(-1) ?? "");
6622
+ }
6623
+ /**
6624
+ * Drop the entries a skill directory must not carry.
6625
+ *
6626
+ * Three rules. `classifyNeverCarried` comes first, evaluated against the
6627
+ * resolved real path as well as the literal one: names that are never skill
6628
+ * content stay out however they are reached, so renaming a symbolic link
6629
+ * does not turn `~/.aws` into content a skill carries. Next, a real path
6630
+ * inside a kernel pseudo-filesystem is refused outright — that is process
6631
+ * state, not a file.
6632
+ *
6633
+ * The third concerns hidden entries a symbolic link reaches outside the
6634
+ * directory. Following symlinks out of a source tree is deliberate and
6635
+ * documented — it is how a shared skill is referenced from several projects
6636
+ * without being duplicated (issue #1707), and the trust boundary is the tree
6637
+ * you point Rulesync at. Carrying hidden entries changes what that costs,
6638
+ * though: one ordinary-looking link to a home directory would pull in every
6639
+ * dotfile under it, and those are the entries with credential value. What
6640
+ * decides is the name in the skill directory, not what the link resolves
6641
+ * through: a named file keeps its documented behavior even when the target
6642
+ * sits under a dot-directory such as `~/.dotfiles`, because somebody chose
6643
+ * that name. What the link resolves *to* still counts at the end of the path,
6644
+ * though — `notes.md` pointing at `~/.claude/.credentials.json` reaches a
6645
+ * file nobody named for a skill — so a hidden final segment is refused on
6646
+ * either side. Reaching outside is reported either way, since content from
6647
+ * outside the tree is about to be copied into every enabled tool root.
6648
+ *
6649
+ * A path that cannot be resolved is kept: `realpath` fails on a broken link
6650
+ * or a race, and neither is a reason to silently drop a file.
6651
+ */
6652
+ /** Report what a carried directory left out, once both walks have had their say. */
6653
+ static warnOnRefusedCarriedFiles(dirPath, carried) {
6654
+ const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
6655
+ if (carried.refusedCredentials.size > 0) {
6656
+ const { count, list } = formatReportedPaths(carried.refusedCredentials);
6657
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} named as a credential store: ${list}. A skill must not ship secrets; read them from the environment instead.`);
6658
+ }
6659
+ if (carried.refusedPseudoPaths.size > 0) {
6660
+ const { count, list } = formatReportedPaths(carried.refusedPseudoPaths);
6661
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a system pseudo-filesystem: ${list}. Those read back process state, not skill content.`);
6662
+ }
6663
+ if (carried.refusedNoiseAliases.size > 0) {
6664
+ const { count, list } = formatReportedPaths(carried.refusedNoiseAliases);
6665
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a directory a skill never carries: ${list}. A nested repository, or a build or cache tree, is the usual target.`);
6666
+ }
6667
+ if (carried.refusedEscapedHidden.size > 0) {
6668
+ const { count, list } = formatReportedPaths(carried.refusedEscapedHidden);
6669
+ warnOnceWithFallback(void 0, `Not carrying ${count} hidden ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Copy them into the directory if the skill really needs them.`);
6670
+ }
6671
+ if (carried.carriedFromOutside.size > 0) {
6672
+ const { count, list } = formatReportedPaths([...carried.carriedFromOutside].map((filePath) => {
6673
+ const realFilePath = carried.realFilePathByPath.get(filePath);
6674
+ return realFilePath === void 0 ? filePath : `${filePath} -> ${realFilePath}`;
6675
+ }));
6676
+ warnOnceWithFallback(void 0, `Carrying ${count} ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Their content is copied into every generated tool directory.`);
6677
+ }
6678
+ }
6679
+ static async filterCarriedFiles(dirPath, filePaths) {
6680
+ let realDirPath;
6681
+ try {
6682
+ realDirPath = await realpath(dirPath);
6683
+ } catch {
6684
+ realDirPath = resolve(dirPath);
6685
+ }
6686
+ const refusedCredentials = /* @__PURE__ */ new Set();
6687
+ const refusedPseudoPaths = /* @__PURE__ */ new Set();
6688
+ const refusedEscapedHidden = /* @__PURE__ */ new Set();
6689
+ const refusedNoiseAliases = /* @__PURE__ */ new Set();
6690
+ const carriedFromOutside = /* @__PURE__ */ new Set();
6691
+ const realFilePathByPath = /* @__PURE__ */ new Map();
6692
+ const verdicts = await mapWithConcurrency({
6693
+ items: filePaths,
6694
+ limit: CARRIED_REALPATH_CONCURRENCY,
6695
+ mapper: async (filePath) => {
6696
+ const literalPath = relative(dirPath, filePath);
6697
+ const literalReason = classifyNeverCarried(literalPath);
6698
+ if (literalReason !== void 0) {
6699
+ if (literalReason === "credential") refusedCredentials.add(filePath);
6700
+ return false;
6701
+ }
6702
+ let realFilePath;
6703
+ try {
6704
+ realFilePath = await realpath(filePath);
6705
+ } catch {
6706
+ return true;
6707
+ }
6708
+ realFilePathByPath.set(filePath, realFilePath);
6709
+ if (isSystemPseudoPath(realFilePath)) {
6710
+ refusedPseudoPaths.add(filePath);
6711
+ return false;
6712
+ }
6713
+ const realPath = relative(realDirPath, realFilePath);
6714
+ const realReason = classifyNeverCarried(realPath);
6715
+ if (realReason !== void 0) {
6716
+ if (realReason === "credential") refusedCredentials.add(filePath);
6717
+ else refusedNoiseAliases.add(filePath);
6718
+ return false;
6719
+ }
6720
+ if (!pathEscapesRoot(realPath)) return true;
6721
+ if ((await resolvesThroughSystemPseudoPath(filePath)).throughPseudoPath) {
6722
+ refusedPseudoPaths.add(filePath);
6723
+ return false;
6724
+ }
6725
+ if (endsWithNeverCarriedSuffix(realFilePath)) {
6726
+ refusedCredentials.add(filePath);
6727
+ return false;
6728
+ }
6729
+ if (escapesIntoCredentialDir({
6730
+ realDirPath,
6731
+ realFilePath
6732
+ })) {
6733
+ refusedCredentials.add(filePath);
6734
+ return false;
6735
+ }
6736
+ if (AiDir.hasHiddenSegment(literalPath) || AiDir.hasHiddenName(realPath)) {
6737
+ refusedEscapedHidden.add(filePath);
6738
+ return false;
6739
+ }
6740
+ carriedFromOutside.add(filePath);
6741
+ return true;
6742
+ }
6743
+ });
6744
+ return {
6745
+ filePaths: filePaths.filter((_filePath, index) => verdicts[index]),
6746
+ realFilePathByPath,
6747
+ refusedCredentials,
6748
+ refusedPseudoPaths,
6749
+ refusedEscapedHidden,
6750
+ refusedNoiseAliases,
6751
+ carriedFromOutside
6752
+ };
6753
+ }
6754
+ /**
6755
+ * Report what the walk had to leave behind, so a skill that silently lost
6756
+ * files says so rather than generating a directory that is quietly short.
6757
+ */
6758
+ static warnOnCarriedWalkLimits({ reportedDirPath, truncations, unreadablePaths }) {
6759
+ if (truncations.has("depth")) warnOnceWithFallback(void 0, `Not carrying the entries more than 12 directories below ${reportedDirPath}: a skill directory is walked to that depth only. A symbolic link that reaches a large tree is the usual cause.`);
6760
+ if (truncations.has("count")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} beyond the first ${MAX_CARRIED_FILES}: a directory may carry at most that many files. A symbolic link that reaches a large tree is the usual cause.`);
6761
+ if (truncations.has("directories")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} below the first ${MAX_CARRIED_DIRECTORIES} directories: a directory may carry files from at most that many directories. A symbolic link that reaches a large tree is the usual cause.`);
6762
+ if (truncations.has("entries")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} that come after the first ${MAX_CARRIED_ENTRIES_EXAMINED} looked at: a directory is walked over at most that many entries. A tree of symbolic links that lead back into it is the usual cause.`);
6763
+ if (unreadablePaths.length > 0) {
6764
+ const { count, list } = formatReportedPaths(unreadablePaths);
6765
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that could not be read: ${list}. A permission the current user does not hold is the usual cause.`);
6766
+ }
6767
+ }
6768
+ /**
6769
+ * Walk the directory once more with the hidden routes pruned, and take the
6770
+ * files the first walk had to leave behind because the route that reached
6771
+ * them ran through a hidden directory. This can only add files, and only ones
6772
+ * a fully named route reaches.
6773
+ */
6774
+ static async recoverCarriedFilesFromNamedRoutes({ dirPath, carried, carriedPaths, carriedRealPaths, walk }) {
6775
+ const named = await walkCarriedFiles(dirPath, { skipHiddenRoutes: true });
6776
+ const recovered = await AiDir.filterCarriedFiles(dirPath, named.filePaths);
6777
+ for (const truncation of named.truncations) walk.truncations.add(truncation);
6778
+ for (const unreadablePath of named.unreadablePaths) if (!walk.unreadablePaths.includes(unreadablePath)) walk.unreadablePaths.push(unreadablePath);
6779
+ for (const pseudoPath of named.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
6780
+ for (const refused of recovered.refusedCredentials) carried.refusedCredentials.add(refused);
6781
+ for (const refused of recovered.refusedPseudoPaths) carried.refusedPseudoPaths.add(refused);
6782
+ for (const refused of recovered.refusedNoiseAliases) carried.refusedNoiseAliases.add(refused);
6783
+ for (const refused of recovered.refusedEscapedHidden) carried.refusedEscapedHidden.add(refused);
6784
+ for (const filePath of recovered.filePaths) {
6785
+ const realFilePath = recovered.realFilePathByPath.get(filePath) ?? filePath;
6786
+ if (carriedRealPaths.has(realFilePath)) continue;
6787
+ if (carriedPaths.length >= 1e4) {
6788
+ walk.truncations.add("count");
6789
+ break;
6790
+ }
6791
+ carriedRealPaths.add(realFilePath);
6792
+ carriedPaths.push(filePath);
6793
+ carried.realFilePathByPath.set(filePath, realFilePath);
6794
+ if (recovered.carriedFromOutside.has(filePath)) carried.carriedFromOutside.add(filePath);
6795
+ }
6796
+ for (const filePath of carried.refusedEscapedHidden) {
6797
+ const realFilePath = carried.realFilePathByPath.get(filePath) ?? filePath;
6798
+ if (carriedRealPaths.has(realFilePath)) carried.refusedEscapedHidden.delete(filePath);
6799
+ }
6800
+ }
6801
+ /**
5940
6802
  * Recursively collects all files from a directory, excluding the specified main file.
5941
6803
  * This is a common utility for loading additional files alongside the main file.
5942
6804
  *
6805
+ * Hidden entries are included. The directories this walks are skill trees,
6806
+ * whose specification says a skill directory "may contain any files and
6807
+ * directories beyond the required `SKILL.md`" — a `.env.example` beside the
6808
+ * scripts that read it is content, not noise, and dropping it silently on
6809
+ * both import and generate loses part of the skill. What is left out is the
6810
+ * set of entries that are never skill content — a nested repository's `.git`,
6811
+ * the macOS Finder's `.DS_Store`, credential stores, build and cache trees —
6812
+ * as decided by `classifyNeverCarried`. Whole directories from that set are
6813
+ * pruned during the walk too, so it never descends into them at all.
6814
+ *
6815
+ * The walk is bounded and cycle-aware — see `walkCarriedFiles` — because the
6816
+ * tree may contain symbolic links that somebody else chose.
6817
+ *
6818
+ * @see https://agentskills.io/specification
6819
+ *
5943
6820
  * @param outputRoot - The base directory path
5944
6821
  * @param relativeDirPath - The relative path to the directory containing the skill
5945
6822
  * @param dirName - The name of the directory
@@ -5948,14 +6825,57 @@ var AiDir = class {
5948
6825
  */
5949
6826
  static async collectOtherFiles(outputRoot, relativeDirPath, dirName, excludeFileName) {
5950
6827
  const dirPath = join(outputRoot, relativeDirPath, dirName);
5951
- const filteredPaths = (await findFilesByGlobs(join(dirPath, "**", "*"), { type: "file" })).filter((filePath) => basename(filePath) !== excludeFileName);
5952
- return await Promise.all(filteredPaths.map(async (filePath) => {
5953
- const fileBuffer = await readFileBuffer(filePath);
5954
- return {
5955
- relativeFilePathToDirPath: relative(dirPath, filePath),
5956
- fileBuffer
5957
- };
5958
- }));
6828
+ const walk = await walkCarriedFiles(dirPath);
6829
+ const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
6830
+ await AiDir.warnOnNestedGitDirectory(dirPath);
6831
+ const carried = await AiDir.filterCarriedFiles(dirPath, walk.filePaths);
6832
+ const carriedPaths = [...carried.filePaths];
6833
+ const carriedRealPaths = new Set(carriedPaths.map((filePath) => carried.realFilePathByPath.get(filePath) ?? filePath));
6834
+ if (carried.refusedEscapedHidden.size > 0) await AiDir.recoverCarriedFilesFromNamedRoutes({
6835
+ dirPath,
6836
+ carried,
6837
+ carriedPaths,
6838
+ carriedRealPaths,
6839
+ walk
6840
+ });
6841
+ for (const pseudoPath of walk.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
6842
+ AiDir.warnOnCarriedWalkLimits({
6843
+ reportedDirPath,
6844
+ truncations: walk.truncations,
6845
+ unreadablePaths: walk.unreadablePaths
6846
+ });
6847
+ AiDir.warnOnRefusedCarriedFiles(dirPath, carried);
6848
+ const filteredPaths = carriedPaths.toSorted().filter((filePath) => basename(filePath) !== excludeFileName);
6849
+ const files = [];
6850
+ let carriedBytes = 0;
6851
+ for (const [index, filePath] of filteredPaths.entries()) {
6852
+ const classifiedPath = carried.realFilePathByPath.get(filePath) ?? filePath;
6853
+ let fileHandle;
6854
+ try {
6855
+ fileHandle = await open(classifiedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
6856
+ } catch (error) {
6857
+ warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
6858
+ continue;
6859
+ }
6860
+ try {
6861
+ const fileSize = (await fileHandle.stat()).size;
6862
+ if (carriedBytes + fileSize > 104857600) {
6863
+ warnOnceWithFallback(void 0, `Not carrying ${filteredPaths.length - index} of the ${filteredPaths.length} entries under ${reportedDirPath}: a directory may carry at most ${MAX_CARRIED_BYTES / 1024 / 1024}MB. A symbolic link that reaches a large tree is the usual cause.`);
6864
+ break;
6865
+ }
6866
+ const fileBuffer = await fileHandle.readFile();
6867
+ carriedBytes += fileBuffer.byteLength;
6868
+ files.push({
6869
+ relativeFilePathToDirPath: relative(dirPath, filePath),
6870
+ fileBuffer
6871
+ });
6872
+ } catch (error) {
6873
+ warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
6874
+ } finally {
6875
+ await fileHandle.close();
6876
+ }
6877
+ }
6878
+ return files;
5959
6879
  }
5960
6880
  };
5961
6881
  const RulesyncSkillFrontmatterSchema = z.looseObject({
@@ -6182,7 +7102,7 @@ var RulesyncSkill = class RulesyncSkill extends AiDir {
6182
7102
  const skillDirPath = join(outputRoot, relativeDirPath, dirName);
6183
7103
  const skillFilePath = join(skillDirPath, SKILL_FILE_NAME);
6184
7104
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
6185
- const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
7105
+ const { frontmatter, body: content, hasFrontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
6186
7106
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${skillFilePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
6187
7107
  const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter);
6188
7108
  if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
@@ -6619,27 +7539,6 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
6619
7539
  return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
6620
7540
  }
6621
7541
  //#endregion
6622
- //#region src/utils/control-characters.ts
6623
- /**
6624
- * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
6625
- * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
6626
- * line and paragraph separators, and the plain LRM/RLM marks. A name or value
6627
- * copied out of an untrusted config file, a fetched repository, or a tool's own
6628
- * settings file must never reach the terminal with these intact: they let the
6629
- * text forge log lines, reorder what is printed around them, or inject escape
6630
- * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
6631
- * neutral characters beside them, so they go too — a diagnostic line is not the
6632
- * place to preserve the typography of a right-to-left name.
6633
- */
6634
- const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
6635
- /**
6636
- * Removes every control character from `text` so it is safe to splice into a
6637
- * log line or other terminal output.
6638
- */
6639
- function stripControlCharacters(text) {
6640
- return text.replace(CONTROL_CHARACTERS_PATTERN, "");
6641
- }
6642
- //#endregion
6643
7542
  //#region src/types/feature-processor.ts
6644
7543
  var FeatureProcessor = class {
6645
7544
  outputRoot;
@@ -11566,6 +12465,26 @@ function toolSkillSearchRoots(paths) {
11566
12465
  * https://agentskills.io/client-implementation/adding-skills-support
11567
12466
  */
11568
12467
  const AGENT_SKILLS_INTEROP_ROOTS = /* @__PURE__ */ new Set([toPosixPath(AGENTSMD_SKILLS_DIR_PATH), toPosixPath(AMP_SKILLS_GLOBAL_DIR)]);
12468
+ /**
12469
+ * The one spec violation both sides of the conversion report. Generation warns
12470
+ * about it before writing the file; import warns about it because a conformant
12471
+ * client would skip the skill entirely, and a user who never sees that has no
12472
+ * reason to fix it.
12473
+ */
12474
+ const EMPTY_SKILL_DESCRIPTION_VIOLATION = "`description` is required and must not be empty; conformant clients skip a skill without one";
12475
+ /**
12476
+ * Report a skill read from disk whose `description` is empty. This lives on the
12477
+ * read rather than on any one tool class because several targets share one
12478
+ * `.agents/skills` tree: which of them reports the skill must not depend on
12479
+ * which target the user happened to enable. Every loader calls it — the two
12480
+ * directory loaders (`loadSkillDirContent`, `SimulatedSkill.fromDirDefault`)
12481
+ * and the flat-file one — so the form a tool stores its skills in does not
12482
+ * decide whether the problem is reported either.
12483
+ */
12484
+ function warnOnEmptyLoadedDescription({ skillFilePath, description }) {
12485
+ if (typeof description !== "string" || description.length > 0) return;
12486
+ warnOnceWithFallback(void 0, `${stripControlCharacters(toPosixPath(skillFilePath))}: ${EMPTY_SKILL_DESCRIPTION_VIOLATION}. Rulesync imports it anyway so the content is not lost; fill it in before relying on this skill.`);
12487
+ }
11569
12488
  function isAgentSkillsInteropRoot(relativeDirPath) {
11570
12489
  return AGENT_SKILLS_INTEROP_ROOTS.has(toPosixPath(relativeDirPath));
11571
12490
  }
@@ -11678,7 +12597,11 @@ var ToolSkill = class extends AiDir {
11678
12597
  const skillDirPath = join(outputRoot, actualRelativeDirPath, dirName);
11679
12598
  const skillFilePath = join(skillDirPath, SKILL_FILE_NAME);
11680
12599
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
11681
- const { frontmatter, body: content } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
12600
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
12601
+ warnOnEmptyLoadedDescription({
12602
+ skillFilePath,
12603
+ description: frontmatter.description
12604
+ });
11682
12605
  const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
11683
12606
  return {
11684
12607
  outputRoot,
@@ -11803,6 +12726,10 @@ function toSpecConformantAgentSkillFields(section, { coerceMetadata = true } = {
11803
12726
  ...allowedTools !== void 0 && allowedTools.length > 0 && { "allowed-tools": allowedTools }
11804
12727
  };
11805
12728
  }
12729
+ /** The `SKILL.md` a diagnostic should point at, in the scope it is written to. */
12730
+ function agentSkillFilePath({ outputRoot, relativeDirPath, dirName }) {
12731
+ return join(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
12732
+ }
11806
12733
  /**
11807
12734
  * Collect the normative violations the Agent Skills spec defines for a skill
11808
12735
  * about to be written. These are reported as warnings rather than errors:
@@ -11829,7 +12756,7 @@ function collectAgentSkillViolations({ frontmatter, dirName, sourceAllowedTools
11829
12756
  if (!NAME_PATTERN.test(name)) violations.push(`\`name\` "${name}" must contain only lowercase letters, digits and single hyphens, with no leading, trailing or consecutive hyphens`);
11830
12757
  if (name !== dirName) violations.push(`\`name\` "${name}" must match its parent directory name "${dirName}"; conformant clients require them to be equal`);
11831
12758
  }
11832
- if (description.length === 0) violations.push("`description` is required and must not be empty; conformant clients skip a skill without one");
12759
+ if (description.length === 0) violations.push(EMPTY_SKILL_DESCRIPTION_VIOLATION);
11833
12760
  else if (description.length > DESCRIPTION_MAX_LENGTH) violations.push(`\`description\` is ${description.length} characters; the Agent Skills spec allows at most ${DESCRIPTION_MAX_LENGTH}`);
11834
12761
  const { compatibility } = frontmatter;
11835
12762
  if (typeof compatibility !== "string" && compatibility !== void 0) violations.push("`compatibility` must be a string; the Agent Skills spec does not allow a mapping here");
@@ -11948,12 +12875,16 @@ var AgentsSkillsSkill = class AgentsSkillsSkill extends ToolSkill {
11948
12875
  * directory rather than a same-named project path.
11949
12876
  */
11950
12877
  static reportSpecViolations({ outputRoot, relativeDirPath, dirName, frontmatter, sourceAllowedTools, logger }) {
11951
- const skillPath = join(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
12878
+ const skillPath = agentSkillFilePath({
12879
+ outputRoot,
12880
+ relativeDirPath,
12881
+ dirName
12882
+ });
11952
12883
  for (const violation of collectAgentSkillViolations({
11953
12884
  frontmatter,
11954
12885
  dirName,
11955
12886
  sourceAllowedTools
11956
- })) warnWithFallback(logger, `${skillPath}: ${violation}`);
12887
+ })) warnWithFallback(logger, `${stripControlCharacters(toPosixPath(skillPath))}: ${violation}`);
11957
12888
  }
11958
12889
  static isTargetedByRulesyncSkill(rulesyncSkill) {
11959
12890
  const targets = rulesyncSkill.getFrontmatter().targets;
@@ -38550,9 +39481,13 @@ var SimulatedSkill = class extends ToolSkill {
38550
39481
  const skillDirPath = join(outputRoot, actualRelativeDirPath, dirName);
38551
39482
  const skillFilePath = join(skillDirPath, SKILL_FILE_NAME);
38552
39483
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
38553
- const { frontmatter, body: content } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
39484
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
38554
39485
  const result = SimulatedSkillFrontmatterSchema.safeParse(frontmatter);
38555
39486
  if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
39487
+ warnOnEmptyLoadedDescription({
39488
+ skillFilePath,
39489
+ description: result.data.description
39490
+ });
38556
39491
  const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
38557
39492
  return {
38558
39493
  outputRoot,
@@ -42080,7 +43015,7 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
42080
43015
  }
42081
43016
  static async fromFlatFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
42082
43017
  const filePath = join(outputRoot, relativeDirPath, relativeFilePath);
42083
- const { frontmatter, body } = parseFrontmatter(await readFileContent(filePath), filePath);
43018
+ const { frontmatter, body } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath);
42084
43019
  const result = KimiCodeFlatSkillFrontmatterSchema.safeParse(frontmatter);
42085
43020
  if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
42086
43021
  const fileName = basename(relativeFilePath, extname(relativeFilePath));
@@ -42090,6 +43025,10 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
42090
43025
  name: result.data.name ?? fileName,
42091
43026
  description: result.data.description ?? firstBodyLine?.slice(0, 240) ?? "No description provided."
42092
43027
  };
43028
+ warnOnEmptyLoadedDescription({
43029
+ skillFilePath: filePath,
43030
+ description: normalizedFrontmatter.description
43031
+ });
42093
43032
  return new KimiCodeSkill({
42094
43033
  outputRoot,
42095
43034
  relativeDirPath,
@@ -43093,7 +44032,7 @@ var ReasonixSkill = class ReasonixSkill extends ToolSkill {
43093
44032
  static async isDirOwned({ outputRoot, relativeDirPath, dirName }) {
43094
44033
  const skillFilePath = join(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
43095
44034
  try {
43096
- const { frontmatter } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
44035
+ const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath, { quiet: true });
43097
44036
  return frontmatter["runAs"] !== REASONIX_SUBAGENT_RUN_AS;
43098
44037
  } catch {
43099
44038
  return true;
@@ -43462,8 +44401,7 @@ var TaktSkill = class TaktSkill extends ToolSkill {
43462
44401
  const fullPath = join(this.outputRoot, this.relativeDirPath);
43463
44402
  const resolvedFull = resolve(fullPath);
43464
44403
  const resolvedBase = resolve(this.outputRoot);
43465
- const rel = relative(resolvedBase, resolvedFull);
43466
- if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
44404
+ if (pathEscapesRoot(relative(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
43467
44405
  return fullPath;
43468
44406
  }
43469
44407
  getRelativePathFromCwd() {
@@ -48430,7 +49368,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
48430
49368
  const paths = this.getSettablePaths({ global });
48431
49369
  const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
48432
49370
  const fileContent = await readFileContent(filePath);
48433
- const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
49371
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(fileContent, filePath);
48434
49372
  const result = ReasonixSubagentFrontmatterSchema.safeParse(frontmatter);
48435
49373
  if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
48436
49374
  return new ReasonixSubagent({
@@ -48459,7 +49397,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
48459
49397
  static async isFileOwned({ outputRoot, relativeDirPath, relativeFilePath }) {
48460
49398
  const filePath = join(outputRoot, relativeDirPath, relativeFilePath);
48461
49399
  try {
48462
- const { frontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
49400
+ const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath, { quiet: true });
48463
49401
  return frontmatter["runAs"] === REASONIX_SUBAGENT_RUN_AS;
48464
49402
  } catch {
48465
49403
  return false;
@@ -55853,6 +56791,7 @@ function resolveToolOutputRoot({ outputRoot, toolTarget, global }) {
55853
56791
  * `.rulesync/` files to disk. Rulesync file instances live in memory only.
55854
56792
  */
55855
56793
  async function convertFromTool(params) {
56794
+ resetWarnedOnceMessages();
55856
56795
  const packagingTarget = [params.fromTool, ...params.toTools].find(isPackagingToolTarget);
55857
56796
  if (packagingTarget) throw new Error(`Plugin packaging target '${packagingTarget}' is not supported by convert. Use import and generate with explicit plugin directories.`);
55858
56797
  const ctx = params;
@@ -56782,6 +57721,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
56782
57721
  async function generate(params) {
56783
57722
  const { config, logger } = params;
56784
57723
  resetRootShadowingWarnings({ logger });
57724
+ resetWarnedOnceMessages();
56785
57725
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
56786
57726
  toolTarget,
56787
57727
  outputRoot
@@ -57341,6 +58281,7 @@ function getToolOutputRoot({ config, tool }) {
57341
58281
  */
57342
58282
  async function importFromTool(params) {
57343
58283
  const { config, tool, logger } = params;
58284
+ resetWarnedOnceMessages();
57344
58285
  await assertPluginRootSafe({
57345
58286
  toolTarget: tool,
57346
58287
  outputRoot: getToolOutputRoot({
@@ -57662,6 +58603,6 @@ async function importChecksCore(params) {
57662
58603
  return writtenCount;
57663
58604
  }
57664
58605
  //#endregion
57665
- export { SourceEntrySchema as $, RULESYNC_MCP_SCHEMA_URL as $t, RulesyncRule as A, writeFileBuffer as At, RulesyncCommandFrontmatterSchema as B, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Bt, CODEXCLI_DIR as C, removeDirectoryStrict as Ct, RulesyncSubagentFrontmatterSchema as D, resolvePath as Dt, RulesyncSubagent as E, removeTempDirectory as Et, RulesyncHooks as F, ToolTargetSchema as Ft, SHARED_USER_MANAGED_CONFIG_PATHS as G, RULESYNC_HOOKS_FILE_NAME as Gt, RulesyncCheckFrontmatterSchema as H, RULESYNC_CONFIG_SCHEMA_URL as Ht, getRulesyncSourceCandidates as I, MAX_FILE_SIZE as It, mergeInputRootConfigs as J, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Jt, SKILL_FILE_NAME as K, RULESYNC_HOOKS_LEGACY_FILE_NAME as Kt, resolveRulesyncSourceWritePath as L, RULESYNC_AIIGNORE_FILE_NAME as Lt, RulesyncPermissions as M, ALL_TOOL_TARGETS as Mt, RulesyncMcp as N, ALL_TOOL_TARGETS_WITH_WILDCARD as Nt, RulesyncSkill as O, runWithDirectoryRollback as Ot, RulesyncIgnore as P, PACKAGING_TOOL_TARGETS as Pt, GITIGNORE_DESTINATION_KEY as Q, RULESYNC_MCP_RELATIVE_FILE_PATH as Qt, parseJsonc as R, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Rt, CODEXCLI_BASH_RULES_FILE_NAME as S, removeDirectory as St, getLocalSkillDirNames as T, removeFileStrict as Tt, stringifyFrontmatter as U, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Ut, RulesyncCheck as V, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Vt, loadYaml as W, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Wt, CONFLICTING_TARGET_PAIRS as X, RULESYNC_MCP_FILE_NAME as Xt, resolveEffectiveInputRoots as Y, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Yt, ConfigFileSchema as Z, RULESYNC_MCP_LEGACY_FILE_NAME as Zt, CLAUDECODE_LOCAL_RULE_FILE_NAME as _, getHomeDirectory as _t, convertFromTool as a, RULESYNC_RELATIVE_DIR_PATH as an, CLIError as at, CLAUDECODE_SKILLS_DIR_PATH as b, readFileContent as bt, SubagentsProcessor as c, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as cn, assertTreeContainsNoSymlinks as ct, IgnoreProcessor as d, ALL_FEATURES_WITH_WILDCARD as dn, createTempDirectory as dt, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as en, findControlCharacter as et, HooksProcessor as f, DEPRECATED_FEATURE_REPLACEMENTS as fn, directoryExists as ft, CLAUDECODE_DIR as g, getFileSize as gt, QWENCODE_LOCAL_RULE_FILE_NAME as h, findFilesByGlobs as ht, getProcessorRegistryEntry as i, RULESYNC_PERMISSIONS_SCHEMA_URL as in, warnOnConflictingFlags as it, RulesyncRuleFrontmatterSchema as j, writeFileContent as jt, RulesyncSkillFrontmatterSchema as k, toPosixPath as kt, SkillsProcessor as l, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as ln, assertWritablePathInsideRoot as lt, QWENCODE_DIR as m, fileExists as mt, generate as n, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as nn, JsonLogger as nt, isPackagingToolTarget as o, RULESYNC_RULES_RELATIVE_DIR_PATH as on, ErrorCodes as ot, CommandsProcessor as p, formatError as pn, ensureDir as pt, ConfigResolver as q, RULESYNC_HOOKS_RELATIVE_FILE_PATH as qt, inspectInputRoots as r, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as rn, fallbackLogger as rt, RulesProcessor as s, RULESYNC_SKILLS_RELATIVE_DIR_PATH as sn, assertDirectoryIfExists as st, importFromTool as t, RULESYNC_PERMISSIONS_FILE_NAME as tn, ConsoleLogger as tt, McpProcessor as u, ALL_FEATURES as un, checkPathTraversal as ut, CLAUDECODE_MEMORIES_DIR_NAME as v, isSymlink as vt, stripControlCharacters as w, removeFile as wt, ChecksProcessor as x, readFileContentOrNull as xt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as y, listDirectoryFiles as yt, RulesyncCommand as z, RULESYNC_CHECKS_RELATIVE_DIR_PATH as zt };
58606
+ export { SourceEntrySchema as $, RULESYNC_MCP_LEGACY_FILE_NAME as $t, RulesyncRuleFrontmatterSchema as A, runWithDirectoryRollback as At, RulesyncCheck as B, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Bt, CODEXCLI_DIR as C, readFileContentOrNull as Ct, RulesyncSkill as D, removeFileStrict as Dt, RulesyncSubagentFrontmatterSchema as E, removeFile as Et, getRulesyncSourceCandidates as F, ALL_TOOL_TARGETS_WITH_WILDCARD as Ft, SHARED_USER_MANAGED_CONFIG_PATHS as G, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Gt, stringifyFrontmatter as H, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Ht, resolveRulesyncSourceWritePath as I, PACKAGING_TOOL_TARGETS as It, mergeInputRootConfigs as J, RULESYNC_HOOKS_LEGACY_FILE_NAME as Jt, SKILL_FILE_NAME as K, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Kt, parseJsonc as L, ToolTargetSchema as Lt, RulesyncMcp as M, writeFileBuffer as Mt, RulesyncIgnore as N, writeFileContent as Nt, RulesyncSkillFrontmatterSchema as O, removeTempDirectory as Ot, RulesyncHooks as P, ALL_TOOL_TARGETS as Pt, GITIGNORE_DESTINATION_KEY as Q, RULESYNC_MCP_FILE_NAME as Qt, RulesyncCommand as R, MAX_FILE_SIZE as Rt, CODEXCLI_BASH_RULES_FILE_NAME as S, readFileContent as St, RulesyncSubagent as T, removeDirectoryStrict as Tt, loadYaml as U, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Ut, RulesyncCheckFrontmatterSchema as V, RULESYNC_CHECKS_RELATIVE_DIR_PATH as Vt, stripControlCharacters as W, RULESYNC_CONFIG_SCHEMA_URL as Wt, CONFLICTING_TARGET_PAIRS as X, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Xt, resolveEffectiveInputRoots as Y, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Yt, ConfigFileSchema as Z, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Zt, CLAUDECODE_LOCAL_RULE_FILE_NAME as _, getFileSize as _t, convertFromTool as a, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as an, resetWarnedOnceMessages as at, CLAUDECODE_SKILLS_DIR_PATH as b, listDirectoryFiles as bt, SubagentsProcessor as c, RULESYNC_RULES_RELATIVE_DIR_PATH as cn, assertDirectoryIfExists as ct, IgnoreProcessor as d, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as dn, checkPathTraversal as dt, RULESYNC_MCP_RELATIVE_FILE_PATH as en, findControlCharacter as et, HooksProcessor as f, ALL_FEATURES as fn, createTempDirectory as ft, CLAUDECODE_DIR as g, findFilesByGlobs as gt, QWENCODE_LOCAL_RULE_FILE_NAME as h, formatError as hn, fileExists as ht, getProcessorRegistryEntry as i, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as in, warnOnConflictingFlags as it, RulesyncPermissions as j, toPosixPath as jt, RulesyncRule as k, resolvePath as kt, SkillsProcessor as l, RULESYNC_SKILLS_RELATIVE_DIR_PATH as ln, assertTreeContainsNoSymlinks as lt, QWENCODE_DIR as m, DEPRECATED_FEATURE_REPLACEMENTS as mn, ensureDir as mt, generate as n, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as nn, JsonLogger as nt, isPackagingToolTarget as o, RULESYNC_PERMISSIONS_SCHEMA_URL as on, CLIError as ot, CommandsProcessor as p, ALL_FEATURES_WITH_WILDCARD as pn, directoryExists as pt, ConfigResolver as q, RULESYNC_HOOKS_FILE_NAME as qt, inspectInputRoots as r, RULESYNC_PERMISSIONS_FILE_NAME as rn, fallbackLogger as rt, RulesProcessor as s, RULESYNC_RELATIVE_DIR_PATH as sn, ErrorCodes as st, importFromTool as t, RULESYNC_MCP_SCHEMA_URL as tn, ConsoleLogger as tt, McpProcessor as u, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as un, assertWritablePathInsideRoot as ut, CLAUDECODE_MEMORIES_DIR_NAME as v, getHomeDirectory as vt, getLocalSkillDirNames as w, removeDirectory as wt, ChecksProcessor as x, pathEscapesRoot as xt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as y, isSymlink as yt, RulesyncCommandFrontmatterSchema as z, RULESYNC_AIIGNORE_FILE_NAME as zt };
57666
58607
 
57667
- //# sourceMappingURL=import-DimKwtQ6.js.map
58608
+ //# sourceMappingURL=import-u8yswsGB.js.map