Skip to content

Commit 964e972

Browse files
committed
feat(skills): scan codex skills root and public
1 parent 76b0e0a commit 964e972

File tree

7 files changed

+83
-40
lines changed

7 files changed

+83
-40
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
CodexSkillManager is a small macOS SwiftUI app built with SwiftPM (no Xcode project) that lists the Codex skills installed in the user's public skills folder.
55

66
## How it works
7-
- The app scans `~/.codex/skills/public` for subdirectories.
7+
- The app scans `~/.codex/skills` and `~/.codex/skills/public` for subdirectories.
88
- Each directory name becomes a skill entry.
99
- The UI is a SwiftUI `NavigationSplitView` with a list on the left and a detail view on the right.
1010
- The detail view shows the skill name and its full path.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Codex Skill Manager is a macOS SwiftUI app built with SwiftPM (no Xcode project). It manages local skills for Codex and Claude Code, renders each `SKILL.md`, and lets you browse remote skills from Clawdhub.
66

77
## Features
8-
- Browse local skills from `~/.codex/skills/public` and `~/.claude/skills`
8+
- Browse local skills from `~/.codex/skills`, `~/.codex/skills/public`, and `~/.claude/skills`
99
- Render `SKILL.md` with Markdown, plus inline reference previews
1010
- Import skills from a folder or zip
1111
- Delete skills from the sidebar

Sources/CodexSkillManager/Skills/Local/SkillStore.swift

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ import Observation
8282
detailState = .idle
8383
referenceState = .idle
8484
do {
85-
let platforms = SkillPlatform.allCases.map { platform in
86-
(platform, platform.rootURL, platform.storageKey)
85+
let platforms = SkillPlatform.allCases.flatMap { platform in
86+
zip(platform.relativePaths, platform.rootURLs).map { relativePath, rootURL in
87+
(platform, rootURL, platform.storageKey(forRelativePath: relativePath))
88+
}
8789
}
8890
var skills: [Skill] = []
8991

@@ -110,25 +112,26 @@ import Observation
110112
let fileManager = FileManager.default
111113
for customPath in customPathStore.customPaths {
112114
for platform in SkillPlatform.allCases {
113-
let platformURL = platform.skillsURL(in: customPath.url)
114-
guard fileManager.fileExists(atPath: platformURL.path) else { continue }
115-
116-
let storageKey = "\(customPath.storageKey)-\(platform.storageKey)"
117-
let scanned = try await fileWorker.scanSkills(at: platformURL, storageKey: storageKey)
118-
skills.append(contentsOf: scanned.map { scannedSkill in
119-
Skill(
120-
id: scannedSkill.id,
121-
name: scannedSkill.name,
122-
displayName: scannedSkill.displayName,
123-
description: scannedSkill.description,
124-
platform: platform,
125-
customPath: customPath,
126-
folderURL: scannedSkill.folderURL,
127-
skillMarkdownURL: scannedSkill.skillMarkdownURL,
128-
references: scannedSkill.references,
129-
stats: scannedSkill.stats
130-
)
131-
})
115+
for (relativePath, platformURL) in zip(platform.relativePaths, platform.skillsURLs(in: customPath.url)) {
116+
guard fileManager.fileExists(atPath: platformURL.path) else { continue }
117+
118+
let storageKey = "\(customPath.storageKey)-\(platform.storageKey(forRelativePath: relativePath))"
119+
let scanned = try await fileWorker.scanSkills(at: platformURL, storageKey: storageKey)
120+
skills.append(contentsOf: scanned.map { scannedSkill in
121+
Skill(
122+
id: scannedSkill.id,
123+
name: scannedSkill.name,
124+
displayName: scannedSkill.displayName,
125+
description: scannedSkill.description,
126+
platform: platform,
127+
customPath: customPath,
128+
folderURL: scannedSkill.folderURL,
129+
skillMarkdownURL: scannedSkill.skillMarkdownURL,
130+
references: scannedSkill.references,
131+
stats: scannedSkill.stats
132+
)
133+
})
134+
}
132135
}
133136
}
134137

Sources/CodexSkillManager/Skills/Settings/AddCustomPathView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ struct AddCustomPathView: View {
4949
VStack(alignment: .leading, spacing: 6) {
5050
Text("Add Custom Skill Path")
5151
.font(.title.bold())
52-
Text("Select a project folder. Skills will be auto-discovered from platform directories (e.g., .claude/skills, .codex/skills/public).")
52+
Text("Select a project folder. Skills will be auto-discovered from platform directories (e.g., .claude/skills, .codex/skills, .codex/skills/public).")
5353
.foregroundStyle(.secondary)
5454
}
5555
}
@@ -218,7 +218,7 @@ struct AddCustomPathView: View {
218218

219219
discoveredSkills = discovered
220220
if discovered.isEmpty {
221-
errorMessage = "No skills found. Make sure the folder contains platform directories like .claude/skills or .codex/skills/public with SKILL.md files."
221+
errorMessage = "No skills found. Make sure the folder contains platform directories like .claude/skills or .codex/skills (including .codex/skills/public) with SKILL.md files."
222222
} else {
223223
errorMessage = nil
224224
}

Sources/CodexSkillManager/Skills/Shared/SkillPlatform.swift

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,31 @@ enum SkillPlatform: String, CaseIterable, Identifiable, Hashable, Sendable, Coda
2121
}
2222
}
2323

24-
/// Relative path from a base directory to the skills folder
24+
func storageKey(forRelativePath relativePath: String) -> String {
25+
guard relativePath != self.relativePath else { return storageKey }
26+
let sanitized = relativePath
27+
.replacingOccurrences(of: ".", with: "")
28+
.replacingOccurrences(of: "/", with: "-")
29+
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
30+
return "\(storageKey)-\(sanitized)"
31+
}
32+
33+
/// Primary relative path from a base directory to the skills folder.
2534
var relativePath: String {
35+
relativePaths.first ?? ".codex/skills"
36+
}
37+
38+
/// Relative paths from a base directory to the skills folder(s).
39+
var relativePaths: [String] {
2640
switch self {
2741
case .codex:
28-
return ".codex/skills/public"
42+
return [".codex/skills", ".codex/skills/public"]
2943
case .claude:
30-
return ".claude/skills"
44+
return [".claude/skills"]
3145
case .opencode:
32-
return ".config/opencode/skill"
46+
return [".config/opencode/skill"]
3347
case .copilot:
34-
return ".copilot/skills"
48+
return [".copilot/skills"]
3549
}
3650
}
3751

@@ -40,11 +54,21 @@ enum SkillPlatform: String, CaseIterable, Identifiable, Hashable, Sendable, Coda
4054
return home.appendingPathComponent(relativePath)
4155
}
4256

