Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

- Browse Skills now opens to a Trending list of popular skills
- Faster, broader skill search with an Official-only filter

## [1.15.0] - 2026-04-28

- AI Assist now drives your installed Claude and Codex CLIs directly — fewer moving parts, more reliable responses
Expand Down
108 changes: 107 additions & 1 deletion Chops/Services/SkillRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ final class SkillRegistry {
private var treeCache: [String: [String]] = [:] // source@branch -> [SKILL.md paths]
private var branchCache: [String: String] = [:] // source -> default branch

// Popular/trending skills, scraped from skills.sh. Cached in memory for the session
// and on disk (with a TTL) so it survives app relaunches.
private var trendingCache: [RegistrySkill]?

private static let trendingTTL: TimeInterval = 6 * 60 * 60 // 6 hours

private struct TrendingDiskCache: Codable {
let fetchedAt: Date
let skills: [RegistrySkill]
}

private static var trendingCacheURL: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return support.appendingPathComponent("Chops/trending-cache.json")
}

// MARK: - Search

struct SearchResponse: Codable {
Expand All @@ -17,11 +33,16 @@ final class SkillRegistry {
}

struct RegistrySkill: Identifiable, Codable {
let id: String
let skillId: String
let name: String
let installs: Int
let source: String
let isOfficial: Bool?

// Derived rather than decoded: the search API sends an `id` field equal to
// "<source>/<skillId>", but the trending payload omits it. Computing it keeps
// both sources Identifiable without a fragile optional.
var id: String { "\(source)/\(skillId)" }

var formattedInstalls: String {
if installs >= 1_000_000 {
Expand All @@ -48,6 +69,91 @@ final class SkillRegistry {
return decoded.skills
}

// MARK: - Trending / Browse

/// Fetches the most-installed skills by scraping skills.sh's server-rendered
/// trending page (no public JSON API exists for this). The result — ~600 skills
/// ranked by install count — is cached for the session and powers instant local
/// browse + filtering, which is both faster and broader than the fuzzy search API.
func fetchTrending() async throws -> [RegistrySkill] {
if let cached = trendingCache { return cached }

// Reuse a fresh on-disk cache so trending shows instantly on relaunch and we
// don't re-scrape skills.sh on every cold start.
if let disk = Self.readTrendingDiskCache(),
Date().timeIntervalSince(disk.fetchedAt) < Self.trendingTTL {
trendingCache = disk.skills
return disk.skills
}

var request = URLRequest(url: URL(string: "https://www.skills.sh/trending")!)
// Identify ourselves honestly since we're reading their HTML rather than a JSON API.
request.setValue(
"Chops/macOS (+https://github.com/Shpigford/chops)",
forHTTPHeaderField: "User-Agent"
)

let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
let html = String(data: data, encoding: .utf8) else {
throw RegistryError.searchFailed
}

let skills = Self.parseTrending(html: html)
guard !skills.isEmpty else { throw RegistryError.searchFailed }
trendingCache = skills
Self.writeTrendingDiskCache(skills)
return skills
}

private static func readTrendingDiskCache() -> TrendingDiskCache? {
guard let data = try? Data(contentsOf: trendingCacheURL) else { return nil }
return try? JSONDecoder().decode(TrendingDiskCache.self, from: data)
}

private static func writeTrendingDiskCache(_ skills: [RegistrySkill]) {
let cache = TrendingDiskCache(fetchedAt: Date(), skills: skills)
guard let data = try? JSONEncoder().encode(cache) else { return }
let url = trendingCacheURL
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try? data.write(to: url, options: .atomic)
}

/// Extracts skill objects from the Next.js RSC payload embedded in the trending HTML.
/// The payload lives inside JS string literals, so JSON quotes arrive as `\"`; we
/// unescape, then pull out each `{"source":…,"skillId":…,"installs":…}` object and
/// decode it. Page order is install-count descending, which we preserve.
static func parseTrending(html: String) -> [RegistrySkill] {
let unescaped = html.replacingOccurrences(of: "\\\"", with: "\"")
let pattern = #/\{"source":"[^"]*","skillId":"[^"]*","name":"[^"]*","installs":\d+(?:,"isOfficial":(?:true|false))?\}/#

let decoder = JSONDecoder()
var seen = Set<String>()
var result: [RegistrySkill] = []
for match in unescaped.matches(of: pattern) {
let json = String(match.output)
guard let skill = try? decoder.decode(RegistrySkill.self, from: Data(json.utf8)) else { continue }
if seen.insert(skill.id).inserted {
result.append(skill)
}
}
return result
}

/// Case-insensitive substring match across name, skillId, and source.
static func filter(_ skills: [RegistrySkill], query: String) -> [RegistrySkill] {
let q = query.lowercased()
guard !q.isEmpty else { return skills }
return skills.filter {
$0.name.lowercased().contains(q)
|| $0.skillId.lowercased().contains(q)
|| $0.source.lowercased().contains(q)
}
}

// MARK: - Content Resolution

func fetchContent(skill: RegistrySkill) async throws -> String {
Expand Down
70 changes: 62 additions & 8 deletions Chops/Views/Shared/RegistrySheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ struct RegistrySheet: View {
@State private var registry = SkillRegistry()
@State private var searchText = ""
@State private var results: [SkillRegistry.RegistrySkill] = []
@State private var trending: [SkillRegistry.RegistrySkill] = []
@State private var isLoadingTrending = false
@State private var officialOnly = false
@State private var selectedSkill: SkillRegistry.RegistrySkill?
@State private var skillContent: String?
@State private var selectedAgents: Set<String> = []
Expand All @@ -20,6 +23,22 @@ struct RegistrySheet: View {
AgentTarget.installed
}

/// What the list renders: trending when idle, locally-filtered trending plus any
/// long-tail API hits when searching. Local matches come first (they're the popular
/// ones), API extras fill in skills that aren't in the trending set.
private var visibleSkills: [SkillRegistry.RegistrySkill] {
let base: [SkillRegistry.RegistrySkill]
if searchText.count < 2 {
base = trending
} else {
let local = SkillRegistry.filter(trending, query: searchText)
let localIDs = Set(local.map(\.id))
let extra = results.filter { !localIDs.contains($0.id) }
base = local + extra
}
return officialOnly ? base.filter { $0.isOfficial == true } : base
}

var body: some View {
VStack(spacing: 0) {
// Header
Expand Down Expand Up @@ -64,9 +83,10 @@ struct RegistrySheet: View {
}
}
.frame(width: 560, height: 500)
.onAppear {
.task {
// Pre-select all installed agents
selectedAgents = Set(installedAgents.map(\.id))
await loadTrending()
}
.onDisappear {
searchTask?.cancel()
Expand Down Expand Up @@ -98,28 +118,54 @@ struct RegistrySheet: View {
debounceSearch(query: newValue)
}

// Browse header: section label + Official filter toggle
HStack {
Text(searchText.count < 2 ? "Trending" : "Results")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
Spacer()
Toggle("Official only", isOn: $officialOnly)
.toggleStyle(.checkbox)
.font(.caption)
.controlSize(.small)
}
.padding(.horizontal, 20)
.padding(.bottom, 8)

Divider()

// Results
if results.isEmpty && (isSearching || searchText.count < 2) {
if isLoadingTrending && trending.isEmpty && searchText.count < 2 {
Spacer()
ProgressView("Loading popular skills…")
Spacer()
} else if visibleSkills.isEmpty && searchText.count >= 2 && !isSearching {
ContentUnavailableView.search(text: searchText)
.frame(maxHeight: .infinity)
} else if visibleSkills.isEmpty {
ContentUnavailableView {
Label("Search the Skills Registry", systemImage: "globe")
} description: {
Text("Find and install skills from the open agent skills ecosystem.")
}
.frame(maxHeight: .infinity)
} else if results.isEmpty && !isSearching && searchText.count >= 2 {
ContentUnavailableView.search(text: searchText)
.frame(maxHeight: .infinity)
} else {
List(results) { skill in
List(visibleSkills) { skill in
Button {
selectSkill(skill)
} label: {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(skill.name)
.fontWeight(.medium)
HStack(spacing: 5) {
Text(skill.name)
.fontWeight(.medium)
if skill.isOfficial == true {
Image(systemName: "checkmark.seal.fill")
.font(.caption2)
.foregroundStyle(.blue)
}
}
Text(skill.source)
.font(.caption)
.foregroundStyle(.secondary)
Expand Down Expand Up @@ -280,6 +326,14 @@ struct RegistrySheet: View {

// MARK: - Actions

private func loadTrending() async {
guard trending.isEmpty else { return }
isLoadingTrending = true
// Non-fatal: if scraping fails, the API search path still works.
trending = (try? await registry.fetchTrending()) ?? []
isLoadingTrending = false
}

private func debounceSearch(query: String) {
searchTask?.cancel()
error = nil
Expand Down
Loading