reskill 1.7.0 → 1.8.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.
package/dist/index.js CHANGED
@@ -818,6 +818,384 @@ const git_execAsync = (0, __WEBPACK_EXTERNAL_MODULE_node_util__.promisify)(__WEB
818
818
  if (external_node_fs_.existsSync(tempArchive)) external_node_fs_.unlinkSync(tempArchive);
819
819
  }
820
820
  }
821
+ /**
822
+ * Skill Parser - SKILL.md parser
823
+ *
824
+ * Following agentskills.io specification: https://agentskills.io/specification
825
+ *
826
+ * SKILL.md format requirements:
827
+ * - YAML frontmatter containing name and description (required)
828
+ * - name: max 64 characters, lowercase letters, numbers, hyphens
829
+ * - description: max 1024 characters
830
+ * - Optional fields: license, compatibility, metadata, allowed-tools
831
+ */ /**
832
+ * Skill validation error
833
+ */ class SkillValidationError extends Error {
834
+ field;
835
+ constructor(message, field){
836
+ super(message), this.field = field;
837
+ this.name = 'SkillValidationError';
838
+ }
839
+ }
840
+ /**
841
+ * Simple YAML frontmatter parser
842
+ * Parses --- delimited YAML header
843
+ *
844
+ * Supports:
845
+ * - Basic key: value pairs
846
+ * - Multiline strings (| and >)
847
+ * - Nested objects (one level deep, for metadata field)
848
+ */ function parseFrontmatter(content) {
849
+ const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
850
+ const match = content.match(frontmatterRegex);
851
+ if (!match) return {
852
+ data: {},
853
+ content
854
+ };
855
+ const yamlContent = match[1];
856
+ const markdownContent = match[2];
857
+ // Simple YAML parsing (supports basic key: value, one level of nesting,
858
+ // block scalars (| and >), and plain scalars spanning multiple indented lines)
859
+ const data = {};
860
+ const lines = yamlContent.split('\n');
861
+ let currentKey = '';
862
+ let currentValue = '';
863
+ let inMultiline = false;
864
+ let inNestedObject = false;
865
+ let inPlainScalar = false;
866
+ let nestedObject = {};
867
+ /**
868
+ * Save the current key/value accumulated so far, then reset state.
869
+ */ function flushCurrent() {
870
+ if (!currentKey) return;
871
+ if (inNestedObject) {
872
+ data[currentKey] = nestedObject;
873
+ nestedObject = {};
874
+ inNestedObject = false;
875
+ } else if (inPlainScalar || inMultiline) {
876
+ data[currentKey] = currentValue.trim();
877
+ inPlainScalar = false;
878
+ inMultiline = false;
879
+ } else data[currentKey] = parseYamlValue(currentValue.trim());
880
+ currentKey = '';
881
+ currentValue = '';
882
+ }
883
+ for (const line of lines){
884
+ const trimmedLine = line.trim();
885
+ if (!trimmedLine || trimmedLine.startsWith('#')) continue;
886
+ const isIndented = line.startsWith(' ');
887
+ // ---- Inside a block scalar (| or >) ----
888
+ if (inMultiline) {
889
+ if (isIndented) {
890
+ currentValue += (currentValue ? '\n' : '') + line.slice(2);
891
+ continue;
892
+ }
893
+ // Unindented line ends the block scalar — fall through to top-level parsing
894
+ flushCurrent();
895
+ }
896
+ // ---- Inside a plain scalar (multiline value without | or >) ----
897
+ if (inPlainScalar) {
898
+ if (isIndented) {
899
+ // Continuation line: join with a space (YAML plain scalar folding)
900
+ currentValue += ` ${trimmedLine}`;
901
+ continue;
902
+ }
903
+ // Unindented line ends the plain scalar — fall through to top-level parsing
904
+ flushCurrent();
905
+ }
906
+ // ---- Inside a nested object ----
907
+ if (inNestedObject && isIndented) {
908
+ const nestedMatch = line.match(/^ {2}([a-zA-Z_-]+):\s*(.*)$/);
909
+ if (nestedMatch) {
910
+ const [, nestedKey, nestedValue] = nestedMatch;
911
+ nestedObject[nestedKey] = parseYamlValue(nestedValue.trim());
912
+ continue;
913
+ }
914
+ // Indented line that isn't a nested key:value — this key was actually
915
+ // a plain scalar, not a nested object. Switch modes.
916
+ inNestedObject = false;
917
+ inPlainScalar = true;
918
+ currentValue = trimmedLine;
919
+ continue;
920
+ }
921
+ // ---- Top-level key: value ----
922
+ const keyValueMatch = line.match(/^([a-zA-Z_-]+):\s*(.*)$/);
923
+ if (keyValueMatch) {
924
+ flushCurrent();
925
+ currentKey = keyValueMatch[1];
926
+ currentValue = keyValueMatch[2];
927
+ if ('|' === currentValue || '>' === currentValue) {
928
+ inMultiline = true;
929
+ currentValue = '';
930
+ } else if ('' === currentValue) {
931
+ // Empty value — could be nested object or plain scalar; peek at next lines
932
+ inNestedObject = true;
933
+ nestedObject = {};
934
+ }
935
+ continue;
936
+ }
937
+ // ---- Unindented line that isn't key:value while in nested object ----
938
+ if (inNestedObject) flushCurrent();
939
+ }
940
+ // Save last accumulated value
941
+ flushCurrent();
942
+ return {
943
+ data,
944
+ content: markdownContent
945
+ };
946
+ }
947
+ /**
948
+ * Parse YAML value
949
+ */ function parseYamlValue(value) {
950
+ if (!value) return '';
951
+ // Boolean value
952
+ if ('true' === value) return true;
953
+ if ('false' === value) return false;
954
+ // Number
955
+ if (/^-?\d+$/.test(value)) return parseInt(value, 10);
956
+ if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value);
957
+ // Remove quotes
958
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
959
+ return value;
960
+ }
961
+ /**
962
+ * Validate skill name format
963
+ *
964
+ * Specification requirements:
965
+ * - Max 64 characters
966
+ * - Only lowercase letters, numbers, hyphens allowed
967
+ * - Cannot start or end with hyphen
968
+ * - Cannot contain consecutive hyphens
969
+ */ function validateSkillName(name) {
970
+ if (!name) throw new SkillValidationError('Skill name is required', 'name');
971
+ if (name.length > 64) throw new SkillValidationError('Skill name must be at most 64 characters', 'name');
972
+ if (!/^[a-z0-9]/.test(name)) throw new SkillValidationError('Skill name must start with a lowercase letter or number', 'name');
973
+ if (!/[a-z0-9]$/.test(name)) throw new SkillValidationError('Skill name must end with a lowercase letter or number', 'name');
974
+ if (/--/.test(name)) throw new SkillValidationError('Skill name cannot contain consecutive hyphens', 'name');
975
+ if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(name) && name.length > 1) throw new SkillValidationError('Skill name can only contain lowercase letters, numbers, and hyphens', 'name');
976
+ // Single character name
977
+ if (1 === name.length && !/^[a-z0-9]$/.test(name)) throw new SkillValidationError('Single character skill name must be a lowercase letter or number', 'name');
978
+ }
979
+ /**
980
+ * Validate skill description
981
+ *
982
+ * Specification requirements:
983
+ * - Max 1024 characters
984
+ * - Angle brackets are allowed per agentskills.io spec
985
+ */ function validateSkillDescription(description) {
986
+ if (!description) throw new SkillValidationError('Skill description is required', 'description');
987
+ if (description.length > 1024) throw new SkillValidationError('Skill description must be at most 1024 characters', 'description');
988
+ // Note: angle brackets are allowed per agentskills.io spec
989
+ }
990
+ /**
991
+ * Parse SKILL.md content
992
+ *
993
+ * @param content - SKILL.md file content
994
+ * @param options - Parse options
995
+ * @returns Parsed skill info, or null if format is invalid
996
+ * @throws SkillValidationError if validation fails in strict mode
997
+ */ function parseSkillMd(content, options = {}) {
998
+ const { strict = false } = options;
999
+ try {
1000
+ const { data, content: body } = parseFrontmatter(content);
1001
+ // Check required fields
1002
+ if (!data.name || !data.description) {
1003
+ if (strict) throw new SkillValidationError('SKILL.md must have name and description in frontmatter');
1004
+ return null;
1005
+ }
1006
+ const name = String(data.name);
1007
+ const description = String(data.description);
1008
+ // Validate field format
1009
+ if (strict) {
1010
+ validateSkillName(name);
1011
+ validateSkillDescription(description);
1012
+ }
1013
+ // Parse allowed-tools
1014
+ let allowedTools;
1015
+ if (data['allowed-tools']) {
1016
+ const toolsStr = String(data['allowed-tools']);
1017
+ allowedTools = toolsStr.split(/\s+/).filter(Boolean);
1018
+ }
1019
+ return {
1020
+ name,
1021
+ description,
1022
+ version: data.version ? String(data.version) : void 0,
1023
+ license: data.license ? String(data.license) : void 0,
1024
+ compatibility: data.compatibility ? String(data.compatibility) : void 0,
1025
+ metadata: data.metadata,
1026
+ allowedTools,
1027
+ content: body,
1028
+ rawContent: content
1029
+ };
1030
+ } catch (error) {
1031
+ if (error instanceof SkillValidationError) throw error;
1032
+ if (strict) throw new SkillValidationError(`Failed to parse SKILL.md: ${error}`);
1033
+ return null;
1034
+ }
1035
+ }
1036
+ /**
1037
+ * Parse SKILL.md from file path
1038
+ */ function skill_parser_parseSkillMdFile(filePath, options = {}) {
1039
+ if (!external_node_fs_.existsSync(filePath)) {
1040
+ if (options.strict) throw new SkillValidationError(`SKILL.md not found: ${filePath}`);
1041
+ return null;
1042
+ }
1043
+ const content = external_node_fs_.readFileSync(filePath, 'utf-8');
1044
+ return parseSkillMd(content, options);
1045
+ }
1046
+ /**
1047
+ * Parse SKILL.md from skill directory
1048
+ */ function parseSkillFromDir(dirPath, options = {}) {
1049
+ const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(dirPath, 'SKILL.md');
1050
+ return skill_parser_parseSkillMdFile(skillMdPath, options);
1051
+ }
1052
+ /**
1053
+ * Check if directory contains valid SKILL.md
1054
+ */ function hasValidSkillMd(dirPath) {
1055
+ const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(dirPath, 'SKILL.md');
1056
+ if (!external_node_fs_.existsSync(skillMdPath)) return false;
1057
+ try {
1058
+ const skill = skill_parser_parseSkillMdFile(skillMdPath);
1059
+ return null !== skill;
1060
+ } catch {
1061
+ return false;
1062
+ }
1063
+ }
1064
+ const SKIP_DIRS = [
1065
+ 'node_modules',
1066
+ '.git',
1067
+ 'dist',
1068
+ 'build',
1069
+ '__pycache__'
1070
+ ];
1071
+ const MAX_DISCOVER_DEPTH = 5;
1072
+ const PRIORITY_SKILL_DIRS = [
1073
+ 'skills',
1074
+ '.agents/skills',
1075
+ '.cursor/skills',
1076
+ '.claude/skills',
1077
+ '.windsurf/skills',
1078
+ '.github/skills'
1079
+ ];
1080
+ function findSkillDirsRecursive(dir, depth, maxDepth, visitedDirs) {
1081
+ if (depth > maxDepth) return [];
1082
+ const resolvedDir = __WEBPACK_EXTERNAL_MODULE_node_path__.resolve(dir);
1083
+ if (visitedDirs.has(resolvedDir)) return [];
1084
+ if (!external_node_fs_.existsSync(dir) || !external_node_fs_.statSync(dir).isDirectory()) return [];
1085
+ visitedDirs.add(resolvedDir);
1086
+ const results = [];
1087
+ let entries;
1088
+ try {
1089
+ entries = external_node_fs_.readdirSync(dir);
1090
+ } catch {
1091
+ return [];
1092
+ }
1093
+ for (const entry of entries){
1094
+ if (SKIP_DIRS.includes(entry)) continue;
1095
+ const fullPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(dir, entry);
1096
+ const resolvedFull = __WEBPACK_EXTERNAL_MODULE_node_path__.resolve(fullPath);
1097
+ if (visitedDirs.has(resolvedFull)) continue;
1098
+ let stat;
1099
+ try {
1100
+ stat = external_node_fs_.statSync(fullPath);
1101
+ } catch {
1102
+ continue;
1103
+ }
1104
+ if (!!stat.isDirectory()) {
1105
+ if (hasValidSkillMd(fullPath)) results.push(fullPath);
1106
+ results.push(...findSkillDirsRecursive(fullPath, depth + 1, maxDepth, visitedDirs));
1107
+ }
1108
+ }
1109
+ return results;
1110
+ }
1111
+ /**
1112
+ * Discover all skills in a directory by scanning for SKILL.md files.
1113
+ *
1114
+ * Strategy:
1115
+ * 1. Check root for SKILL.md
1116
+ * 2. Search priority directories (skills/, .agents/skills/, .cursor/skills/, etc.)
1117
+ * 3. Fall back to recursive search (max depth 5, skip node_modules, .git, dist, etc.)
1118
+ *
1119
+ * @param basePath - Root directory to search
1120
+ * @returns List of parsed skills with their directory paths (absolute)
1121
+ */ function discoverSkillsInDir(basePath) {
1122
+ const resolvedBase = __WEBPACK_EXTERNAL_MODULE_node_path__.resolve(basePath);
1123
+ const results = [];
1124
+ const seenNames = new Set();
1125
+ function addSkill(dirPath) {
1126
+ const skill = parseSkillFromDir(dirPath);
1127
+ if (skill && !seenNames.has(skill.name)) {
1128
+ seenNames.add(skill.name);
1129
+ results.push({
1130
+ ...skill,
1131
+ dirPath: __WEBPACK_EXTERNAL_MODULE_node_path__.resolve(dirPath)
1132
+ });
1133
+ }
1134
+ }
1135
+ if (hasValidSkillMd(resolvedBase)) addSkill(resolvedBase);
1136
+ // Track visited directories to avoid redundant I/O during recursive scan
1137
+ const visitedDirs = new Set();
1138
+ for (const sub of PRIORITY_SKILL_DIRS){
1139
+ const dir = __WEBPACK_EXTERNAL_MODULE_node_path__.join(resolvedBase, sub);
1140
+ if (!!external_node_fs_.existsSync(dir) && !!external_node_fs_.statSync(dir).isDirectory()) {
1141
+ visitedDirs.add(__WEBPACK_EXTERNAL_MODULE_node_path__.resolve(dir));
1142
+ try {
1143
+ const entries = external_node_fs_.readdirSync(dir);
1144
+ for (const entry of entries){
1145
+ const skillDir = __WEBPACK_EXTERNAL_MODULE_node_path__.join(dir, entry);
1146
+ try {
1147
+ if (external_node_fs_.statSync(skillDir).isDirectory() && hasValidSkillMd(skillDir)) {
1148
+ addSkill(skillDir);
1149
+ visitedDirs.add(__WEBPACK_EXTERNAL_MODULE_node_path__.resolve(skillDir));
1150
+ }
1151
+ } catch {
1152
+ // Skip entries that can't be stat'd (race condition, permission, etc.)
1153
+ }
1154
+ }
1155
+ } catch {
1156
+ // Skip if unreadable
1157
+ }
1158
+ }
1159
+ }
1160
+ const recursiveDirs = findSkillDirsRecursive(resolvedBase, 0, MAX_DISCOVER_DEPTH, visitedDirs);
1161
+ for (const skillDir of recursiveDirs)addSkill(skillDir);
1162
+ return results;
1163
+ }
1164
+ /**
1165
+ * Filter skills by name (case-insensitive exact match).
1166
+ *
1167
+ * Note: an empty `names` array returns an empty result (not all skills).
1168
+ * Callers should check `names.length` before calling if "no filter = all" is desired.
1169
+ *
1170
+ * @param skills - List of discovered skills
1171
+ * @param names - Skill names to match (e.g. from --skill pdf commit)
1172
+ * @returns Skills whose name matches any of the given names
1173
+ */ function filterSkillsByName(skills, names) {
1174
+ const normalized = names.map((n)=>n.toLowerCase());
1175
+ return skills.filter((skill)=>{
1176
+ // Match against SKILL.md name field
1177
+ if (normalized.includes(skill.name.toLowerCase())) return true;
1178
+ // Also match against the directory name (basename of dirPath)
1179
+ // Users naturally refer to skills by their directory name
1180
+ const dirName = __WEBPACK_EXTERNAL_MODULE_node_path__.basename(skill.dirPath).toLowerCase();
1181
+ return normalized.includes(dirName);
1182
+ });
1183
+ }
1184
+ /**
1185
+ * Generate SKILL.md content
1186
+ */ function generateSkillMd(skill) {
1187
+ const frontmatter = [
1188
+ '---'
1189
+ ];
1190
+ frontmatter.push(`name: ${skill.name}`);
1191
+ frontmatter.push(`description: ${skill.description}`);
1192
+ if (skill.license) frontmatter.push(`license: ${skill.license}`);
1193
+ if (skill.compatibility) frontmatter.push(`compatibility: ${skill.compatibility}`);
1194
+ if (skill.allowedTools && skill.allowedTools.length > 0) frontmatter.push(`allowed-tools: ${skill.allowedTools.join(' ')}`);
1195
+ frontmatter.push('---');
1196
+ frontmatter.push('');
1197
+ return frontmatter.join('\n') + skill.content;
1198
+ }
821
1199
  /**
822
1200
  * Installer - Multi-Agent installer
823
1201
  *
@@ -828,6 +1206,10 @@ const git_execAsync = (0, __WEBPACK_EXTERNAL_MODULE_node_util__.promisify)(__WEB
828
1206
  * Reference: https://github.com/vercel-labs/add-skill/blob/main/src/installer.ts
829
1207
  */ const installer_AGENTS_DIR = '.agents';
