reskill 1.7.0 → 1.8.0-beta.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
@@ -1836,11 +2284,25 @@ const installer_SKILLS_SUBDIR = 'skills';
1836
2284
  }
1837
2285
  // Parse owner/repo and possible subPath
1838
2286
  // E.g.: user/repo or org/monorepo/skills/pdf
2287
+ // Also handle GitHub web URL style: owner/repo/tree/branch/path
1839
2288
  const parts = remaining.split('/');
1840
2289
  if (parts.length < 2) throw new Error(`Invalid skill reference: ${ref}. Expected format: owner/repo[@version]`);
1841
2290
  const owner = parts[0];
1842
2291
  const repo = parts[1];
1843
- const subPath = parts.length > 2 ? parts.slice(2).join('/') : void 0;
2292
+ let subPath;
2293
+ // Check for GitHub/GitLab web URL pattern: owner/repo/(tree|blob|raw)/branch/path
2294
+ // e.g. vercel-labs/skills/tree/main/skills/find-skills
2295
+ // Only apply this heuristic when no explicit @version is provided.
2296
+ // With @version, treat tree/blob/raw as literal directory names (standard monorepo subPath).
2297
+ if (parts.length >= 4 && [
2298
+ 'tree',
2299
+ 'blob',
2300
+ 'raw'
2301
+ ].includes(parts[2]) && !version) {
2302
+ const branch = parts[3];
2303
+ version = `branch:${branch}`;
2304
+ subPath = parts.length > 4 ? parts.slice(4).join('/') : void 0;
2305
+ } else subPath = parts.length > 2 ? parts.slice(2).join('/') : void 0;
1844
2306
  return {
1845
2307
  registry,
1846
2308
  owner,
@@ -2069,20 +2531,31 @@ const installer_SKILLS_SUBDIR = 'skills';
2069
2531
  * Check if a reference is an HTTP/OSS URL (for archive downloads)
2070
2532
  *
2071
2533
  * Returns true for:
2072
- * - http:// or https:// URLs pointing to archive files (.tar.gz, .tgz, .zip, .tar)
2073
- * - Explicit oss:// or s3:// protocol URLs
2534
+ * - http:// or https:// URLs with archive file extensions (.tar.gz, .tgz, .zip, .tar)
2535
+ * - Explicit oss:// or s3:// protocol URLs (always treated as archive sources)
2074
2536
  *
2075
2537
  * Returns false for:
2076
2538
  * - Git repository URLs (*.git)
2077
2539
  * - GitHub/GitLab web URLs (/tree/, /blob/, /raw/)
2540
+ * - Bare HTTPS repo URLs without archive extensions (e.g., https://github.com/user/repo)
2541
+ * These are treated as Git references and handled by GitResolver.
2078
2542
  */ static isHttpUrl(ref) {
2079
2543
  // Remove version suffix for checking (e.g., url@v1.0.0)
2080
2544
  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://');
2545
+ // oss:// and s3:// are always archive download sources
2546
+ if (urlPart.startsWith('oss://') || urlPart.startsWith('s3://')) return true;
2547
+ // For http:// and https:// URLs, distinguish between Git repos and archive downloads
2548
+ if (urlPart.startsWith('http://') || urlPart.startsWith('https://')) {
2549
+ // Exclude Git repository URLs (ending with .git)
2550
+ if (urlPart.endsWith('.git')) return false;
2551
+ // Exclude GitHub/GitLab web URLs (containing /tree/, /blob/, /raw/)
2552
+ if (/\/(tree|blob|raw)\//.test(urlPart)) return false;
2553
+ // Only classify as HTTP archive if URL has a recognized archive extension.
2554
+ // Bare HTTPS URLs like https://github.com/user/repo are Git references,
2555
+ // not archive downloads, and should fall through to GitResolver.
2556
+ return /\.(tar\.gz|tgz|zip|tar)$/i.test(urlPart);
2557
+ }
2558
+ return false;
2086
2559
  }
2087
2560
  /**
2088
2561
  * Parse an HTTP/OSS URL reference
@@ -2391,46 +2864,21 @@ const installer_SKILLS_SUBDIR = 'skills';
2391
2864
  * Maps registry URLs to their corresponding scopes.
2392
2865
  * Currently hardcoded; TODO: fetch from /api/registry/info in the future.
2393
2866
  */ /**
2394
- * 公共 Registry URL
2395
- * 用于无 scope skill 安装
2396
- */ const PUBLIC_REGISTRY = 'https://reskill.info/';
2867
+ * Public Registry URL
2868
+ * Used for installing skills without a scope
2869
+ */ const registry_scope_PUBLIC_REGISTRY = 'https://reskill.info/';
2397
2870
  /**
2398
2871
  * Hardcoded registry to scope mapping
2399
2872
  * TODO: Replace with dynamic fetching from /api/registry/info
2400
2873
  */ const REGISTRY_SCOPE_MAP = {
2401
2874
  // rush-app (private registry, new)
2402
- 'https://rush-test.zhenguanyu.com': '@kanyun',
2875
+ 'https://rush-test.zhenguanyu.com': '@kanyun-test',
2403
2876
  'https://rush.zhenguanyu.com': '@kanyun',
2404
2877
  // reskill-app (private registry, legacy)
2405
- 'https://reskill-test.zhenguanyu.com': '@kanyun',
2878
+ 'https://reskill-test.zhenguanyu.com': '@kanyun-test',
2406
2879
  // 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'
2880
+ 'http://localhost:3000': '@kanyun-test'
2418
2881
  };
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
2882
  /**
2435
2883
  * Get the registry URL for a given scope (reverse lookup)
2436
2884
  *
@@ -2478,7 +2926,7 @@ const installer_SKILLS_SUBDIR = 'skills';
2478
2926
  * getRegistryUrl('@mycompany', { '@mycompany': 'https://my.registry.com/' }) // 'https://my.registry.com/'
2479
2927
  */ function getRegistryUrl(scope, customRegistries) {
2480
2928
  // No scope → return public Registry
2481
- if (!scope) return PUBLIC_REGISTRY;
2929
+ if (!scope) return registry_scope_PUBLIC_REGISTRY;
2482
2930
  // With scope → lookup private Registry
2483
2931
  const registry = getRegistryForScope(scope, customRegistries);
2484
2932
  if (!registry) {
@@ -2529,21 +2977,21 @@ const installer_SKILLS_SUBDIR = 'skills';
2529
2977
  /**
2530
2978
  * Parse a skill identifier into its components (with version support)
2531
2979
  *
2532
- * 支持私有 Registry(带 @scope)和公共 Registry(无 scope)两种格式。
2980
+ * Supports both private registry (with @scope) and public registry (without scope) formats.
2533
2981
  *
2534
2982
  * @param identifier - Skill identifier string
2535
2983
  * @returns Parsed skill identifier with scope, name, version, and fullName
2536
2984
  * @throws Error if identifier is invalid
2537
2985
  *
2538
2986
  * @example
2539
- * // 私有 Registry
2987
+ * // Private registry
2540
2988
  * parseSkillIdentifier('@kanyun/planning-with-files')
2541
2989
  * // { scope: '@kanyun', name: 'planning-with-files', version: undefined, fullName: '@kanyun/planning-with-files' }
2542
2990
  *
2543
2991
  * parseSkillIdentifier('@kanyun/skill@2.4.5')
2544
2992
  * // { scope: '@kanyun', name: 'skill', version: '2.4.5', fullName: '@kanyun/skill' }
2545
2993
  *
2546
- * // 公共 Registry
2994
+ * // Public registry
2547
2995
  * parseSkillIdentifier('planning-with-files')
2548
2996
  * // { scope: null, name: 'planning-with-files', version: undefined, fullName: 'planning-with-files' }
2549
2997
  *
@@ -2551,18 +2999,18 @@ const installer_SKILLS_SUBDIR = 'skills';
2551
2999
  * // { scope: null, name: 'skill', version: 'latest', fullName: 'skill' }
2552
3000
  */ function parseSkillIdentifier(identifier) {
2553
3001
  const trimmed = identifier.trim();
2554
- // 空字符串或仅空白
3002
+ // Empty string or whitespace only
2555
3003
  if (!trimmed) throw new Error('Invalid skill identifier: empty string');
2556
- // @@ 开头无效
3004
+ // Starting with @@ is invalid
2557
3005
  if (trimmed.startsWith('@@')) throw new Error('Invalid skill identifier: invalid scope format');
2558
- // 只有 @ 无效
3006
+ // Bare @ is invalid
2559
3007
  if ('@' === trimmed) throw new Error('Invalid skill identifier: missing scope and name');
2560
- // scope 的格式: @scope/name[@version]
3008
+ // Scoped format: @scope/name[@version]
2561
3009
  if (trimmed.startsWith('@')) {
2562
- // 正则匹配: @scope/name[@version]
2563
- // scope: @ 开头,后面跟字母数字、连字符、下划线
2564
- // name: 字母数字、连字符、下划线
2565
- // version: 可选,@ 后跟任意非空字符
3010
+ // Regex: @scope/name[@version]
3011
+ // scope: starts with @, followed by alphanumeric, hyphens, underscores
3012
+ // name: alphanumeric, hyphens, underscores
3013
+ // version: optional, @ followed by any non-empty string
2566
3014
  const scopedMatch = trimmed.match(/^(@[\w-]+)\/([\w-]+)(?:@(.+))?$/);
2567
3015
  if (!scopedMatch) throw new Error(`Invalid skill identifier: ${identifier}`);
2568
3016
  const [, scope, name, version] = scopedMatch;
@@ -2573,8 +3021,8 @@ const installer_SKILLS_SUBDIR = 'skills';
2573
3021
  fullName: `${scope}/${name}`
2574
3022
  };
2575
3023
  }
2576
- // scope 的格式: name[@version](公共 Registry)
2577
- // name 不能包含 /(否则可能是 git shorthand
3024
+ // Unscoped format: name[@version] (public registry)
3025
+ // name must not contain / (otherwise it might be a git shorthand)
2578
3026
  const unscopedMatch = trimmed.match(/^([\w-]+)(?:@(.+))?$/);
2579
3027
  if (!unscopedMatch) throw new Error(`Invalid skill identifier: ${identifier}`);
2580
3028
  const [, name, version] = unscopedMatch;
@@ -2608,13 +3056,14 @@ class RegistryClient {
2608
3056
  this.config = config;
2609
3057
  }
2610
3058
  /**
2611
- * Get API base URL (registry + apiPrefix)
3059
+ * Get API base URL (registry + /api)
2612
3060
  *
2613
- * @returns Base URL for API calls, e.g., 'https://example.com/api' or 'https://rush.com/api/reskill'
3061
+ * All registries use the unified '/api' prefix.
3062
+ *
3063
+ * @returns Base URL for API calls, e.g., 'https://example.com/api'
2614
3064
  */ getApiBase() {
2615
- const prefix = this.config.apiPrefix || '/api';
2616
3065
  const registry = this.config.registry.endsWith('/') ? this.config.registry.slice(0, -1) : this.config.registry;
2617
- return `${registry}${prefix}`;
3066
+ return `${registry}/api`;
2618
3067
  }
2619
3068
  /**
2620
3069
  * Get authorization headers
@@ -2629,7 +3078,7 @@ class RegistryClient {
2629
3078
  /**
2630
3079
  * Get current user info (whoami)
2631
3080
  */ async whoami() {
2632
- const url = `${this.getApiBase()}/auth/me`;
3081
+ const url = `${this.getApiBase()}/skill-auth/me`;
2633
3082
  const response = await fetch(url, {
2634
3083
  method: 'GET',
2635
3084
  headers: this.getAuthHeaders()
@@ -2641,13 +3090,13 @@ class RegistryClient {
2641
3090
  /**
2642
3091
  * CLI login - verify token and get user info
2643
3092
  *
2644
- * Calls POST /api/auth/login-cli to validate the token and retrieve user information.
3093
+ * Calls POST /api/skill-auth/login-cli to validate the token and retrieve user information.
2645
3094
  * This is the preferred method for CLI authentication.
2646
3095
  *
2647
3096
  * @returns User information if authentication succeeds
2648
3097
  * @throws RegistryError if authentication fails
2649
3098
  */ async loginCli() {
2650
- const url = `${this.getApiBase()}/auth/login-cli`;
3099
+ const url = `${this.getApiBase()}/skill-auth/login-cli`;
2651
3100
  const response = await fetch(url, {
2652
3101
  method: 'POST',
2653
3102
  headers: this.getAuthHeaders()
@@ -2679,7 +3128,7 @@ class RegistryClient {
2679
3128
  if (external_node_fs_.existsSync(filePath)) {
2680
3129
  const content = external_node_fs_.readFileSync(filePath);
2681
3130
  const stat = external_node_fs_.statSync(filePath);
2682
- // 如果提供了 shortName,则在路径前添加顶层目录
3131
+ // Prepend shortName as top-level directory if provided
2683
3132
  const entryName = shortName ? `${shortName}/${file}` : file;
2684
3133
  tarPack.entry({
2685
3134
  name: entryName,
@@ -2693,15 +3142,15 @@ class RegistryClient {
2693
3142
  });
2694
3143
  }
2695
3144
  // ============================================================================
2696
- // Skill Info Methods (页面发布适配)
3145
+ // Skill Info Methods (web-published skill support)
2697
3146
  // ============================================================================
2698
3147
  /**
2699
- * 获取 skill 基本信息(包含 source_type
2700
- * 用于 install 命令判断安装逻辑分支
3148
+ * Get basic skill info (including source_type).
3149
+ * Used by the install command to determine the installation logic branch.
2701
3150
  *
2702
- * @param skillName - 完整名称,如 @kanyun/my-skill
2703
- * @returns Skill 基本信息
2704
- * @throws RegistryError 如果 skill 不存在或请求失败
3151
+ * @param skillName - Full skill name, e.g., @kanyun/my-skill
3152
+ * @returns Basic skill information
3153
+ * @throws RegistryError if skill not found or request failed
2705
3154
  */ async getSkillInfo(skillName) {
2706
3155
  const url = `${this.getApiBase()}/skills/${encodeURIComponent(skillName)}`;
2707
3156
  const response = await fetch(url, {
@@ -2710,15 +3159,50 @@ class RegistryClient {
2710
3159
  });
2711
3160
  if (!response.ok) {
2712
3161
  const data = await response.json();
2713
- // 404 时给出明确的 skill 不存在错误
3162
+ // Return a clear "not found" error for 404 responses
2714
3163
  if (404 === response.status) throw new RegistryError(`Skill not found: ${skillName}`, response.status, data);
2715
3164
  throw new RegistryError(data.error || `Failed to get skill info: ${response.statusText}`, response.status, data);
2716
3165
  }
2717
- // API 返回格式: { success: true, data: { ... } }
3166
+ // API response format: { success: true, data: { ... } }
2718
3167
  const responseData = await response.json();
2719
3168
  return responseData.data || responseData;
2720
3169
  }
2721
3170
  // ============================================================================
3171
+ // Search Methods
3172
+ // ============================================================================
3173
+ /**
3174
+ * Search for skills in the registry
3175
+ *
3176
+ * @param query - Search query string
3177
+ * @param options - Search options (limit, offset)
3178
+ * @returns Array of matching skills
3179
+ * @throws RegistryError if the request fails
3180
+ *
3181
+ * @example
3182
+ * const results = await client.search('typescript');
3183
+ * const results = await client.search('planning', { limit: 5 });
3184
+ */ async search(query, options = {}) {
3185
+ const params = new URLSearchParams({
3186
+ q: query
3187
+ });
3188
+ if (void 0 !== options.limit) params.set('limit', String(options.limit));
3189
+ if (void 0 !== options.offset) params.set('offset', String(options.offset));
3190
+ const url = `${this.getApiBase()}/skills?${params.toString()}`;
3191
+ const response = await fetch(url, {
3192
+ method: 'GET',
3193
+ headers: this.getAuthHeaders()
3194
+ });
3195
+ if (!response.ok) {
3196
+ const data = await response.json();
3197
+ throw new RegistryError(data.error || `Search failed: ${response.status}`, response.status, data);
3198
+ }
3199
+ const data = await response.json();
3200
+ return {
3201
+ items: data.data || [],
3202
+ total: data.meta?.pagination?.totalItems ?? data.data?.length ?? 0
3203
+ };
3204
+ }
3205
+ // ============================================================================
2722
3206
  // Download Methods (Step 3.3)
2723
3207
  // ============================================================================
2724
3208
  /**
@@ -2731,12 +3215,12 @@ class RegistryClient {
2731
3215
  *
2732
3216
  * @example
2733
3217
  * await client.resolveVersion('@kanyun/test-skill', 'latest') // '2.4.5'
2734
- * await client.resolveVersion('@kanyun/test-skill', '2.4.5') // '2.4.5' (直接返回)
3218
+ * await client.resolveVersion('@kanyun/test-skill', '2.4.5') // '2.4.5' (returned as-is)
2735
3219
  */ async resolveVersion(skillName, tagOrVersion) {
2736
3220
  const version = tagOrVersion || 'latest';
2737
- // 如果是 semver 版本号,直接返回
3221
+ // If it's already a semver version number, return as-is
2738
3222
  if (/^\d+\.\d+\.\d+/.test(version)) return version;
2739
- // 否则视为 tag,需要查询 dist-tags
3223
+ // Otherwise treat it as a tag and query dist-tags
2740
3224
  const url = `${this.getApiBase()}/skills/${encodeURIComponent(skillName)}`;
2741
3225
  const response = await fetch(url, {
2742
3226
  method: 'GET',
@@ -2746,14 +3230,14 @@ class RegistryClient {
2746
3230
  const data = await response.json();
2747
3231
  throw new RegistryError(data.error || `Failed to fetch skill metadata: ${response.status}`, response.status, data);
2748
3232
  }
2749
- // API 返回格式: { success: true, data: { dist_tags: [{ tag, version }] } }
3233
+ // API response format: { success: true, data: { dist_tags: [{ tag, version }] } }
2750
3234
  const responseData = await response.json();
2751
- // 优先使用 npm 风格的 dist-tags(如果存在)
3235
+ // Prefer npm-style dist-tags if present
2752
3236
  if (responseData['dist-tags']) {
2753
3237
  const resolvedVersion = responseData['dist-tags'][version];
2754
3238
  if (resolvedVersion) return resolvedVersion;
2755
3239
  }
2756
- // 使用 reskill-app dist_tags 数组格式
3240
+ // Fall back to reskill-app's dist_tags array format
2757
3241
  const distTags = responseData.data?.dist_tags;
2758
3242
  if (distTags && Array.isArray(distTags)) {
2759
3243
  const tagEntry = distTags.find((t)=>t.tag === version);
@@ -2834,11 +3318,11 @@ class RegistryClient {
2834
3318
  * @example
2835
3319
  * RegistryClient.verifyIntegrity(buffer, 'sha256-abc123...') // true or false
2836
3320
  */ static verifyIntegrity(content, expectedIntegrity) {
2837
- // 解析 integrity 格式: algorithm-hash
3321
+ // Parse integrity format: algorithm-hash
2838
3322
  const match = expectedIntegrity.match(/^(\w+)-(.+)$/);
2839
3323
  if (!match) throw new Error(`Invalid integrity format: ${expectedIntegrity}`);
2840
3324
  const [, algorithm, expectedHash] = match;
2841
- // 只支持 sha256 sha512
3325
+ // Only sha256 and sha512 are supported
2842
3326
  if ('sha256' !== algorithm && 'sha512' !== algorithm) throw new Error(`Unsupported integrity algorithm: ${algorithm}`);
2843
3327
  const actualHash = __WEBPACK_EXTERNAL_MODULE_node_crypto__.createHash(algorithm).update(content).digest('base64');
2844
3328
  return actualHash === expectedHash;
@@ -2850,7 +3334,7 @@ class RegistryClient {
2850
3334
  * Publish a skill to the registry
2851
3335
  */ async publish(skillName, payload, skillPath, options = {}) {
2852
3336
  const url = `${this.getApiBase()}/skills/publish`;
2853
- // 提取短名称作为 tarball 顶层目录(不含 scope 前缀)
3337
+ // Extract short name as tarball top-level directory (without scope prefix)
2854
3338
  const shortName = getShortName(skillName);
2855
3339
  // Create tarball with short name as top-level directory
2856
3340
  const tarball = await this.createTarball(skillPath, payload.files, shortName);
@@ -3109,352 +3593,122 @@ class RegistryClient {
3109
3593
  let topDir = null;
3110
3594
  extractor.on('entry', (header, stream, next)=>{
3111
3595
  if (!topDir && header.name) {
3112
- // Get top-level directory from first entry
3113
- const parts = header.name.split('/');
3114
- if (parts.length > 0 && parts[0]) topDir = parts[0];
3115
- }
3116
- stream.resume();
3117
- next();
3118
- });
3119
- extractor.on('finish', ()=>{
3120
- 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');
3596
+ // Get top-level directory from first entry
3597
+ const parts = header.name.split('/');
3598
+ if (parts.length > 0 && parts[0]) topDir = parts[0];
3599
+ }
3600
+ stream.resume();
3601
+ next();
3602
+ });
3603
+ extractor.on('finish', ()=>{
3604
+ resolve(topDir);
3605
+ });
3606
+ extractor.on('error', (err)=>{
3607
+ reject(new Error(`Failed to read tarball: ${err.message}`));
3608
+ });
3609
+ gunzip.on('error', (err)=>{
3610
+ reject(new Error(`Failed to decompress tarball: ${err.message}`));
3611
+ });
3612
+ gunzip.pipe(extractor);
3613
+ gunzip.end(tarball);
3614
+ });
3357
3615
  }
3358
3616
  /**
3359
- * Validate skill description
3617
+ * Registry Resolver (Step 5.1)
3360
3618
  *
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
3619
+ * Resolves skill references from npm-style registries:
3620
+ * - Private registry: @scope/name[@version] (e.g., @kanyun/planning-with-files@2.4.5)
3621
+ * - Public registry: name[@version] (e.g., my-skill@1.0.0)
3371
3622
  *
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);
3623
+ * Uses RegistryClient to download and verify skills.
3624
+ */ // ============================================================================
3625
+ // RegistryResolver Class
3626
+ // ============================================================================
3627
+ class RegistryResolver {
3628
+ /**
3629
+ * Check if a reference is a registry source (not Git or HTTP)
3630
+ *
3631
+ * Registry formats:
3632
+ * - @scope/name[@version] - private registry
3633
+ * - name[@version] - public registry (if not matching other formats)
3634
+ *
3635
+ * Explicitly excluded:
3636
+ * - Git SSH: git@github.com:user/repo.git
3637
+ * - Git HTTPS: https://github.com/user/repo.git
3638
+ * - GitHub web: https://github.com/user/repo/tree/...
3639
+ * - HTTP/OSS: https://example.com/skill.tar.gz
3640
+ * - Registry shorthand: github:user/repo, gitlab:org/repo
3641
+ */ static isRegistryRef(ref) {
3642
+ // Exclude Git SSH format (git@...)
3643
+ if (ref.startsWith('git@') || ref.startsWith('git://')) return false;
3644
+ // Exclude URLs ending with .git
3645
+ if (ref.includes('.git')) return false;
3646
+ // Exclude HTTP/HTTPS/OSS URLs
3647
+ if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('oss://') || ref.startsWith('s3://')) return false;
3648
+ // Exclude registry shorthand format (github:, gitlab:, custom.com:)
3649
+ // These follow "registry:owner/repo" pattern, not "@scope/name"
3650
+ if (/^[a-zA-Z0-9.-]+:[^@]/.test(ref)) return false;
3651
+ // Check for @scope/name format (private registry)
3652
+ if (ref.startsWith('@') && ref.includes('/')) {
3653
+ // @scope/name or @scope/name@version
3654
+ const scopeNamePattern = /^@[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
3655
+ return scopeNamePattern.test(ref);
3397
3656
  }
3657
+ // Check for simple name or name@version format (public registry)
3658
+ // Simple names contain only letters, digits, hyphens, underscores, and dots
3659
+ const namePattern = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
3660
+ return namePattern.test(ref);
3661
+ }
3662
+ /**
3663
+ * Resolve a registry skill reference
3664
+ *
3665
+ * @param ref - Skill reference (e.g., "@kanyun/planning-with-files@2.4.5" or "my-skill@latest")
3666
+ * @param overrideRegistryUrl - Optional registry URL override (bypasses scope-based lookup)
3667
+ * @returns Resolved skill information including downloaded tarball
3668
+ *
3669
+ * @example
3670
+ * const result = await resolver.resolve('@kanyun/planning-with-files@2.4.5');
3671
+ * console.log(result.shortName); // 'planning-with-files'
3672
+ * console.log(result.version); // '2.4.5'
3673
+ */ async resolve(ref, overrideRegistryUrl) {
3674
+ // 1. Parse skill identifier
3675
+ const parsed = parseSkillIdentifier(ref);
3676
+ const shortName = getShortName(parsed.fullName);
3677
+ // 2. Get registry URL (CLI override takes precedence)
3678
+ const registryUrl = overrideRegistryUrl || getRegistryUrl(parsed.scope);
3679
+ // 3. Create client and resolve version
3680
+ const client = new RegistryClient({
3681
+ registry: registryUrl
3682
+ });
3683
+ const version = await client.resolveVersion(parsed.fullName, parsed.version);
3684
+ // 4. Download tarball
3685
+ const { tarball, integrity } = await client.downloadSkill(parsed.fullName, version);
3686
+ // 5. Verify integrity
3687
+ const isValid = RegistryClient.verifyIntegrity(tarball, integrity);
3688
+ if (!isValid) throw new Error(`Integrity verification failed for ${ref}`);
3398
3689
  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
3690
+ parsed,
3691
+ shortName,
3692
+ version,
3693
+ registryUrl,
3694
+ tarball,
3695
+ integrity
3408
3696
  };
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
3697
  }
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;
3698
+ /**
3699
+ * Extract tarball to a target directory
3700
+ *
3701
+ * @param tarball - Tarball buffer
3702
+ * @param destDir - Destination directory
3703
+ * @returns Path to the extracted skill directory
3704
+ */ async extract(tarball, destDir) {
3705
+ await extractTarballBuffer(tarball, destDir);
3706
+ // Get top-level directory name (i.e. skill name)
3707
+ const topDir = await getTarballTopDir(tarball);
3708
+ if (topDir) return `${destDir}/${topDir}`;
3709
+ return destDir;
3441
3710
  }
3442
3711
  }
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
3712
  /**
3459
3713
  * SkillManager - Core Skill management class
3460
3714
  *
@@ -3936,12 +4190,112 @@ class RegistryResolver {
3936
4190
  * @param options - Installation options
3937
4191
  */ async installToAgents(ref, targetAgents, options = {}) {
3938
4192
  // Detect source type and delegate to appropriate installer
3939
- // Priority: Registry > HTTP > Git (registry 优先,因为它的格式最受限)
4193
+ // Priority: Registry > HTTP > Git (registry first, as its format is most constrained)
3940
4194
  if (this.isRegistrySource(ref)) return this.installToAgentsFromRegistry(ref, targetAgents, options);
3941
4195
  if (this.isHttpSource(ref)) return this.installToAgentsFromHttp(ref, targetAgents, options);
3942
4196
  return this.installToAgentsFromGit(ref, targetAgents, options);
3943
4197
  }
3944
4198
  /**
4199
+ * Multi-skill install: discover skills in a Git repo and install selected ones (or list only).
4200
+ * Only Git references are supported (including https://github.com/...); registry refs are not.
4201
+ *
4202
+ * @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
4203
+ * @param skillNames - If non-empty, install only these skills (by SKILL.md name). If empty and !listOnly, install all.
4204
+ * @param targetAgents - Target agents
4205
+ * @param options - Install options; listOnly: true means discover and return skills without installing
4206
+ */ async installSkillsFromRepo(ref, skillNames, targetAgents, options = {}) {
4207
+ const { listOnly = false, force = false, save = true, mode = 'symlink' } = options;
4208
+ const refForResolve = ref.replace(/#.*$/, '').trim();
4209
+ const resolved = await this.resolver.resolve(refForResolve);
4210
+ const { parsed, repoUrl } = resolved;
4211
+ const gitRef = resolved.ref;
4212
+ let cacheResult = await this.cache.get(parsed, gitRef);
4213
+ if (!cacheResult) {
4214
+ logger_logger.debug(`Caching from ${repoUrl}@${gitRef}`);
4215
+ cacheResult = await this.cache.cache(repoUrl, parsed, gitRef, gitRef);
4216
+ }
4217
+ const cachePath = this.cache.getCachePath(parsed, gitRef);
4218
+ const discovered = discoverSkillsInDir(cachePath);
4219
+ if (0 === discovered.length) throw new Error('No valid skills found. Skills require a SKILL.md with name and description.');
4220
+ if (listOnly) return {
4221
+ listOnly: true,
4222
+ skills: discovered
4223
+ };
4224
+ const selected = skillNames.length > 0 ? filterSkillsByName(discovered, skillNames) : discovered;
4225
+ if (skillNames.length > 0 && 0 === selected.length) {
4226
+ const available = discovered.map((s)=>s.name).join(', ');
4227
+ throw new Error(`No matching skills found for: ${skillNames.join(', ')}. Available skills: ${available}`);
4228
+ }
4229
+ const baseRefForSave = this.config.normalizeSkillRef(refForResolve);
4230
+ const defaults = this.config.getDefaults();
4231
+ // Only pass custom installDir to Installer; default '.skills' should use
4232
+ // the Installer's built-in canonical path (.agents/skills/)
4233
+ const customInstallDir = '.skills' !== defaults.installDir ? defaults.installDir : void 0;
4234
+ const installer = new Installer({
4235
+ cwd: this.projectRoot,
4236
+ global: this.isGlobal,
4237
+ installDir: customInstallDir
4238
+ });
4239
+ const installed = [];
4240
+ const skipped = [];
4241
+ const skillSource = `${parsed.registry}:${parsed.owner}/${parsed.repo}${parsed.subPath ? `/${parsed.subPath}` : ''}`;
4242
+ for (const skillInfo of selected){
4243
+ const semanticVersion = skillInfo.version ?? gitRef;
4244
+ // Skip already-installed skills unless --force is set
4245
+ if (!force) {
4246
+ const existingSkill = this.getInstalledSkill(skillInfo.name);
4247
+ if (existingSkill) {
4248
+ const locked = this.lockManager.get(skillInfo.name);
4249
+ const lockedRef = locked?.ref || locked?.version;
4250
+ if (lockedRef === gitRef) {
4251
+ const reason = `already installed at ${gitRef}`;
4252
+ logger_logger.info(`${skillInfo.name}@${gitRef} is already installed, skipping`);
4253
+ skipped.push({
4254
+ name: skillInfo.name,
4255
+ reason
4256
+ });
4257
+ continue;
4258
+ }
4259
+ // Different version installed — allow upgrade without --force
4260
+ // Only skip when the exact same ref is already locked
4261
+ }
4262
+ }
4263
+ logger_logger["package"](`Installing ${skillInfo.name}@${gitRef} to ${targetAgents.length} agent(s)...`);
4264
+ // Note: force is handled at the SkillManager level (skip-if-installed check above).
4265
+ // The Installer always overwrites (remove + copy), so no force flag is needed there.
4266
+ const results = await installer.installToAgents(skillInfo.dirPath, skillInfo.name, targetAgents, {
4267
+ mode: mode
4268
+ });
4269
+ if (!this.isGlobal) this.lockManager.lockSkill(skillInfo.name, {
4270
+ source: skillSource,
4271
+ version: semanticVersion,
4272
+ ref: gitRef,
4273
+ resolved: repoUrl,
4274
+ commit: cacheResult.commit
4275
+ });
4276
+ if (!this.isGlobal && save) {
4277
+ this.config.ensureExists();
4278
+ this.config.addSkill(skillInfo.name, `${baseRefForSave}#${skillInfo.name}`);
4279
+ }
4280
+ const successCount = Array.from(results.values()).filter((r)=>r.success).length;
4281
+ logger_logger.success(`Installed ${skillInfo.name}@${semanticVersion} to ${successCount} agent(s)`);
4282
+ installed.push({
4283
+ skill: {
4284
+ name: skillInfo.name,
4285
+ path: skillInfo.dirPath,
4286
+ version: semanticVersion,
4287
+ source: skillSource
4288
+ },
4289
+ results
4290
+ });
4291
+ }
4292
+ return {
4293
+ listOnly: false,
4294
+ installed,
4295
+ skipped
4296
+ };
4297
+ }
4298
+ /**
3945
4299
  * Install skill from Git to multiple agents
3946
4300
  */ async installToAgentsFromGit(ref, targetAgents, options = {}) {
3947
4301
  const { save = true, mode = 'symlink' } = options;
@@ -4082,14 +4436,13 @@ class RegistryResolver {
4082
4436
  * - Web-published skills (github/gitlab/oss_url/custom_url/local)
4083
4437
  */ async installToAgentsFromRegistry(ref, targetAgents, options = {}) {
4084
4438
  const { force = false, save = true, mode = 'symlink' } = options;
4085
- // 解析 skill 标识(获取 fullName version)
4439
+ // Parse skill identifier and resolve registry URL once (single source of truth)
4086
4440
  const parsed = parseSkillIdentifier(ref);
4087
- const registryUrl = getRegistryUrl(parsed.scope);
4441
+ const registryUrl = options.registry || getRegistryUrl(parsed.scope);
4088
4442
  const client = new RegistryClient({
4089
- registry: registryUrl,
4090
- apiPrefix: getApiPrefix(registryUrl)
4443
+ registry: registryUrl
4091
4444
  });
4092
- // 新增:先查询 skill 信息获取 source_type
4445
+ // Query skill info to determine source_type
4093
4446
  let skillInfo;
4094
4447
  try {
4095
4448
  skillInfo = await client.getSkillInfo(parsed.fullName);
@@ -4100,12 +4453,15 @@ class RegistryResolver {
4100
4453
  };
4101
4454
  else throw error;
4102
4455
  }
4103
- // 新增:根据 source_type 分支
4456
+ // Branch based on source_type (pass resolved registryUrl via options to avoid re-computation)
4104
4457
  const sourceType = skillInfo.source_type;
4105
- if (sourceType && 'registry' !== sourceType) return this.installFromWebPublished(skillInfo, parsed, targetAgents, options);
4106
- // 1. Resolve registry skill(现有流程)
4458
+ if (sourceType && 'registry' !== sourceType) return this.installFromWebPublished(skillInfo, parsed, targetAgents, {
4459
+ ...options,
4460
+ registry: registryUrl
4461
+ });
4462
+ // 1. Resolve registry skill (pass pre-resolved registryUrl)
4107
4463
  logger_logger["package"](`Resolving ${ref} from registry...`);
4108
- const resolved = await this.registryResolver.resolve(ref);
4464
+ const resolved = await this.registryResolver.resolve(ref, registryUrl);
4109
4465
  const { shortName, version, registryUrl: resolvedRegistryUrl, tarball, parsed: resolvedParsed } = resolved;
4110
4466
  // 2. Check if already installed (skip if --force)
4111
4467
  const skillPath = this.getSkillPath(shortName);
@@ -4144,101 +4500,157 @@ class RegistryResolver {
4144
4500
  };
4145
4501
  }
4146
4502
  logger_logger["package"](`Installing ${shortName}@${version} from ${resolvedRegistryUrl} to ${targetAgents.length} agent(s)...`);
4147
- // 3. Create temp directory for extraction
4503
+ // 3. Create temp directory for extraction (clean stale files first)
4148
4504
  const tempDir = __WEBPACK_EXTERNAL_MODULE_node_path__.join(this.cache.getCacheDir(), 'registry-temp', `${shortName}-${version}`);
4505
+ await remove(tempDir);
4149
4506
  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);
4507
+ try {
4508
+ // 4. Extract tarball
4509
+ const extractedPath = await this.registryResolver.extract(tarball, tempDir);
4510
+ logger_logger.debug(`Extracted to ${extractedPath}`);
4511
+ // 5. Create Installer with custom installDir from config
4512
+ const defaults = this.config.getDefaults();
4513
+ const installer = new Installer({
4514
+ cwd: this.projectRoot,
4515
+ global: this.isGlobal,
4516
+ installDir: defaults.installDir
4517
+ });
4518
+ // 6. Install to all target agents
4519
+ const results = await installer.installToAgents(extractedPath, shortName, targetAgents, {
4520
+ mode: mode
4521
+ });
4522
+ // 7. Update lock file (project mode only)
4523
+ if (!this.isGlobal) this.lockManager.lockSkill(shortName, {
4524
+ source: `registry:${resolvedParsed.fullName}`,
4525
+ version,
4526
+ ref: version,
4527
+ resolved: resolvedRegistryUrl,
4528
+ commit: resolved.integrity
4529
+ });
4530
+ // 8. Update skills.json (project mode only)
4531
+ if (!this.isGlobal && save) {
4532
+ this.config.ensureExists();
4533
+ // Save with full name for registry skills
4534
+ this.config.addSkill(shortName, ref);
4535
+ }
4536
+ // 9. Count results and log
4537
+ const successCount = Array.from(results.values()).filter((r)=>r.success).length;
4538
+ const failCount = results.size - successCount;
4539
+ if (0 === failCount) logger_logger.success(`Installed ${shortName}@${version} to ${successCount} agent(s)`);
4540
+ else logger_logger.warn(`Installed ${shortName}@${version} to ${successCount} agent(s), ${failCount} failed`);
4541
+ // 10. Build the InstalledSkill to return
4542
+ const skill = {
4543
+ name: shortName,
4544
+ path: extractedPath,
4545
+ version,
4546
+ source: `registry:${resolvedParsed.fullName}`
4547
+ };
4548
+ return {
4549
+ skill,
4550
+ results
4551
+ };
4552
+ } finally{
4553
+ // Clean up temp directory after installation
4554
+ await remove(tempDir);
4177
4555
  }
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
4556
  }
4195
4557
  // ============================================================================
4196
- // Web-published skill installation (页面发布适配)
4558
+ // Web-published skill installation
4197
4559
  // ============================================================================
4198
4560
  /**
4199
- * 安装页面发布的 skill
4561
+ * Install a web-published skill.
4200
4562
  *
4201
- * 页面发布的 skill 不支持版本管理,根据 source_type 分支到不同的安装逻辑:
4202
- * - github/gitlab: 复用 installToAgentsFromGit
4203
- * - oss_url/custom_url: 复用 installToAgentsFromHttp
4204
- * - local: 通过 Registry API 下载 tarball
4563
+ * Web-published skills do not support versioning. Branches to different
4564
+ * installation logic based on source_type:
4565
+ * - github/gitlab: reuses installToAgentsFromGit
4566
+ * - oss_url/custom_url: reuses installToAgentsFromHttp
4567
+ * - local: downloads tarball via Registry API
4205
4568
  */ async installFromWebPublished(skillInfo, parsed, targetAgents, options = {}) {
4206
4569
  const { source_type, source_url } = skillInfo;
4207
- // 页面发布的 skill 不支持版本指定
4570
+ // Web-published skills do not support version specifiers
4208
4571
  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
4572
  if (!source_url) throw new Error(`Missing source_url for web-published skill: ${parsed.fullName}`);
4210
4573
  logger_logger["package"](`Installing ${parsed.fullName} from ${source_type} source...`);
4211
4574
  switch(source_type){
4212
4575
  case 'github':
4213
4576
  case 'gitlab':
4214
- // source_url 是完整的 Git URL(包含 ref path
4215
- // 复用已有的 Git 安装逻辑
4577
+ // source_url is a full Git URL (includes ref and path)
4578
+ // Reuse existing Git installation logic
4216
4579
  return this.installToAgentsFromGit(source_url, targetAgents, options);
4217
4580
  case 'oss_url':
4218
4581
  case 'custom_url':
4219
- // 直接下载 URL
4582
+ // Direct download URL
4220
4583
  return this.installToAgentsFromHttp(source_url, targetAgents, options);
4221
4584
  case 'local':
4222
- // 通过 Registry API 下载 tarball
4223
- return this.installFromRegistryLocal(skillInfo, parsed, targetAgents, options);
4585
+ // Download tarball via Registry API
4586
+ return this.installFromRegistryLocal(parsed, targetAgents, options);
4224
4587
  default:
4225
4588
  throw new Error(`Unknown source_type: ${source_type}`);
4226
4589
  }
4227
4590
  }
4228
4591
  /**
4229
- * 安装 Local Folder 模式发布的 skill
4592
+ * Install a skill published via "local folder" mode.
4230
4593
  *
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);
4594
+ * Downloads tarball via RegistryClient (handles 302 redirects to signed OSS URLs),
4595
+ * then extracts and installs using the same flow as registry source_type.
4596
+ */ async installFromRegistryLocal(parsed, targetAgents, options = {}) {
4597
+ const { save = true, mode = 'symlink' } = options;
4598
+ const registryUrl = options.registry || getRegistryUrl(parsed.scope);
4599
+ const shortName = getShortName(parsed.fullName);
4600
+ const version = 'latest';
4601
+ // Download tarball via RegistryClient (handles auth + 302 redirect to signed URL)
4602
+ const client = new RegistryClient({
4603
+ registry: registryUrl
4604
+ });
4605
+ const { tarball } = await client.downloadSkill(parsed.fullName, version);
4606
+ logger_logger["package"](`Installing ${shortName} from ${registryUrl} to ${targetAgents.length} agent(s)...`);
4607
+ // Extract tarball to temp directory (clean stale files first)
4608
+ const tempDir = __WEBPACK_EXTERNAL_MODULE_node_path__.join(this.cache.getCacheDir(), 'registry-temp', `${shortName}-${version}`);
4609
+ await remove(tempDir);
4610
+ await ensureDir(tempDir);
4611
+ try {
4612
+ const extractedPath = await this.registryResolver.extract(tarball, tempDir);
4613
+ logger_logger.debug(`Extracted to ${extractedPath}`);
4614
+ // Install to all target agents
4615
+ const defaults = this.config.getDefaults();
4616
+ const installer = new Installer({
4617
+ cwd: this.projectRoot,
4618
+ global: this.isGlobal,
4619
+ installDir: defaults.installDir
4620
+ });
4621
+ const results = await installer.installToAgents(extractedPath, shortName, targetAgents, {
4622
+ mode: mode
4623
+ });
4624
+ // Get metadata from extracted path
4625
+ const metadata = this.getSkillMetadataFromDir(extractedPath);
4626
+ const skillName = metadata?.name ?? shortName;
4627
+ const semanticVersion = metadata?.version ?? version;
4628
+ // Update lock file (project mode only)
4629
+ if (!this.isGlobal) this.lockManager.lockSkill(skillName, {
4630
+ source: `registry:${parsed.fullName}`,
4631
+ version: semanticVersion,
4632
+ ref: version,
4633
+ resolved: registryUrl,
4634
+ commit: ''
4635
+ });
4636
+ // Update skills.json (project mode only)
4637
+ if (!this.isGlobal && save) {
4638
+ this.config.ensureExists();
4639
+ this.config.addSkill(skillName, parsed.fullName);
4640
+ }
4641
+ return {
4642
+ skill: {
4643
+ name: skillName,
4644
+ path: extractedPath,
4645
+ version: semanticVersion,
4646
+ source: `registry:${parsed.fullName}`
4647
+ },
4648
+ results
4649
+ };
4650
+ } finally{
4651
+ // Clean up temp directory after installation
4652
+ await remove(tempDir);
4653
+ }
4242
4654
  }
4243
4655
  /**
4244
4656
  * Get default target agents