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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions Chops.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -97,6 +98,7 @@
3D23D43EC788E51ED1C265FE /* StoreBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreBootstrap.swift; sourceTree = "<group>"; };
4CA102A49ACFB23BC15794F8 /* CollectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionListView.swift; sourceTree = "<group>"; };
5155D141EF812A3FE9ADF59C /* SkillParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillParser.swift; sourceTree = "<group>"; };
5252495017119789B3F4B78F /* CommandPaletteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteView.swift; sourceTree = "<group>"; };
5487D9027B97F7E9EE9A1F4A /* ToolSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolSource.swift; sourceTree = "<group>"; };
5CAA9B98C7A69F32C6950710 /* ComposeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeModel.swift; sourceTree = "<group>"; };
60E0F71982C4AF0045962380 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -185,6 +187,7 @@
children = (
125B3A8A0094D3BF9E068E75 /* ActivityRow.swift */,
F8C32D91C01AA0A0D63D00ED /* AgentLogViewer.swift */,
5252495017119789B3F4B78F /* CommandPaletteView.swift */,
5CAA9B98C7A69F32C6950710 /* ComposeModel.swift */,
937F83BF3496CB231C535608 /* ComposePanel.swift */,
B955C8B935BD62ED4811FCA2 /* DiffReviewPanel.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
12 changes: 10 additions & 2 deletions Chops/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions Chops/App/ChopsApp.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwiftUI
import SwiftData
import Sparkle
import AppKit

@main
struct ChopsApp: App {
Expand Down Expand Up @@ -39,13 +40,44 @@ 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)
}
.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)
}
Expand Down
22 changes: 22 additions & 0 deletions Chops/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,35 @@ struct ContentView: View {
.sheet(isPresented: $appState.showingRegistrySheet) {
RegistrySheet()
}
.sheet(isPresented: $appState.showingCommandPalette, onDismiss: runPaletteAction) {
CommandPaletteView()
}
.onChange(of: appState.sidebarFilter) {
appState.toolKindFilter = nil
}
.frame(minWidth: 900, minHeight: 500)
.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() {
Expand Down
122 changes: 121 additions & 1 deletion Chops/Services/SkillRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
// "<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 +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<String>()
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 {
Expand Down
1 change: 1 addition & 0 deletions Chops/Views/Detail/SkillEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion Chops/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()

Expand All @@ -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 {
Expand Down
Loading