rulesync 7.15.2 → 7.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.
@@ -155,6 +155,8 @@ var RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = (0, import_node_path.join)(
155
155
  var RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
156
156
  var RULESYNC_MCP_FILE_NAME = "mcp.json";
157
157
  var RULESYNC_HOOKS_FILE_NAME = "hooks.json";
158
+ var RULESYNC_CONFIG_SCHEMA_URL = "https://github.com/dyoshikawa/rulesync/releases/latest/download/config-schema.json";
159
+ var RULESYNC_MCP_SCHEMA_URL = "https://github.com/dyoshikawa/rulesync/releases/latest/download/mcp-schema.json";
158
160
  var MAX_FILE_SIZE = 10 * 1024 * 1024;
159
161
  var FETCH_CONCURRENCY_LIMIT = 10;
160
162
 
@@ -408,6 +410,7 @@ var import_node_path5 = require("path");
408
410
 
409
411
  // src/utils/frontmatter.ts
410
412
  var import_gray_matter = __toESM(require("gray-matter"), 1);
413
+ var import_js_yaml = require("js-yaml");
411
414
  function isPlainObject(value) {
412
415
  if (value === null || typeof value !== "object") return false;
413
416
  const prototype = Object.getPrototypeOf(value);
@@ -446,8 +449,55 @@ function deepRemoveNullishObject(obj) {
446
449
  }
447
450
  return result;
448
451
  }
449
- function stringifyFrontmatter(body, frontmatter) {
450
- const cleanFrontmatter = deepRemoveNullishObject(frontmatter);
452
+ function deepFlattenStringsValue(value) {
453
+ if (value === null || value === void 0) {
454
+ return void 0;
455
+ }
456
+ if (typeof value === "string") {
457
+ return value.replace(/\n+/g, " ").trim();
458
+ }
459
+ if (Array.isArray(value)) {
460
+ const cleanedArray = value.map((item) => deepFlattenStringsValue(item)).filter((item) => item !== void 0);
461
+ return cleanedArray;
462
+ }
463
+ if (isPlainObject(value)) {
464
+ const result = {};
465
+ for (const [key, val] of Object.entries(value)) {
466
+ const cleaned = deepFlattenStringsValue(val);
467
+ if (cleaned !== void 0) {
468
+ result[key] = cleaned;
469
+ }
470
+ }
471
+ return result;
472
+ }
473
+ return value;
474
+ }
475
+ function deepFlattenStringsObject(obj) {
476
+ if (!obj || typeof obj !== "object") {
477
+ return {};
478
+ }
479
+ const result = {};
480
+ for (const [key, val] of Object.entries(obj)) {
481
+ const cleaned = deepFlattenStringsValue(val);
482
+ if (cleaned !== void 0) {
483
+ result[key] = cleaned;
484
+ }
485
+ }
486
+ return result;
487
+ }
488
+ function stringifyFrontmatter(body, frontmatter, options) {
489
+ const { avoidBlockScalars = false } = options ?? {};
490
+ const cleanFrontmatter = avoidBlockScalars ? deepFlattenStringsObject(frontmatter) : deepRemoveNullishObject(frontmatter);
491
+ if (avoidBlockScalars) {
492
+ return import_gray_matter.default.stringify(body, cleanFrontmatter, {
493
+ engines: {
494
+ yaml: {
495
+ parse: (input) => (0, import_js_yaml.load)(input) ?? {},
496
+ stringify: (data) => (0, import_js_yaml.dump)(data, { lineWidth: -1 })
497
+ }
498
+ }
499
+ });
500
+ }
451
501
  return import_gray_matter.default.stringify(body, cleanFrontmatter);
452
502
  }
453
503
  function parseFrontmatter(content, filePath) {
@@ -1599,7 +1649,7 @@ var CursorCommand = class _CursorCommand extends ToolCommand {
1599
1649
  }
1600
1650
  super({
1601
1651
  ...rest,
1602
- fileContent: stringifyFrontmatter(body, frontmatter)
1652
+ fileContent: stringifyFrontmatter(body, frontmatter, { avoidBlockScalars: true })
1603
1653
  });
1604
1654
  this.frontmatter = frontmatter;
1605
1655
  this.body = body;
@@ -5397,7 +5447,7 @@ var import_mini19 = require("zod/mini");
5397
5447
 
5398
5448
  // src/types/mcp.ts
5399
5449
  var import_mini18 = require("zod/mini");
5400
- var McpServerSchema = import_mini18.z.object({
5450
+ var McpServerSchema = import_mini18.z.looseObject({
5401
5451
  type: import_mini18.z.optional(import_mini18.z.enum(["stdio", "sse", "http"])),
5402
5452
  command: import_mini18.z.optional(import_mini18.z.union([import_mini18.z.string(), import_mini18.z.array(import_mini18.z.string())])),
5403
5453
  args: import_mini18.z.optional(import_mini18.z.array(import_mini18.z.string())),
@@ -5429,6 +5479,10 @@ var RulesyncMcpServerSchema = import_mini19.z.extend(McpServerSchema, {
5429
5479
  var RulesyncMcpConfigSchema = import_mini19.z.object({
5430
5480
  mcpServers: import_mini19.z.record(import_mini19.z.string(), RulesyncMcpServerSchema)
5431
5481
  });
5482
+ var RulesyncMcpFileSchema = import_mini19.z.looseObject({
5483
+ $schema: import_mini19.z.optional(import_mini19.z.string()),
5484
+ ...RulesyncMcpConfigSchema.shape
5485
+ });
5432
5486
  var RulesyncMcp = class _RulesyncMcp extends RulesyncFile {
5433
5487
  json;
5434
5488
  constructor(params) {
@@ -5454,7 +5508,7 @@ var RulesyncMcp = class _RulesyncMcp extends RulesyncFile {
5454
5508
  };
5455
5509
  }
5456
5510
  validate() {
5457
- const result = RulesyncMcpConfigSchema.safeParse(this.json);
5511
+ const result = RulesyncMcpFileSchema.safeParse(this.json);
5458
5512
  if (!result.success) {
5459
5513
  return { success: false, error: result.error };
5460
5514
  }
@@ -5557,11 +5611,17 @@ var ToolMcp = class extends ToolFile {
5557
5611
  toRulesyncMcpDefault({
5558
5612
  fileContent = void 0
5559
5613
  } = {}) {
5614
+ const content = fileContent ?? this.fileContent;
5615
+ const { $schema: _, ...json } = JSON.parse(content);
5616
+ const withSchema = {
5617
+ $schema: RULESYNC_MCP_SCHEMA_URL,
5618
+ ...json
5619
+ };
5560
5620
  return new RulesyncMcp({
5561
5621
  baseDir: this.baseDir,
5562
5622
  relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
5563
- relativeFilePath: ".mcp.json",
5564
- fileContent: fileContent ?? this.fileContent
5623
+ relativeFilePath: RULESYNC_MCP_FILE_NAME,
5624
+ fileContent: JSON.stringify(withSchema, null, 2)
5565
5625
  });
5566
5626
  }
5567
5627
  static async fromFile(_params) {
@@ -5864,10 +5924,7 @@ var CodexcliMcp = class _CodexcliMcp extends ToolMcp {
5864
5924
  toRulesyncMcp() {
5865
5925
  const mcpServers = this.toml.mcp_servers ?? {};
5866
5926
  const converted = convertFromCodexFormat(mcpServers);
5867
- return new RulesyncMcp({
5868
- baseDir: this.baseDir,
5869
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
5870
- relativeFilePath: ".mcp.json",
5927
+ return this.toRulesyncMcpDefault({
5871
5928
  fileContent: JSON.stringify({ mcpServers: converted }, null, 2)
5872
5929
  });
5873
5930
  }
@@ -6025,12 +6082,26 @@ var CursorMcp = class _CursorMcp extends ToolMcp {
6025
6082
  json;
6026
6083
  constructor(params) {
6027
6084
  super(params);
6028
- this.json = this.fileContent !== void 0 ? JSON.parse(this.fileContent) : {};
6085
+ if (this.fileContent !== void 0) {
6086
+ try {
6087
+ this.json = JSON.parse(this.fileContent);
6088
+ } catch (error) {
6089
+ throw new Error(
6090
+ `Failed to parse Cursor MCP config at ${(0, import_node_path46.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(error)}`,
6091
+ { cause: error }
6092
+ );
6093
+ }
6094
+ } else {
6095
+ this.json = {};
6096
+ }
6029
6097
  }
6030
6098
  getJson() {
6031
6099
  return this.json;
6032
6100
  }
6033
- static getSettablePaths() {
6101
+ isDeletable() {
6102
+ return !this.global;
6103
+ }
6104
+ static getSettablePaths(_options) {
6034
6105
  return {
6035
6106
  relativeDirPath: ".cursor",
6036
6107
  relativeFilePath: "mcp.json"
@@ -6038,41 +6109,62 @@ var CursorMcp = class _CursorMcp extends ToolMcp {
6038
6109
  }
6039
6110
  static async fromFile({
6040
6111
  baseDir = process.cwd(),
6041
- validate = true
6112
+ validate = true,
6113
+ global = false
6042
6114
  }) {
6043
- const fileContent = await readFileContent(
6044
- (0, import_node_path46.join)(
6045
- baseDir,
6046
- this.getSettablePaths().relativeDirPath,
6047
- this.getSettablePaths().relativeFilePath
6048
- )
6049
- );
6115
+ const paths = this.getSettablePaths({ global });
6116
+ const filePath = (0, import_node_path46.join)(baseDir, paths.relativeDirPath, paths.relativeFilePath);
6117
+ const fileContent = await readFileContentOrNull(filePath) ?? '{"mcpServers":{}}';
6118
+ let json;
6119
+ try {
6120
+ json = JSON.parse(fileContent);
6121
+ } catch (error) {
6122
+ throw new Error(
6123
+ `Failed to parse Cursor MCP config at ${(0, import_node_path46.join)(paths.relativeDirPath, paths.relativeFilePath)}: ${formatError(error)}`,
6124
+ { cause: error }
6125
+ );
6126
+ }
6127
+ const newJson = { ...json, mcpServers: json.mcpServers ?? {} };
6050
6128
  return new _CursorMcp({
6051
6129
  baseDir,
6052
- relativeDirPath: this.getSettablePaths().relativeDirPath,
6053
- relativeFilePath: this.getSettablePaths().relativeFilePath,
6054
- fileContent,
6055
- validate
6130
+ relativeDirPath: paths.relativeDirPath,
6131
+ relativeFilePath: paths.relativeFilePath,
6132
+ fileContent: JSON.stringify(newJson, null, 2),
6133
+ validate,
6134
+ global
6056
6135
  });
6057
6136
  }
6058
- static fromRulesyncMcp({
6137
+ static async fromRulesyncMcp({
6059
6138
  baseDir = process.cwd(),
6060
6139
  rulesyncMcp,
6061
- validate = true
6140
+ validate = true,
6141
+ global = false
6062
6142
  }) {
6063
- const json = rulesyncMcp.getJson();
6064
- const mcpServers = isMcpServers(json.mcpServers) ? json.mcpServers : {};
6143
+ const paths = this.getSettablePaths({ global });
6144
+ const fileContent = await readOrInitializeFileContent(
6145
+ (0, import_node_path46.join)(baseDir, paths.relativeDirPath, paths.relativeFilePath),
6146
+ JSON.stringify({ mcpServers: {} }, null, 2)
6147
+ );
6148
+ let json;
6149
+ try {
6150
+ json = JSON.parse(fileContent);
6151
+ } catch (error) {
6152
+ throw new Error(
6153
+ `Failed to parse Cursor MCP config at ${(0, import_node_path46.join)(paths.relativeDirPath, paths.relativeFilePath)}: ${formatError(error)}`,
6154
+ { cause: error }
6155
+ );
6156
+ }
6157
+ const rulesyncJson = rulesyncMcp.getJson();
6158
+ const mcpServers = isMcpServers(rulesyncJson.mcpServers) ? rulesyncJson.mcpServers : {};
6065
6159
  const transformedServers = convertEnvToCursorFormat(mcpServers);
6066
- const cursorConfig = {
6067
- mcpServers: transformedServers
6068
- };
6069
- const fileContent = JSON.stringify(cursorConfig, null, 2);
6160
+ const cursorConfig = { ...json, mcpServers: transformedServers };
6070
6161
  return new _CursorMcp({
6071
6162
  baseDir,
6072
- relativeDirPath: this.getSettablePaths().relativeDirPath,
6073
- relativeFilePath: this.getSettablePaths().relativeFilePath,
6074
- fileContent,
6075
- validate
6163
+ relativeDirPath: paths.relativeDirPath,
6164
+ relativeFilePath: paths.relativeFilePath,
6165
+ fileContent: JSON.stringify(cursorConfig, null, 2),
6166
+ validate,
6167
+ global
6076
6168
  });
6077
6169
  }
6078
6170
  toRulesyncMcp() {
@@ -6082,12 +6174,8 @@ var CursorMcp = class _CursorMcp extends ToolMcp {
6082
6174
  ...this.json,
6083
6175
  mcpServers: transformedServers
6084
6176
  };
6085
- return new RulesyncMcp({
6086
- baseDir: this.baseDir,
6087
- relativeDirPath: this.relativeDirPath,
6088
- relativeFilePath: "rulesync.mcp.json",
6089
- fileContent: JSON.stringify(transformedJson),
6090
- validate: true
6177
+ return this.toRulesyncMcpDefault({
6178
+ fileContent: JSON.stringify(transformedJson, null, 2)
6091
6179
  });
6092
6180
  }
6093
6181
  validate() {
@@ -6096,14 +6184,16 @@ var CursorMcp = class _CursorMcp extends ToolMcp {
6096
6184
  static forDeletion({
6097
6185
  baseDir = process.cwd(),
6098
6186
  relativeDirPath,
6099
- relativeFilePath
6187
+ relativeFilePath,
6188
+ global = false
6100
6189
  }) {
6101
6190
  return new _CursorMcp({
6102
6191
  baseDir,
6103
6192
  relativeDirPath,
6104
6193
  relativeFilePath,
6105
6194
  fileContent: "{}",
6106
- validate: false
6195
+ validate: false,
6196
+ global
6107
6197
  });
6108
6198
  }
6109
6199
  };
@@ -6163,13 +6253,7 @@ var FactorydroidMcp = class _FactorydroidMcp extends ToolMcp {
6163
6253
  });
6164
6254
  }
6165
6255
  toRulesyncMcp() {
6166
- return new RulesyncMcp({
6167
- baseDir: this.baseDir,
6168
- relativeDirPath: this.relativeDirPath,
6169
- relativeFilePath: "rulesync.mcp.json",
6170
- fileContent: JSON.stringify(this.json),
6171
- validate: true
6172
- });
6256
+ return this.toRulesyncMcpDefault();
6173
6257
  }
6174
6258
  validate() {
6175
6259
  return { success: true, error: null };
@@ -6932,7 +7016,7 @@ var toolMcpFactories = /* @__PURE__ */ new Map([
6932
7016
  class: CursorMcp,
6933
7017
  meta: {
6934
7018
  supportsProject: true,
6935
- supportsGlobal: false,
7019
+ supportsGlobal: true,
6936
7020
  supportsEnabledTools: false,
6937
7021
  supportsDisabledTools: false
6938
7022
  }
@@ -7645,9 +7729,15 @@ var import_node_path59 = require("path");
7645
7729
  var DirFeatureProcessor = class {
7646
7730
  baseDir;
7647
7731
  dryRun;
7648
- constructor({ baseDir = process.cwd(), dryRun = false }) {
7732
+ avoidBlockScalars;
7733
+ constructor({
7734
+ baseDir = process.cwd(),
7735
+ dryRun = false,
7736
+ avoidBlockScalars = false
7737
+ }) {
7649
7738
  this.baseDir = baseDir;
7650
7739
  this.dryRun = dryRun;
7740
+ this.avoidBlockScalars = avoidBlockScalars;
7651
7741
  }
7652
7742
  /**
7653
7743
  * Return tool targets that this feature supports.
@@ -7673,7 +7763,9 @@ var DirFeatureProcessor = class {
7673
7763
  let mainFileContent;
7674
7764
  if (mainFile) {
7675
7765
  const mainFilePath = (0, import_node_path59.join)(dirPath, mainFile.name);
7676
- const content = stringifyFrontmatter(mainFile.body, mainFile.frontmatter);
7766
+ const content = stringifyFrontmatter(mainFile.body, mainFile.frontmatter, {
7767
+ avoidBlockScalars: this.avoidBlockScalars
7768
+ });
7677
7769
  mainFileContent = addTrailingNewline(content);
7678
7770
  const existingContent = await readFileContentOrNull(mainFilePath);
7679
7771
  if (existingContent !== mainFileContent) {
@@ -10412,7 +10504,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
10412
10504
  getFactory = defaultGetFactory4,
10413
10505
  dryRun = false
10414
10506
  }) {
10415
- super({ baseDir, dryRun });
10507
+ super({ baseDir, dryRun, avoidBlockScalars: toolTarget === "cursor" });
10416
10508
  const result = SkillsProcessorToolTargetSchema.safeParse(toolTarget);
10417
10509
  if (!result.success) {
10418
10510
  throw new Error(
@@ -11493,7 +11585,7 @@ var CursorSubagent = class _CursorSubagent extends ToolSubagent {
11493
11585
  ...cursorSection
11494
11586
  };
11495
11587
  const body = rulesyncSubagent.getBody();
11496
- const fileContent = stringifyFrontmatter(body, cursorFrontmatter);
11588
+ const fileContent = stringifyFrontmatter(body, cursorFrontmatter, { avoidBlockScalars: true });
11497
11589
  const paths = this.getSettablePaths({ global });
11498
11590
  return new _CursorSubagent({
11499
11591
  baseDir,
@@ -16932,13 +17024,22 @@ var import_jsonc_parser2 = require("jsonc-parser");
16932
17024
  // src/config/config.ts
16933
17025
  var import_node_path117 = require("path");
16934
17026
  var import_mini59 = require("zod/mini");
16935
- function hasControlCharacters(value) {
17027
+
17028
+ // src/utils/validation.ts
17029
+ function findControlCharacter(value) {
16936
17030
  for (let i = 0; i < value.length; i++) {
16937
17031
  const code = value.charCodeAt(i);
16938
- if (code >= 0 && code <= 31 || code === 127) return true;
17032
+ if (code >= 0 && code <= 31 || code === 127) {
17033
+ return { position: i, hex: `0x${code.toString(16).padStart(2, "0")}` };
17034
+ }
16939
17035
  }
16940
- return false;
17036
+ return null;
17037
+ }
17038
+ function hasControlCharacters(value) {
17039
+ return findControlCharacter(value) !== null;
16941
17040
  }
17041
+
17042
+ // src/config/config.ts
16942
17043
  var SourceEntrySchema = import_mini59.z.object({
16943
17044
  source: import_mini59.z.string().check((0, import_mini59.minLength)(1, "source must be a non-empty string")),
16944
17045
  skills: (0, import_mini59.optional)(import_mini59.z.array(import_mini59.z.string())),
@@ -18219,6 +18320,7 @@ async function createConfigFile() {
18219
18320
  path4,
18220
18321
  JSON.stringify(
18221
18322
  {
18323
+ $schema: RULESYNC_CONFIG_SCHEMA_URL,
18222
18324
  targets: ["copilot", "cursor", "claudecode", "codexcli"],
18223
18325
  features: ["rules", "ignore", "mcp", "commands", "subagents", "skills", "hooks"],
18224
18326
  baseDirs: ["."],
@@ -18276,6 +18378,7 @@ globs: ["**/*"]
18276
18378
  const sampleMcpFile = {
18277
18379
  filename: "mcp.json",
18278
18380
  content: `{
18381
+ "$schema": "${RULESYNC_MCP_SCHEMA_URL}",
18279
18382
  "mcpServers": {
18280
18383
  "serena": {
18281
18384
  "type": "stdio",
@@ -18458,17 +18561,8 @@ var import_node_path122 = require("path");
18458
18561
  var import_node_util = require("util");
18459
18562
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
18460
18563
  var GIT_TIMEOUT_MS = 6e4;
18461
- var ALLOWED_URL_SCHEMES = /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/\/|[a-zA-Z0-9_.+-]+@[a-zA-Z0-9.-]+:[a-zA-Z0-9_.+/~-]+)/;
18564
+ var ALLOWED_URL_SCHEMES = /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/\/).+$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9.-]+:[a-zA-Z0-9_.+/~-]+$/;
18462
18565
  var INSECURE_URL_SCHEMES = /^(git:\/\/|http:\/\/)/;
18463
- function findControlCharacter(value) {
18464
- for (let i = 0; i < value.length; i++) {
18465
- const code = value.charCodeAt(i);
18466
- if (code >= 0 && code <= 31 || code === 127) {
18467
- return { position: i, hex: `0x${code.toString(16).padStart(2, "0")}` };
18468
- }
18469
- }
18470
- return null;
18471
- }
18472
18566
  var GitClientError = class extends Error {
18473
18567
  constructor(message, cause) {
18474
18568
  super(message, { cause });
@@ -18524,6 +18618,7 @@ async function resolveDefaultRef(url) {
18524
18618
  const ref = stdout.match(/^ref: refs\/heads\/(.+)\tHEAD$/m)?.[1];
18525
18619
  const sha = stdout.match(/^([0-9a-f]{40})\tHEAD$/m)?.[1];
18526
18620
  if (!ref || !sha) throw new GitClientError(`Could not parse default branch from: ${url}`);
18621
+ validateRef(ref);
18527
18622
  return { ref, sha };
18528
18623
  } catch (error) {
18529
18624
  if (error instanceof GitClientError) throw error;
@@ -18550,6 +18645,17 @@ async function fetchSkillFiles(params) {
18550
18645
  const { url, ref, skillsPath } = params;
18551
18646
  validateGitUrl(url);
18552
18647
  validateRef(ref);
18648
+ if (skillsPath.split(/[/\\]/).includes("..") || (0, import_node_path122.isAbsolute)(skillsPath)) {
18649
+ throw new GitClientError(
18650
+ `Invalid skillsPath "${skillsPath}": must be a relative path without ".."`
18651
+ );
18652
+ }
18653
+ const ctrl = findControlCharacter(skillsPath);
18654
+ if (ctrl) {
18655
+ throw new GitClientError(
18656
+ `skillsPath contains control character ${ctrl.hex} at position ${ctrl.position}`
18657
+ );
18658
+ }
18553
18659
  await checkGitAvailable();
18554
18660
  const tmpDir = await createTempDirectory("rulesync-git-");
18555
18661
  try {
@@ -18584,7 +18690,9 @@ async function fetchSkillFiles(params) {
18584
18690
  }
18585
18691
  }
18586
18692
  var MAX_WALK_DEPTH = 20;
18587
- async function walkDirectory(dir, baseDir, depth = 0) {
18693
+ var MAX_TOTAL_FILES = 1e4;
18694
+ var MAX_TOTAL_SIZE = 100 * 1024 * 1024;
18695
+ async function walkDirectory(dir, baseDir, depth = 0, ctx = { totalFiles: 0, totalSize: 0 }) {
18588
18696
  if (depth > MAX_WALK_DEPTH) {
18589
18697
  throw new GitClientError(
18590
18698
  `Directory tree exceeds max depth of ${MAX_WALK_DEPTH}: "${dir}". Aborting to prevent resource exhaustion.`
@@ -18599,7 +18707,7 @@ async function walkDirectory(dir, baseDir, depth = 0) {
18599
18707
  continue;
18600
18708
  }
18601
18709
  if (await directoryExists(fullPath)) {
18602
- results.push(...await walkDirectory(fullPath, baseDir, depth + 1));
18710
+ results.push(...await walkDirectory(fullPath, baseDir, depth + 1, ctx));
18603
18711
  } else {
18604
18712
  const size = await getFileSize(fullPath);
18605
18713
  if (size > MAX_FILE_SIZE) {
@@ -18608,8 +18716,20 @@ async function walkDirectory(dir, baseDir, depth = 0) {
18608
18716
  );
18609
18717
  continue;
18610
18718
  }
18719
+ ctx.totalFiles++;
18720
+ ctx.totalSize += size;
18721
+ if (ctx.totalFiles >= MAX_TOTAL_FILES) {
18722
+ throw new GitClientError(
18723
+ `Repository exceeds max file count of ${MAX_TOTAL_FILES}. Aborting to prevent resource exhaustion.`
18724
+ );
18725
+ }
18726
+ if (ctx.totalSize >= MAX_TOTAL_SIZE) {
18727
+ throw new GitClientError(
18728
+ `Repository exceeds max total size of ${MAX_TOTAL_SIZE / 1024 / 1024}MB. Aborting to prevent resource exhaustion.`
18729
+ );
18730
+ }
18611
18731
  const content = await readFileContent(fullPath);
18612
- results.push({ relativePath: fullPath.substring(baseDir.length + 1), content, size });
18732
+ results.push({ relativePath: (0, import_node_path122.relative)(baseDir, fullPath), content, size });
18613
18733
  }
18614
18734
  }
18615
18735
  return results;
@@ -18625,7 +18745,7 @@ var LockedSkillSchema = import_mini60.z.object({
18625
18745
  });
18626
18746
  var LockedSourceSchema = import_mini60.z.object({
18627
18747
  requestedRef: (0, import_mini60.optional)(import_mini60.z.string()),
18628
- resolvedRef: import_mini60.z.string(),
18748
+ resolvedRef: import_mini60.z.string().check((0, import_mini60.refine)((v) => /^[0-9a-f]{40}$/.test(v), "resolvedRef must be a 40-character hex SHA")),
18629
18749
  resolvedAt: (0, import_mini60.optional)(import_mini60.z.string()),
18630
18750
  skills: import_mini60.z.record(import_mini60.z.string(), LockedSkillSchema)
18631
18751
  });
@@ -18828,6 +18948,8 @@ async function resolveAndFetchSources(params) {
18828
18948
  logger.error(`Failed to fetch source "${sourceEntry.source}": ${formatError(error)}`);
18829
18949
  if (error instanceof GitHubClientError) {
18830
18950
  logGitHubAuthHints(error);
18951
+ } else if (error instanceof GitClientError) {
18952
+ logGitClientHints(error);
18831
18953
  }
18832
18954
  }
18833
18955
  }
@@ -18848,6 +18970,13 @@ async function resolveAndFetchSources(params) {
18848
18970
  }
18849
18971
  return { fetchedSkillCount: totalSkillCount, sourcesProcessed: sources.length };
18850
18972
  }
18973
+ function logGitClientHints(error) {
18974
+ if (error.message.includes("not installed")) {
18975
+ logger.info("Hint: Install git and ensure it is available on your PATH.");
18976
+ } else {
18977
+ logger.info("Hint: Check your git credentials (SSH keys, credential helper, or access token).");
18978
+ }
18979
+ }
18851
18980
  async function checkLockedSkillsExist(curatedDir, skillNames) {
18852
18981
  if (skillNames.length === 0) return true;
18853
18982
  for (const name of skillNames) {
@@ -18857,9 +18986,88 @@ async function checkLockedSkillsExist(curatedDir, skillNames) {
18857
18986
  }
18858
18987
  return true;
18859
18988
  }
18989
+ async function cleanPreviousCuratedSkills(curatedDir, lockedSkillNames) {
18990
+ const resolvedCuratedDir = (0, import_node_path124.resolve)(curatedDir);
18991
+ for (const prevSkill of lockedSkillNames) {
18992
+ const prevDir = (0, import_node_path124.join)(curatedDir, prevSkill);
18993
+ if (!(0, import_node_path124.resolve)(prevDir).startsWith(resolvedCuratedDir + import_node_path124.sep)) {
18994
+ logger.warn(
18995
+ `Skipping removal of "${prevSkill}": resolved path is outside the curated directory.`
18996
+ );
18997
+ continue;
18998
+ }
18999
+ if (await directoryExists(prevDir)) {
19000
+ await removeDirectory(prevDir);
19001
+ }
19002
+ }
19003
+ }
19004
+ function shouldSkipSkill(params) {
19005
+ const { skillName, sourceKey, localSkillNames, alreadyFetchedSkillNames } = params;
19006
+ if (skillName.includes("..") || skillName.includes("/") || skillName.includes("\\")) {
19007
+ logger.warn(
19008
+ `Skipping skill with invalid name "${skillName}" from ${sourceKey}: contains path traversal characters.`
19009
+ );
19010
+ return true;
19011
+ }
19012
+ if (localSkillNames.has(skillName)) {
19013
+ logger.debug(
19014
+ `Skipping remote skill "${skillName}" from ${sourceKey}: local skill takes precedence.`
19015
+ );
19016
+ return true;
19017
+ }
19018
+ if (alreadyFetchedSkillNames.has(skillName)) {
19019
+ logger.warn(
19020
+ `Skipping duplicate skill "${skillName}" from ${sourceKey}: already fetched from another source.`
19021
+ );
19022
+ return true;
19023
+ }
19024
+ return false;
19025
+ }
19026
+ async function writeSkillAndComputeIntegrity(params) {
19027
+ const { skillName, files, curatedDir, locked, resolvedSha, sourceKey } = params;
19028
+ const written = [];
19029
+ for (const file of files) {
19030
+ checkPathTraversal({
19031
+ relativePath: file.relativePath,
19032
+ intendedRootDir: (0, import_node_path124.join)(curatedDir, skillName)
19033
+ });
19034
+ await writeFileContent((0, import_node_path124.join)(curatedDir, skillName, file.relativePath), file.content);
19035
+ written.push({ path: file.relativePath, content: file.content });
19036
+ }
19037
+ const integrity = computeSkillIntegrity(written);
19038
+ const lockedSkillEntry = locked?.skills[skillName];
19039
+ if (lockedSkillEntry?.integrity && lockedSkillEntry.integrity !== integrity && resolvedSha === locked?.resolvedRef) {
19040
+ logger.warn(
19041
+ `Integrity mismatch for skill "${skillName}" from ${sourceKey}: expected "${lockedSkillEntry.integrity}", got "${integrity}". Content may have been tampered with.`
19042
+ );
19043
+ }
19044
+ return { integrity };
19045
+ }
19046
+ function buildLockUpdate(params) {
19047
+ const { lock, sourceKey, fetchedSkills, locked, requestedRef, resolvedSha } = params;
19048
+ const fetchedNames = Object.keys(fetchedSkills);
19049
+ const mergedSkills = { ...fetchedSkills };
19050
+ if (locked) {
19051
+ for (const [skillName, skillEntry] of Object.entries(locked.skills)) {
19052
+ if (!(skillName in mergedSkills)) {
19053
+ mergedSkills[skillName] = skillEntry;
19054
+ }
19055
+ }
19056
+ }
19057
+ const updatedLock = setLockedSource(lock, sourceKey, {
19058
+ requestedRef,
19059
+ resolvedRef: resolvedSha,
19060
+ resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
19061
+ skills: mergedSkills
19062
+ });
19063
+ logger.info(
19064
+ `Fetched ${fetchedNames.length} skill(s) from ${sourceKey}: ${fetchedNames.join(", ") || "(none)"}`
19065
+ );
19066
+ return { updatedLock, fetchedNames };
19067
+ }
18860
19068
  async function fetchSource(params) {
18861
19069
  const { sourceEntry, client, baseDir, localSkillNames, alreadyFetchedSkillNames, updateSources } = params;
18862
- let { lock } = params;
19070
+ const { lock } = params;
18863
19071
  const parsed = parseSource(sourceEntry.source);
18864
19072
  if (parsed.provider === "gitlab") {
18865
19073
  logger.warn(`GitLab sources are not yet supported. Skipping "${sourceEntry.source}".`);
@@ -18912,37 +19120,15 @@ async function fetchSource(params) {
18912
19120
  const semaphore = new import_promise2.Semaphore(FETCH_CONCURRENCY_LIMIT);
18913
19121
  const fetchedSkills = {};
18914
19122
  if (locked) {
18915
- const resolvedCuratedDir = (0, import_node_path124.resolve)(curatedDir);
18916
- for (const prevSkill of lockedSkillNames) {
18917
- const prevDir = (0, import_node_path124.join)(curatedDir, prevSkill);
18918
- if (!(0, import_node_path124.resolve)(prevDir).startsWith(resolvedCuratedDir + import_node_path124.sep)) {
18919
- logger.warn(
18920
- `Skipping removal of "${prevSkill}": resolved path is outside the curated directory.`
18921
- );
18922
- continue;
18923
- }
18924
- if (await directoryExists(prevDir)) {
18925
- await removeDirectory(prevDir);
18926
- }
18927
- }
19123
+ await cleanPreviousCuratedSkills(curatedDir, lockedSkillNames);
18928
19124
  }
18929
19125
  for (const skillDir of filteredDirs) {
18930
- if (skillDir.name.includes("..") || skillDir.name.includes("/") || skillDir.name.includes("\\")) {
18931
- logger.warn(
18932
- `Skipping skill with invalid name "${skillDir.name}" from ${sourceKey}: contains path traversal characters.`
18933
- );
18934
- continue;
18935
- }
18936
- if (localSkillNames.has(skillDir.name)) {
18937
- logger.debug(
18938
- `Skipping remote skill "${skillDir.name}" from ${sourceKey}: local skill takes precedence.`
18939
- );
18940
- continue;
18941
- }
18942
- if (alreadyFetchedSkillNames.has(skillDir.name)) {
18943
- logger.warn(
18944
- `Skipping duplicate skill "${skillDir.name}" from ${sourceKey}: already fetched from another source.`
18945
- );
19126
+ if (shouldSkipSkill({
19127
+ skillName: skillDir.name,
19128
+ sourceKey,
19129
+ localSkillNames,
19130
+ alreadyFetchedSkillNames
19131
+ })) {
18946
19132
  continue;
18947
19133
  }
18948
19134
  const allFiles = await listDirectoryRecursive({
@@ -18965,55 +19151,39 @@ async function fetchSource(params) {
18965
19151
  const skillFiles = [];
18966
19152
  for (const file of files) {
18967
19153
  const relativeToSkill = file.path.substring(skillDir.path.length + 1);
18968
- const localFilePath = (0, import_node_path124.join)(curatedDir, skillDir.name, relativeToSkill);
18969
- checkPathTraversal({
18970
- relativePath: relativeToSkill,
18971
- intendedRootDir: (0, import_node_path124.join)(curatedDir, skillDir.name)
18972
- });
18973
19154
  const content = await withSemaphore(
18974
19155
  semaphore,
18975
19156
  () => client.getFileContent(parsed.owner, parsed.repo, file.path, ref)
18976
19157
  );
18977
- await writeFileContent(localFilePath, content);
18978
- skillFiles.push({ path: relativeToSkill, content });
19158
+ skillFiles.push({ relativePath: relativeToSkill, content });
18979
19159
  }
18980
- const integrity = computeSkillIntegrity(skillFiles);
18981
- const lockedSkillEntry = locked?.skills[skillDir.name];
18982
- if (lockedSkillEntry && lockedSkillEntry.integrity && lockedSkillEntry.integrity !== integrity && resolvedSha === locked?.resolvedRef) {
18983
- logger.warn(
18984
- `Integrity mismatch for skill "${skillDir.name}" from ${sourceKey}: expected "${lockedSkillEntry.integrity}", got "${integrity}". Content may have been tampered with.`
18985
- );
18986
- }
18987
- fetchedSkills[skillDir.name] = { integrity };
19160
+ fetchedSkills[skillDir.name] = await writeSkillAndComputeIntegrity({
19161
+ skillName: skillDir.name,
19162
+ files: skillFiles,
19163
+ curatedDir,
19164
+ locked,
19165
+ resolvedSha,
19166
+ sourceKey
19167
+ });
18988
19168
  logger.debug(`Fetched skill "${skillDir.name}" from ${sourceKey}`);
18989
19169
  }
18990
- const fetchedNames = Object.keys(fetchedSkills);
18991
- const mergedSkills = { ...fetchedSkills };
18992
- if (locked) {
18993
- for (const [skillName, skillEntry] of Object.entries(locked.skills)) {
18994
- if (!(skillName in mergedSkills)) {
18995
- mergedSkills[skillName] = skillEntry;
18996
- }
18997
- }
18998
- }
18999
- lock = setLockedSource(lock, sourceKey, {
19170
+ const result = buildLockUpdate({
19171
+ lock,
19172
+ sourceKey,
19173
+ fetchedSkills,
19174
+ locked,
19000
19175
  requestedRef,
19001
- resolvedRef: resolvedSha,
19002
- resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
19003
- skills: mergedSkills
19176
+ resolvedSha
19004
19177
  });
19005
- logger.info(
19006
- `Fetched ${fetchedNames.length} skill(s) from ${sourceKey}: ${fetchedNames.join(", ") || "(none)"}`
19007
- );
19008
19178
  return {
19009
- skillCount: fetchedNames.length,
19010
- fetchedSkillNames: fetchedNames,
19011
- updatedLock: lock
19179
+ skillCount: result.fetchedNames.length,
19180
+ fetchedSkillNames: result.fetchedNames,
19181
+ updatedLock: result.updatedLock
19012
19182
  };
19013
19183
  }
19014
19184
  async function fetchSourceViaGit(params) {
19015
19185
  const { sourceEntry, baseDir, localSkillNames, alreadyFetchedSkillNames, updateSources, frozen } = params;
19016
- let { lock } = params;
19186
+ const { lock } = params;
19017
19187
  const url = sourceEntry.source;
19018
19188
  const locked = getLockedSource(lock, url);
19019
19189
  const lockedSkillNames = locked ? getLockedSkillNames(locked) : [];
@@ -19069,68 +19239,35 @@ async function fetchSourceViaGit(params) {
19069
19239
  const allNames = [...skillFileMap.keys()];
19070
19240
  const filteredNames = isWildcard ? allNames : allNames.filter((n) => skillFilter.includes(n));
19071
19241
  if (locked) {
19072
- const base = (0, import_node_path124.resolve)(curatedDir);
19073
- for (const prev of lockedSkillNames) {
19074
- const dir = (0, import_node_path124.join)(curatedDir, prev);
19075
- if ((0, import_node_path124.resolve)(dir).startsWith(base + import_node_path124.sep) && await directoryExists(dir)) {
19076
- await removeDirectory(dir);
19077
- }
19078
- }
19242
+ await cleanPreviousCuratedSkills(curatedDir, lockedSkillNames);
19079
19243
  }
19080
19244
  const fetchedSkills = {};
19081
19245
  for (const skillName of filteredNames) {
19082
- if (skillName.includes("..") || skillName.includes("/") || skillName.includes("\\")) {
19083
- logger.warn(
19084
- `Skipping skill with invalid name "${skillName}" from ${url}: contains path traversal characters.`
19085
- );
19246
+ if (shouldSkipSkill({ skillName, sourceKey: url, localSkillNames, alreadyFetchedSkillNames })) {
19086
19247
  continue;
19087
19248
  }
19088
- if (localSkillNames.has(skillName)) {
19089
- logger.debug(
19090
- `Skipping remote skill "${skillName}" from ${url}: local skill takes precedence.`
19091
- );
19092
- continue;
19093
- }
19094
- if (alreadyFetchedSkillNames.has(skillName)) {
19095
- logger.warn(
19096
- `Skipping duplicate skill "${skillName}" from ${url}: already fetched from another source.`
19097
- );
19098
- continue;
19099
- }
19100
- const files = skillFileMap.get(skillName) ?? [];
19101
- const written = [];
19102
- for (const file of files) {
19103
- checkPathTraversal({
19104
- relativePath: file.relativePath,
19105
- intendedRootDir: (0, import_node_path124.join)(curatedDir, skillName)
19106
- });
19107
- await writeFileContent((0, import_node_path124.join)(curatedDir, skillName, file.relativePath), file.content);
19108
- written.push({ path: file.relativePath, content: file.content });
19109
- }
19110
- const integrity = computeSkillIntegrity(written);
19111
- const lockedSkillEntry = locked?.skills[skillName];
19112
- if (lockedSkillEntry?.integrity && lockedSkillEntry.integrity !== integrity && resolvedSha === locked?.resolvedRef) {
19113
- logger.warn(`Integrity mismatch for skill "${skillName}" from ${url}.`);
19114
- }
19115
- fetchedSkills[skillName] = { integrity };
19116
- }
19117
- const fetchedNames = Object.keys(fetchedSkills);
19118
- const mergedSkills = { ...fetchedSkills };
19119
- if (locked) {
19120
- for (const [k, v] of Object.entries(locked.skills)) {
19121
- if (!(k in mergedSkills)) mergedSkills[k] = v;
19122
- }
19249
+ fetchedSkills[skillName] = await writeSkillAndComputeIntegrity({
19250
+ skillName,
19251
+ files: skillFileMap.get(skillName) ?? [],
19252
+ curatedDir,
19253
+ locked,
19254
+ resolvedSha,
19255
+ sourceKey: url
19256
+ });
19123
19257
  }
19124
- lock = setLockedSource(lock, url, {
19258
+ const result = buildLockUpdate({
19259
+ lock,
19260
+ sourceKey: url,
19261
+ fetchedSkills,
19262
+ locked,
19125
19263
  requestedRef,
19126
- resolvedRef: resolvedSha,
19127
- resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
19128
- skills: mergedSkills
19264
+ resolvedSha
19129
19265
  });
19130
- logger.info(
19131
- `Fetched ${fetchedNames.length} skill(s) from ${url}: ${fetchedNames.join(", ") || "(none)"}`
19132
- );
19133
- return { skillCount: fetchedNames.length, fetchedSkillNames: fetchedNames, updatedLock: lock };
19266
+ return {
19267
+ skillCount: result.fetchedNames.length,
19268
+ fetchedSkillNames: result.fetchedNames,
19269
+ updatedLock: result.updatedLock
19270
+ };
19134
19271
  }
19135
19272
 
19136
19273
  // src/cli/commands/install.ts
@@ -20946,7 +21083,7 @@ async function updateCommand(currentVersion, options) {
20946
21083
  }
20947
21084
 
20948
21085
  // src/cli/index.ts
20949
- var getVersion = () => "7.15.2";
21086
+ var getVersion = () => "7.17.0";
20950
21087
  var main = async () => {
20951
21088
  const program = new import_commander.Command();
20952
21089
  const version = getVersion();