// // AgentSkillsView.swift // oAI // // Modal for managing SKILL.md-style agent skills (opened via /skills command) // 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() }