681 lines
28 KiB
Swift
681 lines
28 KiB
Swift
//
|
|
// AgentSkillsView.swift
|
|
// oAI
|
|
//
|
|
// Modal for managing SKILL.md-style agent skills (opened via /skills command)
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
|
// Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public
|
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
/// Wrapper so .sheet(item:) always gets a fresh identity, avoiding the timing bug
|
|
/// where the sheet captures state before editingSkill is set.
|
|
private struct SkillEditContext: Identifiable {
|
|
let id = UUID()
|
|
let skill: AgentSkill? // nil → new, non-nil → edit
|
|
}
|
|
|
|
struct AgentSkillsView: View {
|
|
@Environment(\.dismiss) var dismiss
|
|
@Bindable private var settings = SettingsService.shared
|
|
|
|
@State private var editContext: SkillEditContext? = nil
|
|
@State private var statusMessage: String? = nil
|
|
|
|
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Header
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Label("Agent Skills", systemImage: "brain")
|
|
.font(.system(size: 18, weight: .bold))
|
|
if activeCount > 0 {
|
|
Text("\(activeCount) active — injected into every conversation")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
Button { dismiss() } label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.title2).foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.keyboardShortcut(.escape, modifiers: [])
|
|
}
|
|
.padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 12)
|
|
|
|
// Toolbar
|
|
HStack(spacing: 10) {
|
|
Button { editContext = SkillEditContext(skill: nil) } label: {
|
|
Label("New Skill", systemImage: "plus")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button { importSkills() } label: {
|
|
Label("Import", systemImage: "square.and.arrow.down")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button { exportAll() } label: {
|
|
Label("Export All", systemImage: "square.and.arrow.up")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(settings.agentSkills.isEmpty)
|
|
|
|
Spacer()
|
|
|
|
if let msg = statusMessage {
|
|
Text(msg).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 24).padding(.bottom, 12)
|
|
|
|
Divider()
|
|
|
|
if settings.agentSkills.isEmpty {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "brain")
|
|
.font(.system(size: 40)).foregroundStyle(.tertiary)
|
|
Text("No skills yet")
|
|
.font(.title3).foregroundStyle(.secondary)
|
|
Text("Skills are markdown instruction files that teach the AI how to behave. Active skills are automatically injected into the system prompt.")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center).frame(maxWidth: 380)
|
|
Text("You can import any SKILL.md file from skill0.io or write your own.")
|
|
.font(.caption).foregroundStyle(.tertiary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
|
|
} else {
|
|
List {
|
|
ForEach(settings.agentSkills) { skill in
|
|
AgentSkillRow(
|
|
skill: skill,
|
|
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
|
onEdit: { editContext = SkillEditContext(skill: skill) },
|
|
onExport: { exportOne(skill) },
|
|
onDelete: { settings.deleteAgentSkill(id: skill.id) }
|
|
)
|
|
}
|
|
}
|
|
.listStyle(.inset(alternatesRowBackgrounds: true))
|
|
}
|
|
|
|
Divider()
|
|
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "info.circle").foregroundStyle(.purple).font(.callout)
|
|
Text("Active skills are appended to the system prompt. Toggle them per-skill to control what the AI knows.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("Done") { dismiss() }
|
|
.buttonStyle(.borderedProminent)
|
|
.keyboardShortcut(.return, modifiers: [])
|
|
}
|
|
.padding(.horizontal, 24).padding(.vertical, 12)
|
|
}
|
|
.frame(minWidth: 620, idealWidth: 700, minHeight: 480, idealHeight: 600)
|
|
.sheet(item: $editContext) { ctx in
|
|
AgentSkillEditorSheet(skill: ctx.skill) { saved in
|
|
if ctx.skill != nil { settings.updateAgentSkill(saved) }
|
|
else { settings.addAgentSkill(saved) }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Import / Export
|
|
|
|
private func importSkills() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowsMultipleSelection = true
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowedContentTypes = [
|
|
.plainText,
|
|
UTType(filenameExtension: "md") ?? .plainText,
|
|
.zip
|
|
]
|
|
panel.message = "Select SKILL.md or .zip skill bundles to import"
|
|
guard panel.runModal() == .OK else { return }
|
|
|
|
var imported = 0
|
|
for url in panel.urls {
|
|
let ext = url.pathExtension.lowercased()
|
|
if ext == "zip" {
|
|
if importZip(url) { imported += 1 }
|
|
} else {
|
|
if importMarkdown(url) { imported += 1 }
|
|
}
|
|
}
|
|
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
|
|
}
|
|
|
|
@discardableResult
|
|
private func importMarkdown(_ url: URL) -> Bool {
|
|
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return false }
|
|
let name = skillName(from: content, fallback: url.deletingPathExtension().lastPathComponent)
|
|
let description = skillDescription(from: content)
|
|
let skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true)
|
|
if let existing = settings.agentSkills.first(where: { $0.name == name }) {
|
|
var updated = skill; updated.id = existing.id
|
|
settings.updateAgentSkill(updated)
|
|
} else {
|
|
settings.addAgentSkill(skill)
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
private func importZip(_ zipURL: URL) -> Bool {
|
|
let tmpDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: tmpDir) }
|
|
|
|
unzip(zipURL, to: tmpDir)
|
|
|
|
// Recursively enumerate all files (zip may contain a subdirectory)
|
|
let allFiles = recursiveFiles(in: tmpDir)
|
|
|
|
// Prefer skill.md by name, fall back to any .md
|
|
guard let mdURL = allFiles.first(where: { $0.lastPathComponent.lowercased() == "skill.md" })
|
|
?? allFiles.first(where: { $0.pathExtension.lowercased() == "md" }),
|
|
let content = try? String(contentsOf: mdURL, encoding: .utf8)
|
|
else { return false }
|
|
|
|
let name = skillName(from: content, fallback: zipURL.deletingPathExtension().lastPathComponent)
|
|
let description = skillDescription(from: content)
|
|
|
|
// Find or create skill
|
|
var skill: AgentSkill
|
|
if let existing = settings.agentSkills.first(where: { $0.name == name }) {
|
|
skill = AgentSkill(id: existing.id, name: name, skillDescription: description,
|
|
content: content, isActive: existing.isActive,
|
|
createdAt: existing.createdAt, updatedAt: Date())
|
|
settings.updateAgentSkill(skill)
|
|
} else {
|
|
skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true)
|
|
settings.addAgentSkill(skill)
|
|
}
|
|
|
|
// Copy all non-skill-md files to skill directory (flatten hierarchy)
|
|
let dataFiles = allFiles.filter { $0 != mdURL }
|
|
if !dataFiles.isEmpty {
|
|
AgentSkillFilesService.shared.ensureDirectory(for: skill.id)
|
|
for file in dataFiles {
|
|
try? AgentSkillFilesService.shared.addFile(from: file, to: skill.id)
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/// Recursively list all regular files under a directory
|
|
private func recursiveFiles(in directory: URL) -> [URL] {
|
|
guard let enumerator = FileManager.default.enumerator(
|
|
at: directory,
|
|
includingPropertiesForKeys: [.isRegularFileKey],
|
|
options: [.skipsHiddenFiles])
|
|
else { return [] }
|
|
return (enumerator.allObjects as? [URL] ?? []).filter {
|
|
(try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
|
|
}
|
|
}
|
|
|
|
private func exportAll() {
|
|
let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
|
var exported = 0
|
|
for skill in settings.agentSkills {
|
|
let safeName = skill.name.lowercased()
|
|
.components(separatedBy: .whitespaces).joined(separator: "-")
|
|
let files = AgentSkillFilesService.shared.listFiles(for: skill.id)
|
|
if files.isEmpty {
|
|
let url = downloadsURL.appendingPathComponent(safeName + ".md")
|
|
try? skill.content.write(to: url, atomically: true, encoding: .utf8)
|
|
} else {
|
|
let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip")
|
|
exportAsZip(skill: skill, files: files, to: zipURL)
|
|
}
|
|
exported += 1
|
|
}
|
|
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
|
|
}
|
|
|
|
private func exportOne(_ skill: AgentSkill) {
|
|
let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
|
let safeName = skill.name.lowercased()
|
|
.components(separatedBy: .whitespaces).joined(separator: "-")
|
|
let files = AgentSkillFilesService.shared.listFiles(for: skill.id)
|
|
|
|
if files.isEmpty {
|
|
let url = downloadsURL.appendingPathComponent(safeName + ".md")
|
|
try? skill.content.write(to: url, atomically: true, encoding: .utf8)
|
|
show("Exported \(safeName).md")
|
|
} else {
|
|
let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip")
|
|
exportAsZip(skill: skill, files: files, to: zipURL)
|
|
show("Exported \(safeName).zip")
|
|
}
|
|
}
|
|
|
|
private func exportAsZip(skill: AgentSkill, files: [URL], to zipURL: URL) {
|
|
let tmp = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: tmp) }
|
|
|
|
try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
|
try? skill.content.write(to: tmp.appendingPathComponent("skill.md"),
|
|
atomically: true, encoding: .utf8)
|
|
for f in files {
|
|
try? FileManager.default.copyItem(at: f, to: tmp.appendingPathComponent(f.lastPathComponent))
|
|
}
|
|
zip(directory: tmp, to: zipURL)
|
|
}
|
|
|
|
// MARK: - zip / unzip helpers (use system binaries, always present on macOS)
|
|
|
|
private func unzip(_ zipURL: URL, to destDir: URL) {
|
|
try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true)
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
|
process.arguments = ["-o", zipURL.path, "-d", destDir.path]
|
|
try? process.run(); process.waitUntilExit()
|
|
}
|
|
|
|
private func zip(directory: URL, to destZip: URL) {
|
|
if FileManager.default.fileExists(atPath: destZip.path) {
|
|
try? FileManager.default.removeItem(at: destZip)
|
|
}
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
|
process.currentDirectoryURL = directory
|
|
process.arguments = ["-r", destZip.path, "."]
|
|
try? process.run(); process.waitUntilExit()
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Extract skill name from first # heading, fallback to filename
|
|
private func skillName(from content: String, fallback: String) -> String {
|
|
for line in content.components(separatedBy: .newlines) {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
if trimmed.hasPrefix("# ") {
|
|
return String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
}
|
|
return fallback.isEmpty ? "Untitled Skill" : fallback
|
|
}
|
|
|
|
/// Extract first non-heading, non-empty line as description
|
|
private func skillDescription(from content: String) -> String {
|
|
for line in content.components(separatedBy: .newlines) {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
if !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
|
return String(trimmed.prefix(120))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
private func show(_ text: String) {
|
|
statusMessage = text
|
|
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
|
|
}
|
|
}
|
|
|
|
// MARK: - Agent Skill Row
|
|
|
|
private struct AgentSkillRow: View {
|
|
let skill: AgentSkill
|
|
let onToggle: () -> Void
|
|
let onEdit: () -> Void
|
|
let onExport: () -> Void
|
|
let onDelete: () -> Void
|
|
|
|
private var fileCount: Int {
|
|
AgentSkillFilesService.shared.listFiles(for: skill.id).count
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// Active toggle
|
|
Toggle("", isOn: Binding(get: { skill.isActive }, set: { _ in onToggle() }))
|
|
.labelsHidden()
|
|
.help(skill.isActive ? "Skill is active — click to deactivate" : "Skill is inactive — click to activate")
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(spacing: 6) {
|
|
Text(skill.name)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
if skill.isActive {
|
|
Text("active")
|
|
.font(.caption2).foregroundStyle(.white)
|
|
.padding(.horizontal, 5).padding(.vertical, 2)
|
|
.background(.purple, in: Capsule())
|
|
}
|
|
}
|
|
Text(skill.resolvedDescription)
|
|
.font(.callout).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// File count badge
|
|
if fileCount > 0 {
|
|
Label("\(fileCount) file\(fileCount == 1 ? "" : "s")", systemImage: "doc")
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
.padding(.horizontal, 6).padding(.vertical, 3)
|
|
.background(.blue.opacity(0.1), in: Capsule())
|
|
}
|
|
|
|
Text("\(skill.content.count) chars")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
.padding(.horizontal, 6).padding(.vertical, 3)
|
|
.background(.secondary.opacity(0.1), in: Capsule())
|
|
|
|
Button(action: onEdit) { Text("Edit").font(.caption) }
|
|
.buttonStyle(.bordered).controlSize(.small)
|
|
|
|
Button(action: onExport) { Image(systemName: "square.and.arrow.up") }
|
|
.buttonStyle(.bordered).controlSize(.small)
|
|
.help(fileCount > 0 ? "Export as .zip" : "Export as .md")
|
|
|
|
Button(role: .destructive, action: onDelete) { Image(systemName: "trash") }
|
|
.buttonStyle(.bordered).controlSize(.small).help("Delete skill")
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
// MARK: - Agent Skills Tab Content (embedded in SettingsView)
|
|
|
|
struct AgentSkillsTabContent: View {
|
|
@Bindable private var settings = SettingsService.shared
|
|
|
|
@State private var editContext: SkillEditContext? = nil
|
|
@State private var statusMessage: String? = nil
|
|
|
|
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
// Intro
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "brain").foregroundStyle(.purple).font(.title3)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Agent Skills")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
Text("Markdown instruction files injected into the system prompt. Compatible with SKILL.md format.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(.purple.opacity(0.07), in: RoundedRectangle(cornerRadius: 8))
|
|
|
|
if activeCount > 0 {
|
|
Label("\(activeCount) skill\(activeCount == 1 ? "" : "s") active — appended to every system prompt", systemImage: "checkmark.circle.fill")
|
|
.font(.caption).foregroundStyle(.green)
|
|
}
|
|
|
|
// Toolbar
|
|
HStack(spacing: 10) {
|
|
Button { editContext = SkillEditContext(skill: nil) } label: {
|
|
Label("New Skill", systemImage: "plus")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button { importSkills() } label: {
|
|
Label("Import", systemImage: "square.and.arrow.down")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button { exportAll() } label: {
|
|
Label("Export All", systemImage: "square.and.arrow.up")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(settings.agentSkills.isEmpty)
|
|
|
|
Spacer()
|
|
if let msg = statusMessage {
|
|
Text(msg).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
if settings.agentSkills.isEmpty {
|
|
HStack {
|
|
Spacer()
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "brain")
|
|
.font(.system(size: 32)).foregroundStyle(.tertiary)
|
|
Text("No skills yet — click New Skill or Import to get started.")
|
|
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 32)
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(settings.agentSkills.enumerated()), id: \.element.id) { idx, skill in
|
|
AgentSkillRow(
|
|
skill: skill,
|
|
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
|
onEdit: { editContext = SkillEditContext(skill: skill) },
|
|
onExport: { exportOne(skill) },
|
|
onDelete: { settings.deleteAgentSkill(id: skill.id) }
|
|
)
|
|
if idx < settings.agentSkills.count - 1 { Divider() }
|
|
}
|
|
}
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 10))
|
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
|
|
}
|
|
}
|
|
.sheet(item: $editContext) { ctx in
|
|
AgentSkillEditorSheet(skill: ctx.skill) { saved in
|
|
if ctx.skill != nil { settings.updateAgentSkill(saved) }
|
|
else { settings.addAgentSkill(saved) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func importSkills() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowsMultipleSelection = true; panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowedContentTypes = [
|
|
.plainText,
|
|
UTType(filenameExtension: "md") ?? .plainText,
|
|
.zip
|
|
]
|
|
panel.message = "Select SKILL.md or .zip skill bundles to import"
|
|
guard panel.runModal() == .OK else { return }
|
|
var imported = 0
|
|
for url in panel.urls {
|
|
let ext = url.pathExtension.lowercased()
|
|
if ext == "zip" {
|
|
if importZip(url) { imported += 1 }
|
|
} else {
|
|
if importMarkdown(url) { imported += 1 }
|
|
}
|
|
}
|
|
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
|
|
}
|
|
|
|
@discardableResult
|
|
private func importMarkdown(_ url: URL) -> Bool {
|
|
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return false }
|
|
let name = skillName(from: content, fallback: url.deletingPathExtension().lastPathComponent)
|
|
let description = skillDescription(from: content)
|
|
let skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true)
|
|
if let existing = settings.agentSkills.first(where: { $0.name == name }) {
|
|
var updated = skill; updated.id = existing.id; settings.updateAgentSkill(updated)
|
|
} else {
|
|
settings.addAgentSkill(skill)
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
private func importZip(_ zipURL: URL) -> Bool {
|
|
let tmpDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: tmpDir) }
|
|
|
|
unzip(zipURL, to: tmpDir)
|
|
|
|
// Recursively enumerate all files (zip may contain a subdirectory)
|
|
let allFiles = recursiveFiles(in: tmpDir)
|
|
|
|
guard let mdURL = allFiles.first(where: { $0.lastPathComponent.lowercased() == "skill.md" })
|
|
?? allFiles.first(where: { $0.pathExtension.lowercased() == "md" }),
|
|
let content = try? String(contentsOf: mdURL, encoding: .utf8)
|
|
else { return false }
|
|
|
|
let name = skillName(from: content, fallback: zipURL.deletingPathExtension().lastPathComponent)
|
|
let description = skillDescription(from: content)
|
|
|
|
var skill: AgentSkill
|
|
if let existing = settings.agentSkills.first(where: { $0.name == name }) {
|
|
skill = AgentSkill(id: existing.id, name: name, skillDescription: description,
|
|
content: content, isActive: existing.isActive,
|
|
createdAt: existing.createdAt, updatedAt: Date())
|
|
settings.updateAgentSkill(skill)
|
|
} else {
|
|
skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true)
|
|
settings.addAgentSkill(skill)
|
|
}
|
|
|
|
let dataFiles = allFiles.filter { $0 != mdURL }
|
|
if !dataFiles.isEmpty {
|
|
AgentSkillFilesService.shared.ensureDirectory(for: skill.id)
|
|
for file in dataFiles {
|
|
try? AgentSkillFilesService.shared.addFile(from: file, to: skill.id)
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/// Recursively list all regular files under a directory
|
|
private func recursiveFiles(in directory: URL) -> [URL] {
|
|
guard let enumerator = FileManager.default.enumerator(
|
|
at: directory,
|
|
includingPropertiesForKeys: [.isRegularFileKey],
|
|
options: [.skipsHiddenFiles])
|
|
else { return [] }
|
|
return (enumerator.allObjects as? [URL] ?? []).filter {
|
|
(try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
|
|
}
|
|
}
|
|
|
|
private func exportAll() {
|
|
let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
|
var exported = 0
|
|
for skill in settings.agentSkills {
|
|
let safeName = skill.name.lowercased()
|
|
.components(separatedBy: .whitespaces).joined(separator: "-")
|
|
let files = AgentSkillFilesService.shared.listFiles(for: skill.id)
|
|
if files.isEmpty {
|
|
let url = downloadsURL.appendingPathComponent(safeName + ".md")
|
|
try? skill.content.write(to: url, atomically: true, encoding: .utf8)
|
|
} else {
|
|
let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip")
|
|
exportAsZip(skill: skill, files: files, to: zipURL)
|
|
}
|
|
exported += 1
|
|
}
|
|
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
|
|
}
|
|
|
|
private func exportOne(_ skill: AgentSkill) {
|
|
let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
|
let safeName = skill.name.lowercased()
|
|
.components(separatedBy: .whitespaces).joined(separator: "-")
|
|
let files = AgentSkillFilesService.shared.listFiles(for: skill.id)
|
|
|
|
if files.isEmpty {
|
|
let url = downloadsURL.appendingPathComponent(safeName + ".md")
|
|
try? skill.content.write(to: url, atomically: true, encoding: .utf8)
|
|
show("Exported \(safeName).md")
|
|
} else {
|
|
let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip")
|
|
exportAsZip(skill: skill, files: files, to: zipURL)
|
|
show("Exported \(safeName).zip")
|
|
}
|
|
}
|
|
|
|
private func exportAsZip(skill: AgentSkill, files: [URL], to zipURL: URL) {
|
|
let tmp = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: tmp) }
|
|
|
|
try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
|
try? skill.content.write(to: tmp.appendingPathComponent("skill.md"),
|
|
atomically: true, encoding: .utf8)
|
|
for f in files {
|
|
try? FileManager.default.copyItem(at: f, to: tmp.appendingPathComponent(f.lastPathComponent))
|
|
}
|
|
zip(directory: tmp, to: zipURL)
|
|
}
|
|
|
|
private func unzip(_ zipURL: URL, to destDir: URL) {
|
|
try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true)
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
|
process.arguments = ["-o", zipURL.path, "-d", destDir.path]
|
|
try? process.run(); process.waitUntilExit()
|
|
}
|
|
|
|
private func zip(directory: URL, to destZip: URL) {
|
|
if FileManager.default.fileExists(atPath: destZip.path) {
|
|
try? FileManager.default.removeItem(at: destZip)
|
|
}
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
|
process.currentDirectoryURL = directory
|
|
process.arguments = ["-r", destZip.path, "."]
|
|
try? process.run(); process.waitUntilExit()
|
|
}
|
|
|
|
private func skillName(from content: String, fallback: String) -> String {
|
|
for line in content.components(separatedBy: .newlines) {
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
if t.hasPrefix("# ") { return String(t.dropFirst(2)).trimmingCharacters(in: .whitespaces) }
|
|
}
|
|
return fallback.isEmpty ? "Untitled Skill" : fallback
|
|
}
|
|
|
|
private func skillDescription(from content: String) -> String {
|
|
for line in content.components(separatedBy: .newlines) {
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
if !t.isEmpty && !t.hasPrefix("#") { return String(t.prefix(120)) }
|
|
}
|
|
return ""
|
|
}
|
|
|
|
private func show(_ text: String) {
|
|
statusMessage = text
|
|
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
|
|
}
|
|
}
|
|
|
|
#Preview { AgentSkillsView() }
|