diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dbe640..8366fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Chops/Services/SkillRegistry.swift b/Chops/Services/SkillRegistry.swift index bcedf88..506002f 100644 --- a/Chops/Services/SkillRegistry.swift +++ b/Chops/Services/SkillRegistry.swift @@ -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 { @@ -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 + // "/", 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 { @@ -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() + 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 { diff --git a/Chops/Views/Shared/RegistrySheet.swift b/Chops/Views/Shared/RegistrySheet.swift index 1f08f8d..7835fd1 100644 --- a/Chops/Views/Shared/RegistrySheet.swift +++ b/Chops/Views/Shared/RegistrySheet.swift @@ -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 = [] @@ -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 @@ -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() @@ -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) @@ -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 diff --git a/README.md b/README.md index 55d793a..e6c1314 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,32 @@ One macOS app to discover, organize, and edit coding agent skills and agents acr - **Full-text search** — Search across name, description, and content - **Create new skills & agents** — Generates correct boilerplate per tool - **Remote servers** — Connect to servers running [OpenClaw](https://openclaw.ai), [Hermes](https://github.com/NousResearch/hermes-agent), or other layouts to discover, browse, and install skills +- **Skills Discovery** — Browse, search, and install skills directly from the [skills.sh registry](https://skills.sh) -## Prerequisites +## Supported Tools + +Chops scans these directories for skills and agents: + +| Tool | Skills | Agents | +|------|--------|--------| +| Global | `~/.agents/skills/` | — | +| Claude Code | `~/.claude/skills/` | `~/.claude/agents/` | +| Cursor | `~/.cursor/skills/`, `~/.cursor/rules` | `~/.cursor/agents/` | +| Windsurf | `~/.codeium/windsurf/memories/`, `~/.windsurf/rules` | — | +| Codex | `~/.codex/skills/` | `~/.codex/agents/` | +| Amp | `~/.config/amp/skills/` | — | + +Copilot and Aider are also supported but only detect project-level skills and agents (no global paths). Custom scan paths can be added for any tool. + +Tool definitions live in `Chops/Models/ToolSource.swift` — each enum case knows its display name, icon, color, and filesystem paths. + +--- + +## Running from source + +Build and run Chops locally with the steps below. End users can [download the latest release](https://github.com/Shpigford/chops/releases/latest/download/Chops.dmg) instead. + +### Prerequisites - **macOS 15** (Sequoia) or later - **Xcode** with command-line tools (`xcode-select --install`) @@ -38,7 +62,7 @@ One macOS app to discover, organize, and edit coding agent skills and agents acr Sparkle (auto-update framework) is the only external dependency and is pulled automatically by Xcode via Swift Package Manager. No manual setup needed. -## Quick Start +### Quick Start ```bash git clone https://github.com/Shpigford/chops.git @@ -58,7 +82,11 @@ Then hit **Cmd+R** to build and run. xcodebuild -scheme Chops -configuration Debug build ``` -## Project Structure +## Contributing + +Want to change the app? The sections below cover project layout, architecture, and common tasks. + +### Project Structure ``` Chops/ @@ -91,11 +119,11 @@ scripts/ # Release pipeline (release.sh) site/ # Marketing website (Astro 6) ``` -## Architecture +### Architecture **SwiftUI + SwiftData**, native macOS with zero web views. -### App lifecycle +#### App lifecycle 1. `ChopsApp` initializes a SwiftData `ModelContainer` (persists `Skill` and `SkillCollection`) 2. Sparkle updater starts in the background @@ -104,60 +132,43 @@ site/ # Marketing website (Astro 6) 5. `SkillScanner` probes all tool directories and upserts discovered skills 6. `FileWatcher` attaches FSEvents listeners — on any change, the scanner re-runs automatically -### Key design decisions +#### Key design decisions - **No sandbox.** The app needs unrestricted filesystem access to read dotfiles across `~/`. This is intentional and required for core functionality. The entitlements file explicitly disables the app sandbox. - **Dedup via symlinks.** Skills are uniquely identified by their resolved symlink path. If the same file is symlinked into multiple tool directories, it shows up as one skill with multiple tool badges. - **No test suite.** Validate changes manually — build, run, trigger the feature you changed, observe the result. -### State management +#### State management `AppState` is an `@Observable` class that holds all UI state: selected tool filter, selected skill, search text, sidebar filter mode. It's injected via `@Environment` and accessible from any view. -### UI layout +#### UI layout Three-column `NavigationSplitView`: - **Sidebar** — tool filters and collections - **List** — filtered/searched skill list - **Detail** — skill editor (wraps `NSTextView` for native text editing with Cmd+S save) -## Supported Tools +### Common Dev Tasks -Chops scans these directories for skills and agents: - -| Tool | Skills | Agents | -|------|--------|--------| -| Claude Code | `~/.claude/skills/` | `~/.claude/agents/` | -| Cursor | `~/.cursor/skills/`, `~/.cursor/rules` | `~/.cursor/agents/` | -| Windsurf | `~/.codeium/windsurf/memories/`, `~/.windsurf/rules` | — | -| Codex | `~/.codex/skills/` | `~/.codex/agents/` | -| Amp | `~/.config/amp/skills/` | — | -| Global | `~/.agents/skills/` | — | - -Copilot and Aider are also supported but only detect project-level skills and agents (no global paths). Custom scan paths can be added for any tool. - -Tool definitions live in `Chops/Models/ToolSource.swift` — each enum case knows its display name, icon, color, and filesystem paths. - -## Common Dev Tasks - -### Add support for a new tool +#### Add support for a new tool 1. Add a new case to the `ToolSource` enum in `Chops/Models/ToolSource.swift` 2. Fill in `displayName`, `iconName`, `color`, and `globalPaths` 3. Optionally add a logo to the asset catalog and return it from `logoAssetName` 4. Update `SkillScanner` if the new tool uses a non-standard file layout -### Modify skill parsing +#### Modify skill parsing - **Frontmatter (`.md`)** — edit `Chops/Utilities/FrontmatterParser.swift` - **Cursor `.mdc` files** — edit `Chops/Utilities/MDCParser.swift` - **Dispatch logic** — edit `Chops/Services/SkillParser.swift` (decides which parser to use) -### Change the UI +#### Change the UI Views are in `Chops/Views/`, organized by column (Sidebar, Detail) and shared components. The main layout is in `Chops/App/ContentView.swift`. -## Testing +### Testing No automated test suite. Validate manually: @@ -166,6 +177,10 @@ No automated test suite. Validate manually: 3. Observe the result — check for correct behavior and error messages 4. Test edge cases (empty states, missing directories, malformed files) +### AI Agent Setup + +This repo includes a Claude Code skill at `.claude/skills/setup.md` that gives AI coding agents full context on the project — architecture, key files, and common tasks. If you're using Claude Code, it'll pick this up automatically. + ## Website The marketing site lives in `site/` and is built with [Astro](https://astro.build/). @@ -177,10 +192,6 @@ npm run dev # local dev server npm run build # production build → site/dist/ ``` -## AI Agent Setup - -This repo includes a Claude Code skill at `.claude/skills/setup.md` that gives AI coding agents full context on the project — architecture, key files, and common tasks. If you're using Claude Code, it'll pick this up automatically. - ## License FSL-1.1-MIT — see [LICENSE](LICENSE). diff --git a/docs/feature/skills-discovery.md b/docs/feature/skills-discovery.md new file mode 100644 index 0000000..36bccb6 --- /dev/null +++ b/docs/feature/skills-discovery.md @@ -0,0 +1,27 @@ +## Discover / Browse Registry + +A major re-write of skills registry browsing after 1.15.0 + +**Instant browse on open** + - Opening Browse Skills now lands on a populated Trending list instead of a blank "Search the registry" placeholder + - Shows ~600 skills ranked by install count, scraped once from skills.sh's trending page (no API key required) + +**Fast, broader local search** + - Typing filters the trending set locally and instantly — substring match across skill name, ID, and source + - Matches far more than the old API alone (e.g. image → 28 results locally vs ~1–2 from the fuzzy name-search API) + - Long-tail skills not in the trending set are still fetched via the live /api/search API and merged in below the local + matches + +**Caching (fast + good-netizen)** + - Trending data is cached in memory for the session and on disk with a 6-hour TTL (~/Library/Application Support/Chops/trending-cache.json) + - Survives app relaunches — reopening Browse shows results instantly without re-scraping + - Requests send an honest Chops/macOS User-Agent and avoid per-keystroke network hits + +**Trust & popularity signals** + - Official only toggle to filter to verified skills + - A blue checkmark.seal.fill badge marks official skills in the list + - Install counts shown per row (e.g. 16.7K installs) + +**Resilience** + - If the trending scrape fails, the sheet falls back cleanly to the live search path — Browse never breaks + - An expired cache simply re-scrapes; no stale data is served