diff --git a/Chops.xcodeproj/project.pbxproj b/Chops.xcodeproj/project.pbxproj index 4d3d45b..d241060 100644 --- a/Chops.xcodeproj/project.pbxproj +++ b/Chops.xcodeproj/project.pbxproj @@ -46,6 +46,9 @@ A99BA179E0520DCDC98E1382 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45D5C8E8C732B2F4A867E43 /* AppLogger.swift */; }; AAC4A3CBF2A9B4DF50608948 /* SSHService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6797D4AC1970DB9ACB5ACA73 /* SSHService.swift */; }; AFDC3DAB708B352A7606F119 /* DiagnosticExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D3C2567FBF8DBA4DE957B3 /* DiagnosticExporter.swift */; }; + B36ECE472F7C93F9004D01D0 /* SymlinkTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36ECE462F7C93F9004D01D0 /* SymlinkTarget.swift */; }; + B36ECE492F7C9418004D01D0 /* SymlinkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36ECE482F7C9418004D01D0 /* SymlinkService.swift */; }; + B36ECE4B2F7C944D004D01D0 /* VendorLinkingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36ECE4A2F7C944D004D01D0 /* VendorLinkingPanel.swift */; }; B4526ECE136AC044CD3C7603 /* EditorTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24B049638C76AC6248FD5D2 /* EditorTheme.swift */; }; B5B95B9DA63A734F25470FF5 /* SchemaVersions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F8AA042D5D8A9F92F35CA3 /* SchemaVersions.swift */; }; BD584933CCE2B650345C4CF1 /* ToolSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5487D9027B97F7E9EE9A1F4A /* ToolSource.swift */; }; @@ -118,6 +121,9 @@ A924245DDDA4C6A59BE15567 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; AA64A368BBF950CD36C4F495 /* SkillListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillListView.swift; sourceTree = ""; }; B1F8AA042D5D8A9F92F35CA3 /* SchemaVersions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaVersions.swift; sourceTree = ""; }; + B36ECE462F7C93F9004D01D0 /* SymlinkTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymlinkTarget.swift; sourceTree = ""; }; + B36ECE482F7C9418004D01D0 /* SymlinkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymlinkService.swift; sourceTree = ""; }; + B36ECE4A2F7C944D004D01D0 /* VendorLinkingPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VendorLinkingPanel.swift; sourceTree = ""; }; B3F5CF5BB1FC8455FDDB004A /* Chops.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Chops.app; sourceTree = BUILT_PRODUCTS_DIR; }; B955C8B935BD62ED4811FCA2 /* DiffReviewPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffReviewPanel.swift; sourceTree = ""; }; C9AFE1AF02B0F8057791C2A9 /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; @@ -253,6 +259,7 @@ 3D23D43EC788E51ED1C265FE /* StoreBootstrap.swift */, 12C5CD1627D4EFF58AEF4F71 /* TemplateManager.swift */, 3B8985D8816557B264BF0AD8 /* ACP */, + B36ECE482F7C9418004D01D0 /* SymlinkService.swift */, ); path = Services; sourceTree = ""; @@ -269,6 +276,7 @@ 27D6ED655F951177D2152351 /* Skill.swift */, 5487D9027B97F7E9EE9A1F4A /* ToolSource.swift */, 181FD0056E54DEB3957FB4DC /* WizardTemplate.swift */, + B36ECE462F7C93F9004D01D0 /* SymlinkTarget.swift */, ); path = Models; sourceTree = ""; @@ -303,6 +311,7 @@ 31B4DB87833D5160A11E39C9 /* SkillEditorView.swift */, 8C812515FD8DC222B89745F2 /* SkillMetadataBar.swift */, 77E332B9970E6E3D00DCFBA3 /* SkillPreviewView.swift */, + B36ECE4A2F7C944D004D01D0 /* VendorLinkingPanel.swift */, ); path = Detail; sourceTree = ""; @@ -359,8 +368,6 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; - TargetAttributes = { - }; }; buildConfigurationList = 719777DE0F769FDB488A0FDB /* Build configuration list for PBXProject "Chops" */; developmentRegion = en; @@ -427,6 +434,7 @@ 6E02BA7498763274DD403CD0 /* DiffReviewPanel.swift in Sources */, B4526ECE136AC044CD3C7603 /* EditorTheme.swift in Sources */, 99EE8F6124FE91AC9576D6FD /* FileWatcher.swift in Sources */, + B36ECE492F7C9418004D01D0 /* SymlinkService.swift in Sources */, CBD8FECF1C5A453C8B021665 /* FrontmatterParser.swift in Sources */, A0862ECC573FDC0AA7CB9950 /* LibrarySettingsView.swift in Sources */, 7FD1DE6640F9339D5E97E09A /* MDCParser.swift in Sources */, @@ -445,12 +453,14 @@ 95163AEB7980654C901EC693 /* Skill.swift in Sources */, 09D3F065191A83BDEE922789 /* SkillDetailView.swift in Sources */, FB295303DD30AED03290C726 /* SkillEditorView.swift in Sources */, + B36ECE472F7C93F9004D01D0 /* SymlinkTarget.swift in Sources */, E98F7490B18A194F82ED2C0E /* SkillListView.swift in Sources */, 87A469B631CC80C89B14B1C7 /* SkillMetadataBar.swift in Sources */, 60A6711BAB0C3B8A3ACBF985 /* SkillParser.swift in Sources */, E6F4E1CF50F2F78F5BA9BAC9 /* SkillPreviewView.swift in Sources */, 846B58F3B105D1734DC6FA75 /* SkillRegistry.swift in Sources */, 6A8E0927D95BB14C9F377F4E /* SkillScanner.swift in Sources */, + B36ECE4B2F7C944D004D01D0 /* VendorLinkingPanel.swift in Sources */, C2A028F40A1C665D29495DB6 /* StoreBootstrap.swift in Sources */, 3F0ED402E77D8D25C600BAED /* TemplateManager.swift in Sources */, 038F5975DA97A3211EF4D448 /* ThinkingView.swift in Sources */, diff --git a/Chops/App/ChopsApp.swift b/Chops/App/ChopsApp.swift index 4e58128..6cdbcf8 100644 --- a/Chops/App/ChopsApp.swift +++ b/Chops/App/ChopsApp.swift @@ -17,7 +17,7 @@ struct ChopsApp: App { } var sharedModelContainer: ModelContainer = { - let schema = Schema(versionedSchema: SchemaV1.self) + let schema = Schema(versionedSchema: SchemaV2.self) do { let config = try StoreBootstrap.makeConfiguration(schema: schema) diff --git a/Chops/App/ContentView.swift b/Chops/App/ContentView.swift index 963e704..6515731 100644 --- a/Chops/App/ContentView.swift +++ b/Chops/App/ContentView.swift @@ -42,6 +42,7 @@ struct ContentView: View { } .frame(minWidth: 900, minHeight: 500) .onReceive(NotificationCenter.default.publisher(for: .customScanPathsChanged)) { _ in + // reconcile runs inside applyResults() after each scan completes scanner?.scanAll() } } @@ -51,7 +52,7 @@ struct ContentView: View { let scanner = SkillScanner(modelContext: modelContext) self.scanner = scanner scanner.removeDeletedSkills() - scanner.scanAll() + scanner.scanAll() // reconcile runs inside applyResults() after scan completes var allPaths: [String] = [] for tool in ToolSource.allCases { diff --git a/Chops/Models/ChopsSettings.swift b/Chops/Models/ChopsSettings.swift index cea7101..1ba19c1 100644 --- a/Chops/Models/ChopsSettings.swift +++ b/Chops/Models/ChopsSettings.swift @@ -1,21 +1,8 @@ import Foundation -/// User-configurable source-of-truth root directory. -/// Sub-directories for skills, agents, and rules are derived from the root. struct ChopsSettings { private init() {} - private static let home = FileManager.default.homeDirectoryForCurrentUser.path - - static var sotDir: String { - get { UserDefaults.standard.string(forKey: "sotDir") ?? "\(home)/.chops" } - set { UserDefaults.standard.set(newValue, forKey: "sotDir") } - } - - static var sotSkillsDir: String { "\(sotDir)/skills" } - static var sotAgentsDir: String { "\(sotDir)/agents" } - static var sotRulesDir: String { "\(sotDir)/rules" } - /// When false (default), skills installed by CLI and Desktop plugins are excluded from the library. static var includePluginSkills: Bool { get { UserDefaults.standard.bool(forKey: "includePluginSkills") } diff --git a/Chops/Models/SchemaVersions.swift b/Chops/Models/SchemaVersions.swift index 393933f..c593020 100644 --- a/Chops/Models/SchemaVersions.swift +++ b/Chops/Models/SchemaVersions.swift @@ -1,6 +1,8 @@ import Foundation import SwiftData +// MARK: - v1.0.0 — Original schema (Skill, SkillCollection, RemoteServer) + enum SchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0) @@ -118,14 +120,159 @@ enum SchemaV1: VersionedSchema { } } -typealias Skill = SchemaV1.Skill -typealias SkillCollection = SchemaV1.SkillCollection -typealias RemoteServer = SchemaV1.RemoteServer +// MARK: - v1.1.0 — Adds SymlinkTarget for multi-vendor symlink tracking + +enum SchemaV2: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 1, 0) + + static var models: [any PersistentModel.Type] { + [Skill.self, SkillCollection.self, RemoteServer.self, SymlinkTarget.self] + } + + @Model + final class Skill { + @Attribute(.unique) var resolvedPath: String + var filePath: String + var isDirectory: Bool + var name: String + var skillDescription: String + var content: String + var frontmatterData: Data? + + var collections: [SkillCollection] + var isFavorite: Bool + var lastOpened: Date? + var fileModifiedDate: Date + var fileSize: Int + var isGlobal: Bool + + var remoteServer: RemoteServer? + var remotePath: String? + + var toolSourcesRaw: String + var installedPathsData: Data? + var kind: String = ItemKind.skill.rawValue + + init( + filePath: String, + toolSource: ToolSource, + isDirectory: Bool = false, + name: String = "", + skillDescription: String = "", + content: String = "", + frontmatter: [String: String] = [:], + collections: [SkillCollection] = [], + isFavorite: Bool = false, + lastOpened: Date? = nil, + fileModifiedDate: Date = .now, + fileSize: Int = 0, + isGlobal: Bool = true, + resolvedPath: String = "", + kind: ItemKind = .skill + ) { + self.resolvedPath = resolvedPath.isEmpty ? filePath : resolvedPath + self.filePath = filePath + self.toolSourcesRaw = toolSource.rawValue + self.installedPathsData = try? JSONEncoder().encode([filePath]) + self.isDirectory = isDirectory + self.name = name + self.skillDescription = skillDescription + self.content = content + self.frontmatterData = try? JSONEncoder().encode(frontmatter) + self.collections = collections + self.isFavorite = isFavorite + self.lastOpened = lastOpened + self.fileModifiedDate = fileModifiedDate + self.fileSize = fileSize + self.isGlobal = isGlobal + self.kind = kind.rawValue + } + } + + @Model + final class SkillCollection { + @Attribute(.unique) var name: String + var icon: String + var sortOrder: Int + + @Relationship(inverse: \Skill.collections) + var skills: [Skill] + + init(name: String, icon: String = "folder", skills: [Skill] = [], sortOrder: Int = 0) { + self.name = name + self.icon = icon + self.skills = skills + self.sortOrder = sortOrder + } + } + + @Model + final class RemoteServer { + @Attribute(.unique) var id: String + var label: String + var host: String + var port: Int + var username: String + var skillsBasePath: String + var sshKeyPath: String? + var lastSyncDate: Date? + var lastSyncError: String? + + @Relationship(deleteRule: .cascade, inverse: \Skill.remoteServer) + var skills: [Skill] + + init( + label: String, + host: String, + port: Int = 22, + username: String, + skillsBasePath: String + ) { + self.id = UUID().uuidString + self.label = label + self.host = host + self.port = port + self.username = username + self.skillsBasePath = skillsBasePath + self.skills = [] + } + } + + @Model + final class SymlinkTarget { + @Attribute(.unique) var id: String + var skillResolvedPath: String + // Stored as raw String so #Predicate can filter without a computed property. + var toolSource: String + var linkedPath: String + var kind: String + var isBroken: Bool = false + + init(skillResolvedPath: String, toolSource: ToolSource, linkedPath: String, kind: ItemKind) { + self.id = "\(skillResolvedPath)\n\(toolSource.rawValue)" + self.skillResolvedPath = skillResolvedPath + self.toolSource = toolSource.rawValue + self.linkedPath = linkedPath + self.kind = kind.rawValue + } + } +} + +// MARK: - Typealiases (always point to the latest schema) + +typealias Skill = SchemaV2.Skill +typealias SkillCollection = SchemaV2.SkillCollection +typealias RemoteServer = SchemaV2.RemoteServer +typealias SymlinkTarget = SchemaV2.SymlinkTarget + +// MARK: - Migration plan enum ChopsMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [SchemaV1.self] + [SchemaV1.self, SchemaV2.self] } - static var stages: [MigrationStage] { [] } + static var stages: [MigrationStage] { + [.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)] + } } diff --git a/Chops/Models/SymlinkTarget.swift b/Chops/Models/SymlinkTarget.swift new file mode 100644 index 0000000..16c57b4 --- /dev/null +++ b/Chops/Models/SymlinkTarget.swift @@ -0,0 +1,11 @@ +import Foundation + +// MARK: - Schema Migration Notes +// v1.1 (SchemaV2) additions: +// - SymlinkTarget: tracks active (resolvedPath, toolSource) symlink pairs +// @Model definition lives in SchemaVersions.swift (SchemaV2.SymlinkTarget). + +extension SymlinkTarget { + var toolSourceEnum: ToolSource? { ToolSource(rawValue: toolSource) } + var itemKind: ItemKind { ItemKind(rawValue: kind) ?? .skill } +} diff --git a/Chops/Models/ToolSource.swift b/Chops/Models/ToolSource.swift index 3f65ddb..e81a214 100644 --- a/Chops/Models/ToolSource.swift +++ b/Chops/Models/ToolSource.swift @@ -16,6 +16,7 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { case pi case antigravity case claudeDesktop + case shared case custom var id: String { rawValue } @@ -47,6 +48,7 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { case .agents: "Global" case .antigravity: "Antigravity" case .claudeDesktop: "Claude Desktop" + case .shared: "Shared" case .custom: "Custom" } } @@ -69,6 +71,7 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { case .agents: "globe" case .antigravity: "arrow.up.circle" case .claudeDesktop: "desktopcomputer" + case .shared: "square.stack.3d.up" case .custom: "folder" } } @@ -107,6 +110,7 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { case .agents: .mint case .antigravity: .red case .claudeDesktop: .orange + case .shared: .brown case .custom: .gray } } @@ -114,9 +118,13 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { var globalAgentPaths: [String] { let home = FileManager.default.homeDirectoryForCurrentUser.path switch self { + case .augment: return ["\(home)/.augment/agents"] case .claude: return ["\(home)/.claude/agents"] case .cursor: return ["\(home)/.cursor/agents"] case .codex: return ["\(home)/.codex/agents"] + case .shared: + guard let base = Self.sharedBase else { return [] } + return ["\(base)/agents"] default: return [] } } @@ -179,6 +187,9 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { case .agents: return ["\(home)/.agents/skills"] case .antigravity: return ["\(home)/.gemini/antigravity/skills"] case .claudeDesktop: return [] + case .shared: + guard let base = Self.sharedBase else { return [] } + return ["\(base)/skills"] case .custom: return [] } } @@ -186,8 +197,13 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { var globalRulePaths: [String] { let home = FileManager.default.homeDirectoryForCurrentUser.path switch self { + case .augment: return ["\(home)/.augment/rules"] + case .claude: return ["\(home)/.claude/rules"] case .cursor: return ["\(home)/.cursor/rules"] case .windsurf: return ["\(home)/.codeium/windsurf/memories", "\(home)/.windsurf/rules"] + case .shared: + guard let base = Self.sharedBase else { return [] } + return ["\(base)/rules"] default: return [] } } @@ -203,17 +219,16 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { case .claude: return fm.fileExists(atPath: "\(home)/.claude/settings.json") || fm.fileExists(atPath: "\(home)/.claude/CLAUDE.md") - || fm.fileExists(atPath: "\(home)/.claude/plugins/installed_plugins.json") + || fm.fileExists(atPath: "\(home)/.claude/installed_plugins.json") || Self.cliBinaryExists("claude") case .cursor: return fm.fileExists(atPath: "/Applications/Cursor.app") - || fm.fileExists(atPath: "\(home)/.cursor/argv.json") + || fm.fileExists(atPath: "\(home)/.cursor") case .windsurf: return fm.fileExists(atPath: "/Applications/Windsurf.app") || fm.fileExists(atPath: "\(home)/.codeium/windsurf/argv.json") case .codex: - return fm.fileExists(atPath: "\(home)/.codex/config.toml") - || fm.fileExists(atPath: "\(home)/.codex/auth.json") + return fm.fileExists(atPath: "\(home)/.codex") || Self.cliBinaryExists("codex") case .amp: let configHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] @@ -222,7 +237,8 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { || fm.fileExists(atPath: "\(configHome)/amp/settings.json") || Self.cliBinaryExists("amp") case .pi: - return Self.cliBinaryExists("pi") + return fm.fileExists(atPath: "\(home)/.pi") + || Self.cliBinaryExists("pi") case .copilot: return fm.fileExists(atPath: "\(home)/.copilot") || Self.cliBinaryExists("copilot") @@ -251,9 +267,13 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { || Self.cliBinaryExists("openclaw") || fm.fileExists(atPath: "/opt/homebrew/lib/node_modules/openclaw") || fm.fileExists(atPath: "/usr/local/lib/node_modules/openclaw") + || globalPaths.contains { fm.fileExists(atPath: $0) } case .hermes: return fm.fileExists(atPath: "\(home)/.hermes") || Self.cliBinaryExists("hermes") + case .shared: + guard let base = Self.sharedBase else { return false } + return fm.fileExists(atPath: base) case .aider, .custom: return true } @@ -289,3 +309,30 @@ enum ToolSource: String, Codable, CaseIterable, Identifiable { return false } } + +extension ToolSource { + /// Returns the global directories this tool uses for the given item kind. + func globalDirs(for kind: ItemKind) -> [String] { + switch kind { + case .skill: return globalPaths + case .agent: return globalAgentPaths + case .rule: return globalRulePaths + } + } + + /// Returns true if this tool requires hard links (same inode) for the given item kind. + /// Hard-linked targets are invisible to symlink resolution, so the scanner skips those + /// directories to avoid creating duplicate Skill records for the same content. + func usesHardLink(for kind: ItemKind) -> Bool { + self == .cursor && kind == .agent + } +} + +private extension ToolSource { + /// Expanded absolute path of the user-configured shared library root, or nil if unset. + static var sharedBase: String? { + let raw = UserDefaults.standard.string(forKey: "sharedLibraryPath") ?? "" + let expanded = (raw as NSString).expandingTildeInPath + return expanded.isEmpty ? nil : expanded + } +} diff --git a/Chops/Services/SkillParser.swift b/Chops/Services/SkillParser.swift index f2ee541..3e4e6e3 100644 --- a/Chops/Services/SkillParser.swift +++ b/Chops/Services/SkillParser.swift @@ -12,7 +12,7 @@ enum SkillParser { return MDCParser.parse(content) } return FrontmatterParser.parse(content) - case .codex, .amp, .windsurf, .copilot, .aider, .hermes, .openclaw, .opencode, .pi, .agents, .augment, .antigravity, .custom: + case .codex, .amp, .windsurf, .copilot, .aider, .hermes, .openclaw, .opencode, .pi, .agents, .augment, .antigravity, .shared, .custom: // Try frontmatter first, fall back to heading let parsed = FrontmatterParser.parse(content) if !parsed.name.isEmpty { return parsed } diff --git a/Chops/Services/SkillScanner.swift b/Chops/Services/SkillScanner.swift index 10e815d..1da7679 100644 --- a/Chops/Services/SkillScanner.swift +++ b/Chops/Services/SkillScanner.swift @@ -51,6 +51,7 @@ final class SkillScanner { private static let projectProbes: [(subpath: String, tool: ToolSource, kind: ItemKind)] = [ (".claude/skills", .claude, .skill), (".claude/agents", .claude, .agent), + (".claude/rules", .claude, .rule), (".cursor/skills", .cursor, .skill), (".cursor/rules", .cursor, .rule), (".cursor/agents", .cursor, .agent), @@ -101,13 +102,19 @@ final class SkillScanner { let url = URL(fileURLWithPath: path) collectFromDirectory(url, toolSource: tool, isGlobal: true, kind: .skill, into: &results) } - for path in tool.globalAgentPaths { - let url = URL(fileURLWithPath: path) - collectFromDirectory(url, toolSource: tool, isGlobal: true, kind: .agent, into: &results) + // Skip directories that are hard-link targets — those files are already represented + // by their source location and cannot be deduplicated via symlink resolution. + if !tool.usesHardLink(for: .agent) { + for path in tool.globalAgentPaths { + let url = URL(fileURLWithPath: path) + collectFromDirectory(url, toolSource: tool, isGlobal: true, kind: .agent, into: &results) + } } - for path in tool.globalRulePaths { - let url = URL(fileURLWithPath: path) - collectFromDirectory(url, toolSource: tool, isGlobal: true, kind: .rule, into: &results) + if !tool.usesHardLink(for: .rule) { + for path in tool.globalRulePaths { + let url = URL(fileURLWithPath: path) + collectFromDirectory(url, toolSource: tool, isGlobal: true, kind: .rule, into: &results) + } } } @@ -270,6 +277,11 @@ final class SkillScanner { } else if toolSource == .hermes, kind == .skill { // Hermes nests skills as ~/.hermes/skills///SKILL.md (agentskills.io layout). collectFromDirectory(item, toolSource: toolSource, isGlobal: isGlobal, kind: kind, into: &results) + } else if kind == .rule { + // Recurse into plain subdirectories to support nested rule structures (e.g. rules/subdir/rule.md). + let isSymlink = (try? rawItem.resourceValues(forKeys: [.isSymbolicLinkKey]))?.isSymbolicLink == true + guard !isSymlink else { continue } + collectFromDirectory(item, toolSource: toolSource, isGlobal: isGlobal, kind: kind, into: &results) } } else if item.pathExtension == "md" || item.pathExtension == "mdc" || item.pathExtension == "toml" { guard !shouldIgnoreLooseMarkdownFile(named: item.lastPathComponent) else { continue } @@ -497,6 +509,9 @@ final class SkillScanner { let preferredPath = installedPaths.contains(existing.filePath) ? existing.filePath : primary.fileURL.path let preferredData = installations.first(where: { $0.fileURL.path == preferredPath }) ?? primary + // Use the non-symlink installation's kind so symlinked copies don't override the source kind. + let authoritativeData = installations.first(where: { $0.fileURL.path == $0.resolvedPath }) ?? preferredData + existing.filePath = preferredPath existing.isDirectory = preferredData.isDirectory existing.name = preferredData.name @@ -508,8 +523,10 @@ final class SkillScanner { existing.isGlobal = preferredData.isGlobal existing.installedPaths = installedPaths existing.toolSources = toolSources - existing.itemKind = preferredData.kind + existing.itemKind = authoritativeData.kind } else { + let authoritativeData = installations.first(where: { $0.fileURL.path == $0.resolvedPath }) ?? primary + let skill = Skill( filePath: primary.fileURL.path, toolSource: primary.toolSource, @@ -522,7 +539,7 @@ final class SkillScanner { fileSize: primary.fileSize, isGlobal: primary.isGlobal, resolvedPath: primary.resolvedPath, - kind: primary.kind + kind: authoritativeData.kind ) skill.installedPaths = installedPaths skill.toolSources = toolSources @@ -537,6 +554,8 @@ final class SkillScanner { do { try modelContext.save() } catch { AppLogger.scanning.error("SwiftData save failed: \(error.localizedDescription)") } + + SymlinkService.shared.reconcile(context: modelContext) } // MARK: - Remote Server Scanning diff --git a/Chops/Services/SymlinkService.swift b/Chops/Services/SymlinkService.swift new file mode 100644 index 0000000..e953883 --- /dev/null +++ b/Chops/Services/SymlinkService.swift @@ -0,0 +1,284 @@ +import SwiftData +import Foundation + +enum SymlinkError: LocalizedError { + case destinationExists(String) + case notOurFile(String) + case sourceNotFound(String) + case noTargetDirectory(ToolSource, ItemKind) + + var errorDescription: String? { + let home = FileManager.default.homeDirectoryForCurrentUser.path + func tilde(_ p: String) -> String { p.hasPrefix(home) ? "~" + p.dropFirst(home.count) : p } + switch self { + case .destinationExists(let p): + return "\(tilde(p)) already exists and is not our hard link." + case .notOurFile(let p): + return "\(tilde(p)) is not our hard link — refusing to remove." + case .sourceNotFound(let p): + return "Source file not found at \(tilde(p))." + case .noTargetDirectory(let tool, let kind): + return "\(tool.displayName) has no global directory for \(kind.displayName.lowercased())." + } + } +} + +@MainActor +final class SymlinkService { + static let shared = SymlinkService() + private let fm = FileManager.default + + private init() {} + + // MARK: - Link + + /// Creates a hard link in the vendor's global directory pointing at the same inode as `skill.resolvedPath`. + func link(_ skill: Skill, to tool: ToolSource, context: ModelContext) throws { + let source = skill.resolvedPath + guard fm.fileExists(atPath: source) else { + throw SymlinkError.sourceNotFound(source) + } + + let targetDir = try vendorDirectory(for: tool, kind: skill.itemKind) + let relativePath = relativePathFromScanBase(source: source, kind: skill.itemKind, toolSources: skill.toolSources) + let destination = useMarkdownExtension( + tool, skill, + URL(fileURLWithPath: targetDir).appendingPathComponent(relativePath) + ).path + let destinationParent = (destination as NSString).deletingLastPathComponent + try fm.createDirectory(atPath: destinationParent, withIntermediateDirectories: true) + + if fm.fileExists(atPath: destination) { + // Idempotent if destination is already our link to source. + guard isOurLink(at: destination, source: source, tool: tool, kind: skill.itemKind) else { + throw SymlinkError.destinationExists(destination) + } + } else { + if tool.usesHardLink(for: skill.itemKind) { + try fm.linkItem(atPath: source, toPath: destination) + } else { + try fm.createSymbolicLink(atPath: destination, withDestinationPath: source) + } + } + + let targetID = "\(source)\n\(tool.rawValue)" + let existingDescriptor = FetchDescriptor(predicate: #Predicate { $0.id == targetID }) + if let existingRecord = try context.fetch(existingDescriptor).first { + if existingRecord.linkedPath == destination && existingRecord.kind == skill.itemKind.rawValue { + // Hard link was recreated on disk after being marked broken — keep consistency. + if existingRecord.isBroken { + existingRecord.isBroken = false + try context.save() + NotificationCenter.default.post(name: .customScanPathsChanged, object: nil) + } + return + } + // Stale record (e.g. kind changed after a bug fix) — remove only if it is our link. + if fm.fileExists(atPath: existingRecord.linkedPath) { + if isOurLink(at: existingRecord.linkedPath, source: source, tool: tool, kind: existingRecord.itemKind) { + do { + try fm.removeItem(atPath: existingRecord.linkedPath) + } catch { + AppLogger.fileIO.error("SymlinkService: failed to remove stale link at \(existingRecord.linkedPath): \(error.localizedDescription)") + } + } else { + AppLogger.fileIO.warning("SymlinkService: stale record path \(existingRecord.linkedPath) is not our link — leaving file intact") + } + } + context.delete(existingRecord) + } + + context.insert(SymlinkTarget( + skillResolvedPath: source, + toolSource: tool, + linkedPath: destination, + kind: skill.itemKind + )) + try context.save() + NotificationCenter.default.post(name: .customScanPathsChanged, object: nil) + } + + // MARK: - Unlink + + /// Removes the hard link from the vendor directory and deletes the `SymlinkTarget` record. + func unlink(_ skill: Skill, from tool: ToolSource, context: ModelContext) throws { + let targetID = "\(skill.resolvedPath)\n\(tool.rawValue)" + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetID } + ) + guard let record = try context.fetch(descriptor).first else { return } + + let path = record.linkedPath + guard fm.fileExists(atPath: path) else { + // File already gone — just clean up the record. + context.delete(record) + try context.save() + return + } + + if tool.usesHardLink(for: skill.itemKind) { + // Hard link: require positive inode match. If source is gone and we can't confirm, + // skip removal — the destination may be the last copy of the data. + let srcInode = (try? fm.attributesOfItem(atPath: skill.resolvedPath))?[.systemFileNumber] as? UInt64 + let dstInode = (try? fm.attributesOfItem(atPath: path))?[.systemFileNumber] as? UInt64 + guard let s = srcInode, let d = dstInode else { + AppLogger.fileIO.warning("SymlinkService.unlink: cannot verify inode for \(path) — skipping removal, cleaning record only") + context.delete(record) + try context.save() + return + } + guard s == d else { + throw SymlinkError.notOurFile(path) + } + } else { + // Soft link: verify the symlink points to our source before removing. + let target = try? fm.destinationOfSymbolicLink(atPath: path) + guard target == skill.resolvedPath else { + throw SymlinkError.notOurFile(path) + } + } + try fm.removeItem(atPath: path) + + context.delete(record) + try context.save() + NotificationCenter.default.post(name: .customScanPathsChanged, object: nil) + } + + // MARK: - Reconcile + + /// Validates every `SymlinkTarget` record and syncs hard-link tool associations into + /// `Skill.toolSources`. The scanner skips hard-link target directories, so those + /// associations must be maintained here from the record state. + func reconcile(context: ModelContext) { + guard let records = try? context.fetch(FetchDescriptor()) else { return } + let allSkills = (try? context.fetch(FetchDescriptor())) ?? [] + let skillsByPath = Dictionary(uniqueKeysWithValues: allSkills.map { ($0.resolvedPath, $0) }) + + var dirty = false + // skillResolvedPath -> set of hard-link tools that have active (non-broken) records. + var activeHardLinks: [String: Set] = [:] + + for record in records { + if let skill = skillsByPath[record.skillResolvedPath], skill.kind != record.kind { + // Stale record — kind changed. Remove only if we can confirm it is our link. + if fm.fileExists(atPath: record.linkedPath), + let tool = record.toolSourceEnum, + isOurLink(at: record.linkedPath, source: record.skillResolvedPath, tool: tool, kind: record.itemKind) { + do { + try fm.removeItem(atPath: record.linkedPath) + } catch { + AppLogger.fileIO.error("SymlinkService: failed to remove stale link at \(record.linkedPath): \(error.localizedDescription)") + } + } + context.delete(record) + dirty = true + continue + } + + let broken = !fm.fileExists(atPath: record.linkedPath) + if record.isBroken != broken { + record.isBroken = broken + dirty = true + } + + // Collect active hard-link associations for toolSources sync below. + if !record.isBroken, + let tool = record.toolSourceEnum, + tool.usesHardLink(for: record.itemKind) { + activeHardLinks[record.skillResolvedPath, default: []].insert(tool) + } + } + + // Sync toolSources for hard-link tools, scoped to the skill's own kind. + // A tool may use hard links for .agent but soft links for .rule — only manage the + // toolSources slot for the combination that actually uses hard links. Scanner-discovered + // entries for soft-linked kinds must be left untouched. + for skill in allSkills { + let hardLinkToolsForKind: Set = Set( + ToolSource.allCases.filter { $0.usesHardLink(for: skill.itemKind) } + ) + guard !hardLinkToolsForKind.isEmpty else { continue } + + let activeLinked = activeHardLinks[skill.resolvedPath] ?? [] + let scannerOnly = skill.toolSources.filter { !hardLinkToolsForKind.contains($0) } + let expectedSet = Set(scannerOnly).union(activeLinked) + if expectedSet != Set(skill.toolSources) { + skill.toolSources = ToolSource.allCases.filter { expectedSet.contains($0) } + dirty = true + } + } + + if dirty { + do { + try context.save() + } catch { + AppLogger.fileIO.error("SymlinkService.reconcile save failed: \(error.localizedDescription)") + } + } + } + + // MARK: - Query + + func targets(for skill: Skill, context: ModelContext) -> [SymlinkTarget] { + let path = skill.resolvedPath + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.skillResolvedPath == path && !$0.isBroken } + ) + do { + return try context.fetch(descriptor) + } catch { + AppLogger.fileIO.error("SymlinkService.targets fetch failed: \(error.localizedDescription)") + return [] + } + } + + // MARK: - Private + + /// Swaps `.md` → `.mdc` on the last path component when the tool and kind require it. + /// Only Cursor agents need this — rules use soft links and must keep `.md` so the + /// scanner can resolve and deduplicate them via symlink resolution. + private func useMarkdownExtension(_ tool: ToolSource, _ skill: Skill, _ url: URL) -> URL { + guard tool == .cursor, skill.itemKind == .agent, + url.pathExtension == "md" else { return url } + return url.deletingPathExtension().appendingPathExtension("mdc") + } + + /// Returns true if the file at `path` is the link we created to `source`. + /// For hard-link tools: confirms both paths share the same inode. + /// For soft-link tools: confirms the symlink at `path` resolves to `source`. + private func isOurLink(at path: String, source: String, tool: ToolSource, kind: ItemKind) -> Bool { + if tool.usesHardLink(for: kind) { + guard let srcInode = (try? fm.attributesOfItem(atPath: source))?[.systemFileNumber] as? UInt64, + let dstInode = (try? fm.attributesOfItem(atPath: path))?[.systemFileNumber] as? UInt64 else { + return false + } + return srcInode == dstInode + } else { + let target = try? fm.destinationOfSymbolicLink(atPath: path) + return target == source + } + } + + /// Returns `source` relative to its scan base, preserving subdirectory structure. + private func relativePathFromScanBase(source: String, kind: ItemKind, toolSources: [ToolSource]) -> String { + for toolSource in toolSources { + for base in toolSource.globalDirs(for: kind) { + let prefix = base.hasSuffix("/") ? base : base + "/" + if source.hasPrefix(prefix) { + return String(source.dropFirst(prefix.count)) + } + } + } + let fallback = URL(fileURLWithPath: source).lastPathComponent + AppLogger.fileIO.warning("SymlinkService: no scan base found for \(source), falling back to filename '\(fallback)' — collision possible if other skills share this name") + return fallback + } + + private func vendorDirectory(for tool: ToolSource, kind: ItemKind) throws -> String { + guard let dir = tool.globalDirs(for: kind).first else { + throw SymlinkError.noTargetDirectory(tool, kind) + } + return dir + } + +} diff --git a/Chops/Views/Detail/SkillDetailView.swift b/Chops/Views/Detail/SkillDetailView.swift index e81d1b9..d8f1a9a 100644 --- a/Chops/Views/Detail/SkillDetailView.swift +++ b/Chops/Views/Detail/SkillDetailView.swift @@ -121,6 +121,10 @@ struct SkillDetailView: View { .id(skill.filePath) } + if !skill.isPlugin && !skill.isRemote { + VendorLinkingPanel(skill: skill) + } + Divider() SkillMetadataBar(skill: skill) diff --git a/Chops/Views/Detail/SkillEditorView.swift b/Chops/Views/Detail/SkillEditorView.swift index 1ea16d8..c079cc7 100644 --- a/Chops/Views/Detail/SkillEditorView.swift +++ b/Chops/Views/Detail/SkillEditorView.swift @@ -75,7 +75,9 @@ final class SkillEditorDocument { private func saveLocal(_ skill: Skill) { do { - try editorContent.write(toFile: skill.filePath, atomically: true, encoding: .utf8) + // Resolve symlinks before the atomic write so rename(2) operates on the real file. + let writePath = URL(fileURLWithPath: skill.filePath).resolvingSymlinksInPath().path + try editorContent.write(toFile: writePath, atomically: true, encoding: .utf8) fullFileContent = editorContent hasUnsavedChanges = false diff --git a/Chops/Views/Detail/VendorLinkingPanel.swift b/Chops/Views/Detail/VendorLinkingPanel.swift new file mode 100644 index 0000000..6bcb8ec --- /dev/null +++ b/Chops/Views/Detail/VendorLinkingPanel.swift @@ -0,0 +1,209 @@ +import SwiftUI +import SwiftData + +/// Collapsible panel for linking/unlinking a skill, agent, or rule to vendor directories. +struct VendorLinkingPanel: View { + let skill: Skill + @Environment(\.modelContext) private var modelContext + @State private var isExpanded = false + @State private var errorMessage: String? + @State private var showingError = false + + @Query private var allSymlinks: [SymlinkTarget] + + private var linkedToolRawValues: Set { + Set(allSymlinks + .filter { $0.skillResolvedPath == skill.resolvedPath && !$0.isBroken } + .map(\.toolSource)) + } + + private var eligibleTools: [ToolSource] { + ToolSource.allCases.filter { tool in + guard tool.isInstalled else { return false } + let dirs = tool.globalDirs(for: skill.itemKind) + let hasRecord = linkedToolRawValues.contains(tool.rawValue) + + // Always include if there's an existing link — lets the user unlink even when + // the tool has no configured dirs for this kind. + guard !dirs.isEmpty || hasRecord else { return false } + + let isOrigin = dirs.contains { skill.resolvedPath.hasPrefix($0 + "/") } + + // Exclude the origin tool (skill physically lives there) unless a record exists (allows unlinking). + if isOrigin && !hasRecord { return false } + + // Vendor-origin skills must not link to Shared (only unlink if a stale record exists). + if tool == .shared && !isOrigin && !hasRecord { return false } + + return true + } + } + + var body: some View { + VStack(spacing: 0) { + Divider() + + Button { + withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() } + } label: { + HStack { + Image(systemName: "link") + .font(.caption) + .foregroundStyle(.secondary) + Text("Vendor Links") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + let tools = eligibleTools + if tools.isEmpty { + Text("No other installed vendors support \(skill.itemKind.displayName.lowercased()).") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } else { + VStack(spacing: 0) { + ForEach(tools) { tool in + VendorLinkRow( + skill: skill, + tool: tool, + initiallyLinked: linkedToolRawValues.contains(tool.rawValue), + onError: { msg in + errorMessage = msg + showingError = true + } + ) + if tool.id != tools.last?.id { + Divider().padding(.leading, 36) + } + } + } + .padding(.bottom, 4) + } + } + } + .alert("Link Error", isPresented: $showingError) { + Button("OK") {} + } message: { + Text(errorMessage ?? "") + } + } + +} + +private struct VendorLinkRow: View { + let skill: Skill + let tool: ToolSource + let initiallyLinked: Bool + let onError: (String) -> Void + + @Environment(\.modelContext) private var modelContext + @State private var linked: Bool + @State private var linkedPath: String? + @State private var isSyncingFromParent = false + + init( + skill: Skill, + tool: ToolSource, + initiallyLinked: Bool, + onError: @escaping (String) -> Void + ) { + self.skill = skill + self.tool = tool + self.initiallyLinked = initiallyLinked + self.onError = onError + self._linked = State(initialValue: initiallyLinked) + } + + var body: some View { + HStack(spacing: 8) { + ToolIcon(tool: tool) + .frame(width: 20, height: 20) + + Text(tool.displayName) + .font(.caption) + .fixedSize() + + PathCrumb(source: skill.resolvedPath, destination: linked ? linkedPath : nil) + + Spacer(minLength: 4) + + Toggle("", isOn: $linked) + .toggleStyle(.switch) + .controlSize(.mini) + .labelsHidden() + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .onAppear { refreshLinkedPath() } + .onChange(of: initiallyLinked) { _, newValue in + guard linked != newValue else { return } + isSyncingFromParent = true + linked = newValue + refreshLinkedPath() + } + .onChange(of: linked) { _, newValue in + guard !isSyncingFromParent else { + isSyncingFromParent = false + return + } + do { + if newValue { + try SymlinkService.shared.link(skill, to: tool, context: modelContext) + } else { + try SymlinkService.shared.unlink(skill, from: tool, context: modelContext) + } + refreshLinkedPath() + } catch { + linked = !newValue + onError(error.localizedDescription) + } + } + } + + private func refreshLinkedPath() { + linkedPath = SymlinkService.shared.targets(for: skill, context: modelContext) + .first { $0.toolSource == tool.rawValue } + .map(\.linkedPath) + } +} + +/// Single-line path display: `~/src/file.md` or `~/src/file.md → ~/dst/file.md` +private struct PathCrumb: View { + let source: String + let destination: String? + + private let home = FileManager.default.homeDirectoryForCurrentUser.path + + private func tilde(_ path: String) -> String { + path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path + } + + var body: some View { + HStack(spacing: 3) { + Text(tilde(source)) + .lineLimit(1) + .truncationMode(.middle) + if let dst = destination { + Text("→") + .foregroundStyle(Color.accentColor.opacity(0.8)) + Text(tilde(dst)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } +} diff --git a/Chops/Views/Settings/LibrarySettingsView.swift b/Chops/Views/Settings/LibrarySettingsView.swift index 00af6f1..ca0056d 100644 --- a/Chops/Views/Settings/LibrarySettingsView.swift +++ b/Chops/Views/Settings/LibrarySettingsView.swift @@ -1,68 +1,70 @@ import SwiftUI -/// Settings for the source-of-truth directory used when symlinking library items. struct LibrarySettingsView: View { - @AppStorage("sotDir") private var sotDir = FileManager.default.homeDirectoryForCurrentUser.path + "/.chops" @AppStorage("includePluginSkills") private var includePluginSkills = false + @AppStorage("sharedLibraryPath") private var sharedLibraryPath = "" + @AppStorage("sharedLibraryShowHidden") private var includeHiddenFiles = false var body: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 4) { - Toggle("Include plugin skills", isOn: $includePluginSkills) - .onChange(of: includePluginSkills) { - NotificationCenter.default.post(name: .customScanPathsChanged, object: nil) + Text("Shared Library") + .font(.headline) + Text("A vendor-neutral directory (e.g. ~/.tools) whose skills, agents, and rules you can symlink into any installed tool.") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("Path, e.g. ~/.chops", text: $sharedLibraryPath) + .textFieldStyle(.roundedBorder) + .onSubmit { + sharedLibraryPath = normalize(sharedLibraryPath) + triggerRescan() + } + Button("Browse") { browseForSharedLibrary() } + Toggle("Hidden files", isOn: $includeHiddenFiles) + .controlSize(.small) + } + if !sharedLibraryPath.isEmpty { + Button("Clear") { + sharedLibraryPath = "" + triggerRescan() } - Text("When enabled, skills installed by Claude CLI and Claude Desktop plugins are listed in the library. These are read-only and managed by the plugin.") .font(.caption) .foregroundStyle(.secondary) + } } - } - .padding() - } - private var displayPath: String { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return sotDir.hasPrefix(home) ? "~" + sotDir.dropFirst(home.count) : sotDir - } -} + Divider() -private struct DirectoryPickerRow: View { - let label: String - @Binding var path: String - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(label) - Text(displayPath) + VStack(alignment: .leading, spacing: 4) { + Toggle("Include plugin skills", isOn: $includePluginSkills) + .onChange(of: includePluginSkills) { triggerRescan() } + Text("When enabled, skills installed by Claude CLI and Claude Desktop plugins are listed in the library. These are read-only and managed by the plugin.") .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - - Spacer() - - Button("Choose...") { - pickDirectory() } } + .padding() } - private var displayPath: String { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path + private func triggerRescan() { + NotificationCenter.default.post(name: .customScanPathsChanged, object: nil) } - private func pickDirectory() { + private func normalize(_ path: String) -> String { + (path as NSString).expandingTildeInPath + } + + private func browseForSharedLibrary() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false - panel.showsHiddenFiles = true - panel.prompt = "Select" - panel.directoryURL = URL(fileURLWithPath: (path as NSString).expandingTildeInPath) - guard panel.runModal() == .OK, let url = panel.url else { return } - path = url.path + panel.showsHiddenFiles = includeHiddenFiles + panel.title = "Choose Shared Library Directory" + if panel.runModal() == .OK, let url = panel.url { + sharedLibraryPath = normalize(url.path) + triggerRescan() + } } } diff --git a/Chops/Views/Shared/ToolBadge.swift b/Chops/Views/Shared/ToolBadge.swift index 9256c76..f0b4bd1 100644 --- a/Chops/Views/Shared/ToolBadge.swift +++ b/Chops/Views/Shared/ToolBadge.swift @@ -51,6 +51,7 @@ extension ToolSource { case .agents: "AG" case .antigravity: "AV" case .claudeDesktop: "CD" + case .shared: "SH" case .custom: "?" } } diff --git a/Chops/Views/Sidebar/SidebarView.swift b/Chops/Views/Sidebar/SidebarView.swift index 45181d0..8cc2bdb 100644 --- a/Chops/Views/Sidebar/SidebarView.swift +++ b/Chops/Views/Sidebar/SidebarView.swift @@ -13,7 +13,7 @@ struct SidebarView: View { private var activeSources: [ToolSource] { ToolSource.allCases.filter { tool in guard tool.listable else { return false } - return allSkills.contains { $0.toolSources.contains(tool) } + return tool.isInstalled || allSkills.contains { $0.toolSources.contains(tool) } } } diff --git a/Chops/Views/Sidebar/ToolFilterView.swift b/Chops/Views/Sidebar/ToolFilterView.swift index 5db7ea4..7d17476 100644 --- a/Chops/Views/Sidebar/ToolFilterView.swift +++ b/Chops/Views/Sidebar/ToolFilterView.swift @@ -11,7 +11,8 @@ struct ToolFilterView: View { private var activeSources: [ToolSource] { ToolSource.allCases.filter { tool in - allSkills.contains { $0.toolSources.contains(tool) } + guard tool.listable else { return false } + return tool.isInstalled || allSkills.contains { $0.toolSources.contains(tool) } } }