Files
scarf/scarf/scarf/Core/Services/ProjectScaffolder.swift
T
Alan Wizemann 4efd84c119 feat(projects,cron): new project wizard + keychain env mirror + #75 fix
Three coordinated additions to the project surface:

1. New Project from Scratch wizard. Toolbar entry that scaffolds a
   Scarf-standard project skeleton (`<project>/.scarf/dashboard.json`
   placeholder + `AGENTS.md` marker block), registers it, opens an ACP
   chat session in the project's cwd, and auto-sends a kickoff prompt
   that activates the bundled `scarf-template-author` skill. The skill
   drives the substantive setup conversationally — widgets, optional
   config schema, optional cron, AGENTS.md content.

2. Keychain secrets mirror into ~/.hermes/.env. Cron jobs can now
   reference Keychain-backed config values via env vars named
   `SCARF_<UPPER_SLUG>_<UPPER_FIELDKEY>`. Hermes reloads .env per cron
   tick (cron/scheduler.py:897-903), so credential rotation is free.
   Source of truth stays in the Keychain — config.json keeps
   `keychain://` URIs unchanged. Mirror runs at install, post-install
   Configuration save, uninstall, "Remove from List", and on app
   launch (reconcileAll). Mode 0600 on `.env` enforced by
   LocalTransport's existing `.env` heuristic.

3. Configuration form layout recursion fix (issue #75). Per-stage
   frame sizes on `ConfigEditorSheet` triggered
   `_NSDetectedLayoutRecursion` for projects with manifest.json.
   Stabilized the outer frame at the editing stage's intrinsic size so
   transitions only swap content, never resize the container.

New services:
- `ProjectScaffolder` (Mac) — bare-shell project + AGENTS.md marker
- `SkillBootstrapService` (Mac) — copies bundled skills into ~/.hermes/skills/
- `KeychainEnvMirror` (Mac) — splice/unmirror/reconcileAll over ~/.hermes/.env
- `SecretsEnvBlock` (ScarfCore) — pure marker-block helpers

Bundled skill `scarf-template-author` v1.1.0 ships in
`Resources/BuiltinSkills.bundle/`; SkillBootstrapService copies it
into `~/.hermes/skills/scarf-template-author/` on launch (idempotent +
version-gated). The skill grew a "Using secrets in cron prompts"
section documenting the env-var convention.

Migration: launch reconciler auto-populates .env on first v2.8 launch.
Users with cron prompts authored against the old (broken) pattern need
to update them to use $SCARF_… references — see release notes.

Tests:
- SecretsEnvBlockTests: 24/24 (`swift test --filter SecretsEnvBlock`)
- KeychainEnvMirrorTests: 11/11 (`xcodebuild ... -only-testing:scarfTests/KeychainEnvMirror`)

The idempotent-mirror test caught a real bug: applyBlock's replace
path consumed the trailing newline from blockRange but didn't restore
it, breaking the no-op-when-unchanged contract that the launch
reconciler relies on. Fixed.

v2.8 RELEASE_NOTES.md committed but no release cut yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:44:23 +02:00

281 lines
11 KiB
Swift

import Foundation
import os
import ScarfCore
/// Creates a Scarf-standard project from scratch a minimal directory
/// tree with a placeholder `dashboard.json` + a stub `AGENTS.md` (just
/// the Scarf-managed marker block) and registers it. The
/// counterpart to `ProjectTemplateInstaller`: that one synthesizes a
/// project from a `.scarftemplate` plan; this one synthesizes a bare
/// shell that the agent fills in conversationally via the
/// `scarf-template-author` skill.
///
/// **Why this exists.** `AddProjectSheet` registers an existing
/// directory but doesn't create one; `ProjectTemplateInstaller`
/// creates a directory but only from a manifest. Neither produces a
/// fresh, hand-rolled, Scarf-standard project.
///
/// **What lands on disk.**
/// ```
/// <parent>/<slug>/
/// .scarf/
/// dashboard.json # placeholder single text widget
/// AGENTS.md # marker block only; refresh() populates it
/// ```
///
/// No `manifest.json` scratch projects don't have a config schema,
/// so the Configuration sheet correctly degrades when missing.
/// No `template.lock.json` there's no template install to undo.
struct ProjectScaffolder: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectScaffolder")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Public
/// Scaffold a new project at `<parentDir>/<slug>` and register it.
/// On any failure after the project dir is created, deletes the
/// dir and rethrows so the user isn't left with a half-created
/// project that doesn't show in the sidebar.
nonisolated func scaffold(
name: String,
slug: String,
parentDir: String,
description: String?
) throws -> ProjectEntry {
let cleanedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanedSlug = slug.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanedParent = Self.normalizeDirectoryPath(parentDir)
let cleanedDescription = description?.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanedName.isEmpty else { throw ProjectScaffolderError.invalidName }
guard Self.isValidSlug(cleanedSlug) else {
throw ProjectScaffolderError.invalidSlug(cleanedSlug)
}
let transport = context.makeTransport()
// 1. Validate parent + collisions.
guard transport.fileExists(cleanedParent) else {
throw ProjectScaffolderError.parentDirMissing(cleanedParent)
}
let projectDir = cleanedParent + "/" + cleanedSlug
if transport.fileExists(projectDir) {
throw ProjectScaffolderError.projectDirExists(projectDir)
}
let dashboardService = ProjectDashboardService(context: context)
let registry = dashboardService.loadRegistry()
if registry.projects.contains(where: { $0.name == cleanedName }) {
throw ProjectScaffolderError.nameAlreadyRegistered(cleanedName)
}
if registry.projects.contains(where: { $0.path == projectDir }) {
throw ProjectScaffolderError.pathAlreadyRegistered(projectDir)
}
// 2. Create project + .scarf/ dir.
do {
try transport.createDirectory(projectDir + "/.scarf")
} catch {
// No partial state to clean up createDirectory is the
// first write. Surface the error directly.
throw ProjectScaffolderError.createFailed(error.localizedDescription)
}
// From here on, on any failure, we clean up the project dir
// before rethrowing so the user can retry without bumping
// into the collision check.
do {
// 3. Write placeholder dashboard.json.
let dashboardData = try Self.makePlaceholderDashboard(
name: cleanedName,
description: cleanedDescription
)
try transport.writeFile(
projectDir + "/.scarf/dashboard.json",
data: dashboardData
)
// 4. Write AGENTS.md with just the marker block the
// refresh() call below populates between the markers.
let agentsMd = ProjectContextBlock.beginMarker + "\n"
+ ProjectContextBlock.endMarker + "\n"
try transport.writeFile(
projectDir + "/AGENTS.md",
data: Data(agentsMd.utf8)
)
// 5. Register the project.
let entry = ProjectEntry(name: cleanedName, path: projectDir)
var nextRegistry = registry
nextRegistry.projects.append(entry)
try dashboardService.saveRegistry(nextRegistry)
// 6. Populate the marker block with project identity.
// Non-fatal the chat handoff calls refresh() again
// anyway via startACPSession's project-prep step. Logging
// the failure here is enough.
do {
try ProjectAgentContextService(context: context).refresh(for: entry)
} catch {
Self.logger.warning(
"couldn't populate AGENTS.md marker block for \(entry.name, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
}
Self.logger.info(
"scaffolded project \(cleanedName, privacy: .public) at \(projectDir, privacy: .public)"
)
return entry
} catch {
// Roll back the project dir. `LocalTransport.removeFile` is
// backed by `FileManager.removeItem` which is recursive for
// directories, so this cleans the dir + its `.scarf/` child
// in one call on local. SSH's `rm -f` is non-recursive, but
// the wizard's NSOpenPanel only browses local filesystems
// anyway remote scaffolding isn't a supported entry point
// today. Best-effort either way: a failed cleanup logs but
// doesn't mask the original failure.
do {
try transport.removeFile(projectDir)
} catch {
Self.logger.warning(
"cleanup after scaffold failure left files at \(projectDir, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
}
throw error
}
}
// MARK: - Slug helpers
/// Default slug derivation from a project's display name. Used
/// by the wizard to pre-fill the editable "Folder Name" field.
/// Lowercases, replaces whitespace runs with `-`, strips any
/// character outside `[a-z0-9-]`, collapses `--` `-`, trims
/// leading/trailing `-`.
nonisolated static func suggestedSlug(from name: String) -> String {
let lowered = name.lowercased()
var slug = ""
var lastWasDash = false
for scalar in lowered.unicodeScalars {
let c = Character(scalar)
if c.isLetter || c.isNumber {
slug.append(c)
lastWasDash = false
} else if c.isWhitespace || c == "-" || c == "_" || c == "." {
if !lastWasDash && !slug.isEmpty {
slug.append("-")
lastWasDash = true
}
}
// Other characters (emoji, punctuation) silently dropped.
}
// Trim trailing dash.
while slug.hasSuffix("-") { slug.removeLast() }
return slug
}
/// Validate a slug: at least one character, every character in
/// `[a-z0-9-]`, no leading/trailing `-`, no consecutive `--`.
nonisolated static func isValidSlug(_ slug: String) -> Bool {
guard !slug.isEmpty else { return false }
guard !slug.hasPrefix("-"), !slug.hasSuffix("-") else { return false }
if slug.contains("--") { return false }
for scalar in slug.unicodeScalars {
let c = Character(scalar)
let isLowerAlpha = ("a"..."z").contains(c)
let isDigit = ("0"..."9").contains(c)
let isDash = c == "-"
if !(isLowerAlpha || isDigit || isDash) {
return false
}
}
return true
}
// MARK: - Dashboard placeholder
nonisolated static func makePlaceholderDashboard(
name: String,
description: String?
) throws -> Data {
let placeholderWidget = DashboardWidget(
type: "text",
title: "Configure this project",
content: """
This project was just scaffolded by Scarf. \
Chat with the agent to add widgets, schedule jobs, and write \
instructions for future sessions. The `scarf-template-author` \
skill knows the project standard end-to-end.
""",
format: "markdown"
)
let section = DashboardSection(
title: "Setup",
columns: 1,
widgets: [placeholderWidget]
)
let dashboard = ProjectDashboard(
version: 1,
title: name,
description: description?.isEmpty == false ? description : nil,
updatedAt: ISO8601DateFormatter().string(from: Date()),
theme: nil,
sections: [section]
)
// Pretty-print so the file is readable when the user
// opens it in an editor, matches the dashboard.json
// shape produced by template installs.
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(dashboard)
}
// MARK: - Helpers
/// Strip a single trailing `/` from a path so subsequent
/// `parent + "/" + slug` joins don't produce a `//` segment.
nonisolated static func normalizeDirectoryPath(_ path: String) -> String {
var p = path.trimmingCharacters(in: .whitespacesAndNewlines)
while p.count > 1 && p.hasSuffix("/") {
p.removeLast()
}
return p
}
}
enum ProjectScaffolderError: Error, LocalizedError {
case invalidName
case invalidSlug(String)
case parentDirMissing(String)
case projectDirExists(String)
case nameAlreadyRegistered(String)
case pathAlreadyRegistered(String)
case createFailed(String)
var errorDescription: String? {
switch self {
case .invalidName:
return "Project name can't be empty."
case .invalidSlug(let s):
return "Folder name \"\(s)\" must be lowercase letters, numbers, and dashes only — no leading/trailing or doubled dashes."
case .parentDirMissing(let p):
return "Parent directory doesn't exist: \(p)"
case .projectDirExists(let p):
return "A folder already exists at \(p). Pick a different name."
case .nameAlreadyRegistered(let n):
return "A project named \"\(n)\" is already registered."
case .pathAlreadyRegistered(let p):
return "A project at \(p) is already registered."
case .createFailed(let msg):
return "Couldn't create the project directory: \(msg)"
}
}
}