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
14 changes: 12 additions & 2 deletions Chops.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -118,6 +121,9 @@
A924245DDDA4C6A59BE15567 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
AA64A368BBF950CD36C4F495 /* SkillListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillListView.swift; sourceTree = "<group>"; };
B1F8AA042D5D8A9F92F35CA3 /* SchemaVersions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaVersions.swift; sourceTree = "<group>"; };
B36ECE462F7C93F9004D01D0 /* SymlinkTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymlinkTarget.swift; sourceTree = "<group>"; };
B36ECE482F7C9418004D01D0 /* SymlinkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymlinkService.swift; sourceTree = "<group>"; };
B36ECE4A2F7C944D004D01D0 /* VendorLinkingPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VendorLinkingPanel.swift; sourceTree = "<group>"; };
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 = "<group>"; };
C9AFE1AF02B0F8057791C2A9 /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -253,6 +259,7 @@
3D23D43EC788E51ED1C265FE /* StoreBootstrap.swift */,
12C5CD1627D4EFF58AEF4F71 /* TemplateManager.swift */,
3B8985D8816557B264BF0AD8 /* ACP */,
B36ECE482F7C9418004D01D0 /* SymlinkService.swift */,
);
path = Services;
sourceTree = "<group>";
Expand All @@ -269,6 +276,7 @@
27D6ED655F951177D2152351 /* Skill.swift */,
5487D9027B97F7E9EE9A1F4A /* ToolSource.swift */,
181FD0056E54DEB3957FB4DC /* WizardTemplate.swift */,
B36ECE462F7C93F9004D01D0 /* SymlinkTarget.swift */,
);
path = Models;
sourceTree = "<group>";
Expand Down Expand Up @@ -303,6 +311,7 @@
31B4DB87833D5160A11E39C9 /* SkillEditorView.swift */,
8C812515FD8DC222B89745F2 /* SkillMetadataBar.swift */,
77E332B9970E6E3D00DCFBA3 /* SkillPreviewView.swift */,
B36ECE4A2F7C944D004D01D0 /* VendorLinkingPanel.swift */,
);
path = Detail;
sourceTree = "<group>";
Expand Down Expand Up @@ -359,8 +368,6 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
};
};
buildConfigurationList = 719777DE0F769FDB488A0FDB /* Build configuration list for PBXProject "Chops" */;
developmentRegion = en;
Expand Down Expand Up @@ -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 */,
Expand All @@ -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 */,
Expand Down
2 changes: 1 addition & 1 deletion Chops/App/ChopsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Chops/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand All @@ -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 {
Expand Down
13 changes: 0 additions & 13 deletions Chops/Models/ChopsSettings.swift
Original file line number Diff line number Diff line change
@@ -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") }
Expand Down
157 changes: 152 additions & 5 deletions Chops/Models/SchemaVersions.swift
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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)]
}
}
11 changes: 11 additions & 0 deletions Chops/Models/SymlinkTarget.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading