refactor(mac-skills): delegate loadSkills to shared SkillsScanner (Phase E)

Cleanup pass: HermesFileService.loadSkills() was duplicating walk
logic that the new ScarfCore SkillsScanner now owns. Replaced the
~38-line implementation with a one-line delegation.

Removed:
- HermesFileService.loadSkills() walk body (38 lines).
- HermesFileService.parseSkillFrontmatter (24 lines, supersedes by
  SkillFrontmatterParser.parseV011Fields).
- HermesFileService.parseSkillRequiredConfig (24 lines, superseded by
  SkillFrontmatterParser.parseRequiredConfig).

The remaining HermesFileService surface (loadSkillContent,
saveSkillContent, isValidSkillPath) is unchanged — those are Mac-
target-specific guards on file paths that don't fit ScarfCore.

Tab enum audit: searched for orphan `.memory` / `.more` references
under Scarf iOS/. None found — the worktree refactor cleanly
migrated every selectedTab assignment to the new 5-tab vocabulary.

Verified: ScarfCore 197 tests + 28 catalog tests + Mac + iOS builds
all green (Phase F gate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 10:02:37 +02:00
parent 26c034ea6f
commit f04d95c960
@@ -496,42 +496,13 @@ struct HermesFileService: Sendable {
// MARK: - Skills
/// Walks `~/.hermes/skills/<category>/<name>/`. v2.5 delegates to
/// the shared ScarfCore `SkillsScanner` so iOS and Mac use byte-
/// identical scan logic including the v0.11 frontmatter parsing
/// that populates `HermesSkill.allowedTools` / `relatedSkills` /
/// `dependencies`.
nonisolated func loadSkills() -> [HermesSkillCategory] {
let dir = context.paths.skillsDir
guard let categories = try? transport.listDirectory(dir) else { return [] }
return categories.sorted().compactMap { categoryName in
let categoryPath = dir + "/" + categoryName
guard transport.stat(categoryPath)?.isDirectory == true else { return nil }
guard let skillNames = try? transport.listDirectory(categoryPath) else { return nil }
let skills = skillNames.sorted().compactMap { skillName -> HermesSkill? in
let skillPath = categoryPath + "/" + skillName
guard transport.stat(skillPath)?.isDirectory == true else { return nil }
let files = (try? transport.listDirectory(skillPath)) ?? []
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
// v2.5: Hermes v2026.4.23 ships richer SKILL.md
// frontmatter (allowed_tools, related_skills,
// dependencies). Parse opportunistically old skills
// without a SKILL.md file or without these fields stay
// nil and the detail view skips the chip rows.
let frontmatter = parseSkillFrontmatter(skillPath + "/SKILL.md")
return HermesSkill(
id: categoryName + "/" + skillName,
name: skillName,
category: categoryName,
path: skillPath,
files: files.sorted(),
requiredConfig: requiredConfig,
allowedTools: frontmatter.allowedTools,
relatedSkills: frontmatter.relatedSkills,
dependencies: frontmatter.dependencies
)
}
guard !skills.isEmpty else { return nil }
return HermesSkillCategory(id: categoryName, name: categoryName, skills: skills)
}
SkillsScanner.scan(context: context, transport: transport)
}
nonisolated func loadSkillContent(path: String) -> String {
@@ -552,56 +523,6 @@ struct HermesFileService: Sendable {
return true
}
/// Parse `allowed_tools`, `related_skills`, `dependencies` from a
/// SKILL.md YAML frontmatter block (Hermes v2026.4.23+). Returns
/// nil-filled tuple when the file is absent or has no frontmatter
/// pre-v0.11 skills behave as before.
nonisolated private func parseSkillFrontmatter(
_ path: String
) -> (allowedTools: [String]?, relatedSkills: [String]?, dependencies: [String]?) {
guard let content = readFile(path) else { return (nil, nil, nil) }
let lines = content.components(separatedBy: "\n")
// Frontmatter must be the first line `---` followed by another
// `---` somewhere below. Anything else and we bail.
guard lines.first == "---",
let endIdx = lines.dropFirst().firstIndex(of: "---")
else { return (nil, nil, nil) }
let frontmatter = lines[1..<endIdx].joined(separator: "\n")
let parsed = HermesYAML.parseNestedYAML(frontmatter)
let allowedTools = parsed.lists["allowed_tools"]
let relatedSkills = parsed.lists["related_skills"]
let dependencies = parsed.lists["dependencies"]
return (
allowedTools: (allowedTools?.isEmpty ?? true) ? nil : allowedTools,
relatedSkills: (relatedSkills?.isEmpty ?? true) ? nil : relatedSkills,
dependencies: (dependencies?.isEmpty ?? true) ? nil : dependencies
)
}
nonisolated private func parseSkillRequiredConfig(_ path: String) -> [String] {
guard let content = readFile(path) else { return [] }
var result: [String] = []
var inRequiredConfig = false
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") {
inRequiredConfig = true
continue
}
if inRequiredConfig {
if indent < 2 && !trimmed.isEmpty {
break
}
if trimmed.hasPrefix("- ") {
result.append(String(trimmed.dropFirst(2)))
}
}
}
return result
}
// MARK: - MCP Servers
nonisolated func loadMCPServers() -> [HermesMCPServer] {