830
1208
  const installer_SKILLS_SUBDIR = 'skills';
1209
+ /**
1210
+ * Marker comment in auto-generated Cursor bridge rule files.
1211
+ * Used to distinguish auto-generated files from manually created ones.
1212
+ */ const CURSOR_BRIDGE_MARKER = '<!-- reskill:auto-generated -->';
831
1213
  /**
832
1214
  * Default files to exclude when copying skills
833
1215
  * These files are typically used for repository metadata and should not be copied to agent directories
@@ -1006,45 +1388,50 @@ const installer_SKILLS_SUBDIR = 'skills';
1006
1388
  error: 'Invalid skill name: potential path traversal detected'
1007
1389
  };
1008
1390
  try {
1391
+ let result;
1009
1392
  // Copy mode: directly copy to agent location
1010
1393
  if ('copy' === installMode) {
1011
1394
  installer_ensureDir(agentDir);
1012
1395
  installer_remove(agentDir);
1013
1396
  copyDirectory(sourcePath, agentDir);
1014
- return {
1397
+ result = {
1015
1398
  success: true,
1016
1399
  path: agentDir,
1017
1400
  mode: 'copy'
1018
1401
  };
1019
- }
1020
- // Symlink mode: copy to canonical location, then create symlink
1021
- installer_ensureDir(canonicalDir);
1022
- installer_remove(canonicalDir);
1023
- copyDirectory(sourcePath, canonicalDir);
1024
- const symlinkCreated = await installer_createSymlink(canonicalDir, agentDir);
1025
- if (!symlinkCreated) {
1026
- // Symlink failed, fallback to copy
1027
- try {
1028
- installer_remove(agentDir);
1029
- } catch {
1030
- // Ignore cleanup errors
1031
- }
1032
- installer_ensureDir(agentDir);
1033
- copyDirectory(sourcePath, agentDir);
1034
- return {
1402
+ } else {
1403
+ // Symlink mode: copy to canonical location, then create symlink
1404
+ installer_ensureDir(canonicalDir);
1405
+ installer_remove(canonicalDir);
1406
+ copyDirectory(sourcePath, canonicalDir);
1407
+ const symlinkCreated = await installer_createSymlink(canonicalDir, agentDir);
1408
+ if (symlinkCreated) result = {
1035
1409
  success: true,
1036
1410
  path: agentDir,
1037
1411
  canonicalPath: canonicalDir,
1038
- mode: 'symlink',
1039
- symlinkFailed: true
1412
+ mode: 'symlink'
1040
1413
  };
1414
+ else {
1415
+ // Symlink failed, fallback to copy
1416
+ try {
1417
+ installer_remove(agentDir);
1418
+ } catch {
1419
+ // Ignore cleanup errors
1420
+ }
1421
+ installer_ensureDir(agentDir);
1422
+ copyDirectory(sourcePath, agentDir);
1423
+ result = {
1424
+ success: true,
1425
+ path: agentDir,
1426
+ canonicalPath: canonicalDir,
1427
+ mode: 'symlink',
1428
+ symlinkFailed: true
1429
+ };
1430
+ }
1041
1431
  }
1042
- return {
1043
- success: true,
1044
- path: agentDir,
1045
- canonicalPath: canonicalDir,
1046
- mode: 'symlink'
1047
- };
1432
+ // Create Cursor bridge rule file (project-level only)
1433
+ if ('cursor' === agentType && !this.isGlobal) this.createCursorBridgeRule(sanitized, sourcePath);
1434
+ return result;
1048
1435
  } catch (error) {
1049
1436
  return {
1050
1437
  success: false,
@@ -1082,6 +1469,8 @@ const installer_SKILLS_SUBDIR = 'skills';
1082
1469
  const skillPath = this.getAgentSkillPath(skillName, agentType);
1083
1470
  if (!external_node_fs_.existsSync(skillPath)) return false;
1084
1471
  installer_remove(skillPath);
1472
+ // Remove Cursor bridge rule file (project-level only)
1473
+ if ('cursor' === agentType && !this.isGlobal) this.removeCursorBridgeRule(installer_sanitizeName(skillName));
1085
1474
  return true;
1086
1475
  }
1087
1476
  /**
@@ -1104,6 +1493,65 @@ const installer_SKILLS_SUBDIR = 'skills';
1104
1493
  withFileTypes: true
1105
1494
  }).filter((entry)=>entry.isDirectory() || entry.isSymbolicLink()).map((entry)=>entry.name);
1106
1495
  }
1496
+ /**
1497
+ * Create a Cursor bridge rule file (.mdc) for the installed skill.
1498
+ *
1499
+ * Cursor does not natively read SKILL.md from .cursor/skills/.
1500
+ * This bridge file in .cursor/rules/ references the SKILL.md via @file directive,
1501
+ * allowing Cursor to discover and activate the skill based on the description.
1502
+ *
1503
+ * @param skillName - Sanitized skill name
1504
+ * @param sourcePath - Source directory containing SKILL.md
1505
+ */ createCursorBridgeRule(skillName, sourcePath) {
1506
+ try {
1507
+ const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(sourcePath, 'SKILL.md');
1508
+ if (!external_node_fs_.existsSync(skillMdPath)) return;
1509
+ const content = external_node_fs_.readFileSync(skillMdPath, 'utf-8');
1510
+ const parsed = parseSkillMd(content);
1511
+ if (!parsed || !parsed.description) return;
1512
+ const rulesDir = __WEBPACK_EXTERNAL_MODULE_node_path__.join(this.cwd, '.cursor', 'rules');
1513
+ installer_ensureDir(rulesDir);
1514
+ // Do not overwrite manually created rule files (without auto-generated marker)
1515
+ const bridgePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(rulesDir, `${skillName}.mdc`);
1516
+ if (external_node_fs_.existsSync(bridgePath)) {
1517
+ const existingContent = external_node_fs_.readFileSync(bridgePath, 'utf-8');
1518
+ if (!existingContent.includes(CURSOR_BRIDGE_MARKER)) return;
1519
+ }
1520
+ // Quote description to prevent YAML injection from special characters
1521
+ const safeDescription = parsed.description.replace(/"/g, '\\"');
1522
+ const agent = getAgentConfig('cursor');
1523
+ const bridgeContent = `---
1524
+ description: "${safeDescription}"
1525
+ globs:
1526
+ alwaysApply: false
1527
+ ---
1528
+
1529
+ ${CURSOR_BRIDGE_MARKER}
1530
+ @file ${agent.skillsDir}/${skillName}/SKILL.md
1531
+ `;
1532
+ external_node_fs_.writeFileSync(bridgePath, bridgeContent, 'utf-8');
1533
+ } catch {
1534
+ // Silently skip bridge file creation on errors
1535
+ }
1536
+ }
1537
+ /**
1538
+ * Remove a Cursor bridge rule file (.mdc) for the uninstalled skill.
1539
+ *
1540
+ * Only removes files that contain the auto-generated marker to avoid
1541
+ * deleting manually created rule files.
1542
+ *
1543
+ * @param skillName - Sanitized skill name
1544
+ */ removeCursorBridgeRule(skillName) {
1545
+ try {
1546
+ const bridgePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(this.cwd, '.cursor', 'rules', `${skillName}.mdc`);
1547
+ if (!external_node_fs_.existsSync(bridgePath)) return;
1548
+ const content = external_node_fs_.readFileSync(bridgePath, 'utf-8');
1549
+ if (!content.includes(CURSOR_BRIDGE_MARKER)) return;
1550
+ external_node_fs_.rmSync(bridgePath);
1551
+ } catch {
1552
+ // Silently skip bridge file removal on errors
1553
+ }
1554
+ }
1107
1555
  }
