Files
oai-swift/oAI/Views/Screens/AgentSkillsView.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() }