diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dbe640..b381af2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- Browse Skills now opens to a Trending list of popular skills +- Faster, broader skill search with an Official-only filter +- New ⌘K command palette to jump to any tool's library, browse the registry, or create a skill/agent/rule +- Simplified sidebar: a single unified Library, a Discovery entry, and a cleaner tool list +- Added keyboard shortcuts: ⌘B to toggle the sidebar and ⌘⇧L to jump to Library + ## [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/CLAUDE.md b/CLAUDE.md index 9d597e0..b0fa870 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What is Chops -A native macOS app (SwiftUI + SwiftData) for discovering, organizing, and editing AI coding agent skills across tools (Claude Code, Cursor, Codex, Windsurf, Copilot, Aider, Amp). Fully open source (MIT), public repo at github.com/Shpigford/chops. No sandbox — requires full filesystem access to read user dotfiles. +A native macOS app (SwiftUI + SwiftData) for discovering, organizing, and editing AI coding agent skills across tools (Claude Code, Cursor, Codex, Windsurf, Copilot, Aider, Amp, and many others — see `Chops/Models/ToolSource.swift` for the full set). Source-available under FSL-1.1-MIT (converts to MIT after two years), public repo at github.com/Shpigford/chops. No sandbox — requires full filesystem access to read user dotfiles. ## Build & Run @@ -58,9 +58,11 @@ SwiftData store path is explicit: `~/Library/Application Support/Chops/Chops.sto - Both share `OneShotResponseParser` (`Chops/Services/Agents/OneShotResponseParser.swift`) — the parser tries a structured-edits JSON envelope first and falls back to summary + fenced full-file. Either format produces a `PendingWrite`. - Both conform to `AgentSession` (`Chops/Services/Agents/AgentSession.swift`); `AgentFactory.make(for:)` returns the right one. `ComposePanel` keys off the protocol so the UI doesn't care which transport is in use. -**Views:** Three-column `NavigationSplitView` (Sidebar → List → Detail). Editor uses native `NSTextView` with markdown highlighting. Cmd+S save via `FocusedValues`. Agent responses render via `MarkdownMessageView` (MarkdownUI `.gitHub` theme + syntax highlighting). +**Views:** Three-column `NavigationSplitView` (Sidebar → List → Detail). The sidebar consolidates the library with a Discovery row into the skills registry (`SidebarView.swift`). A ⌘K command palette (`Chops/Views/Shared/CommandPaletteView.swift`, presented via `AppState.showingCommandPalette`) provides fuzzy-search navigation and quick actions. Editor uses native `NSTextView` with markdown highlighting. Agent responses render via `MarkdownMessageView` (MarkdownUI `.gitHub` theme + syntax highlighting). Chops is a **single-window** app — there is no "New Window"; "Close Window" lives in the Window menu. Menus and global shortcuts are wired in `ChopsApp.swift`: ⌘S save (via `FocusedValues`), ⌘B toggle sidebar (via the `.toggleSidebar` NotificationCenter post), ⌘⇧L go to Library, ⌘K command palette. -**Tool sources** are defined in `ToolSource.swift` — each enum case knows its display name, icon, and filesystem paths to scan. +**Registry/Discovery:** `SkillRegistry` fetches trending skills scraped from skills.sh (no public JSON API exists for this), cached in memory (process-wide) and on disk with a 6-hour TTL. + +**Tool sources** are defined in `Chops/Models/ToolSource.swift` — each enum case knows its display name, icon, and filesystem paths to scan. ## Release Pipeline diff --git a/Chops.xcodeproj/project.pbxproj b/Chops.xcodeproj/project.pbxproj index 51302c4..fbf163b 100644 --- a/Chops.xcodeproj/project.pbxproj +++ b/Chops.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ C2A028F40A1C665D29495DB6 /* StoreBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D23D43EC788E51ED1C265FE /* StoreBootstrap.swift */; }; C30BEAE03E54BD36F257A925 /* ToolFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5774429CCE83C2AFBE046C /* ToolFilterView.swift */; }; C6CFF37CC0B155E8846344A4 /* ChopsIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 04C3C644B72DD508A7CBC403 /* ChopsIcon.icon */; }; + CAD849D5D67E6ECBEFC2D556 /* CommandPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5252495017119789B3F4B78F /* CommandPaletteView.swift */; }; CBD8FECF1C5A453C8B021665 /* FrontmatterParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C77C5B30E6C885B39444E5B /* FrontmatterParser.swift */; }; CCD92CB102DFA0965D6AFDB5 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF7822031FD82A28978D9C5 /* SidebarView.swift */; }; CECE9BEEC1D933C543B66A4E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 17F7FE50C96124BA4EE43A6F /* Assets.xcassets */; }; @@ -97,6 +98,7 @@ 3D23D43EC788E51ED1C265FE /* StoreBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreBootstrap.swift; sourceTree = ""; }; 4CA102A49ACFB23BC15794F8 /* CollectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionListView.swift; sourceTree = ""; }; 5155D141EF812A3FE9ADF59C /* SkillParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillParser.swift; sourceTree = ""; }; + 5252495017119789B3F4B78F /* CommandPaletteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteView.swift; sourceTree = ""; }; 5487D9027B97F7E9EE9A1F4A /* ToolSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolSource.swift; sourceTree = ""; }; 5CAA9B98C7A69F32C6950710 /* ComposeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeModel.swift; sourceTree = ""; }; 60E0F71982C4AF0045962380 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -185,6 +187,7 @@ children = ( 125B3A8A0094D3BF9E068E75 /* ActivityRow.swift */, F8C32D91C01AA0A0D63D00ED /* AgentLogViewer.swift */, + 5252495017119789B3F4B78F /* CommandPaletteView.swift */, 5CAA9B98C7A69F32C6950710 /* ComposeModel.swift */, 937F83BF3496CB231C535608 /* ComposePanel.swift */, B955C8B935BD62ED4811FCA2 /* DiffReviewPanel.swift */, @@ -429,6 +432,7 @@ B007633F077CBD9EBA65B017 /* CodexCLIAgent.swift in Sources */, 7A17C99C5EFAE3AA9E788D50 /* Collection.swift in Sources */, 7EC6156934D559226BC78859 /* CollectionListView.swift in Sources */, + CAD849D5D67E6ECBEFC2D556 /* CommandPaletteView.swift in Sources */, E46A2BA5FA20531A06AFB418 /* ComposeModel.swift in Sources */, 74045816B0CF10369DB51ADD /* ComposePanel.swift in Sources */, D4C107BE303FC07CAF9C66AF /* ContentView.swift in Sources */, diff --git a/Chops/App/AppState.swift b/Chops/App/AppState.swift index 57763e6..a734cd9 100644 --- a/Chops/App/AppState.swift +++ b/Chops/App/AppState.swift @@ -7,16 +7,24 @@ final class AppState { var searchText: String = "" var showingNewSkillSheet: Bool = false var showingRegistrySheet: Bool = false + var showingCommandPalette: Bool = false + /// Action chosen in the command palette, run after the palette sheet dismisses + /// (so we never present two sheets at once). + var pendingPaletteAction: PaletteAction? var newItemKind: ItemKind = .skill var sidebarFilter: SidebarFilter = .allSkills /// Filter by item kind within a tool view (nil = show all) var toolKindFilter: ItemKind? } +enum PaletteAction: Equatable { + case navigate(SidebarFilter) + case openDiscovery + case newItem(ItemKind) +} + enum SidebarFilter: Hashable { case allSkills - case allAgents - case allRules case favorites case tool(ToolSource) case collection(String) diff --git a/Chops/App/ChopsApp.swift b/Chops/App/ChopsApp.swift index 554c239..6f806ec 100644 --- a/Chops/App/ChopsApp.swift +++ b/Chops/App/ChopsApp.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData import Sparkle +import AppKit @main struct ChopsApp: App { @@ -39,6 +40,28 @@ struct ChopsApp: App { .modelContainer(sharedModelContainer) .commands { TextEditingCommands() + // Chops is a single-window app — drop the "New Window" command. + CommandGroup(replacing: .newItem) { } + CommandGroup(after: .sidebar) { + Button("Toggle Sidebar") { + NotificationCenter.default.post(name: .toggleSidebar, object: nil) + } + .keyboardShortcut("b", modifiers: .command) + + Button("Go to Library") { + appState.sidebarFilter = .allSkills + } + .keyboardShortcut("l", modifiers: [.command, .shift]) + + Button("Command Palette") { + appState.showingCommandPalette = true + } + .keyboardShortcut("k", modifiers: .command) + + Divider() + } + // Replacing (not after) .saveItem drops SwiftUI's default Close/Close All + // from the File menu; Close lives in the Window menu instead (below). CommandGroup(replacing: .saveItem) { Button("Save") { NotificationCenter.default.post(name: .saveCurrentSkill, object: nil) @@ -46,6 +69,15 @@ struct ChopsApp: App { .keyboardShortcut("s", modifiers: .command) .disabled(appState.selectedSkill == nil) } + // Close Window in the Window menu, next to "Remove Window from Set". + CommandGroup(after: .windowSize) { + Button { + NSApp.keyWindow?.performClose(nil) + } label: { + Label("Close Window", systemImage: "xmark") + } + .keyboardShortcut("w", modifiers: .command) + } CommandGroup(after: .appInfo) { CheckForUpdatesView(updater: updaterController.updater) } diff --git a/Chops/App/ContentView.swift b/Chops/App/ContentView.swift index 963e704..24183d8 100644 --- a/Chops/App/ContentView.swift +++ b/Chops/App/ContentView.swift @@ -37,6 +37,9 @@ struct ContentView: View { .sheet(isPresented: $appState.showingRegistrySheet) { RegistrySheet() } + .sheet(isPresented: $appState.showingCommandPalette, onDismiss: runPaletteAction) { + CommandPaletteView() + } .onChange(of: appState.sidebarFilter) { appState.toolKindFilter = nil } @@ -44,6 +47,25 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .customScanPathsChanged)) { _ in scanner?.scanAll() } + .onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in + columnVisibility = columnVisibility == .doubleColumn ? .all : .doubleColumn + } + } + + /// Runs the action chosen in the command palette, after that sheet has + /// fully dismissed — avoids presenting two sheets simultaneously. + private func runPaletteAction() { + guard let action = appState.pendingPaletteAction else { return } + appState.pendingPaletteAction = nil + switch action { + case .navigate(let filter): + appState.sidebarFilter = filter + case .openDiscovery: + appState.showingRegistrySheet = true + case .newItem(let kind): + appState.newItemKind = kind + appState.showingNewSkillSheet = true + } } private func startScanning() { diff --git a/Chops/Services/SkillRegistry.swift b/Chops/Services/SkillRegistry.swift index bcedf88..098d74b 100644 --- a/Chops/Services/SkillRegistry.swift +++ b/Chops/Services/SkillRegistry.swift @@ -9,6 +9,23 @@ 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 process-wide + // (static, so it survives sheet re-presentations) and on disk, both with a TTL, + // so it survives app relaunches without re-scraping. + private static var trendingCache: (skills: [RegistrySkill], fetchedAt: Date)? + + 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 +34,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 +70,104 @@ 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 = Self.trendingCache, + Date().timeIntervalSince(cached.fetchedAt) < Self.trendingTTL { + return cached.skills + } + + // 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 { + Self.trendingCache = (disk.skills, disk.fetchedAt) + 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 { + AppLogger.scanning.error("Trending scrape parsed 0 skills — skills.sh markup likely changed") + throw RegistryError.searchFailed + } + Self.trendingCache = (skills, Date()) + 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] = [] + var dropped = 0 + 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 { + dropped += 1 + continue + } + if seen.insert(skill.id).inserted { + result.append(skill) + } + } + if dropped > 0 { + AppLogger.scanning.warning("Trending: dropped \(dropped) unparseable skill fragment(s)") + } + 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/Detail/SkillEditorView.swift b/Chops/Views/Detail/SkillEditorView.swift index 1ea16d8..9636e1b 100644 --- a/Chops/Views/Detail/SkillEditorView.swift +++ b/Chops/Views/Detail/SkillEditorView.swift @@ -222,6 +222,7 @@ struct SkillEditorView: View { extension Notification.Name { static let saveCurrentSkill = Notification.Name("saveCurrentSkill") + static let toggleSidebar = Notification.Name("toggleSidebar") } // MARK: - Syntax-highlighted NSTextView wrapper diff --git a/Chops/Views/Settings/SettingsView.swift b/Chops/Views/Settings/SettingsView.swift index 45e12f7..6e3ddc6 100644 --- a/Chops/Views/Settings/SettingsView.swift +++ b/Chops/Views/Settings/SettingsView.swift @@ -44,10 +44,12 @@ struct SettingsView: View { @State private var selectedTab: SettingsTab = .general @State private var customPaths: [String] = [] @AppStorage("defaultTool") private var defaultTool: ToolSource = .claude + @FocusState private var navFocused: Bool var body: some View { VStack(spacing: 0) { - // Tab bar + // Tab bar — focused on open so the keyboard can drive navigation + // immediately; ←/→ move between sections. HStack(spacing: 1) { ForEach(SettingsTab.allCases) { tab in SettingsTabButton(tab: tab, isSelected: selectedTab == tab) { @@ -58,6 +60,13 @@ struct SettingsView: View { .padding(.horizontal, 12) .padding(.top, 8) .padding(.bottom, 4) + .focusable() + .focused($navFocused) + .focusEffectDisabled() + .onKeyPress(.leftArrow) { selectAdjacentTab(-1) } + .onKeyPress(.rightArrow) { selectAdjacentTab(1) } + .accessibilityElement(children: .contain) + .accessibilityLabel("Settings sections") Divider() @@ -67,11 +76,23 @@ struct SettingsView: View { } .frame(width: 520) .fixedSize(horizontal: false, vertical: true) + .defaultFocus($navFocused, true) .onAppear { loadCustomPaths() } } + /// Move the selection one tab left/right, clamped at the ends. Returns + /// `.handled` so the arrow key never falls through to scroll the window. + private func selectAdjacentTab(_ direction: Int) -> KeyPress.Result { + let tabs = SettingsTab.allCases + guard let current = tabs.firstIndex(of: selectedTab) else { return .ignored } + let target = current + direction + guard tabs.indices.contains(target) else { return .handled } + selectedTab = tabs[target] + return .handled + } + @ViewBuilder private var tabContent: some View { switch selectedTab { diff --git a/Chops/Views/Shared/CommandPaletteView.swift b/Chops/Views/Shared/CommandPaletteView.swift new file mode 100644 index 0000000..4dc6314 --- /dev/null +++ b/Chops/Views/Shared/CommandPaletteView.swift @@ -0,0 +1,193 @@ +import SwiftUI +import SwiftData + +/// A ⌘K command palette: fuzzy-search and run quick actions — jump to a tool's +/// library, open the registry, or create a new skill/agent/rule. Keyboard-first +/// (↑/↓ to move, ↵ to run, ⎋ to dismiss); mouse hover/click also work. +struct CommandPaletteView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @Query(sort: \Skill.name) private var allSkills: [Skill] + + @State private var query = "" + @State private var selectedIndex = 0 + @State private var hoveredID: String? + @FocusState private var searchFocused: Bool + + private var activeTools: [ToolSource] { + ToolSource.allCases.filter { tool in + tool.listable && allSkills.contains { $0.toolSources.contains(tool) } + } + } + + private var commands: [PaletteCommand] { + var result: [PaletteCommand] = [ + PaletteCommand(title: "Library", subtitle: nil, systemImage: "doc.text", + tool: nil, category: "Navigate", action: .navigate(.allSkills)), + PaletteCommand(title: "Favorites", subtitle: nil, systemImage: "star", + tool: nil, category: "Navigate", action: .navigate(.favorites)), + ] + for tool in activeTools { + result.append(PaletteCommand(title: tool.displayName, subtitle: nil, + systemImage: tool.iconName, tool: tool, + category: "Navigate", action: .navigate(.tool(tool)))) + } + result.append(PaletteCommand(title: "Browse Registry", + subtitle: nil, systemImage: "safari", + tool: nil, category: "Discover", action: .openDiscovery)) + for kind in ItemKind.allCases { + result.append(PaletteCommand(title: "New \(kind.singularName)", subtitle: nil, + systemImage: "plus", tool: nil, + + category: "Create", action: .newItem(kind))) + } + return result + } + + private var filtered: [PaletteCommand] { + guard !query.isEmpty else { return commands } + return commands.filter { + $0.title.localizedCaseInsensitiveContains(query) || + ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) || + $0.category.localizedCaseInsensitiveContains(query) + } + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search commands…", text: $query) + .textFieldStyle(.plain) + .font(.title3) + .focused($searchFocused) + .onSubmit(runSelected) + .onChange(of: query) { selectedIndex = 0 } + .onKeyPress(.downArrow) { move(1); return .handled } + .onKeyPress(.upArrow) { move(-1); return .handled } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + + Divider() + + if filtered.isEmpty { + Text("No matching commands") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + } else { + resultsList + } + } + .frame(width: 560) + .background(.thickMaterial) + .onAppear { searchFocused = true } + .onExitCommand { dismiss() } + } + + private var resultsList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(Array(filtered.enumerated()), id: \.element.id) { index, command in + VStack(alignment: .leading, spacing: 1) { + if index == 0 || filtered[index - 1].category != command.category { + Text(command.category.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 20) + .padding(.top, index == 0 ? 10 : 14) + .padding(.bottom, 2) + } + CommandRow(command: command, + selected: index == selectedIndex, + hovered: command.id == hoveredID) + .contentShape(Rectangle()) + .onTapGesture { run(command) } + .onHover { hovering in + if hovering { hoveredID = command.id } + else if hoveredID == command.id { hoveredID = nil } + } + } + .id(command.id) + } + } + .padding(.bottom, 8) + } + .frame(maxHeight: 360) + .onChange(of: selectedIndex) { + guard filtered.indices.contains(selectedIndex) else { return } + proxy.scrollTo(filtered[selectedIndex].id, anchor: .center) + } + } + } + + private func move(_ delta: Int) { + guard !filtered.isEmpty else { return } + selectedIndex = min(max(selectedIndex + delta, 0), filtered.count - 1) + } + + private func runSelected() { + guard filtered.indices.contains(selectedIndex) else { return } + run(filtered[selectedIndex]) + } + + private func run(_ command: PaletteCommand) { + appState.pendingPaletteAction = command.action + dismiss() + } +} + +private struct PaletteCommand: Identifiable { + let title: String + let subtitle: String? + let systemImage: String + let tool: ToolSource? + let category: String + let action: PaletteAction + + var id: String { "\(category)/\(title)" } +} + +private struct CommandRow: View { + let command: PaletteCommand + let selected: Bool + let hovered: Bool + + private var background: Color { + if selected { return .accentColor } + if hovered { return .primary.opacity(0.08) } + return .clear + } + + var body: some View { + HStack(spacing: 10) { + Group { + if let tool = command.tool { + ToolIcon(tool: tool, size: 16) + } else { + Image(systemName: command.systemImage) + } + } + .frame(width: 20, height: 18) + .foregroundStyle(selected ? .white : .secondary) + + VStack(alignment: .leading, spacing: 1) { + Text(command.title) + .foregroundStyle(selected ? .white : .primary) + if let subtitle = command.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(selected ? .white.opacity(0.85) : .secondary) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(background, in: RoundedRectangle(cornerRadius: 6)) + .padding(.horizontal, 8) + } +} diff --git a/Chops/Views/Shared/NewSkillSheet.swift b/Chops/Views/Shared/NewSkillSheet.swift index c2302be..c6187a0 100644 --- a/Chops/Views/Shared/NewSkillSheet.swift +++ b/Chops/Views/Shared/NewSkillSheet.swift @@ -160,11 +160,7 @@ struct NewSkillSheet: View { modelContext.insert(skill) try modelContext.save() - switch itemKind { - case .skill: appState.sidebarFilter = .allSkills - case .agent: appState.sidebarFilter = .allAgents - case .rule: appState.sidebarFilter = .allRules - } + appState.sidebarFilter = .allSkills appState.selectedSkill = skill dismiss() } catch { diff --git a/Chops/Views/Shared/RegistrySheet.swift b/Chops/Views/Shared/RegistrySheet.swift index 1f08f8d..8ff9d18 100644 --- a/Chops/Views/Shared/RegistrySheet.swift +++ b/Chops/Views/Shared/RegistrySheet.swift @@ -5,6 +5,10 @@ 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 trendingError: String? + @State private var officialOnly = false @State private var selectedSkill: SkillRegistry.RegistrySkill? @State private var skillContent: String? @State private var selectedAgents: Set = [] @@ -20,6 +24,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 +84,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 +119,65 @@ 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 trendingError != nil && trending.isEmpty && searchText.count < 2 { + ContentUnavailableView { + Label("Couldn't load trending skills", systemImage: "exclamationmark.triangle") + } description: { + Text("Check your connection, or search the registry directly.") + } actions: { + Button("Retry") { + Task { await loadTrending() } + } + } + .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 +338,19 @@ struct RegistrySheet: View { // MARK: - Actions + private func loadTrending() async { + guard trending.isEmpty else { return } + isLoadingTrending = true + trendingError = nil + // Search stays available even if trending fails; surface the failure explicitly. + do { + trending = try await registry.fetchTrending() + } catch { + trendingError = error.localizedDescription + } + isLoadingTrending = false + } + private func debounceSearch(query: String) { searchTask?.cancel() error = nil diff --git a/Chops/Views/Sidebar/SidebarView.swift b/Chops/Views/Sidebar/SidebarView.swift index 45181d0..359af63 100644 --- a/Chops/Views/Sidebar/SidebarView.swift +++ b/Chops/Views/Sidebar/SidebarView.swift @@ -25,25 +25,16 @@ struct SidebarView: View { @Bindable var appState = appState List(selection: $appState.sidebarFilter) { - Section("Library") { - Label("Skills", systemImage: "doc.text") - .badge(allSkills.filter { $0.itemKind == .skill }.count) - .tag(SidebarFilter.allSkills) - - Label("Agents", systemImage: "person.crop.rectangle") - .badge(allSkills.filter { $0.itemKind == .agent }.count) - .tag(SidebarFilter.allAgents) - Label("Rules", systemImage: "list.bullet.rectangle") - .badge(allSkills.filter { $0.itemKind == .rule }.count) - .tag(SidebarFilter.allRules) + Section { + Label("Library", systemImage: "doc.text") + .badge(allSkills.count) + .tag(SidebarFilter.allSkills) Label("Favorites", systemImage: "star") .badge(allSkills.filter(\.isFavorite).count) .tag(SidebarFilter.favorites) - } - - Section("Tools") { + ForEach(activeSources) { tool in Label { Text(tool.displayName) @@ -111,6 +102,15 @@ struct SidebarView: View { Section("Collections") { CollectionListView() } + + Section { + Button { + appState.showingRegistrySheet = true + } label: { + Label("Discovery", systemImage: "safari") + } + .buttonStyle(.plain) + } } .listStyle(.sidebar) .navigationTitle("Chops") diff --git a/Chops/Views/Sidebar/SkillListView.swift b/Chops/Views/Sidebar/SkillListView.swift index bf8cd1c..11ad581 100644 --- a/Chops/Views/Sidebar/SkillListView.swift +++ b/Chops/Views/Sidebar/SkillListView.swift @@ -33,11 +33,7 @@ struct SkillListView: View { switch appState.sidebarFilter { case .allSkills: - result = result.filter { $0.itemKind == .skill } - case .allAgents: - result = result.filter { $0.itemKind == .agent } - case .allRules: - result = result.filter { $0.itemKind == .rule } + break // unified list — all item kinds case .favorites: result = result.filter { $0.isFavorite } case .tool(let tool): @@ -66,9 +62,7 @@ struct SkillListView: View { private var title: String { switch appState.sidebarFilter { - case .allSkills: "Skills" - case .allAgents: "Agents" - case .allRules: "Rules" + case .allSkills: "Library" case .favorites: "Favorites" case .tool(let tool): tool.displayName case .collection(let name): name @@ -80,7 +74,6 @@ struct SkillListView: View { /// Whether the current filter shows mixed item types (skills and agents together) private var showsTypeBadge: Bool { switch appState.sidebarFilter { - case .allSkills, .allAgents, .allRules: false case .tool: appState.toolKindFilter == nil default: true } @@ -101,17 +94,8 @@ struct SkillListView: View { description: Text("No \(kind.displayName.lowercased()) match the current filter.") ) } else { - switch appState.sidebarFilter { - case .allAgents: - ContentUnavailableView("No Agents", systemImage: "person.crop.rectangle", - description: Text("No agents match the current filter.")) - case .allRules: - ContentUnavailableView("No Rules", systemImage: "list.bullet.rectangle", - description: Text("No rules match the current filter.")) - default: - ContentUnavailableView("No Skills", systemImage: "doc.text", - description: Text("No skills match the current filter.")) - } + ContentUnavailableView("No Items", systemImage: "doc.text", + description: Text("No items match the current filter.")) } } 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/images/command-palette.png b/docs/feature/images/command-palette.png new file mode 100644 index 0000000..e4f5908 Binary files /dev/null and b/docs/feature/images/command-palette.png differ diff --git a/docs/feature/keyboard-navigation.md b/docs/feature/keyboard-navigation.md new file mode 100644 index 0000000..cb453f1 --- /dev/null +++ b/docs/feature/keyboard-navigation.md @@ -0,0 +1,69 @@ +# Keyboard Navigation + +Chops is keyboard-driven. The ⌘K command palette is the fastest way to jump +anywhere or create something; everything it does is also bound to a menu +shortcut. + +## ⌘K — Command Palette + +Press **⌘K** (also under the **View** menu) to open the palette. Start typing to +fuzzy-filter across command title and category. It is keyboard-first: + +| Key | Action | +|-----|--------| +| Type | Filter the command list | +| ↑ / ↓ | Move the selection | +| ↵ | Run the selected command | +| ⎋ | Dismiss the palette | +| Click | Run a command directly (hover highlights, but never moves the keyboard selection) | + +The chosen command runs after the palette closes, so it never collides with the +sheet it opens. + +### Commands shown + +**Navigate** — switches the sidebar selection / list view: +- **Library** — all items (skills, agents, and rules together) +- **Favorites** — items marked favorite +- **One row per tool** that currently has items — e.g. Global, Claude Code, + Cursor, Codex, OpenCode — each jumps to that tool's library. Tools with no + items are omitted (matching the sidebar). + +**Discover**: +- **Browse Registry** — opens the registry sheet to discover skills from + skills.sh (see [skills-discovery](skills-discovery.md)) + +**Create** — opens the New Item sheet pre-set to that kind: +- **New Skill** +- **New Agent** +- **New Rule** + +## App shortcuts + +| Shortcut | Action | +|----------|--------| +| ⌘K | Open the command palette | +| ⌘B | Toggle the sidebar | +| ⌘⇧L | Go to Library | +| ⌘S | Save the current skill (disabled when nothing is selected) | +| ⌘, | Open Settings | + +⌘, opens the standard macOS **Settings** window (a separate window, not a modal +sheet). It is provided automatically by SwiftUI's `Settings` scene and listed as +**Settings…** under the **Chops** app menu — no custom binding is needed. The +window opens with the section tab bar focused, so **← / →** switch sections +(General, Library, AI Assist, Scan Directories, Servers, About) without reaching +for the mouse. Close it the standard macOS way — **⌘W** or the window's close +button (not ⎋, which is reserved for modal sheets). + +Standard macOS text-editing shortcuts (⌘C/⌘V/⌘Z, etc.) apply in the editor. + +## Sheet & panel conventions + +Modal sheets and panels follow the usual macOS defaults: + +| Shortcut | Action | +|----------|--------| +| ↵ | Confirm the primary button (e.g. **Create**, **Add**) | +| ⌘↵ | Send / accept in the compose and diff-review panels | +| ⎋ or ⌘. | Cancel / dismiss | diff --git a/docs/feature/sidebar.md b/docs/feature/sidebar.md new file mode 100644 index 0000000..f6e74cf --- /dev/null +++ b/docs/feature/sidebar.md @@ -0,0 +1,84 @@ +## Sidebar + +The left column of the three-pane `NavigationSplitView`. It is the primary navigation surface — +selecting a row filters the middle list (`SkillListView`) and, in turn, the detail pane. + +**Source:** `Chops/Views/Sidebar/SidebarView.swift` (plus `CollectionListView.swift`). +**State:** selection is bound to `appState.sidebarFilter` via `List(selection:)`; each selectable +row carries a `.tag(SidebarFilter…)`. The `SidebarFilter` cases are `allSkills`, `favorites`, +`tool(ToolSource)`, `collection(String)`, and `server(String)` (`Chops/App/AppState.swift`). + +### Layout (top to bottom) + +``` + Library 27 ← all items, every kind + Favorites + + Global 23 + Claude Code 25 + Cursor 11 + … ← one row per tool with skills +Servers (if any) +Collections +───────────── ← faint divider +Discovery ← opens the Browse Registry sheet (not a selection) +``` + + + +### Library — Skills & Favorites + +- **Library** (`doc.text`, tag `.allSkills`) — the unified library. Lists every discovered item + regardless of kind (skill, agent, rule); the badge is the total item count (`allSkills.count`). + Because the list mixes kinds, each row in `SkillListView` shows a type badge. +- **Favorites** (`star`, tag `.favorites`) — items where `isFavorite == true`; badge is that count. + +#### Tools / Agent Harnesses + +One row per tool source that is **listable** and currently has at least one skill +(`activeSources` in `SidebarView`). Each row shows the tool's icon — a custom logo asset when one +exists (`ToolIcon`), otherwise the tool's SF Symbol — its display name, and a badge counting the +skills installed for that tool. Selecting a row (tag `.tool(tool)`) filters the list to that tool +and exposes a per-tool item-kind filter in `SkillListView`. + +- **Global** is `ToolSource.agents` — the shared `~/.agents` location, not a specific editor. +- Tools without a listable surface never appear here (`.custom`, `.claudeDesktop`, `.aider`). +- A tool drops out of the sidebar automatically when its last skill is removed. + +#### Servers + +Shown only when at least one `RemoteServer` exists (`Section("Servers")`). Each row shows the +server label, a `server.rack` icon, and a badge of how many skills came from it (tag +`.server(id)`). Per row: + +- **Sync button** — re-scans the server (`SkillScanner.scanRemoteServer`); shows a spinner while + in flight. +- **Error indicator** — a red triangle appears if the last sync failed; tapping it shows the error + message in a popover. + +#### Collections + +User-created groupings (`SkillCollection`), rendered by `CollectionListView` under +`Section("Collections")`. Collections are pure user data, not filesystem-backed. + +- Each collection shows its chosen icon and a badge of its skill count (tag `.collection(name)`). +- **Drag and drop** a skill from the list onto a collection to add it (deduped by resolved path). +- **Context menu** — Rename (inline text field) or Delete. Renaming updates the active filter if + that collection is selected. +- **New Collection** — a button opening a popover with a name field and an 18-icon picker; + duplicate names (case-insensitive) are rejected. + +### Discovery + +A single action row at the bottom. It is **not** a navigation destination — it has no +`SidebarFilter` tag and does not participate in list selection. Clicking it sets +`appState.showingRegistrySheet = true`, which presents the Browse Registry sheet from +`ContentView` (see [skills-discovery](skills-discovery.md)). Icon: SF Symbol `safari` +(compass-needle glyph). + +### Selection → list behavior + +Whatever is selected sets `appState.sidebarFilter`, which `SkillListView.filteredSkills` switches +on to filter the middle column (search text and, for tools, the kind filter are applied on top). +The list pane's title mirrors the selection (e.g. "Skills", "Favorites", the tool name, the +collection name, or the server label). diff --git a/docs/feature/skills-discovery.md b/docs/feature/skills-discovery.md new file mode 100644 index 0000000..5df6155 --- /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 Discovery browse registry modal 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