1108
1556
  /**
1109
1557
  * CacheManager - Manage global skill cache
@@ -1814,10 +2262,24 @@ const installer_SKILLS_SUBDIR = 'skills';
1814
2262
  * - Monorepo: git@github.com:org/repo.git/subpath[@version]
1815
2263
  */ parseRef(ref) {
1816
2264
  const raw = ref;
2265
+ // Extract #skillName fragment before any parsing (for both Git URLs and shorthand)
2266
+ let skillName;
2267
+ const hashIndex = ref.indexOf('#');
2268
+ if (hashIndex >= 0) {
2269
+ skillName = ref.slice(hashIndex + 1);
2270
+ ref = ref.slice(0, hashIndex);
2271
+ }
1817
2272
  // First check if it's a Git URL (SSH, HTTPS, git://)
1818
2273
  // For Git URLs, need special handling for version separator
1819
2274
  // Format: git@host:user/repo.git[@version] or git@host:user/repo.git/subpath[@version]
1820
- if (isGitUrl(ref)) return this.parseGitUrlRef(ref);
2275
+ if (isGitUrl(ref)) {
2276
+ const parsed = this.parseGitUrlRef(ref);
2277
+ return {
2278
+ ...parsed,
2279
+ raw,
2280
+ skillName
2281
+ };
2282
+ }
1821
2283
  // Standard format parsing for non-Git URLs
1822
2284
  let remaining = ref;
1823
2285
  let registry = this.defaultRegistry;
@@ -1836,18 +2298,33 @@ const installer_SKILLS_SUBDIR = 'skills';
1836
2298
  }
1837
2299
  // Parse owner/repo and possible subPath
1838
2300
  // E.g.: user/repo or org/monorepo/skills/pdf
2301
+ // Also handle GitHub web URL style: owner/repo/tree/branch/path
1839
2302
  const parts = remaining.split('/');
1840
2303
  if (parts.length < 2) throw new Error(`Invalid skill reference: ${ref}. Expected format: owner/repo[@version]`);
1841
2304
  const owner = parts[0];
1842
2305
  const repo = parts[1];
1843
- const subPath = parts.length > 2 ? parts.slice(2).join('/') : void 0;
2306
+ let subPath;
2307
+ // Check for GitHub/GitLab web URL pattern: owner/repo/(tree|blob|raw)/branch/path
2308
+ // e.g. vercel-labs/skills/tree/main/skills/find-skills
2309
+ // Only apply this heuristic when no explicit @version is provided.
2310
+ // With @version, treat tree/blob/raw as literal directory names (standard monorepo subPath).
2311
+ if (parts.length >= 4 && [
2312
+ 'tree',
2313
+ 'blob',
2314
+ 'raw'
2315
+ ].includes(parts[2]) && !version) {
2316
+ const branch = parts[3];
2317
+ version = `branch:${branch}`;
2318
+ subPath = parts.length > 4 ? parts.slice(4).join('/') : void 0;
2319
+ } else subPath = parts.length > 2 ? parts.slice(2).join('/') : void 0;
1844
2320
  return {
1845
2321
  registry,
1846
2322
  owner,
1847
2323
  repo,
1848
2324
  subPath,
1849
2325
  version,
1850
- raw
2326
+ raw,
2327
+ skillName
1851
2328
  };
1852
2329
  }
