* Load skill metadata from a skill directory. * @param skillDir - The resolved path to the skill directory (target of symlink if symlinked) * @param source - Whether this is a global or project skill * @param mode - The mode this skill is specific to (undefined for generic skills) * @param s
( skillDir: string, source: "global" | "project", mode?: string, skillName?: string, )
| 90 | * @param skillName - The skill name (from symlink name if symlinked, otherwise from directory name) |
| 91 | */ |
| 92 | private async loadSkillMetadata( |
| 93 | skillDir: string, |
| 94 | source: "global" | "project", |
| 95 | mode?: string, |
| 96 | skillName?: string, |
| 97 | ): Promise<void> { |
| 98 | const skillMdPath = path.join(skillDir, "SKILL.md") |
| 99 | if (!(await fileExists(skillMdPath))) return |
| 100 | |
| 101 | try { |
| 102 | const fileContent = await fs.readFile(skillMdPath, "utf-8") |
| 103 | |
| 104 | // Use gray-matter to parse frontmatter |
| 105 | const { data: frontmatter, content: body } = matter(fileContent) |
| 106 | |
| 107 | // Validate required fields (only name and description for now) |
| 108 | if (!frontmatter.name || typeof frontmatter.name !== "string") { |
| 109 | console.error(`Skill at ${skillDir} is missing required 'name' field`) |
| 110 | return |
| 111 | } |
| 112 | if (!frontmatter.description || typeof frontmatter.description !== "string") { |
| 113 | console.error(`Skill at ${skillDir} is missing required 'description' field`) |
| 114 | return |
| 115 | } |
| 116 | |
| 117 | // Validate that frontmatter name matches the skill name (directory name or symlink name) |
| 118 | // Per the Agent Skills spec: "name field must match the parent directory name" |
| 119 | const effectiveSkillName = skillName || path.basename(skillDir) |
| 120 | if (frontmatter.name !== effectiveSkillName) { |
| 121 | console.error(`Skill name "${frontmatter.name}" doesn't match directory "${effectiveSkillName}"`) |
| 122 | return |
| 123 | } |
| 124 | |
| 125 | // Validate skill name per agentskills.io spec using shared validation |
| 126 | const nameValidation = validateSkillNameShared(effectiveSkillName) |
| 127 | if (!nameValidation.valid) { |
| 128 | const errorMessage = this.getSkillNameErrorMessage(effectiveSkillName, nameValidation.error!) |
| 129 | console.error(`Skill name "${effectiveSkillName}" is invalid: ${errorMessage}`) |
| 130 | return |
| 131 | } |
| 132 | |
| 133 | // Description constraints: |
| 134 | // - 1-1024 chars |
| 135 | // - non-empty (after trimming) |
| 136 | const description = frontmatter.description.trim() |
| 137 | if (description.length < 1 || description.length > 1024) { |
| 138 | console.error( |
| 139 | `Skill "${effectiveSkillName}" has an invalid description length: must be 1-1024 characters (got ${description.length})`, |
| 140 | ) |
| 141 | return |
| 142 | } |
| 143 | |
| 144 | // Parse modeSlugs from frontmatter (new format) or fall back to directory-based mode |
| 145 | // Priority: frontmatter.modeSlugs > frontmatter.mode > directory mode |
| 146 | let modeSlugs: string[] | undefined |
| 147 | if (Array.isArray(frontmatter.modeSlugs)) { |
| 148 | modeSlugs = frontmatter.modeSlugs.filter((s: unknown) => typeof s === "string" && s.length > 0) |
| 149 | if (modeSlugs.length === 0) { |
no test coverage detected