From 52ca96ad9ab3fb0a79f27b4d5079467f9e12dbbb Mon Sep 17 00:00:00 2001 From: CasualDeveloper <10153929+CasualDeveloper@users.noreply.github.com> Date: Sun, 11 Jan 2026 04:08:11 +0800 Subject: [PATCH] fix(skills): follow symlinks when scanning roots Some users symlink platform skill roots to a shared directory. URL-based directory enumeration can fail on directory symlinks, so scans returned empty. Resolve symlinks before enumerating and add a regression test using a symlinked skills root. --- Package.swift | 2 +- .../Workers/SkillFileWorker.swift | 8 +++-- .../SymlinkScanTests.swift | 35 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 Tests/CodexSkillManagerTests/SymlinkScanTests.swift diff --git a/Package.swift b/Package.swift index 38b60d4..027f29d 100644 --- a/Package.swift +++ b/Package.swift @@ -26,7 +26,7 @@ let package = Package( ]), .testTarget( name: "CodexSkillManagerTests", - dependencies: [], + dependencies: ["CodexSkillManager"], path: "Tests/CodexSkillManagerTests", swiftSettings: [ .unsafeFlags(["-strict-concurrency=complete"]), diff --git a/Sources/CodexSkillManager/Workers/SkillFileWorker.swift b/Sources/CodexSkillManager/Workers/SkillFileWorker.swift index e55ed67..433c800 100644 --- a/Sources/CodexSkillManager/Workers/SkillFileWorker.swift +++ b/Sources/CodexSkillManager/Workers/SkillFileWorker.swift @@ -86,12 +86,16 @@ actor SkillFileWorker { func scanSkills(at baseURL: URL, storageKey: String) throws -> [ScannedSkillData] { let fileManager = FileManager.default - guard fileManager.fileExists(atPath: baseURL.path) else { + + // Directory symlinks can fail URL-based enumeration on macOS. + let directoryURL = baseURL.resolvingSymlinksInPath() + + guard fileManager.fileExists(atPath: directoryURL.path) else { return [] } let items = try fileManager.contentsOfDirectory( - at: baseURL, + at: directoryURL, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) diff --git a/Tests/CodexSkillManagerTests/SymlinkScanTests.swift b/Tests/CodexSkillManagerTests/SymlinkScanTests.swift new file mode 100644 index 0000000..d0da401 --- /dev/null +++ b/Tests/CodexSkillManagerTests/SymlinkScanTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import CodexSkillManager + +@Suite("Symlink Scan") +struct SymlinkScanTests { + @Test("scanSkills follows directory symlinks") + func scanSkillsFollowsDirectorySymlinks() async throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + + let realRoot = tempRoot.appendingPathComponent("real") + let symlinkRoot = tempRoot.appendingPathComponent("link") + + try fileManager.createDirectory(at: realRoot, withIntermediateDirectories: true) + + let skillRoot = realRoot.appendingPathComponent("my-skill") + try fileManager.createDirectory(at: skillRoot, withIntermediateDirectories: true) + try "# My Skill\n".write( + to: skillRoot.appendingPathComponent("SKILL.md"), + atomically: true, + encoding: .utf8 + ) + + try fileManager.createSymbolicLink(at: symlinkRoot, withDestinationURL: realRoot) + + let worker = SkillFileWorker() + let scanned = try await worker.scanSkills(at: symlinkRoot, storageKey: "test") + + #expect(scanned.count == 1) + #expect(scanned.first?.name == "my-skill") + } +}