57+
var rootURLs: [URL] {
58+
let home = FileManager.default.homeDirectoryForCurrentUser
59+
return relativePaths.map { home.appendingPathComponent($0) }
60+
}
61+
4362
/// Returns the skills URL for this platform within a given base directory
4463
func skillsURL(in baseURL: URL) -> URL {
4564
baseURL.appendingPathComponent(relativePath)
4665
}
4766

67+
/// Returns all skills URLs for this platform within a given base directory
68+
func skillsURLs(in baseURL: URL) -> [URL] {
69+
relativePaths.map { baseURL.appendingPathComponent($0) }
70+
}
71+
4872
var description: String {
4973
"Install in \(rootURL.path)"
5074
}

Sources/CodexSkillManager/Skills/SkillSplitView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ struct SkillSplitView: View {
219219
private func openSelectedSkillFolder(platform: SkillPlatform?) {
220220
guard source == .local else { return }
221221
let fallbackURL = FileManager.default.homeDirectoryForCurrentUser
222-
.appendingPathComponent(".codex/skills/public")
222+
.appendingPathComponent(".codex/skills")
223223
let selected = store.selectedSkill
224224
let url: URL
225225
if let platform, let slug = selected?.name {

Tests/CodexSkillManagerTests/SkillPathPatternTests.swift

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@ enum TestSkillPlatform: String, CaseIterable {
2020
}
2121

2222
var relativePath: String {
23+
relativePaths.first ?? ".codex/skills"
24+
}
25+
26+
var relativePaths: [String] {
2327
switch self {
24-
case .codex: return ".codex/skills/public"
25-
case .claude: return ".claude/skills"
26-
case .opencode: return ".config/opencode/skill"
27-
case .copilot: return ".copilot/skills"
28+
case .codex: return [".codex/skills", ".codex/skills/public"]
29+
case .claude: return [".claude/skills"]
30+
case .opencode: return [".config/opencode/skill"]
31+
case .copilot: return [".copilot/skills"]
2832
}
2933
}
3034

@@ -36,6 +40,10 @@ enum TestSkillPlatform: String, CaseIterable {
3640
func skillsURL(in baseURL: URL) -> URL {
3741
baseURL.appendingPathComponent(relativePath)
3842
}
43+
44+
func skillsURLs(in baseURL: URL) -> [URL] {
45+
relativePaths.map { baseURL.appendingPathComponent($0) }
46+
}
3947
}
4048

4149
/// Mirrors CustomSkillPath for testing
@@ -79,7 +87,8 @@ struct SkillPlatformPathTests {
7987

8088
@Test("Platform relative paths are correct")
8189
func platformRelativePaths() {
82-
#expect(TestSkillPlatform.codex.relativePath == ".codex/skills/public")
90+
#expect(TestSkillPlatform.codex.relativePath == ".codex/skills")
91+
#expect(TestSkillPlatform.codex.relativePaths == [".codex/skills", ".codex/skills/public"])
8392
#expect(TestSkillPlatform.claude.relativePath == ".claude/skills")
8493
#expect(TestSkillPlatform.opencode.relativePath == ".config/opencode/skill")
8594
#expect(TestSkillPlatform.copilot.relativePath == ".copilot/skills")
@@ -118,7 +127,14 @@ struct SkillPlatformPathTests {
118127
)
119128
#expect(
120129
TestSkillPlatform.codex.skillsURL(in: customBase).path ==
121-
"/Users/test/projects/my-project/.codex/skills/public"
130+
"/Users/test/projects/my-project/.codex/skills"
131+
)
132+
#expect(
133+
TestSkillPlatform.codex.skillsURLs(in: customBase).map(\.path) ==
134+
[
135+
"/Users/test/projects/my-project/.codex/skills",
136+
"/Users/test/projects/my-project/.codex/skills/public"
137+
]
122138
)
123139
#expect(
124140
TestSkillPlatform.opencode.skillsURL(in: customBase).path ==
@@ -320,13 +336,13 @@ struct SkillGroupingTests {
320336
TestSkill(id: "claude-skill1", name: "skill1", platform: .claude, customPath: nil,
321337
folderURL: home.appendingPathComponent(".claude/skills/skill1")),
322338
TestSkill(id: "codex-skill2", name: "skill2", platform: .codex, customPath: nil,
323-
folderURL: home.appendingPathComponent(".codex/skills/public/skill2")),
339+
folderURL: home.appendingPathComponent(".codex/skills/skill2")),
324340

325341
// Custom path skills
326342
TestSkill(id: "custom-abc-claude-skill3", name: "skill3", platform: .claude, customPath: customPath,
327343
folderURL: URL(fileURLWithPath: "/Users/test/projects/my-project/.claude/skills/skill3")),
328344
TestSkill(id: "custom-abc-codex-skill4", name: "skill4", platform: .codex, customPath: customPath,
329-
folderURL: URL(fileURLWithPath: "/Users/test/projects/my-project/.codex/skills/public/skill4")),
345+
folderURL: URL(fileURLWithPath: "/Users/test/projects/my-project/.codex/skills/skill4")),
330346
]
331347

332348
let userDirSkills = skills.filter { $0.isFromUserDirectory }
@@ -383,7 +399,7 @@ struct SidebarPlatformGroupingTests {
383399
name: "my-skill",
384400
platform: .codex,
385401
customPath: customPath,
386-
folderURL: URL(fileURLWithPath: "/Users/test/projects/my-project/.codex/skills/public/my-skill")
402+
folderURL: URL(fileURLWithPath: "/Users/test/projects/my-project/.codex/skills/my-skill")
387403
)
388404

389405
let groupedAll = groupedLocalSkills(from: [userDirSkill, customPathSkill])

0 commit comments

Comments
 (0)