mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
4efd84c119
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>
281 lines
11 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|