1853
2330
  /**
@@ -2069,20 +2546,31 @@ const installer_SKILLS_SUBDIR = 'skills';
2069
2546
  * Check if a reference is an HTTP/OSS URL (for archive downloads)
2070
2547
  *
2071
2548
  * Returns true for:
2072
- * - http:// or https:// URLs pointing to archive files (.tar.gz, .tgz, .zip, .tar)
2073
- * - Explicit oss:// or s3:// protocol URLs
2549
+ * - http:// or https:// URLs with archive file extensions (.tar.gz, .tgz, .zip, .tar)
2550
+ * - Explicit oss:// or s3:// protocol URLs (always treated as archive sources)
2074
2551
  *
2075
2552
  * Returns false for:
2076
2553
  * - Git repository URLs (*.git)
2077
2554
  * - GitHub/GitLab web URLs (/tree/, /blob/, /raw/)
2555
+ * - Bare HTTPS repo URLs without archive extensions (e.g., https://github.com/user/repo)
2556
+ * These are treated as Git references and handled by GitResolver.
2078
2557
  */ static isHttpUrl(ref) {
2079
2558
  // Remove version suffix for checking (e.g., url@v1.0.0)
2080
2559
  const urlPart = ref.split('@')[0];
2081
- // 排除 Git 仓库 URL(以 .git 结尾)
2082
- if (urlPart.endsWith('.git')) return false;
2083
- // 排除 GitHub/GitLab web URL(包含 /tree/, /blob/, /raw/)
2084
- if (/\/(tree|blob|raw)\//.test(urlPart)) return false;
2085
- return urlPart.startsWith('http://') || urlPart.startsWith('https://') || urlPart.startsWith('oss://') || urlPart.startsWith('s3://');
2560
+ // oss:// and s3:// are always archive download sources
2561
+ if (urlPart.startsWith('oss://') || urlPart.startsWith('s3://')) return true;
2562
+ // For http:// and https:// URLs, distinguish between Git repos and archive downloads
2563
+ if (urlPart.startsWith('http://') || urlPart.startsWith('https://')) {
2564
+ // Exclude Git repository URLs (ending with .git)
2565
+ if (urlPart.endsWith('.git')) return false;
2566
+ // Exclude GitHub/GitLab web URLs (containing /tree/, /blob/, /raw/)
2567
+ if (/\/(tree|blob|raw)\//.test(urlPart)) return false;
2568
+ // Only classify as HTTP archive if URL has a recognized archive extension.
2569
+ // Bare HTTPS URLs like https://github.com/user/repo are Git references,
2570
+ // not archive downloads, and should fall through to GitResolver.
2571
+ return /\.(tar\.gz|tgz|zip|tar)$/i.test(urlPart);
2572
+ }
2573
+ return false;
2086
2574
  }
2087
2575
  /**
2088
2576
  * Parse an HTTP/OSS URL reference
@@ -2391,46 +2879,21 @@ const installer_SKILLS_SUBDIR = 'skills';
2391
2879
  * Maps registry URLs to their corresponding scopes.
2392
2880
  * Currently hardcoded; TODO: fetch from /api/registry/info in the future.
2393
2881
  */ /**
2394
- * 公共 Registry URL
2395
- * 用于无 scope skill 安装
2396
- */ const PUBLIC_REGISTRY = 'https://reskill.info/';
2882
+ * Public Registry URL
2883
+ * Used for installing skills without a scope
2884
+ */ const registry_scope_PUBLIC_REGISTRY = 'https://reskill.info/';
2397
2885
  /**
2398
2886
  * Hardcoded registry to scope mapping
2399
2887
  * TODO: Replace with dynamic fetching from /api/registry/info
2400
2888
  */ const REGISTRY_SCOPE_MAP = {
2401
2889
  // rush-app (private registry, new)
2402
- 'https://rush-test.zhenguanyu.com': '@kanyun',
2890
+ 'https://rush-test.zhenguanyu.com': '@kanyun-test',
2403
2891
  'https://rush.zhenguanyu.com': '@kanyun',
2404
2892
  // reskill-app (private registry, legacy)
2405
- 'https://reskill-test.zhenguanyu.com': '@kanyun',
2893
+ 'https://reskill-test.zhenguanyu.com': '@kanyun-test',
2406
2894
  // Local development
2407
- 'http://localhost:3000': '@kanyun'
2408
- };
2409
- /**
2410
- * Registry API prefix mapping
2411
- *
2412
- * rush-app hosts reskill APIs under /api/reskill/ prefix.
2413
- * Default for unlisted registries: '/api'
2414
- */ const REGISTRY_API_PREFIX = {
2415
- 'https://rush-test.zhenguanyu.com': '/api/reskill',
2416
- 'https://rush.zhenguanyu.com': '/api/reskill',
2417
- 'http://localhost:3000': '/api/reskill'
2895
+ 'http://localhost:3000': '@kanyun-test'
2418
2896
  };
2419
- /**
2420
- * Get the API path prefix for a given registry URL
2421
- *
2422
- * @param registryUrl - Registry URL
2423
- * @returns API prefix string (e.g., '/api' or '/api/reskill')
2424
- *
2425
- * @example
2426
- * getApiPrefix('https://rush-test.zhenguanyu.com') // '/api/reskill'
2427
- * getApiPrefix('https://reskill.info') // '/api'
2428
- * getApiPrefix('https://unknown.com') // '/api'
2429
- */ function getApiPrefix(registryUrl) {
2430
- if (!registryUrl) return '/api';
2431
- const normalized = registryUrl.endsWith('/') ? registryUrl.slice(0, -1) : registryUrl;
2432
- return REGISTRY_API_PREFIX[normalized] || '/api';
2433
- }
2434
2897
  /**
2435
2898
  * Get the registry URL for a given scope (reverse lookup)
2436
2899
  *
@@ -2478,7 +2941,7 @@ const installer_SKILLS_SUBDIR = 'skills';
2478
2941
  * getRegistryUrl('@mycompany', { '@mycompany': 'https://my.registry.com/' }) // 'https://my.registry.com/'
2479
2942
  */ function getRegistryUrl(scope, customRegistries) {
2480
2943
  // No scope → return public Registry
2481
- if (!scope) return PUBLIC_REGISTRY;
2944
+ if (!scope) return registry_scope_PUBLIC_REGISTRY;
2482
2945
  // With scope → lookup private Registry
2483
2946
  const registry = getRegistryForScope(scope, customRegistries);
2484
2947
  if (!registry) {
@@ -2529,21 +2992,21 @@ const installer_SKILLS_SUBDIR = 'skills';
2529
2992
  /**
2530
2993
  * Parse a skill identifier into its components (with version support)
2531
2994
  *
2532
- * 支持私有 Registry(带 @scope)和公共 Registry(无 scope)两种格式。
2995
+ * Supports both private registry (with @scope) and public registry (without scope) formats.
2533
2996
  *
2534
2997
  * @param identifier - Skill identifier string
2535
2998
  * @returns Parsed skill identifier with scope, name, version, and fullName
2536
2999
  * @throws Error if identifier is invalid
2537
3000
  *
2538
3001
  * @example
2539
- * // 私有 Registry
3002
+ * // Private registry
2540
3003
  * parseSkillIdentifier('@kanyun/planning-with-files')
2541
3004
  * // { scope: '@kanyun', name: 'planning-with-files', version: undefined, fullName: '@kanyun/planning-with-files' }
2542
3005
  *
2543
3006
  * parseSkillIdentifier('@kanyun/skill@2.4.5')
2544
3007
  * // { scope: '@kanyun', name: 'skill', version: '2.4.5', fullName: '@kanyun/skill' }
2545
3008
  *
2546
- * // 公共 Registry
3009
+ * // Public registry
2547
3010
  * parseSkillIdentifier('planning-with-files')
2548
3011
  * // { scope: null, name: 'planning-with-files', version: undefined, fullName: 'planning-with-files' }
2549
3012
  *
@@ -2551,18 +3014,18 @@ const installer_SKILLS_SUBDIR = 'skills';
2551
3014
  * // { scope: null, name: 'skill', version: 'latest', fullName: 'skill' }
2552
3015
  */ function parseSkillIdentifier(identifier) {
2553
3016
  const trimmed = identifier.trim();
2554
- // 空字符串或仅空白
3017
+ // Empty string or whitespace only
2555
3018
  if (!trimmed) throw new Error('Invalid skill identifier: empty string');
2556
- // @@ 开头无效
3019
+ // Starting with @@ is invalid
2557
3020
  if (trimmed.startsWith('@@')) throw new Error('Invalid skill identifier: invalid scope format');
2558
- // 只有 @ 无效
3021
+ // Bare @ is invalid
2559
3022
  if ('@' === trimmed) throw new Error('Invalid skill identifier: missing scope and name');
2560
- // scope 的格式: @scope/name[@version]
3023
+ // Scoped format: @scope/name[@version]
2561
3024
  if (trimmed.startsWith('@')) {
2562
- // 正则匹配: @scope/name[@version]
2563
- // scope: @ 开头,后面跟字母数字、连字符、下划线
2564
- // name: 字母数字、连字符、下划线
2565
- // version: 可选,@ 后跟任意非空字符
3025
+ // Regex: @scope/name[@version]
3026
+ // scope: starts with @, followed by alphanumeric, hyphens, underscores
3027
+ // name: alphanumeric, hyphens, underscores
3028
+ // version: optional, @ followed by any non-empty string
2566
3029
  const scopedMatch = trimmed.match(/^(@[\w-]+)\/([\w-]+)(?:@(.+))?$/);
2567
3030
  if (!scopedMatch) throw new Error(`Invalid skill identifier: ${identifier}`);
2568
3031
  const [, scope, name, version] = scopedMatch;
@@ -2573,8 +3036,8 @@ const installer_SKILLS_SUBDIR = 'skills';
2573
3036
  fullName: `${scope}/${name}`
2574
3037
  };
2575
3038
  }
2576
- // scope 的格式: name[@version](公共 Registry)
2577
- // name 不能包含 /(否则可能是 git shorthand
3039
+ // Unscoped format: name[@version] (public registry)
3040
+ // name must not contain / (otherwise it might be a git shorthand)
2578
3041
  const unscopedMatch = trimmed.match(/^([\w-]+)(?:@(.+))?$/);
2579
3042
  if (!unscopedMatch) throw new Error(`Invalid skill identifier: ${identifier}`);
2580
3043
  const [, name, version] = unscopedMatch;
@@ -2608,13 +3071,14 @@ class RegistryClient {
2608
3071
  this.config = config;
2609
3072
  }
2610
3073
  /**
2611
- * Get API base URL (registry + apiPrefix)
3074
+ * Get API base URL (registry + /api)
2612
3075
  *
2613
- * @returns Base URL for API calls, e.g., 'https://example.com/api' or 'https://rush.com/api/reskill'
3076
+ * All registries use the unified '/api' prefix.
3077
+ *
3078
+ * @returns Base URL for API calls, e.g., 'https://example.com/api'
2614
3079
  */ getApiBase() {
2615
- const prefix = this.config.apiPrefix || '/api';
2616
3080
  const registry = this.config.registry.endsWith('/') ? this.config.registry.slice(0, -1) : this.config.registry;
2617
- return `${registry}${prefix}`;
3081
+ return `${registry}/api`;
2618
3082
  }
2619
3083
  /**
2620
3084
  * Get authorization headers
@@ -2629,7 +3093,7 @@ class RegistryClient {
2629
3093
  /**
2630
3094
  * Get current user info (whoami)
2631
3095
  */ async whoami() {
2632
- const url = `${this.getApiBase()}/auth/me`;
3096
+ const url = `${this.getApiBase()}/skill-auth/me`;
2633
3097
  const response = await fetch(url, {
2634
3098
  method: 'GET',
2635
3099
  headers: this.getAuthHeaders()
@@ -2641,13 +3105,13 @@ class RegistryClient {
2641
3105
  /**
2642
3106
  * CLI login - verify token and get user info
2643
3107
  *
2644
- * Calls POST /api/auth/login-cli to validate the token and retrieve user information.
3108
+ * Calls POST /api/skill-auth/login-cli to validate the token and retrieve user information.
2645
3109
  * This is the preferred method for CLI authentication.
2646
3110
  *
2647
3111
  * @returns User information if authentication succeeds
2648
3112
  * @throws RegistryError if authentication fails
2649
3113
  */ async loginCli() {
2650
- const url = `${this.getApiBase()}/auth/login-cli`;
3114
+ const url = `${this.getApiBase()}/skill-auth/login-cli`;
2651
3115
  const response = await fetch(url, {
2652
3116
  method: 'POST',
2653
3117
  headers: this.getAuthHeaders()
@@ -2679,7 +3143,7 @@ class RegistryClient {
2679
3143
  if (external_node_fs_.existsSync(filePath)) {
2680
3144
  const content = external_node_fs_.readFileSync(filePath);
2681
3145
  const stat = external_node_fs_.statSync(filePath);
2682
- // 如果提供了 shortName,则在路径前添加顶层目录
3146
+ // Prepend shortName as top-level directory if provided
2683
3147
  const entryName = shortName ? `${shortName}/${file}` : file;
2684
3148
  tarPack.entry({
2685
3149
  name: entryName,
@@ -2693,15 +3157,15 @@ class RegistryClient {
2693
3157
  });
2694
3158
  }
2695
3159
  // ============================================================================
2696
- // Skill Info Methods (页面发布适配)
3160
+ // Skill Info Methods (web-published skill support)
2697
3161
  // ============================================================================
2698
3162
  /**
2699
- * 获取 skill 基本信息(包含 source_type
2700
- * 用于 install 命令判断安装逻辑分支
3163
+ * Get basic skill info (including source_type).
3164
+ * Used by the install command to determine the installation logic branch.
2701
3165
  *
2702
- * @param skillName - 完整名称,如 @kanyun/my-skill
2703
- * @returns Skill 基本信息
2704
- * @throws RegistryError 如果 skill 不存在或请求失败
3166
+ * @param skillName - Full skill name, e.g., @kanyun/my-skill
3167
+ * @returns Basic skill information
3168
+ * @throws RegistryError if skill not found or request failed
2705
3169
  */ async getSkillInfo(skillName) {
2706
3170
  const url = `${this.getApiBase()}/skills/${encodeURIComponent(skillName)}`;
2707
3171
  const response = await fetch(url, {
@@ -2710,15 +3174,50 @@ class RegistryClient {
2710
3174
  });
2711
3175
  if (!response.ok) {
2712
3176
  const data = await response.json();
2713
- // 404 时给出明确的 skill 不存在错误
3177
+ // Return a clear "not found" error for 404 responses
2714
3178
  if (404 === response.status) throw new RegistryError(`Skill not found: ${skillName}`, response.status, data);
2715
3179
  throw new RegistryError(data.error || `Failed to get skill info: ${response.statusText}`, response.status, data);
2716
3180
  }
2717
- // API 返回格式: { success: true, data: { ... } }
3181
+ // API response format: { success: true, data: { ... } }
2718
3182
  const responseData = await response.json();
2719
3183
  return responseData.data || responseData;
2720
3184
  }
2721
3185
  // ============================================================================
3186
+ // Search Methods
3187
+ // ============================================================================
3188
+ /**
3189
+ * Search for skills in the registry
3190
+ *
3191
+ * @param query - Search query string
3192
+ * @param options - Search options (limit, offset)
3193
+ * @returns Array of matching skills
3194
+ * @throws RegistryError if the request fails
3195
+ *
3196
+ * @example
3197
+ * const results = await client.search('typescript');
3198
+ * const results = await client.search('planning', { limit: 5 });
3199
+ */ async search(query, options = {}) {
3200
+ const params = new URLSearchParams({
3201
+ q: query
3202
+ });
3203
+ if (void 0 !== options.limit) params.set('limit', String(options.limit));
3204
+ if (void 0 !== options.offset) params.set('offset', String(options.offset));
3205
+ const url = `${this.getApiBase()}/skills?${params.toString()}`;
3206
+ const response = await fetch(url, {
3207
+ method: 'GET',
3208
+ headers: this.getAuthHeaders()
3209
+ });
3210
+ if (!response.ok) {
3211
+ const data = await response.json();
3212
+ throw new RegistryError(data.error || `Search failed: ${response.status}`, response.status, data);
3213
+ }
3214
+ const data = await response.json();
3215
+ return {
3216
+ items: data.data || [],
3217
+ total: data.meta?.pagination?.totalItems ?? data.data?.length ?? 0
3218
+ };
3219
+ }
3220
+ // ============================================================================
2722
3221
  // Download Methods (Step 3.3)
2723
3222
  // ============================================================================
2724
3223
  /**
@@ -2731,12 +3230,12 @@ class RegistryClient {
2731
3230
  *
2732
3231
  * @example
2733
3232
  * await client.resolveVersion('@kanyun/test-skill', 'latest') // '2.4.5'
2734
- * await client.resolveVersion('@kanyun/test-skill', '2.4.5') // '2.4.5' (直接返回)
3233
+ * await client.resolveVersion('@kanyun/test-skill', '2.4.5') // '2.4.5' (returned as-is)
2735
3234
  */ async resolveVersion(skillName, tagOrVersion) {
2736
3235
  const version = tagOrVersion || 'latest';
2737
- // 如果是 semver 版本号,直接返回
3236
+ // If it's already a semver version number, return as-is
2738
3237
  if (/^\d+\.\d+\.\d+/.test(version)) return version;
2739
- // 否则视为 tag,需要查询 dist-tags
3238
+ // Otherwise treat it as a tag and query dist-tags
2740
3239
  const url = `${this.getApiBase()}/skills/${encodeURIComponent(skillName)}`;
2741
3240
  const response = await fetch(url, {
2742
3241
  method: 'GET',
@@ -2746,14 +3245,14 @@ class RegistryClient {
2746
3245
  const data = await response.json();
2747
3246
  throw new RegistryError(data.error || `Failed to fetch skill metadata: ${response.status}`, response.status, data);
2748
3247
  }
2749
- // API 返回格式: { success: true, data: { dist_tags: [{ tag, version }] } }
3248
+ // API response format: { success: true, data: { dist_tags: [{ tag, version }] } }
2750
3249
  const responseData = await response.json();
2751
- // 优先使用 npm 风格的 dist-tags(如果存在)
3250
+ // Prefer npm-style dist-tags if present
2752
3251
  if (responseData['dist-tags']) {
2753
3252
  const resolvedVersion = responseData['dist-tags'][version];
2754
3253
  if (resolvedVersion) return resolvedVersion;
2755
3254
  }
2756
- // 使用 reskill-app dist_tags 数组格式
3255
+ // Fall back to reskill-app's dist_tags array format
2757
3256
  const distTags = responseData.data?.dist_tags;
2758
3257
  if (distTags && Array.isArray(distTags)) {
2759
3258
  const tagEntry = distTags.find((t)=>t.tag === version);
@@ -2834,11 +3333,11 @@ class RegistryClient {
2834
3333
  * @example
2835
3334
  * RegistryClient.verifyIntegrity(buffer, 'sha256-abc123...') // true or false
2836
3335
  */ static verifyIntegrity(content, expectedIntegrity) {
2837
- // 解析 integrity 格式: algorithm-hash
3336
+ // Parse integrity format: algorithm-hash
2838
3337
  const match = expectedIntegrity.match(/^(\w+)-(.+)$/);
2839
3338
  if (!match) throw new Error(`Invalid integrity format: ${expectedIntegrity}`);
2840
3339
  const [, algorithm, expectedHash] = match;
2841
- // 只支持 sha256 sha512
3340
+ // Only sha256 and sha512 are supported
2842
3341
  if ('sha256' !== algorithm && 'sha512' !== algorithm) throw new Error(`Unsupported integrity algorithm: ${algorithm}`);
2843
3342
  const actualHash = __WEBPACK_EXTERNAL_MODULE_node_crypto__.createHash(algorithm).update(content).digest('base64');
2844
3343
  return actualHash === expectedHash;
@@ -2850,7 +3349,7 @@ class RegistryClient {
2850
3349
  * Publish a skill to the registry
2851
3350
  */ async publish(skillName, payload, skillPath, options = {}) {
2852
3351
  const url = `${this.getApiBase()}/skills/publish`;
2853
- // 提取短名称作为 tarball 顶层目录(不含 scope 前缀)
3352
+ // Extract short name as tarball top-level directory (without scope prefix)
2854
3353
  const shortName = getShortName(skillName);
2855
3354
  // Create tarball with short name as top-level directory
2856
3355
  const tarball = await this.createTarball(skillPath, payload.files, shortName);
@@ -3118,343 +3617,113 @@ class RegistryClient {
3118
3617
  });
3119
3618
  extractor.on('finish', ()=>{
3120
3619
  resolve(topDir);
3121
- });
3122
- extractor.on('error', (err)=>{
3123
- reject(new Error(`Failed to read tarball: ${err.message}`));
3124
- });
3125
- gunzip.on('error', (err)=>{
3126
- reject(new Error(`Failed to decompress tarball: ${err.message}`));
3127
- });
3128
- gunzip.pipe(extractor);
3129
- gunzip.end(tarball);
3130
- });
3131
- }
3132
- /**
3133
- * Registry Resolver (Step 5.1)
3134
- *
3135
- * Resolves skill references from npm-style registries:
3136
- * - Private registry: @scope/name[@version] (e.g., @kanyun/planning-with-files@2.4.5)
3137
- * - Public registry: name[@version] (e.g., my-skill@1.0.0)
3138
- *
3139
- * Uses RegistryClient to download and verify skills.
3140
- */ // ============================================================================
3141
- // RegistryResolver Class
3142
- // ============================================================================
3143
- class RegistryResolver {
3144
- /**
3145
- * Check if a reference is a registry source (not Git or HTTP)
3146
- *
3147
- * Registry formats:
3148
- * - @scope/name[@version] - private registry
3149
- * - name[@version] - public registry (if not matching other formats)
3150
- *
3151
- * Explicitly excluded:
3152
- * - Git SSH: git@github.com:user/repo.git
3153
- * - Git HTTPS: https://github.com/user/repo.git
3154
- * - GitHub web: https://github.com/user/repo/tree/...
3155
- * - HTTP/OSS: https://example.com/skill.tar.gz
3156
- * - Registry shorthand: github:user/repo, gitlab:org/repo
3157
- */ static isRegistryRef(ref) {
3158
- // 排除 Git SSH 格式 (git@...)
3159
- if (ref.startsWith('git@') || ref.startsWith('git://')) return false;
3160
- // 排除 .git 结尾的 URL
3161
- if (ref.includes('.git')) return false;
3162
- // 排除 HTTP/HTTPS/OSS URL
3163
- if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('oss://') || ref.startsWith('s3://')) return false;
3164
- // 排除 registry shorthand 格式 (github:, gitlab:, custom.com:)
3165
- // 这类格式是 "registry:owner/repo" 而不是 "@scope/name"
3166
- if (/^[a-zA-Z0-9.-]+:[^@]/.test(ref)) return false;
3167
- // 检查是否是 @scope/name 格式(私有 registry)
3168
- if (ref.startsWith('@') && ref.includes('/')) {
3169
- // @scope/name 或 @scope/name@version
3170
- const scopeNamePattern = /^@[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
3171
- return scopeNamePattern.test(ref);
3172
- }
3173
- // 检查是否是简单的 name 或 name@version 格式(公共 registry)
3174
- // 简单名称只包含字母、数字、连字符、下划线和点
3175
- const namePattern = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
3176
- return namePattern.test(ref);
3177
- }
3178
- /**
3179
- * Resolve a registry skill reference
3180
- *
3181
- * @param ref - Skill reference (e.g., "@kanyun/planning-with-files@2.4.5" or "my-skill@latest")
3182
- * @returns Resolved skill information including downloaded tarball
3183
- *
3184
- * @example
3185
- * const result = await resolver.resolve('@kanyun/planning-with-files@2.4.5');
3186
- * console.log(result.shortName); // 'planning-with-files'
3187
- * console.log(result.version); // '2.4.5'
3188
- */ async resolve(ref) {
3189
- // 1. 解析 skill 标识
3190
- const parsed = parseSkillIdentifier(ref);
3191
- const shortName = getShortName(parsed.fullName);
3192
- // 2. 获取 registry URL
3193
- const registryUrl = getRegistryUrl(parsed.scope);
3194
- // 3. 创建 client 并解析版本
3195
- const client = new RegistryClient({
3196
- registry: registryUrl,
3197
- apiPrefix: getApiPrefix(registryUrl)
3198
- });
3199
- const version = await client.resolveVersion(parsed.fullName, parsed.version);
3200
- // 4. 下载 tarball
3201
- const { tarball, integrity } = await client.downloadSkill(parsed.fullName, version);
3202
- // 5. 验证 integrity
3203
- const isValid = RegistryClient.verifyIntegrity(tarball, integrity);
3204
- if (!isValid) throw new Error(`Integrity verification failed for ${ref}`);
3205
- return {
3206
- parsed,
3207
- shortName,
3208
- version,
3209
- registryUrl,
3210
- tarball,
3211
- integrity
3212
- };
3213
- }
3214
- /**
3215
- * Extract tarball to a target directory
3216
- *
3217
- * @param tarball - Tarball buffer
3218
- * @param destDir - Destination directory
3219
- * @returns Path to the extracted skill directory
3220
- */ async extract(tarball, destDir) {
3221
- await extractTarballBuffer(tarball, destDir);
3222
- // 获取顶层目录名(即 skill 名称)
3223
- const topDir = await getTarballTopDir(tarball);
3224
- if (topDir) return `${destDir}/${topDir}`;
3225
- return destDir;
3226
- }
3227
- }
3228
- /**
3229
- * Skill Parser - SKILL.md parser
3230
- *
3231
- * Following agentskills.io specification: https://agentskills.io/specification
3232
- *
3233
- * SKILL.md format requirements:
3234
- * - YAML frontmatter containing name and description (required)
3235
- * - name: max 64 characters, lowercase letters, numbers, hyphens
3236
- * - description: max 1024 characters
3237
- * - Optional fields: license, compatibility, metadata, allowed-tools
3238
- */ /**
3239
- * Skill validation error
3240
- */ class SkillValidationError extends Error {
3241
- field;
3242
- constructor(message, field){
3243
- super(message), this.field = field;
3244
- this.name = 'SkillValidationError';
3245
- }
3246
- }
3247
- /**
3248
- * Simple YAML frontmatter parser
3249
- * Parses --- delimited YAML header
3250
- *
3251
- * Supports:
3252
- * - Basic key: value pairs
3253
- * - Multiline strings (| and >)
3254
- * - Nested objects (one level deep, for metadata field)
3255
- */ function parseFrontmatter(content) {
3256
- const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
3257
- const match = content.match(frontmatterRegex);
3258
- if (!match) return {
3259
- data: {},
3260
- content
3261
- };
3262
- const yamlContent = match[1];
3263
- const markdownContent = match[2];
3264
- // Simple YAML parsing (supports basic key: value format and one level of nesting)
3265
- const data = {};
3266
- const lines = yamlContent.split('\n');
3267
- let currentKey = '';
3268
- let currentValue = '';
3269
- let inMultiline = false;
3270
- let inNestedObject = false;
3271
- let nestedObject = {};
3272
- for (const line of lines){
3273
- const trimmedLine = line.trim();
3274
- if (!trimmedLine || trimmedLine.startsWith('#')) continue;
3275
- // Check if it's a nested key: value pair (indented with 2 spaces)
3276
- const nestedMatch = line.match(/^ {2}([a-zA-Z_-]+):\s*(.*)$/);
3277
- if (nestedMatch && inNestedObject) {
3278
- const [, nestedKey, nestedValue] = nestedMatch;
3279
- nestedObject[nestedKey] = parseYamlValue(nestedValue.trim());
3280
- continue;
3281
- }
3282
- // Check if it's a new key: value pair (no indent)
3283
- const keyValueMatch = line.match(/^([a-zA-Z_-]+):\s*(.*)$/);
3284
- if (keyValueMatch && !inMultiline) {
3285
- // Save previous nested object if any
3286
- if (inNestedObject && currentKey) {
3287
- data[currentKey] = nestedObject;
3288
- nestedObject = {};
3289
- inNestedObject = false;
3290
- }
3291
- // Save previous value
3292
- if (currentKey && !inNestedObject) data[currentKey] = parseYamlValue(currentValue.trim());
3293
- currentKey = keyValueMatch[1];
3294
- currentValue = keyValueMatch[2];
3295
- // Check if it's start of multiline string
3296
- if ('|' === currentValue || '>' === currentValue) {
3297
- inMultiline = true;
3298
- currentValue = '';
3299
- } else if ('' === currentValue) {
3300
- // Empty value - might be start of nested object
3301
- inNestedObject = true;
3302
- nestedObject = {};
3303
- }
3304
- } else if (inMultiline && line.startsWith(' ')) // Multiline string continuation
3305
- currentValue += (currentValue ? '\n' : '') + line.slice(2);
3306
- else if (inMultiline && !line.startsWith(' ')) {
3307
- // Multiline string end
3308
- inMultiline = false;
3309
- data[currentKey] = currentValue.trim();
3310
- // Try to parse new line
3311
- const newKeyMatch = line.match(/^([a-zA-Z_-]+):\s*(.*)$/);
3312
- if (newKeyMatch) {
3313
- currentKey = newKeyMatch[1];
3314
- currentValue = newKeyMatch[2];
3315
- }
3316
- }
3317
- }
3318
- // Save last value
3319
- if (inNestedObject && currentKey) data[currentKey] = nestedObject;
3320
- else if (currentKey) data[currentKey] = parseYamlValue(currentValue.trim());
3321
- return {
3322
- data,
3323
- content: markdownContent
3324
- };
3325
- }
3326
- /**
3327
- * Parse YAML value
3328
- */ function parseYamlValue(value) {
3329
- if (!value) return '';
3330
- // Boolean value
3331
- if ('true' === value) return true;
3332
- if ('false' === value) return false;
3333
- // Number
3334
- if (/^-?\d+$/.test(value)) return parseInt(value, 10);
3335
- if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value);
3336
- // Remove quotes
3337
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
3338
- return value;
3339
- }
3340
- /**
3341
- * Validate skill name format
3342
- *
3343
- * Specification requirements:
3344
- * - Max 64 characters
3345
- * - Only lowercase letters, numbers, hyphens allowed
3346
- * - Cannot start or end with hyphen
3347
- * - Cannot contain consecutive hyphens
3348
- */ function validateSkillName(name) {
3349
- if (!name) throw new SkillValidationError('Skill name is required', 'name');
3350
- if (name.length > 64) throw new SkillValidationError('Skill name must be at most 64 characters', 'name');
3351
- if (!/^[a-z0-9]/.test(name)) throw new SkillValidationError('Skill name must start with a lowercase letter or number', 'name');
3352
- if (!/[a-z0-9]$/.test(name)) throw new SkillValidationError('Skill name must end with a lowercase letter or number', 'name');
3353
- if (/--/.test(name)) throw new SkillValidationError('Skill name cannot contain consecutive hyphens', 'name');
3354
- if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(name) && name.length > 1) throw new SkillValidationError('Skill name can only contain lowercase letters, numbers, and hyphens', 'name');
3355
- // Single character name
3356
- if (1 === name.length && !/^[a-z0-9]$/.test(name)) throw new SkillValidationError('Single character skill name must be a lowercase letter or number', 'name');
3620
+ });
3621
+ extractor.on('error', (err)=>{
3622
+ reject(new Error(`Failed to read tarball: ${err.message}`));
3623
+ });
3624
+ gunzip.on('error', (err)=>{
3625
+ reject(new Error(`Failed to decompress tarball: ${err.message}`));
3626
+ });
3627
+ gunzip.pipe(extractor);
3628
+ gunzip.end(tarball);
3629
+ });
3357
3630
  }
3358
3631
  /**
3359
- * Validate skill description
3632
+ * Registry Resolver (Step 5.1)
3360
3633
  *
3361
- * Specification requirements:
3362
- * - Max 1024 characters
3363
- * - Angle brackets are allowed per agentskills.io spec
3364
- */ function validateSkillDescription(description) {
3365
- if (!description) throw new SkillValidationError('Skill description is required', 'description');
3366
- if (description.length > 1024) throw new SkillValidationError('Skill description must be at most 1024 characters', 'description');
3367
- // Note: angle brackets are allowed per agentskills.io spec
3368
- }
3369
- /**
3370
- * Parse SKILL.md content
3634
+ * Resolves skill references from npm-style registries:
3635
+ * - Private registry: @scope/name[@version] (e.g., @kanyun/planning-with-files@2.4.5)
3636
+ * - Public registry: name[@version] (e.g., my-skill@1.0.0)
3371
3637
  *
3372
- * @param content - SKILL.md file content
3373
- * @param options - Parse options
3374
- * @returns Parsed skill info, or null if format is invalid
3375
- * @throws SkillValidationError if validation fails in strict mode
3376
- */ function parseSkillMd(content, options = {}) {
3377
- const { strict = false } = options;
3378
- try {
3379
- const { data, content: body } = parseFrontmatter(content);
3380
- // Check required fields
3381
- if (!data.name || !data.description) {
3382
- if (strict) throw new SkillValidationError('SKILL.md must have name and description in frontmatter');
3383
- return null;
3384
- }
3385
- const name = String(data.name);
3386
- const description = String(data.description);
3387
- // Validate field format
3388
- if (strict) {
3389
- validateSkillName(name);
3390
- validateSkillDescription(description);
3391
- }
3392
- // Parse allowed-tools
3393
- let allowedTools;
3394
- if (data['allowed-tools']) {
3395
- const toolsStr = String(data['allowed-tools']);
3396
- allowedTools = toolsStr.split(/\s+/).filter(Boolean);
3638
+ * Uses RegistryClient to download and verify skills.
3639
+ */ // ============================================================================
3640
+ // RegistryResolver Class
3641
+ // ============================================================================
3642
+ class RegistryResolver {
3643
+ /**
3644
+ * Check if a reference is a registry source (not Git or HTTP)
3645
+ *
3646
+ * Registry formats:
3647
+ * - @scope/name[@version] - private registry
3648
+ * - name[@version] - public registry (if not matching other formats)
3649
+ *
3650
+ * Explicitly excluded:
3651
+ * - Git SSH: git@github.com:user/repo.git
3652
+ * - Git HTTPS: https://github.com/user/repo.git
3653
+ * - GitHub web: https://github.com/user/repo/tree/...
3654
+ * - HTTP/OSS: https://example.com/skill.tar.gz
3655
+ * - Registry shorthand: github:user/repo, gitlab:org/repo
3656
+ */ static isRegistryRef(ref) {
3657
+ // Exclude Git SSH format (git@...)
3658
+ if (ref.startsWith('git@') || ref.startsWith('git://')) return false;
3659
+ // Exclude URLs ending with .git
3660
+ if (ref.includes('.git')) return false;
3661
+ // Exclude HTTP/HTTPS/OSS URLs
3662
+ if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('oss://') || ref.startsWith('s3://')) return false;
3663
+ // Exclude registry shorthand format (github:, gitlab:, custom.com:)
3664
+ // These follow "registry:owner/repo" pattern, not "@scope/name"
3665
+ if (/^[a-zA-Z0-9.-]+:[^@]/.test(ref)) return false;
3666
+ // Check for @scope/name format (private registry)
3667
+ if (ref.startsWith('@') && ref.includes('/')) {
3668
+ // @scope/name or @scope/name@version
3669
+ const scopeNamePattern = /^@[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
3670
+ return scopeNamePattern.test(ref);
3397
3671
  }
3672
+ // Check for simple name or name@version format (public registry)
3673
+ // Simple names contain only letters, digits, hyphens, underscores, and dots
3674
+ const namePattern = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
3675
+ return namePattern.test(ref);
3676
+ }
3677
+ /**
3678
+ * Resolve a registry skill reference
3679
+ *
3680
+ * @param ref - Skill reference (e.g., "@kanyun/planning-with-files@2.4.5" or "my-skill@latest")
3681
+ * @param overrideRegistryUrl - Optional registry URL override (bypasses scope-based lookup)
3682
+ * @returns Resolved skill information including downloaded tarball
3683
+ *
3684
+ * @example
3685
+ * const result = await resolver.resolve('@kanyun/planning-with-files@2.4.5');
3686
+ * console.log(result.shortName); // 'planning-with-files'
3687
+ * console.log(result.version); // '2.4.5'
3688
+ */ async resolve(ref, overrideRegistryUrl) {
3689
+ // 1. Parse skill identifier
3690
+ const parsed = parseSkillIdentifier(ref);
3691
+ const shortName = getShortName(parsed.fullName);
3692
+ // 2. Get registry URL (CLI override takes precedence)
3693
+ const registryUrl = overrideRegistryUrl || getRegistryUrl(parsed.scope);
3694
+ // 3. Create client and resolve version
3695
+ const client = new RegistryClient({
3696
+ registry: registryUrl
3697
+ });
3698
+ const version = await client.resolveVersion(parsed.fullName, parsed.version);
3699
+ // 4. Download tarball
3700
+ const { tarball, integrity } = await client.downloadSkill(parsed.fullName, version);
3701
+ // 5. Verify integrity
3702
+ const isValid = RegistryClient.verifyIntegrity(tarball, integrity);
3703
+ if (!isValid) throw new Error(`Integrity verification failed for ${ref}`);
3398
3704
  return {
3399
- name,
3400
- description,
3401
- version: data.version ? String(data.version) : void 0,
3402
- license: data.license ? String(data.license) : void 0,
3403
- compatibility: data.compatibility ? String(data.compatibility) : void 0,
3404
- metadata: data.metadata,
3405
- allowedTools,
3406
- content: body,
3407
- rawContent: content
3705
+ parsed,
3706
+ shortName,
3707
+ version,
3708
+ registryUrl,
3709
+ tarball,
3710
+ integrity
3408
3711
  };
3409
- } catch (error) {
3410
- if (error instanceof SkillValidationError) throw error;
3411
- if (strict) throw new SkillValidationError(`Failed to parse SKILL.md: ${error}`);
3412
- return null;
3413
- }
3414
- }
3415
- /**
3416
- * Parse SKILL.md from file path
3417
- */ function skill_parser_parseSkillMdFile(filePath, options = {}) {
3418
- if (!external_node_fs_.existsSync(filePath)) {
3419
- if (options.strict) throw new SkillValidationError(`SKILL.md not found: ${filePath}`);
3420
- return null;
3421
3712
  }
3422
- const content = external_node_fs_.readFileSync(filePath, 'utf-8');
3423
- return parseSkillMd(content, options);
3424
- }
3425
- /**
3426
- * Parse SKILL.md from skill directory
3427
- */ function parseSkillFromDir(dirPath, options = {}) {
3428
- const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(dirPath, 'SKILL.md');
3429
- return skill_parser_parseSkillMdFile(skillMdPath, options);
3430
- }
3431
- /**
3432
- * Check if directory contains valid SKILL.md
3433
- */ function hasValidSkillMd(dirPath) {
3434
- const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(dirPath, 'SKILL.md');
3435
- if (!external_node_fs_.existsSync(skillMdPath)) return false;
3436
- try {
3437
- const skill = skill_parser_parseSkillMdFile(skillMdPath);
3438
- return null !== skill;
3439
- } catch {
3440
- return false;
3713
+ /**
3714
+ * Extract tarball to a target directory
3715
+ *
3716
+ * @param tarball - Tarball buffer
3717
+ * @param destDir - Destination directory
3718
+ * @returns Path to the extracted skill directory
3719
+ */ async extract(tarball, destDir) {
3720
+ await extractTarballBuffer(tarball, destDir);
3721
+ // Get top-level directory name (i.e. skill name)
3722
+ const topDir = await getTarballTopDir(tarball);
3723
+ if (topDir) return `${destDir}/${topDir}`;
3724
+ return destDir;
3441
3725
  }
3442
3726
  }
3443
- /**
3444
- * Generate SKILL.md content
3445
- */ function generateSkillMd(skill) {
3446
- const frontmatter = [
3447
- '---'
3448
- ];
3449
- frontmatter.push(`name: ${skill.name}`);
3450
- frontmatter.push(`description: ${skill.description}`);
3451
- if (skill.license) frontmatter.push(`license: ${skill.license}`);
3452
- if (skill.compatibility) frontmatter.push(`compatibility: ${skill.compatibility}`);
3453
- if (skill.allowedTools && skill.allowedTools.length > 0) frontmatter.push(`allowed-tools: ${skill.allowedTools.join(' ')}`);
3454
- frontmatter.push('---');
3455
- frontmatter.push('');
3456
- return frontmatter.join('\n') + skill.content;
3457
- }
3458
3727
  /**
3459
3728
  * SkillManager - Core Skill management class
3460
3729
  *
@@ -3563,6 +3832,18 @@ class RegistryResolver {
3563
3832
  return null;
3564
3833
  }
3565
3834
  /**
3835
+ * Resolve the actual source path for installation.
3836
+ * For multi-skill repos (parsed.skillName is set), discovers skills in the
3837
+ * cached directory and returns the matching skill's subdirectory.
3838
+ * For single-skill repos, returns basePath as-is.
3839
+ */ resolveSourcePath(basePath, parsed) {
3840
+ if (!parsed.skillName) return basePath;
3841
+ const discovered = discoverSkillsInDir(basePath);
3842
+ const match = discovered.find((s)=>s.name === parsed.skillName);
3843
+ if (!match) throw new Error(`Skill "${parsed.skillName}" not found in repository. Available: ${discovered.map((s)=>s.name).join(', ')}`);
3844
+ return match.dirPath;
3845
+ }
3846
+ /**
3566
3847
  * Install skill
3567
3848
  */ async install(ref, options = {}) {
3568
3849
  // Detect source type and delegate to appropriate installer
@@ -3585,9 +3866,10 @@ class RegistryResolver {
3585
3866
  logger_logger.debug(`Caching from ${repoUrl}@${gitRef}`);
3586
3867
  cacheResult = await this.cache.cache(repoUrl, parsed, gitRef, gitRef);
3587
3868
  }
3588
- // Get the real skill name from SKILL.md in cache
3869
+ // Resolve source path (cache root or skill subdirectory for multi-skill repos)
3589
3870
  const cachePath = this.cache.getCachePath(parsed, gitRef);
3590
- const metadata = this.getSkillMetadataFromDir(cachePath);
3871
+ const sourcePath = this.resolveSourcePath(cachePath, parsed);
3872
+ const metadata = this.getSkillMetadataFromDir(sourcePath);
3591
3873
  const skillName = metadata?.name ?? fallbackName;
3592
3874
  const semanticVersion = metadata?.version ?? gitRef;
3593
3875
  const skillPath = this.getSkillPath(skillName);
@@ -3611,7 +3893,9 @@ class RegistryResolver {
3611
3893
  // Copy to installation directory
3612
3894
  ensureDir(this.getInstallDir());
3613
3895
  if (exists(skillPath)) remove(skillPath);
3614
- await this.cache.copyTo(parsed, gitRef, skillPath);
3896
+ copyDir(sourcePath, skillPath, {
3897
+ exclude: DEFAULT_EXCLUDE_FILES
3898
+ });
3615
3899
  // Update lock file (project mode only)
3616
3900
  if (!this.isGlobal) this.lockManager.lockSkill(skillName, {
3617
3901
  source: `${parsed.registry}:${parsed.owner}/${parsed.repo}${parsed.subPath ? `/${parsed.subPath}` : ''}`,
@@ -3936,12 +4220,112 @@ class RegistryResolver {
3936
4220
  * @param options - Installation options
3937
4221
  */ async installToAgents(ref, targetAgents, options = {}) {
3938
4222
  // Detect source type and delegate to appropriate installer
3939
- // Priority: Registry > HTTP > Git (registry 优先,因为它的格式最受限)
4223
+ // Priority: Registry > HTTP > Git (registry first, as its format is most constrained)
3940
4224
  if (this.isRegistrySource(ref)) return this.installToAgentsFromRegistry(ref, targetAgents, options);
3941
4225
  if (this.isHttpSource(ref)) return this.installToAgentsFromHttp(ref, targetAgents, options);
3942
4226
  return this.installToAgentsFromGit(ref, targetAgents, options);
3943
4227
  }
3944
4228
  /**
4229
+ * Multi-skill install: discover skills in a Git repo and install selected ones (or list only).
4230
+ * Only Git references are supported (including https://github.com/...); registry refs are not.
4231
+ *
4232
+ * @param ref - Git skill reference (e.g. github:user/repo@v1.0.0 or https://github.com/user/repo); any #fragment is stripped for resolution
4233
+ * @param skillNames - If non-empty, install only these skills (by SKILL.md name). If empty and !listOnly, install all.
4234
+ * @param targetAgents - Target agents
4235
+ * @param options - Install options; listOnly: true means discover and return skills without installing
4236
+ */ async installSkillsFromRepo(ref, skillNames, targetAgents, options = {}) {
4237
+ const { listOnly = false, force = false, save = true, mode = 'symlink' } = options;
4238
+ const refForResolve = ref.replace(/#.*$/, '').trim();
4239
+ const resolved = await this.resolver.resolve(refForResolve);
4240
+ const { parsed, repoUrl } = resolved;
4241
+ const gitRef = resolved.ref;
4242
+ let cacheResult = await this.cache.get(parsed, gitRef);
4243
+ if (!cacheResult) {
4244
+ logger_logger.debug(`Caching from ${repoUrl}@${gitRef}`);
4245
+ cacheResult = await this.cache.cache(repoUrl, parsed, gitRef, gitRef);
4246
+ }
4247
+ const cachePath = this.cache.getCachePath(parsed, gitRef);
4248
+ const discovered = discoverSkillsInDir(cachePath);
4249
+ if (0 === discovered.length) throw new Error('No valid skills found. Skills require a SKILL.md with name and description.');
4250
+ if (listOnly) return {
4251
+ listOnly: true,
4252
+ skills: discovered
4253
+ };
4254
+ const selected = skillNames.length > 0 ? filterSkillsByName(discovered, skillNames) : discovered;
4255
+ if (skillNames.length > 0 && 0 === selected.length) {
4256
+ const available = discovered.map((s)=>s.name).join(', ');
4257
+ throw new Error(`No matching skills found for: ${skillNames.join(', ')}. Available skills: ${available}`);
4258
+ }
4259
+ const baseRefForSave = this.config.normalizeSkillRef(refForResolve);
4260
+ const defaults = this.config.getDefaults();
4261
+ // Only pass custom installDir to Installer; default '.skills' should use
4262
+ // the Installer's built-in canonical path (.agents/skills/)
4263
+ const customInstallDir = '.skills' !== defaults.installDir ? defaults.installDir : void 0;
4264
+ const installer = new Installer({
4265
+ cwd: this.projectRoot,
4266
+ global: this.isGlobal,
4267
+ installDir: customInstallDir
4268
+ });
4269
+ const installed = [];
4270
+ const skipped = [];
4271
+ const skillSource = `${parsed.registry}:${parsed.owner}/${parsed.repo}${parsed.subPath ? `/${parsed.subPath}` : ''}`;
4272
+ for (const skillInfo of selected){
4273
+ const semanticVersion = skillInfo.version ?? gitRef;
4274
+ // Skip already-installed skills unless --force is set
4275
+ if (!force) {
4276
+ const existingSkill = this.getInstalledSkill(skillInfo.name);
4277
+ if (existingSkill) {
4278
+ const locked = this.lockManager.get(skillInfo.name);
4279
+ const lockedRef = locked?.ref || locked?.version;
4280
+ if (lockedRef === gitRef) {
4281
+ const reason = `already installed at ${gitRef}`;
4282
+ logger_logger.info(`${skillInfo.name}@${gitRef} is already installed, skipping`);
4283
+ skipped.push({
4284
+ name: skillInfo.name,
4285
+ reason
4286
+ });
4287
+ continue;
4288
+ }
4289
+ // Different version installed — allow upgrade without --force
4290
+ // Only skip when the exact same ref is already locked
4291
+ }
4292
+ }
4293
+ logger_logger["package"](`Installing ${skillInfo.name}@${gitRef} to ${targetAgents.length} agent(s)...`);
4294
+ // Note: force is handled at the SkillManager level (skip-if-installed check above).
4295
+ // The Installer always overwrites (remove + copy), so no force flag is needed there.
4296
+ const results = await installer.installToAgents(skillInfo.dirPath, skillInfo.name, targetAgents, {
4297
+ mode: mode
4298
+ });
4299
+ if (!this.isGlobal) this.lockManager.lockSkill(skillInfo.name, {
4300
+ source: skillSource,
4301
+ version: semanticVersion,
4302
+ ref: gitRef,
4303
+ resolved: repoUrl,
4304
+ commit: cacheResult.commit
4305
+ });
4306
+ if (!this.isGlobal && save) {
4307
+ this.config.ensureExists();
4308
+ this.config.addSkill(skillInfo.name, `${baseRefForSave}#${skillInfo.name}`);
4309
+ }
4310
+ const successCount = Array.from(results.values()).filter((r)=>r.success).length;
4311
+ logger_logger.success(`Installed ${skillInfo.name}@${semanticVersion} to ${successCount} agent(s)`);
4312
+ installed.push({
4313
+ skill: {
4314
+ name: skillInfo.name,
4315
+ path: skillInfo.dirPath,
4316
+ version: semanticVersion,
4317
+ source: skillSource
4318
+ },
4319
+ results
4320
+ });
4321
+ }
4322
+ return {
4323
+ listOnly: false,
4324
+ installed,
4325
+ skipped
4326
+ };
4327
+ }
4328
+ /**
3945
4329
  * Install skill from Git to multiple agents
3946
4330
  */ async installToAgentsFromGit(ref, targetAgents, options = {}) {
3947
4331
  const { save = true, mode = 'symlink' } = options;
@@ -3957,8 +4341,9 @@ class RegistryResolver {
3957
4341
  logger_logger.debug(`Caching from ${repoUrl}@${gitRef}`);
3958
4342
  cacheResult = await this.cache.cache(repoUrl, parsed, gitRef, gitRef);
3959
4343
  }
3960
- // Get cache path as source
3961
- const sourcePath = this.cache.getCachePath(parsed, gitRef);
4344
+ // Resolve source path (cache root or skill subdirectory for multi-skill repos)
4345
+ const cachePath = this.cache.getCachePath(parsed, gitRef);
4346
+ const sourcePath = this.resolveSourcePath(cachePath, parsed);
3962
4347
  // Get the real skill name from SKILL.md in cache
3963
4348
  const metadata = this.getSkillMetadataFromDir(sourcePath);
3964
4349
  const skillName = metadata?.name ?? fallbackName;
@@ -4082,14 +4467,13 @@ class RegistryResolver {
4082
4467
  * - Web-published skills (github/gitlab/oss_url/custom_url/local)
4083
4468
  */ async installToAgentsFromRegistry(ref, targetAgents, options = {}) {
4084
4469
  const { force = false, save = true, mode = 'symlink' } = options;
4085
- // 解析 skill 标识(获取 fullName version)
4470
+ // Parse skill identifier and resolve registry URL once (single source of truth)
4086
4471
  const parsed = parseSkillIdentifier(ref);
4087
- const registryUrl = getRegistryUrl(parsed.scope);
4472
+ const registryUrl = options.registry || getRegistryUrl(parsed.scope);
4088
4473
  const client = new RegistryClient({
4089
- registry: registryUrl,
4090
- apiPrefix: getApiPrefix(registryUrl)
4474
+ registry: registryUrl
4091
4475
  });
4092
- // 新增:先查询 skill 信息获取 source_type
4476
+ // Query skill info to determine source_type
4093
4477
  let skillInfo;
4094
4478
  try {
4095
4479
  skillInfo = await client.getSkillInfo(parsed.fullName);
@@ -4100,12 +4484,15 @@ class RegistryResolver {
4100
4484
  };
4101
4485
  else throw error;
4102
4486
  }
4103
- // 新增:根据 source_type 分支
4487
+ // Branch based on source_type (pass resolved registryUrl via options to avoid re-computation)
4104
4488
  const sourceType = skillInfo.source_type;
4105
- if (sourceType && 'registry' !== sourceType) return this.installFromWebPublished(skillInfo, parsed, targetAgents, options);
4106
- // 1. Resolve registry skill(现有流程)
4489
+ if (sourceType && 'registry' !== sourceType) return this.installFromWebPublished(skillInfo, parsed, targetAgents, {
4490
+ ...options,
4491
+ registry: registryUrl
4492
+ });
4493
+ // 1. Resolve registry skill (pass pre-resolved registryUrl)
4107
4494
  logger_logger["package"](`Resolving ${ref} from registry...`);
4108
- const resolved = await this.registryResolver.resolve(ref);
4495
+ const resolved = await this.registryResolver.resolve(ref, registryUrl);
4109
4496
  const { shortName, version, registryUrl: resolvedRegistryUrl, tarball, parsed: resolvedParsed } = resolved;
4110
4497
  // 2. Check if already installed (skip if --force)
4111
4498
  const skillPath = this.getSkillPath(shortName);
@@ -4144,101 +4531,157 @@ class RegistryResolver {
4144
4531
  };
4145
4532
  }
4146
4533
  logger_logger["package"](`Installing ${shortName}@${version} from ${resolvedRegistryUrl} to ${targetAgents.length} agent(s)...`);
4147
- // 3. Create temp directory for extraction
4534
+ // 3. Create temp directory for extraction (clean stale files first)
4148
4535
  const tempDir = __WEBPACK_EXTERNAL_MODULE_node_path__.join(this.cache.getCacheDir(), 'registry-temp', `${shortName}-${version}`);
4536
+ await remove(tempDir);
4149
4537
  await ensureDir(tempDir);
4150
- // 4. Extract tarball
4151
- const extractedPath = await this.registryResolver.extract(tarball, tempDir);
4152
- logger_logger.debug(`Extracted to ${extractedPath}`);
4153
- // 5. Create Installer with custom installDir from config
4154
- const defaults = this.config.getDefaults();
4155
- const installer = new Installer({
4156
- cwd: this.projectRoot,
4157
- global: this.isGlobal,
4158
- installDir: defaults.installDir
4159
- });
4160
- // 6. Install to all target agents
4161
- const results = await installer.installToAgents(extractedPath, shortName, targetAgents, {
4162
- mode: mode
4163
- });
4164
- // 7. Update lock file (project mode only)
4165
- if (!this.isGlobal) this.lockManager.lockSkill(shortName, {
4166
- source: `registry:${resolvedParsed.fullName}`,
4167
- version,
4168
- ref: version,
4169
- resolved: resolvedRegistryUrl,
4170
- commit: resolved.integrity
4171
- });
4172
- // 8. Update skills.json (project mode only)
4173
- if (!this.isGlobal && save) {
4174
- this.config.ensureExists();
4175
- // Save with full name for registry skills
4176
- this.config.addSkill(shortName, ref);
4538
+ try {
4539
+ // 4. Extract tarball
4540
+ const extractedPath = await this.registryResolver.extract(tarball, tempDir);
4541
+ logger_logger.debug(`Extracted to ${extractedPath}`);
4542
+ // 5. Create Installer with custom installDir from config
4543
+ const defaults = this.config.getDefaults();
4544
+ const installer = new Installer({
4545
+ cwd: this.projectRoot,
4546
+ global: this.isGlobal,
4547
+ installDir: defaults.installDir
4548
+ });
4549
+ // 6. Install to all target agents
4550
+ const results = await installer.installToAgents(extractedPath, shortName, targetAgents, {
4551
+ mode: mode
4552
+ });
4553
+ // 7. Update lock file (project mode only)
4554
+ if (!this.isGlobal) this.lockManager.lockSkill(shortName, {
4555
+ source: `registry:${resolvedParsed.fullName}`,
4556
+ version,
4557
+ ref: version,
4558
+ resolved: resolvedRegistryUrl,
4559
+ commit: resolved.integrity
4560
+ });
4561
+ // 8. Update skills.json (project mode only)
4562
+ if (!this.isGlobal && save) {
4563
+ this.config.ensureExists();
4564
+ // Save with full name for registry skills
4565
+ this.config.addSkill(shortName, ref);
4566
+ }
4567
+ // 9. Count results and log
4568
+ const successCount = Array.from(results.values()).filter((r)=>r.success).length;
4569
+ const failCount = results.size - successCount;
4570
+ if (0 === failCount) logger_logger.success(`Installed ${shortName}@${version} to ${successCount} agent(s)`);
4571
+ else logger_logger.warn(`Installed ${shortName}@${version} to ${successCount} agent(s), ${failCount} failed`);
4572
+ // 10. Build the InstalledSkill to return
4573
+ const skill = {
4574
+ name: shortName,
4575
+ path: extractedPath,
4576
+ version,
4577
+ source: `registry:${resolvedParsed.fullName}`
4578
+ };
4579
+ return {
4580
+ skill,
4581
+ results
4582
+ };
4583
+ } finally{
4584
+ // Clean up temp directory after installation
4585
+ await remove(tempDir);
4177
4586
  }
4178
- // 9. Count results and log
4179
- const successCount = Array.from(results.values()).filter((r)=>r.success).length;
4180
- const failCount = results.size - successCount;
4181
- if (0 === failCount) logger_logger.success(`Installed ${shortName}@${version} to ${successCount} agent(s)`);
4182
- else logger_logger.warn(`Installed ${shortName}@${version} to ${successCount} agent(s), ${failCount} failed`);
4183
- // 9. Build the InstalledSkill to return
4184
- const skill = {
4185
- name: shortName,
4186
- path: extractedPath,
4187
- version,
4188
- source: `registry:${resolvedParsed.fullName}`
4189
- };
4190
- return {
4191
- skill,
4192
- results
4193
- };
4194
4587
  }
4195
4588
  // ============================================================================
4196
- // Web-published skill installation (页面发布适配)
4589
+ // Web-published skill installation
4197
4590
  // ============================================================================
4198
4591
  /**
4199
- * 安装页面发布的 skill
4592
+ * Install a web-published skill.
4200
4593
  *
4201
- * 页面发布的 skill 不支持版本管理,根据 source_type 分支到不同的安装逻辑:
4202
- * - github/gitlab: 复用 installToAgentsFromGit
4203
- * - oss_url/custom_url: 复用 installToAgentsFromHttp
4204
- * - local: 通过 Registry API 下载 tarball
4594
+ * Web-published skills do not support versioning. Branches to different
4595
+ * installation logic based on source_type:
4596
+ * - github/gitlab: reuses installToAgentsFromGit
4597
+ * - oss_url/custom_url: reuses installToAgentsFromHttp
4598
+ * - local: downloads tarball via Registry API
4205
4599
  */ async installFromWebPublished(skillInfo, parsed, targetAgents, options = {}) {
4206
4600
  const { source_type, source_url } = skillInfo;
4207
- // 页面发布的 skill 不支持版本指定
4601
+ // Web-published skills do not support version specifiers
4208
4602
  if (parsed.version && 'latest' !== parsed.version) throw new Error(`Version specifier not supported for web-published skills.\n'${parsed.fullName}' was published via web and does not support versioning.\nUse: reskill install ${parsed.fullName}`);
4209
4603
  if (!source_url) throw new Error(`Missing source_url for web-published skill: ${parsed.fullName}`);
4210
4604
  logger_logger["package"](`Installing ${parsed.fullName} from ${source_type} source...`);
4211
4605
  switch(source_type){
4212
4606
  case 'github':
4213
4607
  case 'gitlab':
4214
- // source_url 是完整的 Git URL(包含 ref path
4215
- // 复用已有的 Git 安装逻辑
4608
+ // source_url is a full Git URL (includes ref and path)
4609
+ // Reuse existing Git installation logic
4216
4610
  return this.installToAgentsFromGit(source_url, targetAgents, options);
4217
4611
  case 'oss_url':
4218
4612
  case 'custom_url':
4219
- // 直接下载 URL
4613
+ // Direct download URL
4220
4614
  return this.installToAgentsFromHttp(source_url, targetAgents, options);
4221
4615
  case 'local':
4222
- // 通过 Registry API 下载 tarball
4223
- return this.installFromRegistryLocal(skillInfo, parsed, targetAgents, options);
4616
+ // Download tarball via Registry API
4617
+ return this.installFromRegistryLocal(parsed, targetAgents, options);
4224
4618
  default:
4225
4619
  throw new Error(`Unknown source_type: ${source_type}`);
4226
4620
  }
4227
4621
  }
4228
4622
  /**
4229
- * 安装 Local Folder 模式发布的 skill
4623
+ * Install a skill published via "local folder" mode.
4230
4624
  *
4231
- * 通过 Registry {apiPrefix}/skills/:name/download API 下载 tarball
4232
- * (apiPrefix 根据 registry 不同而不同,如 /api /api/reskill)
4233
- */ async installFromRegistryLocal(_skillInfo, parsed, targetAgents, options = {}) {
4234
- const registryUrl = getRegistryUrl(parsed.scope);
4235
- // 构造下载 URL(通过 Registry API)
4236
- // Ensure trailing slash for proper URL concatenation (defensive coding)
4237
- const baseUrl = registryUrl.endsWith('/') ? registryUrl : `${registryUrl}/`;
4238
- const downloadUrl = `${baseUrl}api/skills/${encodeURIComponent(parsed.fullName)}/download`;
4239
- logger_logger.debug(`Downloading from: ${downloadUrl}`);
4240
- // 复用 HTTP 下载逻辑
4241
- return this.installToAgentsFromHttp(downloadUrl, targetAgents, options);
4625
+ * Downloads tarball via RegistryClient (handles 302 redirects to signed OSS URLs),
4626
+ * then extracts and installs using the same flow as registry source_type.
4627
+ */ async installFromRegistryLocal(parsed, targetAgents, options = {}) {
4628
+ const { save = true, mode = 'symlink' } = options;
4629
+ const registryUrl = options.registry || getRegistryUrl(parsed.scope);
4630
+ const shortName = getShortName(parsed.fullName);
4631
+ const version = 'latest';
4632
+ // Download tarball via RegistryClient (handles auth + 302 redirect to signed URL)
4633
+ const client = new RegistryClient({
4634
+ registry: registryUrl
4635
+ });
4636
+ const { tarball } = await client.downloadSkill(parsed.fullName, version);
4637
+ logger_logger["package"](`Installing ${shortName} from ${registryUrl} to ${targetAgents.length} agent(s)...`);
4638
+ // Extract tarball to temp directory (clean stale files first)
4639
+ const tempDir = __WEBPACK_EXTERNAL_MODULE_node_path__.join(this.cache.getCacheDir(), 'registry-temp', `${shortName}-${version}`);
4640
+ await remove(tempDir);
4641
+ await ensureDir(tempDir);
4642
+ try {
4643
+ const extractedPath = await this.registryResolver.extract(tarball, tempDir);
4644
+ logger_logger.debug(`Extracted to ${extractedPath}`);
4645
+ // Install to all target agents
4646
+ const defaults = this.config.getDefaults();
4647
+ const installer = new Installer({
4648
+ cwd: this.projectRoot,
4649
+ global: this.isGlobal,
4650
+ installDir: defaults.installDir
4651
+ });
4652
+ const results = await installer.installToAgents(extractedPath, shortName, targetAgents, {
4653
+ mode: mode
4654
+ });
4655
+ // Get metadata from extracted path
4656
+ const metadata = this.getSkillMetadataFromDir(extractedPath);
4657
+ const skillName = metadata?.name ?? shortName;
4658
+ const semanticVersion = metadata?.version ?? version;
4659
+ // Update lock file (project mode only)
4660
+ if (!this.isGlobal) this.lockManager.lockSkill(skillName, {
4661
+ source: `registry:${parsed.fullName}`,
4662
+ version: semanticVersion,
4663
+ ref: version,
4664
+ resolved: registryUrl,
4665
+ commit: ''
4666
+ });
4667
+ // Update skills.json (project mode only)
4668
+ if (!this.isGlobal && save) {
4669
+ this.config.ensureExists();
4670
+ this.config.addSkill(skillName, parsed.fullName);
4671
+ }
4672
+ return {
4673
+ skill: {
4674
+ name: skillName,
4675
+ path: extractedPath,
4676
+ version: semanticVersion,
4677
+ source: `registry:${parsed.fullName}`
4678
+ },
4679
+ results
4680
+ };
4681
+ } finally{
4682
+ // Clean up temp directory after installation
4683
+ await remove(tempDir);
4684
+ }
4242
4685
  }
4243
4686
  /**
4244
4687
  * Get default target agents