Files
scarf/scarf/scarf/Features/Templates/ViewModels/TemplateExporterViewModel.swift
T
Alan Wizemann c800b93804 feat: project templates v1 (install + uninstall + export + URL handler)
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>
2026-04-23 00:35:46 +02:00

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
}
}