mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
c800b93804
Shareable `.scarftemplate` bundle format lets users package a project's dashboard, cross-agent AGENTS.md, optional per-agent instruction shims, optional namespaced skills, optional tagged cron jobs, and an optional memory appendix into a single zip that anyone can install with one click. Core: - Bundle format + manifest schema v1 (template.json with contents claim cross-checked against zip entries to prevent hidden files). - ProjectTemplateService inspects + validates + builds an install plan. - ProjectTemplateInstaller executes plans with transport-routed I/O so the v1 local-only flow extends cleanly to remote ServerContexts later. - ProjectTemplateExporter builds bundles from existing projects with user-selected skills + cron jobs. - ProjectTemplateUninstaller reverses installs using template.lock.json. Only lock-tracked files are removed; user-added files are preserved. UI: - Templates menu in Projects toolbar: Install from File, Install from URL, Export as Template. - Preview-and-confirm sheets for install, uninstall, and export with full diff of what will be written/removed before anything runs. - Right-click context menu on project list + dashboard header button for uninstall (only shown when template.lock.json exists). Deep link + file associations: - scarf:// URL scheme registered; onOpenURL in scarfApp.swift routes scarf://install?url=https://... and file:// URLs for .scarftemplate files to the install sheet. - Custom UTType com.scarf.template registered so Finder shows the file with a Scarf icon and double-click opens the install preview. - Cold-launch race fix: .task picks up any URL staged on the router before the onChange observer was installed. Safety: - Never writes to config.yaml, auth.json, sessions, or credentials. - Cron jobs ship paused with a [tmpl:<id>] name prefix. - Skills install to a namespaced ~/.hermes/skills/templates/<slug>/ dir so they never collide with user-authored skills. - Memory appendix is wrapped in scarf-template:<id>:begin/end markers for clean removal during uninstall. - Download cap: 50 MB for URL-fetched templates, enforced on the actual on-disk file size after download so chunked transfers can't bypass it. Tests: 22 tests in 7 suites cover manifest parsing, claim verification, URL routing (scarf:// + file://), end-to-end install and uninstall against a minimal bundle (projects registry is snapshotted + restored), user-added file preservation, and exporter round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
132 lines
4.7 KiB
Swift
132 lines
4.7 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
/// Drives the template export sheet. Holds form state for the author-facing
|
|
/// fields (id, name, version, description, …) and the selection of skills
|
|
/// and cron jobs to include, then builds and writes the `.scarftemplate` on
|
|
/// confirm.
|
|
@Observable
|
|
@MainActor
|
|
final class TemplateExporterViewModel {
|
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateExporterViewModel")
|
|
|
|
enum Stage: Sendable {
|
|
case idle
|
|
case exporting
|
|
case succeeded(path: String)
|
|
case failed(String)
|
|
}
|
|
|
|
let context: ServerContext
|
|
let project: ProjectEntry
|
|
private let exporter: ProjectTemplateExporter
|
|
|
|
init(context: ServerContext, project: ProjectEntry) {
|
|
self.context = context
|
|
self.project = project
|
|
self.exporter = ProjectTemplateExporter(context: context)
|
|
|
|
self.templateName = project.name
|
|
self.templateId = "you/\(ProjectTemplateExporter.slugify(project.name))"
|
|
}
|
|
|
|
// Form fields
|
|
var templateId: String
|
|
var templateName: String
|
|
var templateVersion: String = "1.0.0"
|
|
var templateDescription: String = ""
|
|
var authorName: String = ""
|
|
var authorURL: String = ""
|
|
var category: String = ""
|
|
var tags: String = ""
|
|
var includeSkillIds: Set<String> = []
|
|
var includeCronJobIds: Set<String> = []
|
|
var memoryAppendix: String = ""
|
|
|
|
// Derived: what the author can pick from
|
|
var availableSkills: [HermesSkill] = []
|
|
var availableCronJobs: [HermesCronJob] = []
|
|
|
|
var stage: Stage = .idle
|
|
|
|
func load() {
|
|
let ctx = context
|
|
Task.detached { [weak self] in
|
|
let service = HermesFileService(context: ctx)
|
|
let skills = service.loadSkills().flatMap(\.skills)
|
|
let jobs = service.loadCronJobs()
|
|
await MainActor.run { [weak self] in
|
|
self?.availableSkills = skills
|
|
self?.availableCronJobs = jobs
|
|
}
|
|
}
|
|
}
|
|
|
|
func previewPlan() -> ProjectTemplateExporter.ExportPlan {
|
|
exporter.previewPlan(for: currentInputs)
|
|
}
|
|
|
|
/// Kick off the export, writing to `outputPath`. The caller is
|
|
/// responsible for bouncing the user through an `NSSavePanel` to get
|
|
/// that path.
|
|
func export(to outputPath: String) {
|
|
stage = .exporting
|
|
let exporter = exporter
|
|
let inputs = currentInputs
|
|
Task.detached { [weak self] in
|
|
do {
|
|
try exporter.export(inputs: inputs, outputZipPath: outputPath)
|
|
await MainActor.run { [weak self] in
|
|
self?.stage = .succeeded(path: outputPath)
|
|
}
|
|
} catch {
|
|
await MainActor.run { [weak self] in
|
|
self?.stage = .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private var currentInputs: ProjectTemplateExporter.ExportInputs {
|
|
let parsedTags = tags
|
|
.split(separator: ",")
|
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
.filter { !$0.isEmpty }
|
|
let trimmedAppendix = memoryAppendix.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return ProjectTemplateExporter.ExportInputs(
|
|
project: project,
|
|
templateId: templateId.trimmingCharacters(in: .whitespaces),
|
|
templateName: templateName.trimmingCharacters(in: .whitespaces),
|
|
templateVersion: templateVersion.trimmingCharacters(in: .whitespaces),
|
|
description: templateDescription.trimmingCharacters(in: .whitespaces),
|
|
authorName: authorName.isEmpty ? nil : authorName,
|
|
authorUrl: authorURL.isEmpty ? nil : authorURL,
|
|
category: category.isEmpty ? nil : category,
|
|
tags: parsedTags,
|
|
includeSkillIds: Array(includeSkillIds),
|
|
includeCronJobIds: Array(includeCronJobIds),
|
|
memoryAppendix: trimmedAppendix.isEmpty ? nil : trimmedAppendix
|
|
)
|
|
}
|
|
}
|
|
|
|
extension ProjectTemplateExporter {
|
|
/// Lowercase-and-hyphenate a human name into something safe for a
|
|
/// template id suffix. Only used to seed the default id in the export
|
|
/// form — the author can overwrite it.
|
|
nonisolated static func slugify(_ raw: String) -> String {
|
|
let lower = raw.lowercased()
|
|
let mapped = lower.unicodeScalars.map { scalar -> Character in
|
|
let c = Character(scalar)
|
|
if c.isLetter || c.isNumber { return c }
|
|
return "-"
|
|
}
|
|
let collapsed = String(mapped)
|
|
.split(separator: "-", omittingEmptySubsequences: true)
|
|
.joined(separator: "-")
|
|
return collapsed.isEmpty ? "template" : collapsed
|
|
}
|
|
}
|