mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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>
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
## What's in 2.8.0
|
||||||
|
|
||||||
|
A focused release on **two project-side gaps** that came up in real use:
|
||||||
|
|
||||||
|
1. **Cron jobs can finally use Keychain-backed secrets.** Previously, cron prompts that referenced a `secret`-typed config field got the literal `keychain://...` URI back when reading `config.json`, producing 401s. v2.8 mirrors resolved values into `~/.hermes/.env` under namespaced env-var names, and the bundled skill teaches the agent to reach for them via `$SCARF_<SLUG>_<FIELD>`. Hermes already reloads `.env` per cron tick, so credential rotation is free. ([#75](https://github.com/awizemann/scarf/issues/75)-adjacent.)
|
||||||
|
2. **New Project from Scratch wizard.** A new toolbar entry that scaffolds a Scarf-standard project skeleton (`<project>/.scarf/dashboard.json` + AGENTS.md marker block), registers it, and hands off to a chat session that activates the bundled `scarf-template-author` skill. The skill drives the substantive setup conversationally — widgets, optional config schema, optional cron — and writes the final files itself.
|
||||||
|
3. **Bug fix: Configuration form layout recursion** ([#75](https://github.com/awizemann/scarf/issues/75)). Per-stage frame sizes on `ConfigEditorSheet` produced `_NSDetectedLayoutRecursion` for projects whose form transitioned between stages with different intrinsic heights. Fixed by stabilizing the outer frame at the editing stage's intrinsic size so transitions only swap content, never resize the container.
|
||||||
|
|
||||||
|
### Cron + Keychain — `$SCARF_<SLUG>_<FIELD>` env vars
|
||||||
|
|
||||||
|
Until v2.8, the documented (and broken) pattern for cron prompts that needed a secret looked like:
|
||||||
|
|
||||||
|
> *"Read `api_token` from `<project>/.scarf/config.json` and call the API with that as a bearer token."*
|
||||||
|
|
||||||
|
Hermes loaded `config.json`, saw `{"api_token": "keychain://com.scarf.template.foo/api_token:abc123"}`, and forwarded the URI as the literal token. The provider returned 401. Hermes has no `keychain://` resolver and doesn't substitute env vars into prompt text — both are intentional design points on the Hermes side.
|
||||||
|
|
||||||
|
**v2.8 leans on what Hermes does have**: [`cron/scheduler.py:897-903`](https://github.com/hermes-agent) reloads `~/.hermes/.env` fresh on every cron tick. Anything in that file becomes a real `os.environ` entry the agent can read via the terminal or `code_exec` tool. Scarf now mirrors a project's resolved Keychain values into `~/.hermes/.env` under a marker-bounded block keyed by the template's slug:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# scarf-secrets:begin local-news-aggregator
|
||||||
|
SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN=actual-value
|
||||||
|
SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL=https://example.com/feed
|
||||||
|
# scarf-secrets:end local-news-aggregator
|
||||||
|
```
|
||||||
|
|
||||||
|
The mirror runs at every state-change point: install, post-install Configuration save, uninstall, "Remove from List", and on app launch (reconciliation pass over registered projects). Source of truth stays in the Keychain — `config.json` keeps `keychain://` URIs unchanged. Mode 0600 enforced on `~/.hermes/.env`, same as the existing `ANTHROPIC_API_KEY` and friends.
|
||||||
|
|
||||||
|
**Cron prompts now reference these env vars directly:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Daily news digest",
|
||||||
|
"schedule": "0 9 * * *",
|
||||||
|
"prompt": "Use the terminal: curl -sS -H \"Authorization: Bearer $SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN\" \"$SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL\" -o {{PROJECT_DIR}}/.scarf/feed.xml. Then summarise the top 5 items into {{PROJECT_DIR}}/.scarf/digest.md."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Naming convention: `SCARF_<UPPER_SLUG>_<UPPER_FIELDKEY>`. Both halves uppercased; non-alphanumerics fold to `_`; leading/trailing/consecutive underscores trimmed. Stable across releases.
|
||||||
|
|
||||||
|
#### Migration — existing projects
|
||||||
|
|
||||||
|
**Automatic part — no action needed.** On the first launch of v2.8, Scarf walks the project registry and writes a managed block per schemaful project into `~/.hermes/.env`. Idempotent — projects whose values haven't changed produce no write.
|
||||||
|
|
||||||
|
**You may need to fix cron prompts you wrote against the old (broken) pattern.** If you have an existing cron job that references a Keychain-backed secret, the prompt will still produce 401s until you update it to use the env-var convention. Two ways:
|
||||||
|
|
||||||
|
1. **Manually**, via Scarf's Cron sidebar — open the job, edit the prompt to reference `$SCARF_<UPPER_SLUG>_<UPPER_FIELDKEY>` via the terminal or `code_exec` tool. The bundled `scarf-template-author` skill (now v1.1.0) documents the convention with worked examples.
|
||||||
|
2. **Via the agent.** With the project loaded in chat, ask: *"Update my Local News cron job's prompt to use the new env var convention."* The skill knows the convention and the project's slug; it'll edit the cron job for you.
|
||||||
|
|
||||||
|
We considered an automatic prompt-rewriter on upgrade, but cron prompts are free-form and a heuristic rewrite has a non-trivial chance of breaking custom phrasings. The documented + agent-assisted path is safer for v2.8; we'll revisit a "scan + fix" UI in v2.9 if the docs path doesn't catch users.
|
||||||
|
|
||||||
|
#### What about `.env` rotation?
|
||||||
|
|
||||||
|
User rotates a secret in Scarf's Configuration sheet → new value lands in the Keychain (via the form's commit step) → Scarf re-mirrors to `~/.hermes/.env` → next cron tick (Hermes reloads `.env` per tick) sees the new value. **No cron-job edit needed.**
|
||||||
|
|
||||||
|
### New Project from Scratch wizard
|
||||||
|
|
||||||
|
Three project entry points now coexist:
|
||||||
|
|
||||||
|
- **Browse Catalog… / Install from File / Install from URL** — install a `.scarftemplate` bundle (existing).
|
||||||
|
- **Add Project (sidebar `+`)** — register an existing directory by name + path (existing).
|
||||||
|
- **New Project from Scratch…** — _new_, scaffolds a fresh Scarf-standard project skeleton and hands off to chat for the rest.
|
||||||
|
|
||||||
|
The wizard asks for project name, folder name (auto-derived from the name but editable), parent directory, and an optional one-liner about what the project is for. On commit, [`ProjectScaffolder`](../../scarf/scarf/Core/Services/ProjectScaffolder.swift) creates `<parent>/<slug>/.scarf/dashboard.json` (one placeholder text widget) plus a stub `AGENTS.md` (just the Scarf-managed marker block — `ProjectAgentContextService` populates between the markers on first chat). The project is registered in the sidebar before the chat opens.
|
||||||
|
|
||||||
|
A new ACP session opens with the project's path as `cwd`, and Scarf auto-sends a kickoff prompt that activates the bundled `scarf-template-author` skill — *"I just created a new Scarf project at /Users/.../local-news. Use the scarf-template-author skill to walk me through configuring it."* The skill drives the substantive setup conversationally: choosing widgets, designing an optional config schema, optionally registering cron jobs, writing AGENTS.md template content. It writes the final `dashboard.json` / `manifest.json` / `AGENTS.md` content in the project directory itself.
|
||||||
|
|
||||||
|
The wizard intentionally stays minimal — every "configure" decision lives in the chat handoff, not in the form, because the agent does it better than a multi-step form.
|
||||||
|
|
||||||
|
#### Skill bootstrap
|
||||||
|
|
||||||
|
The wizard depends on the `scarf-template-author` skill being installed at `~/.hermes/skills/scarf-template-author/`. v2.8 ships a [bundled copy of the skill](../../scarf/scarf/Resources/BuiltinSkills.bundle/scarf-template-author/SKILL.md) inside `Scarf.app/Contents/Resources/BuiltinSkills.bundle/` and copies it into `~/.hermes/skills/` on app launch — idempotent + version-gated, so a user-edited newer destination stays untouched. No Template Author template install required.
|
||||||
|
|
||||||
|
### Bug fix — Configuration form layout recursion (#75)
|
||||||
|
|
||||||
|
Per-stage frames on `ConfigEditorSheet` (`.loading: 320pt`, `.editing: 480pt`, `.succeeded / .notConfigurable / .failed: 280pt`) caused AppKit to relayout the sheet container mid-flight on stage transitions, producing `_NSDetectedLayoutRecursion` and a blank form. Fixed by stabilizing the outer VStack frame at `560 x 480` (matching `TemplateConfigSheet`'s intrinsic size) so transitions only swap content, never resize the container.
|
||||||
|
|
||||||
|
### Schema mirrors
|
||||||
|
|
||||||
|
`SecretsEnvBlock` (the marker-block helper for `~/.hermes/.env`) lives in ScarfCore alongside `ProjectContextBlock` (the AGENTS.md helper). Both follow the same shape — `applyBlock` / `removeBlock` operating on bounded marker regions, byte-identity preservation outside the block, idempotent re-apply.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- macOS 14+ (unchanged).
|
||||||
|
- Hermes target: still **v2026.4.30 (v0.12.0)**. No new Hermes capability gates added.
|
||||||
|
- **Mac-only.** ScarfGo (iOS) doesn't have a Keychain-env-mirror story today; the wizard is also Mac-only for v1.
|
||||||
|
- Existing `~/.hermes/.env` content is preserved byte-identically — Scarf only writes inside its `# scarf-secrets:begin <slug>` / `# scarf-secrets:end <slug>` regions.
|
||||||
|
- Existing `.scarftemplate` bundles install unchanged. Catalog manifest schemaVersion stays at 1/2/3 — no bump.
|
||||||
|
|
||||||
|
### What's deferred
|
||||||
|
|
||||||
|
- **Automatic cron-prompt rewriting on upgrade.** Heuristic rewrites of free-form prompts are risky — see "Migration" above for the docs-and-agent path that ships in v2.8. Revisit a "scan + fix" UI in v2.9 if real users miss the migration.
|
||||||
|
- **iOS New Project wizard + iOS Keychain-env mirror.** ScarfGo's project surface is read-only today; the wizard's chat-handoff pattern depends on Mac-only ACP plumbing.
|
||||||
|
- **Resolution-layer unit tests.** The `KeychainEnvMirror.mirror(project:)` path that resolves `keychain://` URIs would either pollute the user's login keychain on test runs or require a mock-keychain abstraction; the splice-only seam (`mirror(slug:entries:envPath:)`) is fully unit-tested instead, with the resolution path covered by manual end-to-end verification.
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure block-splice logic for Scarf's managed regions inside
|
||||||
|
/// `~/.hermes/.env`. Each registered project that has at least one
|
||||||
|
/// resolved secret carries one block, bounded by:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # scarf-secrets:begin <slug>
|
||||||
|
/// SCARF_<UPPER_SLUG>_<UPPER_FIELDKEY>=<value>
|
||||||
|
/// ...
|
||||||
|
/// # scarf-secrets:end <slug>
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The Mac wraps this in `KeychainEnvMirror` (Keychain-aware, atomic
|
||||||
|
/// write, mode-0600 enforcement). This file handles only the marker
|
||||||
|
/// contract + key naming + splice — logic that's testable in isolation
|
||||||
|
/// against an in-memory string and shared across hosts.
|
||||||
|
///
|
||||||
|
/// **Why `~/.hermes/.env`.** Hermes's cron scheduler reloads that file
|
||||||
|
/// fresh on every tick (cron/scheduler.py:897-903), so values become
|
||||||
|
/// available to the agent's tool-invoked subprocesses (terminal,
|
||||||
|
/// code_exec) without any Hermes-side change. Per-project `.env` is
|
||||||
|
/// not loaded at cron time today, hence we mirror into the global
|
||||||
|
/// file with namespaced keys.
|
||||||
|
///
|
||||||
|
/// **Marker contract is load-bearing.** Both markers carry the slug on
|
||||||
|
/// the same line so a multi-project file is parsed deterministically
|
||||||
|
/// and one project's edits can't disturb another's block.
|
||||||
|
public enum SecretsEnvBlock {
|
||||||
|
|
||||||
|
/// Stable across releases — entries on disk reference these
|
||||||
|
/// strings and a marker change would orphan every existing block.
|
||||||
|
public static let beginMarkerPrefix = "# scarf-secrets:begin "
|
||||||
|
public static let endMarkerPrefix = "# scarf-secrets:end "
|
||||||
|
|
||||||
|
// MARK: - Key naming
|
||||||
|
|
||||||
|
/// Build the env-var name for a (slug, fieldKey) pair. Uppercases,
|
||||||
|
/// replaces every non-alphanumeric character with `_`, prefixes
|
||||||
|
/// `SCARF_`. Stable: rotating a value writes to the same key.
|
||||||
|
public static func envKeyName(slug: String, fieldKey: String) -> String {
|
||||||
|
"SCARF_" + sanitize(slug) + "_" + sanitize(fieldKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitize(_ s: String) -> String {
|
||||||
|
var out = ""
|
||||||
|
for scalar in s.unicodeScalars {
|
||||||
|
let c = Character(scalar)
|
||||||
|
let isAlpha = ("A"..."Z").contains(c) || ("a"..."z").contains(c)
|
||||||
|
let isDigit = ("0"..."9").contains(c)
|
||||||
|
if isAlpha || isDigit {
|
||||||
|
out.append(Character(scalar.properties.uppercaseMapping))
|
||||||
|
} else {
|
||||||
|
out.append("_")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collapse runs of underscores so `foo--bar` doesn't become
|
||||||
|
// `FOO__BAR` (two underscores trips dotenv parsers more often
|
||||||
|
// than one). Trim leading/trailing underscores too.
|
||||||
|
while out.contains("__") {
|
||||||
|
out = out.replacingOccurrences(of: "__", with: "_")
|
||||||
|
}
|
||||||
|
while out.hasPrefix("_") { out.removeFirst() }
|
||||||
|
while out.hasSuffix("_") { out.removeLast() }
|
||||||
|
return out.isEmpty ? "UNNAMED" : out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Block render
|
||||||
|
|
||||||
|
/// Render the bounded block for a single project. Empty `entries`
|
||||||
|
/// produces an empty string — callers should treat that as
|
||||||
|
/// "remove the project's block" rather than "write an empty
|
||||||
|
/// block." `entries` are emitted in stable sort order so two
|
||||||
|
/// runs with the same input produce byte-identical output.
|
||||||
|
public static func renderBlock(
|
||||||
|
slug: String,
|
||||||
|
entries: [(key: String, value: String)]
|
||||||
|
) -> String {
|
||||||
|
guard !entries.isEmpty else { return "" }
|
||||||
|
let sorted = entries.sorted { $0.key < $1.key }
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.append(beginMarkerPrefix + slug)
|
||||||
|
for entry in sorted {
|
||||||
|
lines.append("\(entry.key)=\(escape(entry.value))")
|
||||||
|
}
|
||||||
|
lines.append(endMarkerPrefix + slug)
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quote values that would confuse python-dotenv: anything with
|
||||||
|
/// whitespace, `#`, `$`, or quote characters. Single quotes around
|
||||||
|
/// the value are dotenv-canonical and preserve `$`-style
|
||||||
|
/// references literally (no shell expansion). Backslash-escape
|
||||||
|
/// embedded single quotes by closing+reopening: `'foo'\''bar'`.
|
||||||
|
private static func escape(_ value: String) -> String {
|
||||||
|
let needsQuoting = value.contains(where: { c in
|
||||||
|
c.isWhitespace || c == "#" || c == "$" || c == "\"" || c == "'" || c == "\\"
|
||||||
|
})
|
||||||
|
if !needsQuoting { return value }
|
||||||
|
let escaped = value.replacingOccurrences(of: "'", with: "'\\''")
|
||||||
|
return "'" + escaped + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Splice
|
||||||
|
|
||||||
|
/// Splice `block` (already-rendered, with markers) into `existing`
|
||||||
|
/// for the named `slug`. Three cases:
|
||||||
|
/// 1. `existing` already has a `# scarf-secrets:begin <slug>` /
|
||||||
|
/// `# scarf-secrets:end <slug>` pair → replace the inclusive
|
||||||
|
/// region. Other slugs' blocks are preserved byte-identically.
|
||||||
|
/// 2. `existing` has no block for this slug → append after a
|
||||||
|
/// blank line at the end of file.
|
||||||
|
/// 3. `block` is empty → behave like `removeBlock`.
|
||||||
|
///
|
||||||
|
/// Idempotent: feeding the output of one call back through
|
||||||
|
/// `applyBlock` with the same inputs produces the same string.
|
||||||
|
public static func applyBlock(
|
||||||
|
_ block: String,
|
||||||
|
forSlug slug: String,
|
||||||
|
to existing: String
|
||||||
|
) -> String {
|
||||||
|
if block.isEmpty {
|
||||||
|
return removeBlock(forSlug: slug, from: existing)
|
||||||
|
}
|
||||||
|
if let region = blockRange(forSlug: slug, in: existing) {
|
||||||
|
// Replace the inclusive region. `blockRange` covers the
|
||||||
|
// begin marker line through the end marker line plus any
|
||||||
|
// trailing newline so `removeBlock` doesn't leave a
|
||||||
|
// dangling blank line — but for `applyBlock`, we need to
|
||||||
|
// re-emit that trailing newline so a round-trip
|
||||||
|
// (mirror→read→mirror with identical entries) produces
|
||||||
|
// byte-identical output. Without this, the second mirror
|
||||||
|
// would write a file shorter by one newline byte and
|
||||||
|
// bump the file's mtime, breaking the
|
||||||
|
// no-op-when-unchanged contract that the launch
|
||||||
|
// reconciler relies on.
|
||||||
|
let before = String(existing[existing.startIndex..<region.lowerBound])
|
||||||
|
let after = String(existing[region.upperBound..<existing.endIndex])
|
||||||
|
// Restore a trailing newline only when the consumed region
|
||||||
|
// had one (i.e., the block wasn't at end-of-string with
|
||||||
|
// no terminating newline).
|
||||||
|
let consumedTrailingNewline = region.upperBound > existing.startIndex
|
||||||
|
&& existing[existing.index(before: region.upperBound)] == "\n"
|
||||||
|
let separator = consumedTrailingNewline ? "\n" : ""
|
||||||
|
return before + block + separator + after
|
||||||
|
}
|
||||||
|
// Append at end of file, separated from preceding content by
|
||||||
|
// a blank line. Empty-or-whitespace files just become the
|
||||||
|
// block plus a trailing newline.
|
||||||
|
let trimmed = existing.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
return block + "\n"
|
||||||
|
}
|
||||||
|
let normalized = trimmingRightNewlines(existing)
|
||||||
|
return normalized + "\n\n" + block + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the bounded block for `slug` from `existing`. No-op when
|
||||||
|
/// absent. Preserves all other slugs' blocks and user-authored
|
||||||
|
/// content byte-identically.
|
||||||
|
public static func removeBlock(forSlug slug: String, from existing: String) -> String {
|
||||||
|
guard let region = blockRange(forSlug: slug, in: existing) else {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let before = String(existing[existing.startIndex..<region.lowerBound])
|
||||||
|
let after = String(existing[region.upperBound..<existing.endIndex])
|
||||||
|
// Collapse the blank line we may have inserted at append time
|
||||||
|
// so repeated install/uninstall cycles don't accumulate
|
||||||
|
// blank lines. Specifically: if `before` ends in `\n\n` and
|
||||||
|
// `after` starts with `\n`, drop one of the newlines.
|
||||||
|
var trimmedBefore = before
|
||||||
|
var trimmedAfter = after
|
||||||
|
if trimmedBefore.hasSuffix("\n\n") && trimmedAfter.hasPrefix("\n") {
|
||||||
|
trimmedAfter.removeFirst()
|
||||||
|
} else if trimmedBefore.hasSuffix("\n\n") {
|
||||||
|
trimmedBefore.removeLast()
|
||||||
|
}
|
||||||
|
return trimmedBefore + trimmedAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Range scan
|
||||||
|
|
||||||
|
/// Locate the inclusive character range covering one project's
|
||||||
|
/// block, including a trailing newline if present so removal
|
||||||
|
/// doesn't leave a dangling empty line. Returns nil when the
|
||||||
|
/// block isn't present.
|
||||||
|
private static func blockRange(
|
||||||
|
forSlug slug: String,
|
||||||
|
in existing: String
|
||||||
|
) -> Range<String.Index>? {
|
||||||
|
let beginLine = beginMarkerPrefix + slug
|
||||||
|
let endLine = endMarkerPrefix + slug
|
||||||
|
// Match begin marker as a full line — guard against false
|
||||||
|
// positives where a slug is a prefix of another slug
|
||||||
|
// (e.g. "foo" vs "foo-bar"). Require the marker to be
|
||||||
|
// followed immediately by `\n` or end-of-string.
|
||||||
|
guard let beginRange = lineRange(of: beginLine, in: existing) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Search for the matching end marker AFTER the begin range —
|
||||||
|
// can't use a leading-anchor scan because there may be other
|
||||||
|
// slugs' end markers between begin and the matching end.
|
||||||
|
let searchStart = beginRange.upperBound
|
||||||
|
guard let endRange = lineRange(of: endLine, in: existing, startingAt: searchStart) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Include a trailing newline if the file has one immediately
|
||||||
|
// after the end marker — keeps the file shape clean across
|
||||||
|
// remove operations.
|
||||||
|
var upper = endRange.upperBound
|
||||||
|
if upper < existing.endIndex, existing[upper] == "\n" {
|
||||||
|
upper = existing.index(after: upper)
|
||||||
|
}
|
||||||
|
return beginRange.lowerBound..<upper
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a substring that appears as a complete line — bounded by
|
||||||
|
/// start-of-string or `\n` on the left and `\n` or end-of-string
|
||||||
|
/// on the right. Returns the range of the substring itself, not
|
||||||
|
/// including any surrounding newlines.
|
||||||
|
private static func lineRange(
|
||||||
|
of needle: String,
|
||||||
|
in haystack: String,
|
||||||
|
startingAt start: String.Index? = nil
|
||||||
|
) -> Range<String.Index>? {
|
||||||
|
var searchStart = start ?? haystack.startIndex
|
||||||
|
while searchStart <= haystack.endIndex {
|
||||||
|
guard let range = haystack.range(of: needle, range: searchStart..<haystack.endIndex) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let leftOK = range.lowerBound == haystack.startIndex
|
||||||
|
|| haystack[haystack.index(before: range.lowerBound)] == "\n"
|
||||||
|
let rightOK = range.upperBound == haystack.endIndex
|
||||||
|
|| haystack[range.upperBound] == "\n"
|
||||||
|
if leftOK && rightOK {
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
// Advance past this false positive and keep searching.
|
||||||
|
searchStart = range.upperBound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func trimmingRightNewlines(_ s: String) -> String {
|
||||||
|
var result = s
|
||||||
|
while let last = result.last, last.isNewline {
|
||||||
|
result.removeLast()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ScarfCore
|
||||||
|
|
||||||
|
/// Pure-logic tests for the marker-block splice helpers in
|
||||||
|
/// `SecretsEnvBlock`. No Keychain access, no filesystem I/O — just
|
||||||
|
/// strings in, strings out. The Mac-side `KeychainEnvMirror` wraps
|
||||||
|
/// these with Keychain resolution + transport-aware writes; that
|
||||||
|
/// integration is covered separately in `KeychainEnvMirrorTests`.
|
||||||
|
@Suite("SecretsEnvBlock")
|
||||||
|
struct SecretsEnvBlockTests {
|
||||||
|
|
||||||
|
// MARK: - envKeyName
|
||||||
|
|
||||||
|
@Test func envKeyNameStandardCase() {
|
||||||
|
#expect(
|
||||||
|
SecretsEnvBlock.envKeyName(slug: "local-news", fieldKey: "api_token")
|
||||||
|
== "SCARF_LOCAL_NEWS_API_TOKEN"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envKeyNameNonAlphanumericChars() {
|
||||||
|
// Dashes, underscores, dots, spaces all fold to single underscores.
|
||||||
|
#expect(
|
||||||
|
SecretsEnvBlock.envKeyName(slug: "foo.bar baz", fieldKey: "x-y-z")
|
||||||
|
== "SCARF_FOO_BAR_BAZ_X_Y_Z"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envKeyNameRunsCollapse() {
|
||||||
|
// Three consecutive special chars produce a single underscore,
|
||||||
|
// not three.
|
||||||
|
#expect(
|
||||||
|
SecretsEnvBlock.envKeyName(slug: "foo---bar", fieldKey: "a__b")
|
||||||
|
== "SCARF_FOO_BAR_A_B"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envKeyNameLeadingTrailingTrim() {
|
||||||
|
// Leading/trailing dashes on the slug shouldn't produce
|
||||||
|
// SCARF__... or trailing _ in the result.
|
||||||
|
let key = SecretsEnvBlock.envKeyName(slug: "-foo-", fieldKey: "-bar-")
|
||||||
|
#expect(key == "SCARF_FOO_BAR")
|
||||||
|
#expect(!key.hasSuffix("_"))
|
||||||
|
#expect(!key.contains("__"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envKeyNameAllSymbolsFallsBackToUnnamed() {
|
||||||
|
// Pathological input — slug is all special chars. Sanitizer
|
||||||
|
// emits `UNNAMED` rather than the empty string, so the env
|
||||||
|
// var name is still parseable.
|
||||||
|
#expect(
|
||||||
|
SecretsEnvBlock.envKeyName(slug: "!!!", fieldKey: "...")
|
||||||
|
== "SCARF_UNNAMED_UNNAMED"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - renderBlock
|
||||||
|
|
||||||
|
@Test func renderBlockEmptyEntriesReturnsEmpty() {
|
||||||
|
// Empty entries is the documented "use removeBlock instead"
|
||||||
|
// sentinel — renderBlock should not produce a block with
|
||||||
|
// dangling markers.
|
||||||
|
let result = SecretsEnvBlock.renderBlock(slug: "foo", entries: [])
|
||||||
|
#expect(result.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renderBlockSortsEntries() {
|
||||||
|
// Output is deterministic regardless of input order so two
|
||||||
|
// runs with the same logical content produce byte-identical
|
||||||
|
// bytes — load-bearing for the no-op-when-unchanged check
|
||||||
|
// in the mirror's writeIfChanged.
|
||||||
|
let aFirst = SecretsEnvBlock.renderBlock(
|
||||||
|
slug: "foo",
|
||||||
|
entries: [("ALPHA", "1"), ("BRAVO", "2")]
|
||||||
|
)
|
||||||
|
let bFirst = SecretsEnvBlock.renderBlock(
|
||||||
|
slug: "foo",
|
||||||
|
entries: [("BRAVO", "2"), ("ALPHA", "1")]
|
||||||
|
)
|
||||||
|
#expect(aFirst == bFirst)
|
||||||
|
// Sanity: ALPHA precedes BRAVO in the output regardless of
|
||||||
|
// insertion order.
|
||||||
|
let alphaIdx = aFirst.range(of: "ALPHA")
|
||||||
|
let bravoIdx = aFirst.range(of: "BRAVO")
|
||||||
|
#expect(alphaIdx != nil && bravoIdx != nil)
|
||||||
|
#expect(alphaIdx!.lowerBound < bravoIdx!.lowerBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renderBlockEmitsMarkersAroundEntries() {
|
||||||
|
let result = SecretsEnvBlock.renderBlock(
|
||||||
|
slug: "site-status-checker",
|
||||||
|
entries: [("SCARF_SITE_STATUS_CHECKER_TOKEN", "abc")]
|
||||||
|
)
|
||||||
|
#expect(result.hasPrefix("# scarf-secrets:begin site-status-checker"))
|
||||||
|
#expect(result.hasSuffix("# scarf-secrets:end site-status-checker"))
|
||||||
|
#expect(result.contains("SCARF_SITE_STATUS_CHECKER_TOKEN=abc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renderBlockQuotesValuesWithWhitespace() {
|
||||||
|
let result = SecretsEnvBlock.renderBlock(
|
||||||
|
slug: "x",
|
||||||
|
entries: [("KEY", "hello world")]
|
||||||
|
)
|
||||||
|
// Whitespace forces single-quoting (dotenv canonical) so the
|
||||||
|
// value survives shell expansion and dotenv parsing.
|
||||||
|
#expect(result.contains("KEY='hello world'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renderBlockQuotesValuesWithSpecialChars() {
|
||||||
|
let cases: [(input: String, mustContain: String)] = [
|
||||||
|
("a#b", "KEY='a#b'"), // # is dotenv comment marker
|
||||||
|
("a$b", "KEY='a$b'"), // $ is shell expansion
|
||||||
|
("a\"b", "KEY='a\"b'"), // " conflicts with double-quote literal
|
||||||
|
("a\\b", "KEY='a\\b'"), // backslash needs escaping
|
||||||
|
]
|
||||||
|
for (input, mustContain) in cases {
|
||||||
|
let result = SecretsEnvBlock.renderBlock(
|
||||||
|
slug: "x",
|
||||||
|
entries: [("KEY", input)]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
result.contains(mustContain),
|
||||||
|
"value '\(input)' produced wrong escaping: \(result)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renderBlockEscapesSingleQuotesViaCloseReopen() {
|
||||||
|
// A literal single quote inside a single-quoted string is
|
||||||
|
// dotenv-encoded as `'\''` (close, escape, reopen) — the
|
||||||
|
// canonical sh/dotenv pattern.
|
||||||
|
let result = SecretsEnvBlock.renderBlock(
|
||||||
|
slug: "x",
|
||||||
|
entries: [("KEY", "it's fine")]
|
||||||
|
)
|
||||||
|
#expect(result.contains("KEY='it'\\''s fine'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renderBlockLeavesPlainValuesUnquoted() {
|
||||||
|
// No-special-chars values stay unquoted — readability + matches
|
||||||
|
// the convention Hermes's existing ANTHROPIC_API_KEY entries
|
||||||
|
// follow.
|
||||||
|
let result = SecretsEnvBlock.renderBlock(
|
||||||
|
slug: "x",
|
||||||
|
entries: [("KEY", "abc-123_def")]
|
||||||
|
)
|
||||||
|
#expect(result.contains("\nKEY=abc-123_def\n"))
|
||||||
|
#expect(!result.contains("KEY='abc-123_def'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - applyBlock
|
||||||
|
|
||||||
|
@Test func applyBlockToEmptyFile() {
|
||||||
|
let block = sampleBlock(slug: "foo", entries: [("KEY", "value")])
|
||||||
|
let result = SecretsEnvBlock.applyBlock(block, forSlug: "foo", to: "")
|
||||||
|
#expect(result == block + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func applyBlockToWhitespaceOnlyFile() {
|
||||||
|
let block = sampleBlock(slug: "foo", entries: [("KEY", "value")])
|
||||||
|
let result = SecretsEnvBlock.applyBlock(block, forSlug: "foo", to: " \n \n")
|
||||||
|
// Whitespace-only treated like empty — block + newline, no
|
||||||
|
// attempt to preserve the leading whitespace.
|
||||||
|
#expect(result == block + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func applyBlockAppendsToFileWithUserContent() {
|
||||||
|
let existing = "ANTHROPIC_API_KEY=sk-test\nOPENAI_API_KEY=sk-other\n"
|
||||||
|
let block = sampleBlock(slug: "foo", entries: [("KEY", "value")])
|
||||||
|
let result = SecretsEnvBlock.applyBlock(block, forSlug: "foo", to: existing)
|
||||||
|
// User content is preserved at the top.
|
||||||
|
#expect(result.hasPrefix("ANTHROPIC_API_KEY=sk-test"))
|
||||||
|
#expect(result.contains("OPENAI_API_KEY=sk-other"))
|
||||||
|
// Block appended after a blank-line separator.
|
||||||
|
#expect(result.contains("OPENAI_API_KEY=sk-other\n\n# scarf-secrets:begin foo"))
|
||||||
|
// And ends with a trailing newline.
|
||||||
|
#expect(result.hasSuffix("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func applyBlockReplacesExistingBlockForSameSlug() {
|
||||||
|
let oldBlock = sampleBlock(slug: "foo", entries: [("KEY", "old")])
|
||||||
|
let newBlock = sampleBlock(slug: "foo", entries: [("KEY", "new")])
|
||||||
|
let existing = "USER_VAR=something\n\n" + oldBlock + "\n"
|
||||||
|
let result = SecretsEnvBlock.applyBlock(newBlock, forSlug: "foo", to: existing)
|
||||||
|
#expect(result.contains("KEY=new"))
|
||||||
|
#expect(!result.contains("KEY=old"))
|
||||||
|
// User content above the block is preserved.
|
||||||
|
#expect(result.contains("USER_VAR=something"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func applyBlockPreservesOtherSlugBlocks() {
|
||||||
|
// The most important invariant — multiple project blocks
|
||||||
|
// coexist in one file and editing one mustn't disturb the
|
||||||
|
// other.
|
||||||
|
let blockA = sampleBlock(slug: "alpha", entries: [("A_KEY", "1")])
|
||||||
|
let blockB = sampleBlock(slug: "bravo", entries: [("B_KEY", "2")])
|
||||||
|
let existing = blockA + "\n\n" + blockB + "\n"
|
||||||
|
let updatedA = sampleBlock(slug: "alpha", entries: [("A_KEY", "1-updated")])
|
||||||
|
let result = SecretsEnvBlock.applyBlock(updatedA, forSlug: "alpha", to: existing)
|
||||||
|
// A was updated.
|
||||||
|
#expect(result.contains("A_KEY=1-updated"))
|
||||||
|
#expect(!result.contains("A_KEY=1\n"))
|
||||||
|
// B is byte-identical.
|
||||||
|
#expect(result.contains(blockB))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func applyBlockIdempotent() {
|
||||||
|
// Applying the output of one call back through applyBlock
|
||||||
|
// with the same inputs produces the same string. Critical
|
||||||
|
// for the launch reconciler — a no-op pass shouldn't keep
|
||||||
|
// mutating the file.
|
||||||
|
let block = sampleBlock(slug: "foo", entries: [("KEY", "value")])
|
||||||
|
let existing = "USER_VAR=x\n"
|
||||||
|
let once = SecretsEnvBlock.applyBlock(block, forSlug: "foo", to: existing)
|
||||||
|
let twice = SecretsEnvBlock.applyBlock(block, forSlug: "foo", to: once)
|
||||||
|
#expect(once == twice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func applyBlockEmptyBlockBehavesLikeRemove() {
|
||||||
|
// Documented behaviour: passing an empty block is the same as
|
||||||
|
// calling removeBlock — the splice path uses this when a
|
||||||
|
// project's secrets are all cleared.
|
||||||
|
let block = sampleBlock(slug: "foo", entries: [("KEY", "value")])
|
||||||
|
let withBlock = "USER=x\n\n" + block + "\n"
|
||||||
|
let viaApply = SecretsEnvBlock.applyBlock("", forSlug: "foo", to: withBlock)
|
||||||
|
let viaRemove = SecretsEnvBlock.removeBlock(forSlug: "foo", from: withBlock)
|
||||||
|
#expect(viaApply == viaRemove)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - removeBlock
|
||||||
|
|
||||||
|
@Test func removeBlockNoOpWhenAbsent() {
|
||||||
|
let existing = "USER_VAR=hello\nOTHER=world\n"
|
||||||
|
let result = SecretsEnvBlock.removeBlock(forSlug: "foo", from: existing)
|
||||||
|
#expect(result == existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removeBlockStripsBlockOnly() {
|
||||||
|
let block = sampleBlock(slug: "foo", entries: [("KEY", "value")])
|
||||||
|
let existing = "USER_VAR=x\n\n" + block + "\n\nMORE_USER=y\n"
|
||||||
|
let result = SecretsEnvBlock.removeBlock(forSlug: "foo", from: existing)
|
||||||
|
#expect(!result.contains("scarf-secrets"))
|
||||||
|
#expect(result.contains("USER_VAR=x"))
|
||||||
|
#expect(result.contains("MORE_USER=y"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removeBlockCollapsesAppendedBlankLineSeparator() {
|
||||||
|
// Round-trip: append a block, then remove it. The blank line
|
||||||
|
// we inserted at append time should be absorbed so repeated
|
||||||
|
// install/uninstall cycles don't accumulate blank lines.
|
||||||
|
let block = sampleBlock(slug: "foo", entries: [("KEY", "value")])
|
||||||
|
let original = "USER_VAR=x\n"
|
||||||
|
let appended = SecretsEnvBlock.applyBlock(block, forSlug: "foo", to: original)
|
||||||
|
let removed = SecretsEnvBlock.removeBlock(forSlug: "foo", from: appended)
|
||||||
|
// Removed content should be very close to the original — at
|
||||||
|
// most one trailing newline difference. No accumulation of
|
||||||
|
// blank lines across the cycle.
|
||||||
|
#expect(removed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
== original.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Slug-prefix collision
|
||||||
|
|
||||||
|
@Test func slugPrefixCollisionIsolated() {
|
||||||
|
// A file with both `foo` and `foo-bar` blocks; editing `foo`
|
||||||
|
// must not match the `foo-bar` markers as a prefix-substring
|
||||||
|
// of the begin-line.
|
||||||
|
let blockShort = sampleBlock(slug: "foo", entries: [("SHORT", "1")])
|
||||||
|
let blockLong = sampleBlock(slug: "foo-bar", entries: [("LONG", "2")])
|
||||||
|
let existing = blockShort + "\n\n" + blockLong + "\n"
|
||||||
|
let updatedShort = sampleBlock(slug: "foo", entries: [("SHORT", "1-updated")])
|
||||||
|
let result = SecretsEnvBlock.applyBlock(updatedShort, forSlug: "foo", to: existing)
|
||||||
|
// Short was updated.
|
||||||
|
#expect(result.contains("SHORT=1-updated"))
|
||||||
|
#expect(!result.contains("SHORT=1\n"))
|
||||||
|
// Long block is byte-identical.
|
||||||
|
#expect(result.contains(blockLong))
|
||||||
|
// Both markers still present, exactly once each.
|
||||||
|
#expect(occurrences(of: "# scarf-secrets:begin foo\n", in: result) == 1)
|
||||||
|
#expect(occurrences(of: "# scarf-secrets:begin foo-bar\n", in: result) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removeBlockRespectsSlugPrefixIsolation() {
|
||||||
|
let blockShort = sampleBlock(slug: "foo", entries: [("SHORT", "1")])
|
||||||
|
let blockLong = sampleBlock(slug: "foo-bar", entries: [("LONG", "2")])
|
||||||
|
let existing = blockShort + "\n\n" + blockLong + "\n"
|
||||||
|
let result = SecretsEnvBlock.removeBlock(forSlug: "foo", from: existing)
|
||||||
|
// foo gone, foo-bar preserved byte-identically.
|
||||||
|
#expect(!result.contains("SHORT=1"))
|
||||||
|
#expect(result.contains(blockLong))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func sampleBlock(
|
||||||
|
slug: String,
|
||||||
|
entries: [(key: String, value: String)]
|
||||||
|
) -> String {
|
||||||
|
SecretsEnvBlock.renderBlock(slug: slug, entries: entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func occurrences(of needle: String, in haystack: String) -> Int {
|
||||||
|
var count = 0
|
||||||
|
var search = haystack.startIndex
|
||||||
|
while let range = haystack.range(of: needle, range: search..<haystack.endIndex) {
|
||||||
|
count += 1
|
||||||
|
search = range.upperBound
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
|
/// Mirrors a project's resolved Keychain secrets into a managed region
|
||||||
|
/// of `~/.hermes/.env` so Hermes cron jobs (and any other agent
|
||||||
|
/// process Hermes spawns) can use them via `os.environ`.
|
||||||
|
///
|
||||||
|
/// **Why this exists.** Hermes has no `keychain://` URI resolver. When
|
||||||
|
/// a cron prompt says *"read config.json, get values.api_token, call
|
||||||
|
/// the API,"* Hermes reads the literal `keychain://...` string and
|
||||||
|
/// forwards it as the token — producing 401s. By mirroring resolved
|
||||||
|
/// values into `~/.hermes/.env` (which the cron scheduler reloads
|
||||||
|
/// fresh on every tick at `cron/scheduler.py:897-903`), the agent can
|
||||||
|
/// reference them via shell expansion (`$SCARF_<SLUG>_<FIELD>`) when
|
||||||
|
/// it invokes the terminal or code_exec tool.
|
||||||
|
///
|
||||||
|
/// **Source of truth stays in the Keychain.** This service derives
|
||||||
|
/// content; it never accepts plaintext values from callers. config.json
|
||||||
|
/// continues to store `keychain://` URIs unchanged.
|
||||||
|
///
|
||||||
|
/// **Marker contract.** One block per project, slug-namespaced:
|
||||||
|
/// `# scarf-secrets:begin <slug>` / `# scarf-secrets:end <slug>`. The
|
||||||
|
/// splice logic lives in ScarfCore's `SecretsEnvBlock`. Other slugs'
|
||||||
|
/// blocks and user-authored content outside any block are preserved
|
||||||
|
/// byte-identically.
|
||||||
|
///
|
||||||
|
/// **Trust boundary.** Mode 0600 on `~/.hermes/.env` is enforced by
|
||||||
|
/// `LocalTransport.writeFile`'s heuristic for `.env` paths. Plaintext
|
||||||
|
/// on disk matches the existing trust model for `ANTHROPIC_API_KEY`
|
||||||
|
/// and other Hermes-side credentials in the same file.
|
||||||
|
struct KeychainEnvMirror: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "KeychainEnvMirror")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public
|
||||||
|
|
||||||
|
/// Resolve every `secret`-typed config field for `project` and
|
||||||
|
/// splice the result into `~/.hermes/.env` under a marker-bounded
|
||||||
|
/// block keyed by the template's slug. No-op when the project
|
||||||
|
/// has no cached manifest (schema-less project) or no secret
|
||||||
|
/// fields.
|
||||||
|
nonisolated func mirror(project: ProjectEntry) throws {
|
||||||
|
guard let resolved = try resolveSecrets(for: project) else {
|
||||||
|
// No manifest cache or no secret fields — nothing to mirror.
|
||||||
|
// Don't write an empty block; that would leave dangling
|
||||||
|
// markers if a project briefly had secrets and then dropped
|
||||||
|
// them. Use unmirror() in that path instead.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try mirror(
|
||||||
|
slug: resolved.slug,
|
||||||
|
entries: resolved.entries,
|
||||||
|
envPath: context.paths.envFile
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splice-only seam: takes pre-resolved entries and writes the
|
||||||
|
/// block to `envPath`. Used by `mirror(project:)` after Keychain
|
||||||
|
/// resolution; also exposed for unit tests that don't want to
|
||||||
|
/// touch the user's real Keychain or `~/.hermes/.env`.
|
||||||
|
///
|
||||||
|
/// - Empty `entries` removes the block (idempotent — no error
|
||||||
|
/// when block isn't there). This is the single sentinel for
|
||||||
|
/// "project briefly had secrets, no longer does."
|
||||||
|
/// - Path is checked for `.env`-suffix before writing so the
|
||||||
|
/// `LocalTransport` mode-0600 heuristic kicks in.
|
||||||
|
/// - No-op when the rewritten output equals the existing file —
|
||||||
|
/// avoids file-watcher churn from idempotent reconciles.
|
||||||
|
nonisolated func mirror(
|
||||||
|
slug: String,
|
||||||
|
entries: [(key: String, value: String)],
|
||||||
|
envPath: String
|
||||||
|
) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
if entries.isEmpty {
|
||||||
|
try unmirrorBlock(slug: slug, envPath: envPath, transport: transport)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let block = SecretsEnvBlock.renderBlock(slug: slug, entries: entries)
|
||||||
|
let existing = try readExisting(at: envPath, transport: transport)
|
||||||
|
let rewritten = SecretsEnvBlock.applyBlock(block, forSlug: slug, to: existing)
|
||||||
|
try writeIfChanged(path: envPath, existing: existing, rewritten: rewritten, transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the project's block from `~/.hermes/.env`. Reads the
|
||||||
|
/// project's cached manifest to recover its slug — the slug is
|
||||||
|
/// the only key the env file knows. When the manifest is absent
|
||||||
|
/// (uninstall path may have deleted it before we run), we fall
|
||||||
|
/// back to `derivedSlug(forProject:)`.
|
||||||
|
nonisolated func unmirror(project: ProjectEntry) throws {
|
||||||
|
let slug = cachedSlug(for: project) ?? Self.derivedSlug(forProject: project)
|
||||||
|
try unmirror(slug: slug, envPath: context.paths.envFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splice-only unmirror: strips the block for `slug` from `envPath`.
|
||||||
|
/// Symmetric with `mirror(slug:entries:envPath:)` — no Keychain
|
||||||
|
/// access, suitable for unit tests.
|
||||||
|
nonisolated func unmirror(slug: String, envPath: String) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
try unmirrorBlock(slug: slug, envPath: envPath, transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the project registry and call `mirror(project:)` on each
|
||||||
|
/// entry. Idempotent — projects whose blocks are already current
|
||||||
|
/// produce no write. Used at app launch to catch the case where
|
||||||
|
/// the user upgraded from a pre-mirror Scarf version.
|
||||||
|
nonisolated func reconcileAll() throws {
|
||||||
|
let registry = ProjectDashboardService(context: context).loadRegistry()
|
||||||
|
for project in registry.projects {
|
||||||
|
do {
|
||||||
|
try mirror(project: project)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning(
|
||||||
|
"reconcile failed for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Resolution
|
||||||
|
|
||||||
|
private struct ResolvedSecrets {
|
||||||
|
let slug: String
|
||||||
|
let entries: [(key: String, value: String)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the project's cached manifest + config, resolve every
|
||||||
|
/// secret field's Keychain value, return KEY=VALUE pairs ready
|
||||||
|
/// for `SecretsEnvBlock.renderBlock`. Nil when the project has
|
||||||
|
/// no manifest cache or no secret-typed fields in its schema.
|
||||||
|
nonisolated private func resolveSecrets(
|
||||||
|
for project: ProjectEntry
|
||||||
|
) throws -> ResolvedSecrets? {
|
||||||
|
let configService = ProjectConfigService(context: context)
|
||||||
|
guard let manifest = try configService.loadCachedManifest(project: project) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let schema = manifest.config else { return nil }
|
||||||
|
let secretFields = schema.fields.filter { $0.type == .secret }
|
||||||
|
guard !secretFields.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let configFile = try configService.load(project: project)
|
||||||
|
let values = configFile?.values ?? [:]
|
||||||
|
|
||||||
|
var entries: [(key: String, value: String)] = []
|
||||||
|
for field in secretFields {
|
||||||
|
guard let value = values[field.key] else { continue }
|
||||||
|
let resolved: Data?
|
||||||
|
do {
|
||||||
|
resolved = try configService.resolveSecret(ref: value)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning(
|
||||||
|
"couldn't resolve secret \(field.key, privacy: .public) for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard let data = resolved,
|
||||||
|
let str = String(data: data, encoding: .utf8) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let key = SecretsEnvBlock.envKeyName(slug: manifest.slug, fieldKey: field.key)
|
||||||
|
entries.append((key: key, value: str))
|
||||||
|
}
|
||||||
|
return ResolvedSecrets(slug: manifest.slug, entries: entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File I/O
|
||||||
|
|
||||||
|
nonisolated private func unmirrorBlock(
|
||||||
|
slug: String,
|
||||||
|
envPath: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws {
|
||||||
|
guard transport.fileExists(envPath) else { return }
|
||||||
|
let existing = try readExisting(at: envPath, transport: transport)
|
||||||
|
let rewritten = SecretsEnvBlock.removeBlock(forSlug: slug, from: existing)
|
||||||
|
try writeIfChanged(path: envPath, existing: existing, rewritten: rewritten, transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func readExisting(
|
||||||
|
at path: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws -> String {
|
||||||
|
guard transport.fileExists(path) else { return "" }
|
||||||
|
let data = try transport.readFile(path)
|
||||||
|
return String(data: data, encoding: .utf8) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func writeIfChanged(
|
||||||
|
path: String,
|
||||||
|
existing: String,
|
||||||
|
rewritten: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws {
|
||||||
|
guard rewritten != existing else { return }
|
||||||
|
guard let outData = rewritten.data(using: .utf8) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "com.scarf.keychain-env-mirror",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Couldn't UTF-8 encode env file"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// LocalTransport's writeFile preserves 0600 for paths that match
|
||||||
|
// `.env` conventions (see ServerTransport.writeFile docstring).
|
||||||
|
// The hermes home is ensured by Hermes itself; we don't mkdir
|
||||||
|
// here.
|
||||||
|
try transport.writeFile(path, data: outData)
|
||||||
|
Self.logger.info("rewrote \(path, privacy: .public) — \(outData.count) bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Slug helpers
|
||||||
|
|
||||||
|
/// Read the project's cached manifest to recover its slug. Used
|
||||||
|
/// by `unmirror` since the slug is the only key the env file
|
||||||
|
/// knows. Nil when the manifest cache is absent (schema-less
|
||||||
|
/// project, or uninstall path that already deleted it).
|
||||||
|
nonisolated private func cachedSlug(for project: ProjectEntry) -> String? {
|
||||||
|
let configService = ProjectConfigService(context: context)
|
||||||
|
guard let manifest = try? configService.loadCachedManifest(project: project) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return manifest.slug
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback slug derivation when the cached manifest is gone.
|
||||||
|
/// Mirrors `ProjectScaffolder.suggestedSlug` so a from-scratch
|
||||||
|
/// project has a stable slug shape too — though scratch
|
||||||
|
/// projects don't have schemas so they shouldn't reach the
|
||||||
|
/// mirror path in practice.
|
||||||
|
nonisolated static func derivedSlug(forProject project: ProjectEntry) -> String {
|
||||||
|
let lowered = project.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 !slug.isEmpty && !lastWasDash {
|
||||||
|
slug.append("-")
|
||||||
|
lastWasDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while slug.hasSuffix("-") { slug.removeLast() }
|
||||||
|
return slug.isEmpty ? "project" : slug
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,20 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
let cronJobNames = try createCronJobs(plan: plan)
|
let cronJobNames = try createCronJobs(plan: plan)
|
||||||
let entry = try registerProject(plan: plan)
|
let entry = try registerProject(plan: plan)
|
||||||
try writeLockFile(plan: plan, cronJobNames: cronJobNames)
|
try writeLockFile(plan: plan, cronJobNames: cronJobNames)
|
||||||
|
|
||||||
|
// Mirror resolved Keychain secrets into ~/.hermes/.env so the
|
||||||
|
// template's cron jobs (and any other agent process Hermes
|
||||||
|
// spawns) can use them via $SCARF_<SLUG>_<FIELD>. Hermes
|
||||||
|
// reloads .env fresh on every cron tick, so this takes effect
|
||||||
|
// without a restart. Failure is non-fatal — the install
|
||||||
|
// itself succeeded; the launch-time reconciler retries on
|
||||||
|
// next app start.
|
||||||
|
do {
|
||||||
|
try KeychainEnvMirror(context: context).mirror(project: entry)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("install couldn't mirror secrets to ~/.hermes/.env: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
Self.logger.info("installed template \(plan.manifest.id, privacy: .public) v\(plan.manifest.version, privacy: .public) into \(plan.projectDir, privacy: .public)")
|
Self.logger.info("installed template \(plan.manifest.id, privacy: .public) v\(plan.manifest.version, privacy: .public) into \(plan.projectDir, privacy: .public)")
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,21 @@ struct ProjectTemplateUninstaller: Sendable {
|
|||||||
nonisolated func uninstall(plan: TemplateUninstallPlan) throws {
|
nonisolated func uninstall(plan: TemplateUninstallPlan) throws {
|
||||||
let transport = context.makeTransport()
|
let transport = context.makeTransport()
|
||||||
|
|
||||||
|
// 0. Strip the project's block from ~/.hermes/.env BEFORE we
|
||||||
|
// delete project files — KeychainEnvMirror.unmirror reads the
|
||||||
|
// cached manifest at <project>/.scarf/manifest.json to recover
|
||||||
|
// the slug. After step 1 deletes that file the slug is only
|
||||||
|
// recoverable by name, which is fine but more brittle. Run
|
||||||
|
// first while the cached manifest is still around. Failure is
|
||||||
|
// non-fatal: a stale block in .env is benign (env vars
|
||||||
|
// referencing a deleted project just sit there) and a fresh
|
||||||
|
// install at the same slug will overwrite it.
|
||||||
|
do {
|
||||||
|
try KeychainEnvMirror(context: context).unmirror(project: plan.project)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("uninstall couldn't strip secrets block from ~/.hermes/.env: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Project files (tracked only — user additions untouched).
|
// 1. Project files (tracked only — user additions untouched).
|
||||||
for file in plan.projectFilesToRemove {
|
for file in plan.projectFilesToRemove {
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
|
/// Copies skills shipped inside the app bundle into the user's
|
||||||
|
/// `~/.hermes/skills/` so they're always available without the user
|
||||||
|
/// having to install a template first. Idempotent + version-gated:
|
||||||
|
/// skips when the destination is the same version, copies on missing
|
||||||
|
/// or older, leaves a user-edited newer destination alone.
|
||||||
|
///
|
||||||
|
/// **Why this exists.** The "New Project from Scratch" wizard hands
|
||||||
|
/// off to the agent and expects it to invoke `scarf-template-author`,
|
||||||
|
/// which is the comprehensive interview-and-scaffold skill. That skill
|
||||||
|
/// is currently distributed as part of the `awizemann/template-author`
|
||||||
|
/// template — so installing the wizard's skill story with "first install
|
||||||
|
/// this template" would be a worse first-run experience than today's.
|
||||||
|
/// Bootstrapping it from the app bundle decouples the skill's
|
||||||
|
/// availability from any one template install.
|
||||||
|
///
|
||||||
|
/// **What gets bootstrapped.** Every subdirectory of
|
||||||
|
/// `Bundle.main/Resources/Skills/` is treated as one skill (its name
|
||||||
|
/// is the directory name). Currently that's just
|
||||||
|
/// `scarf-template-author`; future built-in skills can drop their dir
|
||||||
|
/// next to it and be picked up automatically.
|
||||||
|
struct SkillBootstrapService: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "SkillBootstrapService")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk every skill in the app bundle and ensure its installed
|
||||||
|
/// copy at `~/.hermes/skills/<name>/` is at least the bundled
|
||||||
|
/// version. Throws on transport failures (e.g. a missing
|
||||||
|
/// `~/.hermes` for a remote without one set up); callers should
|
||||||
|
/// log and continue — a failed bootstrap shouldn't block app
|
||||||
|
/// launch.
|
||||||
|
nonisolated func ensureBundledSkillsInstalled() throws {
|
||||||
|
guard let bundleSkillsDir = Self.bundleSkillsDir() else {
|
||||||
|
Self.logger.info("no bundled Skills/ directory; skipping bootstrap")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let fm = FileManager.default
|
||||||
|
let entries: [URL]
|
||||||
|
do {
|
||||||
|
entries = try fm.contentsOfDirectory(
|
||||||
|
at: bundleSkillsDir,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("couldn't list bundled skills dir: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let destRoot = context.paths.skillsDir
|
||||||
|
try transport.createDirectory(destRoot)
|
||||||
|
|
||||||
|
for skillDir in entries {
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
guard fm.fileExists(atPath: skillDir.path, isDirectory: &isDir), isDir.boolValue else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let skillName = skillDir.lastPathComponent
|
||||||
|
do {
|
||||||
|
try installSkill(from: skillDir, named: skillName, transport: transport)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("couldn't bootstrap skill \(skillName, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-skill install
|
||||||
|
|
||||||
|
private nonisolated func installSkill(
|
||||||
|
from sourceDir: URL,
|
||||||
|
named skillName: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws {
|
||||||
|
let destDir = context.paths.skillsDir + "/" + skillName
|
||||||
|
let destSkillMd = destDir + "/SKILL.md"
|
||||||
|
|
||||||
|
let bundledSkillMd = sourceDir.appendingPathComponent("SKILL.md")
|
||||||
|
let bundledData = try Data(contentsOf: bundledSkillMd)
|
||||||
|
let bundledVersion = Self.parseVersion(bundledData) ?? "0.0.0"
|
||||||
|
|
||||||
|
let installedVersion: String? = {
|
||||||
|
guard transport.fileExists(destSkillMd) else { return nil }
|
||||||
|
guard let data = try? transport.readFile(destSkillMd) else { return nil }
|
||||||
|
return Self.parseVersion(data)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Only copy when the destination is missing OR older than the
|
||||||
|
// bundled copy. A user with a newer hand-edited skill keeps
|
||||||
|
// their version untouched.
|
||||||
|
if let installed = installedVersion,
|
||||||
|
Self.semverCompare(installed, bundledVersion) >= 0 {
|
||||||
|
Self.logger.info(
|
||||||
|
"skill \(skillName, privacy: .public) at \(installed, privacy: .public) is current (bundled: \(bundledVersion, privacy: .public)); skipping"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try transport.createDirectory(destDir)
|
||||||
|
try transport.writeFile(destSkillMd, data: bundledData)
|
||||||
|
|
||||||
|
// Carry any companion files (assets, examples, etc.) the skill
|
||||||
|
// ships alongside SKILL.md. Walks one level deep — skills don't
|
||||||
|
// ship deep trees today and wider compat for that can wait
|
||||||
|
// until a use case appears.
|
||||||
|
if let extras = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: sourceDir,
|
||||||
|
includingPropertiesForKeys: nil,
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) {
|
||||||
|
for url in extras where url.lastPathComponent != "SKILL.md" {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let dest = destDir + "/" + url.lastPathComponent
|
||||||
|
try transport.writeFile(dest, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self.logger.info(
|
||||||
|
"bootstrapped skill \(skillName, privacy: .public) at v\(bundledVersion, privacy: .public) (was: \(installedVersion ?? "missing", privacy: .public))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Frontmatter version parse
|
||||||
|
|
||||||
|
/// Pull the `version: X.Y.Z` value from a SKILL.md's YAML
|
||||||
|
/// frontmatter. Returns nil when no version line is present so
|
||||||
|
/// the caller can treat the destination as "unknown" and replace
|
||||||
|
/// it with the bundled copy on the safe side.
|
||||||
|
nonisolated static func parseVersion(_ data: Data) -> String? {
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else { return nil }
|
||||||
|
var inFrontmatter = false
|
||||||
|
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||||
|
let line = String(rawLine)
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed == "---" {
|
||||||
|
if !inFrontmatter {
|
||||||
|
inFrontmatter = true
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard inFrontmatter else { return nil }
|
||||||
|
if trimmed.hasPrefix("version:") {
|
||||||
|
let value = trimmed
|
||||||
|
.dropFirst("version:".count)
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||||
|
return value.isEmpty ? nil : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three-component numeric semver compare. Returns -1, 0, +1.
|
||||||
|
/// Non-numeric components fall back to lexicographic — fine for
|
||||||
|
/// the conservative "skip if installed >= bundled" use case.
|
||||||
|
nonisolated static func semverCompare(_ a: String, _ b: String) -> Int {
|
||||||
|
let lhs = a.split(separator: ".").map { String($0) }
|
||||||
|
let rhs = b.split(separator: ".").map { String($0) }
|
||||||
|
let count = max(lhs.count, rhs.count)
|
||||||
|
for i in 0..<count {
|
||||||
|
let l = i < lhs.count ? lhs[i] : "0"
|
||||||
|
let r = i < rhs.count ? rhs[i] : "0"
|
||||||
|
if let li = Int(l), let ri = Int(r) {
|
||||||
|
if li < ri { return -1 }
|
||||||
|
if li > ri { return 1 }
|
||||||
|
} else {
|
||||||
|
if l < r { return -1 }
|
||||||
|
if l > r { return 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bundle access
|
||||||
|
|
||||||
|
/// Locate the bundled-skills directory inside the app bundle.
|
||||||
|
/// We ship skills inside a `.bundle` folder so Xcode preserves the
|
||||||
|
/// internal directory structure (a plain folder of resources gets
|
||||||
|
/// flattened by `PBXFileSystemSynchronizedRootGroup`). The
|
||||||
|
/// `BuiltinSkills.bundle` is then walked at runtime exactly like
|
||||||
|
/// any directory of `<skill-name>/SKILL.md` entries. Returns nil
|
||||||
|
/// when the app wasn't bundled with skills (unit test hosts,
|
||||||
|
/// local dev runs against a stripped-down bundle).
|
||||||
|
nonisolated private static func bundleSkillsDir() -> URL? {
|
||||||
|
Bundle.main.url(forResource: "BuiltinSkills", withExtension: "bundle")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
import Observation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
|
/// State + commit logic for the "New Project from Scratch" wizard.
|
||||||
|
/// Drives `NewProjectSheet`. Hosts the form fields, derives a default
|
||||||
|
/// slug from the project name, validates inputs, and runs the
|
||||||
|
/// `ProjectScaffolder` on commit.
|
||||||
|
///
|
||||||
|
/// Pattern matches `TemplateConfigViewModel`: a tightly-scoped view
|
||||||
|
/// model that owns its sheet's state, exposes typed bindings, and
|
||||||
|
/// surfaces a single error string the sheet renders inline.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class NewProjectViewModel {
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "NewProjectViewModel")
|
||||||
|
private let context: ServerContext
|
||||||
|
|
||||||
|
// MARK: - Form fields
|
||||||
|
|
||||||
|
var projectName: String = "" {
|
||||||
|
didSet {
|
||||||
|
// Auto-derive slug from name as long as the user hasn't
|
||||||
|
// edited the slug field manually. Once they edit it, we
|
||||||
|
// stop tracking — the user's choice wins.
|
||||||
|
if !slugManuallyEdited {
|
||||||
|
folderName = ProjectScaffolder.suggestedSlug(from: projectName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var folderName: String = "" {
|
||||||
|
didSet {
|
||||||
|
// Mark manually edited only if the change isn't from our
|
||||||
|
// own auto-derivation. The didSet on projectName sets
|
||||||
|
// folderName too — we differentiate by checking whether
|
||||||
|
// the new value matches what suggestedSlug would produce.
|
||||||
|
if folderName != ProjectScaffolder.suggestedSlug(from: projectName) {
|
||||||
|
slugManuallyEdited = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentDirectory: String = ""
|
||||||
|
|
||||||
|
var description: String = ""
|
||||||
|
|
||||||
|
/// User-facing error from the most recent commit attempt. Cleared
|
||||||
|
/// when the user edits any field. Sheet renders this as an inline
|
||||||
|
/// banner above the footer.
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
// MARK: - Internal state
|
||||||
|
|
||||||
|
/// Tracks whether the user has typed in the folder-name field.
|
||||||
|
/// Once true, we stop overriding their value when they edit the
|
||||||
|
/// project name.
|
||||||
|
private var slugManuallyEdited: Bool = false
|
||||||
|
|
||||||
|
/// True while a commit is in flight. Disables the Create button
|
||||||
|
/// to prevent double-taps.
|
||||||
|
private(set) var isCommitting: Bool = false
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
self.context = context
|
||||||
|
self.parentDirectory = Self.defaultParentDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
var canCommit: Bool {
|
||||||
|
guard !isCommitting else { return false }
|
||||||
|
guard !projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard ProjectScaffolder.isValidSlug(folderName) else { return false }
|
||||||
|
guard !parentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved absolute path the project will land at — shown as a
|
||||||
|
/// preview line above the footer so the user sees exactly what
|
||||||
|
/// gets created.
|
||||||
|
var resolvedProjectPath: String {
|
||||||
|
let parent = ProjectScaffolder.normalizeDirectoryPath(parentDirectory)
|
||||||
|
return parent + "/" + folderName
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Commit
|
||||||
|
|
||||||
|
/// Attempt to scaffold the project. Returns the registered
|
||||||
|
/// `ProjectEntry` on success, nil on validation/scaffolder
|
||||||
|
/// failure (with `errorMessage` populated for the sheet).
|
||||||
|
func commit() -> ProjectEntry? {
|
||||||
|
guard canCommit else {
|
||||||
|
errorMessage = "Fill in the name, folder, and parent directory."
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
isCommitting = true
|
||||||
|
defer { isCommitting = false }
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let scaffolder = ProjectScaffolder(context: context)
|
||||||
|
do {
|
||||||
|
let entry = try scaffolder.scaffold(
|
||||||
|
name: projectName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
slug: folderName,
|
||||||
|
parentDir: parentDirectory,
|
||||||
|
description: description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? nil
|
||||||
|
: description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
)
|
||||||
|
logger.info("scaffolded \(entry.name, privacy: .public) at \(entry.path, privacy: .public)")
|
||||||
|
return entry
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
logger.warning("scaffold failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the auto-prompt the wizard hands to ChatViewModel after
|
||||||
|
/// scaffolding. Mentions the absolute path so the agent has the
|
||||||
|
/// project's location even if the chat session's cwd slot ever
|
||||||
|
/// drifts; appends the user's optional description so the agent
|
||||||
|
/// can tailor its first question.
|
||||||
|
func buildInitialPrompt(for entry: ProjectEntry) -> String {
|
||||||
|
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var prompt = "I just created a new Scarf project at \(entry.path). "
|
||||||
|
+ "Use the `scarf-template-author` skill to walk me through configuring it — "
|
||||||
|
+ "design the dashboard, optionally schedule cron jobs, and write AGENTS.md instructions."
|
||||||
|
if !trimmedDescription.isEmpty {
|
||||||
|
prompt += " Here's what it's for: \(trimmedDescription)"
|
||||||
|
}
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Defaults
|
||||||
|
|
||||||
|
/// Default parent directory for new projects: `~/Projects` if it
|
||||||
|
/// exists, else `~`. Matches Scarf's convention of preferring the
|
||||||
|
/// user's `~/Projects` folder when available without forcing it.
|
||||||
|
private static func defaultParentDirectory() -> String {
|
||||||
|
let home = NSHomeDirectory()
|
||||||
|
let projectsDir = home + "/Projects"
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
if FileManager.default.fileExists(atPath: projectsDir, isDirectory: &isDir),
|
||||||
|
isDir.boolValue {
|
||||||
|
return projectsDir
|
||||||
|
}
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import AppKit
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Wizard for creating a new Scarf-standard project from scratch.
|
||||||
|
///
|
||||||
|
/// The wizard is intentionally minimal: project name, folder name
|
||||||
|
/// (auto-derived from the name but editable), parent directory, and
|
||||||
|
/// an optional one-liner about what the project is for. On commit,
|
||||||
|
/// `ProjectScaffolder` creates the directory tree with a placeholder
|
||||||
|
/// dashboard and a stub AGENTS.md (just the Scarf-managed marker
|
||||||
|
/// block). Then we hand off to the chat surface with an auto-prompt
|
||||||
|
/// that activates the bundled `scarf-template-author` skill, which
|
||||||
|
/// drives the rest conversationally — choosing widgets, designing a
|
||||||
|
/// config schema if needed, scheduling cron jobs.
|
||||||
|
///
|
||||||
|
/// This sheet replaces nothing. The existing `AddProjectSheet`
|
||||||
|
/// (registers an existing directory) and the template-install flow
|
||||||
|
/// (creates a project from a `.scarftemplate` bundle) both stay —
|
||||||
|
/// this is the third entry point covering the "I want a fresh,
|
||||||
|
/// hand-rolled project" gap.
|
||||||
|
struct NewProjectSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|
||||||
|
@State var viewModel: NewProjectViewModel
|
||||||
|
/// Called with the freshly-registered project AFTER the sheet
|
||||||
|
/// dismisses. Caller refreshes its registry view, updates file
|
||||||
|
/// watches, and selects the new project for visual feedback.
|
||||||
|
let onCreate: (ProjectEntry) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
nameField
|
||||||
|
folderField
|
||||||
|
parentDirField
|
||||||
|
descriptionField
|
||||||
|
pathPreview
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.frame(minWidth: 540, minHeight: 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sections
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("New Project").scarfStyle(.title2)
|
||||||
|
Text("Scarf scaffolds the directory; the agent helps you fill it in.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var nameField: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text("Project Name").scarfStyle(.headline)
|
||||||
|
Text("*").scarfStyle(.headline).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
Text("Display name shown in Scarf's sidebar and at the top of the dashboard.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Acme Q3 Review", text: Bindable(viewModel).projectName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("newProject.name")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var folderField: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text("Folder Name").scarfStyle(.headline)
|
||||||
|
Text("*").scarfStyle(.headline).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
Text("Lowercase letters, numbers, and dashes — created as `<parent>/<folder>`.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("acme-q3", text: Bindable(viewModel).folderName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("newProject.folder")
|
||||||
|
if !viewModel.folderName.isEmpty,
|
||||||
|
!ProjectScaffolder.isValidSlug(viewModel.folderName) {
|
||||||
|
Label(
|
||||||
|
"Folder name needs lowercase letters, digits, or dashes — no leading/trailing or doubled dashes.",
|
||||||
|
systemImage: "exclamationmark.triangle.fill"
|
||||||
|
)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var parentDirField: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text("Parent Directory").scarfStyle(.headline)
|
||||||
|
Text("*").scarfStyle(.headline).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
Text("Where the new project folder lands on disk.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
TextField("~/Projects", text: Bindable(viewModel).parentDirectory)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("newProject.parent")
|
||||||
|
Button("Choose…") {
|
||||||
|
chooseParentDirectory()
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("newProject.parent.choose")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var descriptionField: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("What's it for?").scarfStyle(.headline)
|
||||||
|
Text("Optional — one-liner that helps the agent tailor the setup interview.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextEditor(text: Bindable(viewModel).description)
|
||||||
|
.font(.body)
|
||||||
|
.frame(minHeight: 60, maxHeight: 100)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(.secondary.opacity(0.3))
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier("newProject.description")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var pathPreview: some View {
|
||||||
|
if !viewModel.folderName.isEmpty,
|
||||||
|
!viewModel.parentDirectory.isEmpty,
|
||||||
|
ProjectScaffolder.isValidSlug(viewModel.folderName) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Will create").scarfStyle(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(viewModel.resolvedProjectPath)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(.background.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var footer: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
Label(error, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("newProject.cancelButton")
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
runCommit()
|
||||||
|
} label: {
|
||||||
|
if viewModel.isCommitting {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Create & Open Chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(ScarfPrimaryButton())
|
||||||
|
.disabled(!viewModel.canCommit)
|
||||||
|
.accessibilityIdentifier("newProject.createButton")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func chooseParentDirectory() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
panel.title = "Choose Parent Directory"
|
||||||
|
panel.message = "The new project folder will be created inside this directory."
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
viewModel.parentDirectory = url.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runCommit() {
|
||||||
|
guard let entry = viewModel.commit() else { return }
|
||||||
|
// Stage the chat handoff BEFORE dismissing so SwiftUI's
|
||||||
|
// sheet dismissal doesn't preempt the coordinator update.
|
||||||
|
let prompt = viewModel.buildInitialPrompt(for: entry)
|
||||||
|
coordinator.pendingProjectChat = entry.path
|
||||||
|
coordinator.pendingInitialPrompt = prompt
|
||||||
|
coordinator.selectedSection = .chat
|
||||||
|
onCreate(entry)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ struct ProjectsView: View {
|
|||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
@Environment(\.serverContext) private var serverContext
|
@Environment(\.serverContext) private var serverContext
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
|
@State private var showingNewProjectSheet = false
|
||||||
@State private var showingInstallSheet = false
|
@State private var showingInstallSheet = false
|
||||||
@State private var exportSheetProject: ProjectEntry?
|
@State private var exportSheetProject: ProjectEntry?
|
||||||
@State private var showingInstallURLPrompt = false
|
@State private var showingInstallURLPrompt = false
|
||||||
@@ -129,6 +130,28 @@ struct ProjectsView: View {
|
|||||||
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
|
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingNewProjectSheet) {
|
||||||
|
NewProjectSheet(
|
||||||
|
viewModel: NewProjectViewModel(context: serverContext)
|
||||||
|
) { entry in
|
||||||
|
// Reload the registry so the new project shows in the
|
||||||
|
// sidebar, then select it. The chat handoff is staged
|
||||||
|
// by `NewProjectSheet.runCommit` (it sets
|
||||||
|
// `coordinator.pendingProjectChat` + `pendingInitialPrompt`
|
||||||
|
// and switches `selectedSection` to `.chat`), so when
|
||||||
|
// the user comes back to Projects later, the project
|
||||||
|
// is already there.
|
||||||
|
viewModel.load()
|
||||||
|
coordinator.selectedProjectName = entry.name
|
||||||
|
if let project = viewModel.projects.first(where: { $0.name == entry.name }) {
|
||||||
|
viewModel.selectProject(project)
|
||||||
|
}
|
||||||
|
fileWatcher.updateProjectWatches(
|
||||||
|
dashboardPaths: viewModel.dashboardPaths,
|
||||||
|
scarfDirs: viewModel.projectScarfDirs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(item: $exportSheetProject) { project in
|
.sheet(item: $exportSheetProject) { project in
|
||||||
TemplateExportSheet(
|
TemplateExportSheet(
|
||||||
viewModel: TemplateExporterViewModel(context: serverContext, project: project)
|
viewModel: TemplateExporterViewModel(context: serverContext, project: project)
|
||||||
@@ -183,6 +206,17 @@ struct ProjectsView: View {
|
|||||||
presenting: pendingRemoveFromList
|
presenting: pendingRemoveFromList
|
||||||
) { project in
|
) { project in
|
||||||
Button("Remove from List") {
|
Button("Remove from List") {
|
||||||
|
// Strip the project's secrets block from ~/.hermes/.env
|
||||||
|
// BEFORE removing it from the registry — the env-mirror
|
||||||
|
// resolves slug via the cached manifest, which still
|
||||||
|
// exists at this point. Failure is non-fatal: a stale
|
||||||
|
// block in .env is benign (just unreachable env vars).
|
||||||
|
do {
|
||||||
|
try KeychainEnvMirror(context: serverContext).unmirror(project: project)
|
||||||
|
} catch {
|
||||||
|
// Silent: the mirror's own logger has already
|
||||||
|
// recorded the failure.
|
||||||
|
}
|
||||||
viewModel.removeProject(project)
|
viewModel.removeProject(project)
|
||||||
if coordinator.selectedProjectName == project.name {
|
if coordinator.selectedProjectName == project.name {
|
||||||
coordinator.selectedProjectName = nil
|
coordinator.selectedProjectName = nil
|
||||||
@@ -214,6 +248,11 @@ struct ProjectsView: View {
|
|||||||
private var templatesToolbar: some ToolbarContent {
|
private var templatesToolbar: some ToolbarContent {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Menu {
|
Menu {
|
||||||
|
Button("New Project from Scratch…", systemImage: "sparkles") {
|
||||||
|
showingNewProjectSheet = true
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("templates.newProject")
|
||||||
|
Divider()
|
||||||
Button("Browse Catalog…", systemImage: "books.vertical") {
|
Button("Browse Catalog…", systemImage: "books.vertical") {
|
||||||
showingCatalogSheet = true
|
showingCatalogSheet = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ final class TemplateConfigEditorViewModel {
|
|||||||
stage = .saving
|
stage = .saving
|
||||||
let service = configService
|
let service = configService
|
||||||
let project = project
|
let project = project
|
||||||
|
let context = context
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
try service.save(
|
try service.save(
|
||||||
@@ -100,6 +101,19 @@ final class TemplateConfigEditorViewModel {
|
|||||||
templateId: manifest.id,
|
templateId: manifest.id,
|
||||||
values: values
|
values: values
|
||||||
)
|
)
|
||||||
|
// Re-mirror the project's resolved Keychain values into
|
||||||
|
// ~/.hermes/.env. Catches secret rotations: when the user
|
||||||
|
// updates an API token in the Configuration sheet, the
|
||||||
|
// new value lands in the Keychain via the form's commit
|
||||||
|
// step, then this re-runs the splice so the cron-side
|
||||||
|
// env var picks up the rotated value on Hermes's next
|
||||||
|
// tick. Non-fatal on failure — the config save itself
|
||||||
|
// succeeded.
|
||||||
|
do {
|
||||||
|
try KeychainEnvMirror(context: context).mirror(project: project)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("config save couldn't mirror secrets: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
self?.stage = .succeeded
|
self?.stage = .succeeded
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,18 +25,18 @@ struct ConfigEditorSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
// Single outer frame for every stage. Per-case frames used to
|
||||||
|
// shrink the sheet from 480pt (editing) to 280pt (succeeded /
|
||||||
|
// notConfigurable / failed) on stage transition, which forced
|
||||||
|
// AppKit to relayout the sheet container mid-flight and
|
||||||
|
// produced `_NSDetectedLayoutRecursion` on macOS — issue #75.
|
||||||
|
// Stabilizing the size at the largest stage's intrinsic
|
||||||
|
// (560 x 480, matching `TemplateConfigSheet`) means stage
|
||||||
|
// transitions only change content, never container geometry.
|
||||||
|
VStack(spacing: 0) {
|
||||||
switch viewModel.stage {
|
switch viewModel.stage {
|
||||||
case .idle, .loading:
|
case .idle, .loading:
|
||||||
VStack(spacing: 12) {
|
centeredMessage("Loading configuration…", showSpinner: true)
|
||||||
ProgressView()
|
|
||||||
Text("Loading configuration…")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.frame(minWidth: 560, minHeight: 320)
|
|
||||||
.padding()
|
|
||||||
case .editing:
|
case .editing:
|
||||||
if let form = viewModel.formViewModel,
|
if let form = viewModel.formViewModel,
|
||||||
let manifest = viewModel.manifest {
|
let manifest = viewModel.manifest {
|
||||||
@@ -57,15 +57,7 @@ struct ConfigEditorSheet: View {
|
|||||||
unexpectedState
|
unexpectedState
|
||||||
}
|
}
|
||||||
case .saving:
|
case .saving:
|
||||||
VStack(spacing: 12) {
|
centeredMessage("Saving…", showSpinner: true)
|
||||||
ProgressView()
|
|
||||||
Text("Saving…")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.frame(minWidth: 560, minHeight: 320)
|
|
||||||
.padding()
|
|
||||||
case .succeeded:
|
case .succeeded:
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
@@ -77,7 +69,6 @@ struct ConfigEditorSheet: View {
|
|||||||
.buttonStyle(ScarfPrimaryButton())
|
.buttonStyle(ScarfPrimaryButton())
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.frame(minWidth: 560, minHeight: 280)
|
|
||||||
.padding()
|
.padding()
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -93,7 +84,6 @@ struct ConfigEditorSheet: View {
|
|||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.frame(minWidth: 560, minHeight: 280)
|
|
||||||
.padding()
|
.padding()
|
||||||
case .notConfigurable:
|
case .notConfigurable:
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -110,13 +100,25 @@ struct ConfigEditorSheet: View {
|
|||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.frame(minWidth: 560, minHeight: 280)
|
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: 560, minHeight: 480)
|
||||||
.task { viewModel.begin() }
|
.task { viewModel.begin() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func centeredMessage(_ text: String, showSpinner: Bool) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
if showSpinner { ProgressView() }
|
||||||
|
Text(text)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
private var unexpectedState: some View {
|
private var unexpectedState: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "questionmark.circle")
|
Image(systemName: "questionmark.circle")
|
||||||
|
|||||||
@@ -1032,6 +1032,10 @@
|
|||||||
},
|
},
|
||||||
"<%@>" : {
|
"<%@>" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"~/Projects" : {
|
||||||
|
"comment" : "A placeholder for the parent directory of a new project.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"1.0.0" : {
|
"1.0.0" : {
|
||||||
"comment" : "A placeholder for the version of a template.",
|
"comment" : "A placeholder for the version of a template.",
|
||||||
@@ -1251,6 +1255,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Acme Q3 Review" : {
|
||||||
|
"comment" : "A placeholder project name.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"acme-q3" : {
|
||||||
|
"comment" : "A placeholder for a project folder name.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Actions" : {
|
"Actions" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -1295,6 +1307,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Active" : {
|
"Active" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3824,6 +3837,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bytes %lld" : {
|
||||||
|
"comment" : "A row of a Mac Diagnostics panel that shows the number of bytes transferred.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Cached %@" : {
|
"Cached %@" : {
|
||||||
"comment" : "A label that shows when a list of templates is loaded from the cache. The argument is a relative time, e.g. \"1h ago\".",
|
"comment" : "A label that shows when a list of templates is loaded from the cache. The argument is a relative time, e.g. \"1h ago\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -5864,6 +5881,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Copy as JSON" : {
|
||||||
|
"comment" : "A button that copies the ring-buffer JSON to the pasteboard.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Copy code" : {
|
"Copy code" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -5995,6 +6016,10 @@
|
|||||||
"comment" : "A label for the cost of a session.",
|
"comment" : "A label for the cost of a session.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Could not load image: %@" : {
|
||||||
|
"comment" : "A message indicating that an image could not be loaded. The argument is the error description.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Couldn't reset memory" : {
|
"Couldn't reset memory" : {
|
||||||
"comment" : "A title for an alert that can't reset memory.",
|
"comment" : "A title for an alert that can't reset memory.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -6007,6 +6032,10 @@
|
|||||||
"comment" : "A title for a banner that appears when an error occurs while updating slash commands.",
|
"comment" : "A title for a banner that appears when an error occurs while updating slash commands.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"count %lld" : {
|
||||||
|
"comment" : "A column of numbers.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Create" : {
|
"Create" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -6047,6 +6076,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Create & Open Chat" : {
|
||||||
|
"comment" : "A button to create a new project and open the chat surface.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users." : {
|
"Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -6868,6 +6901,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Default mode emits Instruments signposts only — no measurable cost outside an active profiling session. Switch to Full to keep an in-memory ring buffer (4096 entries) you can inspect below or copy as JSON." : {
|
||||||
|
"comment" : "A description of the performance diagnostics section.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Default: ~/.hermes" : {
|
"Default: ~/.hermes" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -7547,6 +7584,10 @@
|
|||||||
},
|
},
|
||||||
"Display Name" : {
|
"Display Name" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Display name shown in Scarf's sidebar and at the top of the dashboard." : {
|
||||||
|
"comment" : "A description of the project name field.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Docker" : {
|
"Docker" : {
|
||||||
|
|
||||||
@@ -9518,6 +9559,14 @@
|
|||||||
"comment" : "A placeholder for a comma-separated list of tags.",
|
"comment" : "A placeholder for a comma-separated list of tags.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Folder Name" : {
|
||||||
|
"comment" : "A label for the folder name field in the new project wizard.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Folder name needs lowercase letters, digits, or dashes — no leading/trailing or doubled dashes." : {
|
||||||
|
"comment" : "A warning message that appears when a user enters an invalid folder name.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes." : {
|
"Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes." : {
|
||||||
"comment" : "A description of how folders affect project grouping.",
|
"comment" : "A description of how folders affect project grouping.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -9526,6 +9575,10 @@
|
|||||||
"comment" : "A button that adds a server from a preset.",
|
"comment" : "A button that adds a server from a preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Full" : {
|
||||||
|
"comment" : "A label for the \"Full\" mode of the performance diagnostics.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Full copy of active profile (all state)" : {
|
"Full copy of active profile (all state)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -11308,6 +11361,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"last %lld" : {
|
||||||
|
"comment" : "A label that shows the number of lines of the log file being displayed.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Last 7 days" : {
|
"Last 7 days" : {
|
||||||
"comment" : "A heading for the last 7 days of usage statistics.",
|
"comment" : "A heading for the last 7 days of usage statistics.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -11826,10 +11883,6 @@
|
|||||||
"comment" : "A placeholder text that appears when loading slash commands.",
|
"comment" : "A placeholder text that appears when loading slash commands.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Loading configuration…" : {
|
|
||||||
"comment" : "A message displayed while loading the configuration.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Loading earlier…" : {
|
"Loading earlier…" : {
|
||||||
"comment" : "A label displayed while loading older messages.",
|
"comment" : "A label displayed while loading older messages.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -12121,6 +12174,10 @@
|
|||||||
"comment" : "A description of the format of a slash command name.",
|
"comment" : "A description of the format of a slash command name.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Lowercase letters, numbers, and dashes — created as `<parent>/<folder>`." : {
|
||||||
|
"comment" : "A description of the folder name field.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Manage" : {
|
"Manage" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -12299,6 +12356,10 @@
|
|||||||
},
|
},
|
||||||
"Mattermost Setup Docs" : {
|
"Mattermost Setup Docs" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"max %@" : {
|
||||||
|
"comment" : "A column of text in the Diagnostics → Performance panel showing the maximum duration of a performance stat. The argument is the duration of the stat.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"MCP" : {
|
"MCP" : {
|
||||||
|
|
||||||
@@ -12707,6 +12768,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Mode" : {
|
||||||
|
"comment" : "A label displayed alongside a picker to select between different performance diagnostics modes.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Model" : {
|
"Model" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -13078,6 +13143,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"New Project" : {
|
||||||
|
"comment" : "The title of the wizard for creating a new Scarf project.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"New Project from Scratch…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"New Session" : {
|
"New Session" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -13408,6 +13480,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No cells." : {
|
||||||
|
"comment" : "A message displayed when a widget has no cells.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No commands available" : {
|
"No commands available" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -13490,6 +13566,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"No cron job with id `%@`." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No Cron Jobs" : {
|
"No Cron Jobs" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -14217,6 +14296,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No samples yet. Use the app for a few seconds." : {
|
||||||
|
"comment" : "A message displayed when there are no stats to show.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No scheduled jobs configured" : {
|
"No scheduled jobs configured" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -14635,6 +14718,9 @@
|
|||||||
"OAuth providers" : {
|
"OAuth providers" : {
|
||||||
"comment" : "Title of a section in the credential pools view that lists OAuth-authed providers.",
|
"comment" : "Title of a section in the credential pools view that lists OAuth-authed providers.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Off" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"OFF" : {
|
"OFF" : {
|
||||||
"comment" : "A label for a disabled skill.",
|
"comment" : "A label for a disabled skill.",
|
||||||
@@ -15227,6 +15313,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Optional — one-liner that helps the agent tailor the setup interview." : {
|
||||||
|
"comment" : "A description of the description field in the New Project sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Optional inclusions" : {
|
"Optional inclusions" : {
|
||||||
"comment" : "A heading for optional inclusions in a backup.",
|
"comment" : "A heading for optional inclusions in a backup.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -15417,6 +15507,13 @@
|
|||||||
"comment" : "A priority indicator. The argument is the priority level.",
|
"comment" : "A priority indicator. The argument is the priority level.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"p50 %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"p95 %@" : {
|
||||||
|
"comment" : "A column of text that shows the 95th percentile of a stat. The argument is the value of the 95th percentile.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed." : {
|
"Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed." : {
|
||||||
"comment" : "A description of the benefits of using a Nous",
|
"comment" : "A description of the benefits of using a Nous",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -15503,6 +15600,10 @@
|
|||||||
},
|
},
|
||||||
"Parent directory" : {
|
"Parent directory" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Parent Directory" : {
|
||||||
|
"comment" : "A label displayed above a field for the parent directory of a new project.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Paste an https URL pointing at a .scarftemplate file." : {
|
"Paste an https URL pointing at a .scarftemplate file." : {
|
||||||
"comment" : "A description of the URL field in the template installation prompt.",
|
"comment" : "A description of the URL field in the template installation prompt.",
|
||||||
@@ -15728,6 +15829,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Performance Diagnostics" : {
|
||||||
|
"comment" : "Title of the Mac Diagnostics → Performance panel.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Period" : {
|
"Period" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -19201,10 +19306,6 @@
|
|||||||
"comment" : "A message that appears when a user has filled in a secret but has not yet saved it.",
|
"comment" : "A message that appears when a user has filled in a secret but has not yet saved it.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Saving…" : {
|
|
||||||
"comment" : "A label displayed while the configuration is being saved.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Scarf" : {
|
"Scarf" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -19340,6 +19441,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Scarf scaffolds the directory; the agent helps you fill it in." : {
|
||||||
|
"comment" : "A description of the project creation process.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases." : {
|
"Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -21157,6 +21262,10 @@
|
|||||||
"comment" : "A description of a user's subscription to Nous, but",
|
"comment" : "A description of a user's subscription to Nous, but",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Signpost only" : {
|
||||||
|
"comment" : "A mode of ScarfMonDiagnosticsSection.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Singularity" : {
|
"Singularity" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -23991,6 +24100,7 @@
|
|||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Unknown: %@" : {
|
"Unknown: %@" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -25278,6 +25388,10 @@
|
|||||||
"comment" : "A heading for the contents of a catalog entry.",
|
"comment" : "A heading for the contents of a catalog entry.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"What's it for?" : {
|
||||||
|
"comment" : "A label displayed above a text editor for a project description.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"WhatsApp Setup Docs" : {
|
"WhatsApp Setup Docs" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -25327,10 +25441,17 @@
|
|||||||
},
|
},
|
||||||
"Where should this project live?" : {
|
"Where should this project live?" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Where the new project folder lands on disk." : {
|
||||||
|
"comment" : "A description of the parent directory field.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Will be saved to the Keychain on commit." : {
|
"Will be saved to the Keychain on commit." : {
|
||||||
"comment" : "A description of a secret field that will be saved to the Keychain on commit.",
|
"comment" : "A description of a secret field that will be saved to the Keychain on commit.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Will create" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Wipes MEMORY.md and USER.md to empty via `hermes memory reset --yes`. The agent's accumulated knowledge for this server is gone immediately. Use this when a session went off the rails — there's no undo." : {
|
"Wipes MEMORY.md and USER.md to empty via `hermes memory reset --yes`. The agent's accumulated knowledge for this server is gone immediately. Use this when a session went off the rails — there's no undo." : {
|
||||||
"comment" : "A message displayed in a confirmation dialog when the user is about to reset memory.",
|
"comment" : "A message displayed in a confirmation dialog when the user is about to reset memory.",
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ final class AppCoordinator {
|
|||||||
/// yet have an id for.
|
/// yet have an id for.
|
||||||
var pendingProjectChat: String?
|
var pendingProjectChat: String?
|
||||||
|
|
||||||
|
/// Optional first message to send automatically once a
|
||||||
|
/// `pendingProjectChat` session has connected. Set alongside
|
||||||
|
/// `pendingProjectChat` by the "New Project from Scratch" wizard
|
||||||
|
/// (v2.8) so the agent receives a kickoff prompt that activates
|
||||||
|
/// the `scarf-template-author` skill without the user having to
|
||||||
|
/// type one. Sister slot to `pendingProjectChat`: ChatView consumes
|
||||||
|
/// both in lockstep and clears them. Nil for plain "open project
|
||||||
|
/// chat" handoffs (the Sessions tab's "New Chat" button).
|
||||||
|
var pendingInitialPrompt: String?
|
||||||
|
|
||||||
/// Lowercase OAuth provider name to re-authenticate. Set by the
|
/// Lowercase OAuth provider name to re-authenticate. Set by the
|
||||||
/// chat error banner's "Re-authenticate" button, consumed by
|
/// chat error banner's "Re-authenticate" button, consumed by
|
||||||
/// CredentialPoolsView, which auto-presents the OAuth sheet seeded
|
/// CredentialPoolsView, which auto-presents the OAuth sheet seeded
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
---
|
||||||
|
name: scarf-template-author
|
||||||
|
description: Scaffold a new Scarf project — dashboard, optional configuration schema, optional cron job, and AGENTS.md — from a short conversational interview with the user. Output is immediately usable locally and cleanly exportable as a .scarftemplate bundle.
|
||||||
|
version: 1.1.0
|
||||||
|
author: Alan Wizemann
|
||||||
|
license: MIT
|
||||||
|
platforms: [macos]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [Scarf, templates, scaffolding, dashboard, authoring]
|
||||||
|
homepage: https://github.com/awizemann/scarf/wiki/Project-Templates
|
||||||
|
prerequisites:
|
||||||
|
commands: [hermes]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scarf Template Author
|
||||||
|
|
||||||
|
Scaffold a new Scarf-compatible project from a conversational interview. The output is both (a) a working project on disk the user can register with Scarf and use immediately, and (b) correctly shaped to be exported as a `.scarftemplate` bundle via Scarf's Export flow later.
|
||||||
|
|
||||||
|
## When to invoke this skill
|
||||||
|
|
||||||
|
Activate when the user says things like:
|
||||||
|
|
||||||
|
- *"Create a new Scarf project that watches / tracks / reports on …"*
|
||||||
|
- *"Scaffold a dashboard for …"*
|
||||||
|
- *"Set up a project that runs a daily check on …"*
|
||||||
|
- *"Help me author a Scarf template."*
|
||||||
|
- *"Build me a Scarf project to monitor …"*
|
||||||
|
|
||||||
|
Do **not** activate for pure reference questions like *"what widget types does Scarf support?"* or *"how does Scarf handle secrets?"* — answer those inline from the reference sections below.
|
||||||
|
|
||||||
|
Also do not activate when the user explicitly wants to edit an existing project's dashboard — that's a plain file edit, not a scaffold.
|
||||||
|
|
||||||
|
## How a Scarf project is shaped on disk
|
||||||
|
|
||||||
|
A Scarf project is just a directory registered in `~/.hermes/scarf/projects.json`. For Scarf to render a useful dashboard and for the project to be exportable as a `.scarftemplate`, it needs these files at minimum:
|
||||||
|
|
||||||
|
```
|
||||||
|
<project>/
|
||||||
|
├── .scarf/
|
||||||
|
│ ├── dashboard.json # REQUIRED for dashboard rendering
|
||||||
|
│ └── manifest.json # OPTIONAL — required only if the project declares a config schema or you want to export cleanly
|
||||||
|
├── AGENTS.md # Cross-agent instructions (agents.md standard) — ship this for every project
|
||||||
|
└── README.md # User-facing explanation
|
||||||
|
```
|
||||||
|
|
||||||
|
If the project will have a scheduled job, ALSO register a cron entry via `hermes cron create`. For an exportable bundle, also author `cron/jobs.json` in the staging directory — that's where Scarf's exporter will pick jobs up from.
|
||||||
|
|
||||||
|
Secrets never land in `dashboard.json` or `config.json`. At install time, Scarf routes secret-type config values to the macOS Keychain; `config.json` stores `keychain://service/account` URIs. When scaffolding from scratch (no install), the user either manages secrets via the post-install Configuration editor after export, or stashes them in their `~/.hermes/config.yaml` if they're Hermes-level secrets rather than project-level.
|
||||||
|
|
||||||
|
## The interview
|
||||||
|
|
||||||
|
Ask these questions in order. Don't batch. Each answer shapes the next question.
|
||||||
|
|
||||||
|
### 1. Purpose and data source
|
||||||
|
|
||||||
|
- *"In one sentence — what does this project do?"*
|
||||||
|
- *"Where does its data come from? Files, a URL, a shell command's output, an API call, a database, a spreadsheet?"*
|
||||||
|
|
||||||
|
Goal: figure out whether the project is **passive** (user maintains some files, dashboard reflects them), **pull-based** (we fetch from an HTTP endpoint or CLI tool on a schedule), or **push-based** (something external writes to a file we watch).
|
||||||
|
|
||||||
|
### 2. Refresh cadence
|
||||||
|
|
||||||
|
- *"How often should it refresh? Every hour? Daily? Weekly? Only when I ask?"*
|
||||||
|
|
||||||
|
If "only when I ask" → no cron job; user invokes the agent manually. If any scheduled cadence → cron job.
|
||||||
|
|
||||||
|
Map to cron expressions:
|
||||||
|
- Every hour: `0 * * * *`
|
||||||
|
- Daily at 9 AM: `0 9 * * *`
|
||||||
|
- Weekly Monday 9 AM: `0 9 * * 1`
|
||||||
|
- Every 15 minutes: `*/15 * * * *`
|
||||||
|
|
||||||
|
### 3. What the dashboard shows
|
||||||
|
|
||||||
|
Explain the widget catalog (see Widget Catalog sections below) in plain English, then ask which ones feel right. Offer concrete suggestions based on the purpose:
|
||||||
|
|
||||||
|
- Counting things (open PRs, failing tests, up/down sites) → `stat` widgets. Add `sparkline: [Number]` (v2.7+) if you have a recent trend handy.
|
||||||
|
- A list of items with status → `list` with `text` + `status` per item (≤8 items). 12+ items → use `status_grid` (v2.7+) for a denser layout.
|
||||||
|
- Time-series data → `chart` with `line` or `bar` type.
|
||||||
|
- Rows × columns of heterogeneous data → `table`.
|
||||||
|
- A live URL (useful for monitoring a site) → `webview`. **Including a webview widget exposes a Site tab** next to the Dashboard tab — worth noting to the user.
|
||||||
|
- A static image / generated chart → `image` (v2.7+; local file or remote URL).
|
||||||
|
- A progress bar for something with a clear 0-to-N scale → `progress`.
|
||||||
|
- Static help / markdown → `text` with `format: "markdown"`.
|
||||||
|
- A longer markdown report the cron job writes → `markdown_file` (v2.7+; reads from a file under the project, refreshes when the cron job rewrites it).
|
||||||
|
- The last N lines of a log/output file → `log_tail` (v2.7+).
|
||||||
|
- The state of one Hermes cron job (last run / next run / output) → `cron_status` (v2.7+).
|
||||||
|
|
||||||
|
**v2.7 file-reading widgets** (`markdown_file`, `log_tail`, `image`-with-`path`) read files relative to the project root. **By convention, write the underlying files inside `<project>/.scarf/`** (e.g. `.scarf/reports/weekly.md`, `.scarf/reports/run.log`) so the project-wide directory watch picks up changes and the widgets refresh automatically. Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes, so cron jobs writing outside `.scarf/` should `touch dashboard.json` after each run.
|
||||||
|
|
||||||
|
### 4. Configuration needs
|
||||||
|
|
||||||
|
- *"Does this project need anything configurable by the user — URLs to watch, API tokens, thresholds, a list of accounts?"*
|
||||||
|
|
||||||
|
If yes → design a config schema. Fields map to seven types (see Config Schema Design below). Remember: **secret fields never have defaults**; that's a hard validator rule.
|
||||||
|
|
||||||
|
If no → skip `.scarf/manifest.json`; the project works but won't have a Configuration form.
|
||||||
|
|
||||||
|
### 5. Target agents
|
||||||
|
|
||||||
|
- *"Which agents will operate this project? Just Claude Code? Also Cursor / Codex / Aider / other?"*
|
||||||
|
|
||||||
|
For v1 just write `AGENTS.md` — every modern agent reads it, and if you need a specific shim (CLAUDE.md, GEMINI.md, .cursorrules), add it as a symlink to AGENTS.md so content stays in sync.
|
||||||
|
|
||||||
|
## Widget Catalog (JSON shapes)
|
||||||
|
|
||||||
|
All widgets require `type` and `title`. Type-specific fields:
|
||||||
|
|
||||||
|
### `stat` — single metric
|
||||||
|
```json
|
||||||
|
{ "type": "stat", "title": "Sites Up", "value": 0,
|
||||||
|
"icon": "checkmark.circle.fill", "color": "green", "subtitle": "responded 2xx/3xx" }
|
||||||
|
```
|
||||||
|
`value` accepts number OR string (`WidgetValue` enum). `icon` is an SF Symbol name. `color` is one of: `green`, `red`, `blue`, `orange`, `yellow`, `purple`, `gray`.
|
||||||
|
|
||||||
|
### `progress` — 0.0 to 1.0 progress bar
|
||||||
|
```json
|
||||||
|
{ "type": "progress", "title": "Test Coverage", "value": 0.72, "label": "72% of statements" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `text` — markdown or plain text block
|
||||||
|
```json
|
||||||
|
{ "type": "text", "title": "Quick Start", "format": "markdown",
|
||||||
|
"content": "**1.** Click + in the Projects sidebar.\n\n**2.** ..." }
|
||||||
|
```
|
||||||
|
`format` is `"markdown"` or `"plain"`.
|
||||||
|
|
||||||
|
### `table` — columns × rows of strings
|
||||||
|
```json
|
||||||
|
{ "type": "table", "title": "Failing Tests",
|
||||||
|
"columns": ["Test", "Duration", "Last Passed"],
|
||||||
|
"rows": [["testFoo", "4.2s", "Apr 20"], ["testBar", "0.9s", "Apr 18"]] }
|
||||||
|
```
|
||||||
|
Every row MUST have the same length as `columns`.
|
||||||
|
|
||||||
|
### `chart` — line / bar / area / pie with series
|
||||||
|
```json
|
||||||
|
{ "type": "chart", "title": "Requests / day", "chartType": "line",
|
||||||
|
"xLabel": "Date", "yLabel": "Count",
|
||||||
|
"series": [{
|
||||||
|
"name": "staging",
|
||||||
|
"color": "blue",
|
||||||
|
"data": [{"x": "Apr 20", "y": 142}, {"x": "Apr 21", "y": 189}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`chartType` is `"line"`, `"bar"`, `"area"`, or `"pie"`.
|
||||||
|
|
||||||
|
### `list` — items with optional status badge
|
||||||
|
```json
|
||||||
|
{ "type": "list", "title": "Watched Sites",
|
||||||
|
"items": [
|
||||||
|
{ "text": "https://example.com", "status": "success" },
|
||||||
|
{ "text": "https://example.org", "status": "danger" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Status values (typed in v2.7+):** prefer the canonical set — `"success"`, `"warning"`, `"danger"`, `"info"`, `"pending"`, `"done"`, `"neutral"`. Common synonyms also work and map to the canonical case (`"ok"`, `"up"`, `"passing"` → success; `"down"`, `"error"`, `"failed"` → danger; `"active"` → info; `"complete"`, `"finished"` → done; `"warn"`, `"degraded"` → warning). Unknown strings render as plain text rather than crashing — old dashboards using ad-hoc statuses keep working unchanged. **For new templates, prefer the canonical names** so the colors stay predictable across Scarf releases.
|
||||||
|
|
||||||
|
### `webview` — embedded live URL
|
||||||
|
```json
|
||||||
|
{ "type": "webview", "title": "First Watched Site",
|
||||||
|
"url": "https://awizemann.github.io/scarf/", "height": 420 }
|
||||||
|
```
|
||||||
|
**Important:** including any `webview` widget in a dashboard exposes a **Site** tab next to the Dashboard tab in the project view. Useful for templates that watch something renderable. The agent can update `url` on cron runs to keep the Site tab in sync with config (e.g., set it to `values.sites[0]`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Catalog (v2.7+ — file-reading and richer widgets)
|
||||||
|
|
||||||
|
Five new widget types landed in v2.7. They all read from disk relative to the project root, and refresh automatically when any file under `<project>/.scarf/` changes — so a cron job that writes `<project>/.scarf/reports/uptime.md` will trigger the corresponding widget to re-render. **Convention: place the underlying files inside `.scarf/` (or a subdir of it) so the directory watch picks them up.** Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes.
|
||||||
|
|
||||||
|
### `markdown_file` — renders a markdown file from disk
|
||||||
|
```json
|
||||||
|
{ "type": "markdown_file", "title": "This Week", "path": ".scarf/reports/weekly.md" }
|
||||||
|
```
|
||||||
|
`path` is relative to the project root. Refuses absolute paths and `..` escape. Use this when the cron job writes a longer-form report; use `text` when the content is short and authored inline.
|
||||||
|
|
||||||
|
### `log_tail` — last N lines of a file, monospaced
|
||||||
|
```json
|
||||||
|
{ "type": "log_tail", "title": "Last cron run", "path": ".scarf/reports/run.log", "lines": 30 }
|
||||||
|
```
|
||||||
|
Default `lines` is 20, capped at 200. ANSI color codes are stripped automatically. Pair with cron jobs that write atomic log snapshots (write-temp + rename) — in-place appends won't refresh until `dashboard.json` is touched.
|
||||||
|
|
||||||
|
### `cron_status` — last/next run + state for one Hermes cron job
|
||||||
|
```json
|
||||||
|
{ "type": "cron_status", "title": "Uptime sweep", "jobId": "uptime-sweep", "lines": 5 }
|
||||||
|
```
|
||||||
|
`jobId` matches a `HermesCronJob.id` (visible in the Cron tab). Read-only — Run/Pause/Resume actions stay on the Cron tab; this widget only reports state. Great for dashboards that drive a single scheduled task.
|
||||||
|
|
||||||
|
### `image` — local file or remote URL
|
||||||
|
```json
|
||||||
|
{ "type": "image", "title": "Latency p95", "path": ".scarf/reports/latency.png", "height": 200 }
|
||||||
|
{ "type": "image", "title": "Build status", "url": "https://example.com/badge.svg" }
|
||||||
|
```
|
||||||
|
Either `path` (local, relative to project root) OR `url` (remote). `path` wins when both are set. Useful for chart PNGs the cron job generates with matplotlib / Plotly.
|
||||||
|
|
||||||
|
### `status_grid` — compact NxM grid of colored cells
|
||||||
|
```json
|
||||||
|
{ "type": "status_grid", "title": "Fleet", "gridColumns": 6, "cells": [
|
||||||
|
{ "label": "us-east-1", "status": "success", "tooltip": "200ms p50" },
|
||||||
|
{ "label": "us-west-2", "status": "warning", "tooltip": "elevated latency" },
|
||||||
|
{ "label": "eu-central-1", "status": "danger", "tooltip": "down" }
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
Reuses the typed status enum from `list`. Auto-fits columns when `gridColumns` is omitted. Denser than a `list` when monitoring 12+ services at a glance.
|
||||||
|
|
||||||
|
### `stat` — sparkline (v2.7+ additive field)
|
||||||
|
```json
|
||||||
|
{ "type": "stat", "title": "Releases this month", "value": 4,
|
||||||
|
"color": "blue", "sparkline": [1, 2, 1, 3, 2, 4] }
|
||||||
|
```
|
||||||
|
Optional `sparkline: [Number]` renders a 1-line trend under the big number. Min 2 points, no max — tiny SVG path, cheap. Works on every existing `stat` widget without breaking older Scarf builds (they ignore the unknown field).
|
||||||
|
|
||||||
|
### Choosing a widget type — quick guide
|
||||||
|
|
||||||
|
- Counting things → `stat` (add `sparkline` if you have a recent trend).
|
||||||
|
- Progress toward a target → `progress`.
|
||||||
|
- Authored copy or short instructions → `text` (markdown).
|
||||||
|
- A report the cron job writes to disk → `markdown_file`.
|
||||||
|
- The most-recent run output of a cron job → `log_tail` or `cron_status`.
|
||||||
|
- A list of services / URLs / items with health → `list` (≤8 items) or `status_grid` (12+ items).
|
||||||
|
- Tabular data → `table` (or `chart` if it's numeric and you want trends).
|
||||||
|
- A live website or chart from a cron-generated PNG → `webview` (browsable) or `image` (static).
|
||||||
|
|
||||||
|
## Config Schema Design
|
||||||
|
|
||||||
|
If the project needs user-configurable values, design a schema. Put it in `<project>/.scarf/manifest.json` with this shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"id": "author/project",
|
||||||
|
"name": "My Project",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Short one-liner.",
|
||||||
|
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
|
||||||
|
"config": {
|
||||||
|
"schema": [
|
||||||
|
{ "key": "sites", "type": "list", "itemType": "string", "label": "Sites",
|
||||||
|
"required": true, "minItems": 1, "maxItems": 25,
|
||||||
|
"default": ["https://example.com"] },
|
||||||
|
{ "key": "api_token", "type": "secret", "label": "API Token", "required": true }
|
||||||
|
],
|
||||||
|
"modelRecommendation": {
|
||||||
|
"preferred": "claude-haiku-4",
|
||||||
|
"rationale": "Short-running, tool-light workload — haiku is plenty."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `contents.config` is the **count of schema fields**, not a boolean. In the example above it's `2` because there are two fields.
|
||||||
|
|
||||||
|
### Field types and constraints
|
||||||
|
|
||||||
|
| Type | Rendered as | Constraint keys |
|
||||||
|
|---|---|---|
|
||||||
|
| `string` | Text field | `pattern` (regex), `minLength`, `maxLength` |
|
||||||
|
| `text` | Multi-line editor | `minLength`, `maxLength` |
|
||||||
|
| `number` | Number field | `min`, `max` |
|
||||||
|
| `bool` | Toggle | — |
|
||||||
|
| `enum` | Segmented (≤4) / Dropdown (>4) | `options: [{value, label}]` (REQUIRED) |
|
||||||
|
| `list` | Repeatable rows | `itemType: "string"` (required), `minItems`, `maxItems` |
|
||||||
|
| `secret` | Password field, routes to Keychain | — |
|
||||||
|
|
||||||
|
Every field takes `key` (required), `label` (required), `description` (optional — markdown), `required` (bool), `default` (optional; type matches the field type).
|
||||||
|
|
||||||
|
### Writing good descriptions
|
||||||
|
|
||||||
|
Descriptions render inline with markdown support (bold, italic, code, links). Keep them short — a single line or two is ideal.
|
||||||
|
|
||||||
|
**Always use markdown link syntax for URLs**, never bare `https://…` — the Configuration sheet's inline text renderer doesn't word-break mid-URL, so a raw URL in a description will force that whole description's width to the URL's character length. Older Scarf versions clipped the sheet in that case; current versions wrap correctly, but the visible text is still cleaner with named links.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// ✓ Good — short label, URL in the href
|
||||||
|
"description": "Token with `repo` scope. Get one [from the GitHub tokens page](https://github.com/settings/tokens)."
|
||||||
|
|
||||||
|
// ✗ Bad — raw URL bloats the visible text
|
||||||
|
"description": "Token with `repo` scope. Get one at https://github.com/settings/tokens"
|
||||||
|
```
|
||||||
|
|
||||||
|
Same rule for long file paths, API endpoints, or any other unbreakable token — wrap them in inline code (backticks) if they have to appear verbatim, and prefer markdown links otherwise.
|
||||||
|
|
||||||
|
### Hard rules
|
||||||
|
|
||||||
|
- **Secret fields MUST NOT have a `default`.** The validator rejects the manifest if they do — a default makes no sense because the Keychain entry doesn't exist yet at install time.
|
||||||
|
- **Enum fields MUST have non-empty `options`.**
|
||||||
|
- **List fields MUST have `itemType: "string"`** in v1 (only itemType supported).
|
||||||
|
- **Field keys MUST be unique** within a schema.
|
||||||
|
- **`schemaVersion` MUST be 2** when a `config` block is present; it stays 1 if there's no config.
|
||||||
|
- **`contents.config`** must equal the actual count of schema fields — a claim mismatch is rejected.
|
||||||
|
|
||||||
|
## Cron Job Design
|
||||||
|
|
||||||
|
If the project has a scheduled task, register a cron job via `hermes cron create` AND — if you expect the user to export this as a `.scarftemplate` — author a `cron/jobs.json` in the staging layout so the exporter picks it up.
|
||||||
|
|
||||||
|
### Staging shape (for exportable templates)
|
||||||
|
|
||||||
|
```
|
||||||
|
<project>/
|
||||||
|
├── .scarf/
|
||||||
|
├── AGENTS.md
|
||||||
|
├── README.md
|
||||||
|
└── cron/
|
||||||
|
└── jobs.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `cron/jobs.json` is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Check site status",
|
||||||
|
"schedule": "0 9 * * *",
|
||||||
|
"prompt": "Read {{PROJECT_DIR}}/.scarf/config.json — get values.sites and values.timeout_seconds — then HTTP GET each URL with that timeout, write the results to {{PROJECT_DIR}}/status-log.md, and update {{PROJECT_DIR}}/.scarf/dashboard.json's stat widgets by title (Sites Up, Sites Down, Last Checked). Reply with a one-line summary."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using secrets in cron prompts
|
||||||
|
|
||||||
|
`secret`-typed config fields land in the macOS Keychain at install time, with `keychain://` URIs in `<project>/.scarf/config.json` (never plaintext on disk). At install + on every config save, **Scarf mirrors the resolved values into `~/.hermes/.env`** under env var names like `SCARF_<UPPERCASE_SLUG>_<UPPERCASE_FIELDKEY>`. Hermes's cron scheduler reloads `~/.hermes/.env` fresh on every tick, so the values are reachable from any tool the agent invokes.
|
||||||
|
|
||||||
|
**The agent reads them via the terminal or code_exec tool — not from prompt-text substitution.** Hermes does not interpolate env vars into prompt bodies. Tool-invoked subprocesses (the only path through which env vars become visible) DO see them via shell-level expansion or `os.environ`. Cron prompts should reference secrets in tool invocations, not in inline text.
|
||||||
|
|
||||||
|
**Naming.** For a template with `slug = "site-status-checker"` and a secret field `api_token`, the env var is `SCARF_SITE_STATUS_CHECKER_API_TOKEN`. Both halves are upper-cased and any non-`[A-Z0-9_]` characters become `_`. Stable across releases — write your prompts using these names and they'll keep working when the user rotates the secret.
|
||||||
|
|
||||||
|
**Example cron prompt (with a secret):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Daily news digest",
|
||||||
|
"schedule": "0 9 * * *",
|
||||||
|
"prompt": "Use the terminal tool to fetch the RSS feed: `curl -sS -H \"Authorization: Bearer $SCARF_LOCAL_NEWS_API_TOKEN\" \"$SCARF_LOCAL_NEWS_RSS_URL\" -o {{PROJECT_DIR}}/.scarf/feed.xml`. Then summarise the top 5 items into {{PROJECT_DIR}}/.scarf/digest.md."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent runs `curl` via the terminal tool; the shell expands the env vars from the cron process's environment (which Hermes populated by loading `~/.hermes/.env`). For Python via the code_exec tool, use `os.environ['SCARF_LOCAL_NEWS_API_TOKEN']`.
|
||||||
|
|
||||||
|
**What NOT to do:**
|
||||||
|
|
||||||
|
- ❌ *"Read `keychain://...` from config.json and call the API with it."* Hermes treats the URI as opaque text — the API call sends `Authorization: Bearer keychain://...` and gets a 401.
|
||||||
|
- ❌ *"Use the API token from values.api_token in config.json."* Same issue — the value in config.json is the URI, not the secret.
|
||||||
|
- ❌ Inlining a secret into the prompt body and asking the agent to use it. Secrets shouldn't appear in prompts; that's why we route them through env vars.
|
||||||
|
|
||||||
|
**What about `~/.hermes/.env` rotation?** The user rotates a secret in Scarf's Configuration sheet → Scarf re-resolves from the Keychain → re-mirrors to `~/.hermes/.env` → next cron tick (Hermes reloads `.env` per tick) sees the new value. No cron-job edit needed.
|
||||||
|
|
||||||
|
### Gotchas
|
||||||
|
|
||||||
|
- **Hermes does not set a CWD when firing cron jobs.** Relative paths in the prompt resolve against wherever the Hermes process happens to be running, not the project. Always use `{{PROJECT_DIR}}` in the prompt — the installer substitutes the absolute path at install time. This is THE most common template-author mistake.
|
||||||
|
- **Cron jobs created by the installer start paused.** Their name is auto-prefixed with `[tmpl:<template-id>]`. The user enables them from Scarf's Cron sidebar when ready.
|
||||||
|
- **Registering a cron job for a user's local (non-exported) project:** run `hermes cron create --name "<descriptive name>" "<schedule>" "<prompt>"` directly, substituting the absolute `<project>` path for `{{PROJECT_DIR}}` yourself. Then `hermes cron pause <id>` so it doesn't run until the user opts in.
|
||||||
|
- **Hermes does not substitute env vars into prompt text.** `$VAR` references in the prompt body are passed through verbatim. Env vars only become visible when the agent invokes a tool (terminal, code_exec) that runs in a subprocess inheriting the cron process's environment — see the "Using secrets in cron prompts" section above.
|
||||||
|
|
||||||
|
### Schedule quick reference
|
||||||
|
|
||||||
|
| Cadence | Expression |
|
||||||
|
|---|---|
|
||||||
|
| Every 15 minutes | `*/15 * * * *` |
|
||||||
|
| Hourly at :00 | `0 * * * *` |
|
||||||
|
| Daily at 9 AM | `0 9 * * *` |
|
||||||
|
| Weekly Monday 9 AM | `0 9 * * 1` |
|
||||||
|
| First of the month, 9 AM | `0 9 1 * *` |
|
||||||
|
|
||||||
|
## Writing the files
|
||||||
|
|
||||||
|
After the interview, write files in this order.
|
||||||
|
|
||||||
|
### Step 1 — confirm parent directory
|
||||||
|
|
||||||
|
Ask: *"Where should I create the project? Give me an absolute path — I'll make a `<project-name>` directory inside it."*
|
||||||
|
|
||||||
|
Make sure the parent exists and is writable. Make sure `<parent>/<project-name>` does NOT already exist. If it does, ask whether to pick a different name or bail.
|
||||||
|
|
||||||
|
### Step 2 — create the skeleton
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p <parent>/<project-name>/.scarf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — write `dashboard.json`
|
||||||
|
|
||||||
|
Use the Widget Catalog above. Always include:
|
||||||
|
|
||||||
|
- `version: 1`
|
||||||
|
- `title` (the project's display name)
|
||||||
|
- `description` (a one-liner shown under the title)
|
||||||
|
- `sections` (array; each has `title`, optional `columns` (1–4, default 3), `widgets`)
|
||||||
|
|
||||||
|
Keep section titles short. Group related widgets. First section is usually "Current Status" or similar with the key stats.
|
||||||
|
|
||||||
|
### Step 4 — write `manifest.json` (only if the project has a config schema)
|
||||||
|
|
||||||
|
Put the full manifest shape from Config Schema Design above. Use `schemaVersion: 2`, match `contents.config` to the actual field count, and ensure every secret field has no `default`.
|
||||||
|
|
||||||
|
If there's no config schema, skip this file — the project still works, it just won't have a Configuration button. You can add it later.
|
||||||
|
|
||||||
|
### Step 5 — write `AGENTS.md`
|
||||||
|
|
||||||
|
Every scaffolded project needs an `AGENTS.md` that covers:
|
||||||
|
|
||||||
|
- **Purpose** — what the project does.
|
||||||
|
- **Layout** — which files exist and what they're for.
|
||||||
|
- **Configuration** — if there's a config schema, document every field: what it's for, what valid values look like, what happens when it's missing.
|
||||||
|
- **Dashboard** — list every widget the cron job (if any) updates, by title. If the cron updates a webview widget's URL, document that explicitly.
|
||||||
|
- **Cron behaviour** — what the cron job does, what it reads, what it writes, what its exit criteria are.
|
||||||
|
- **Chat prompts** — common user questions and how to answer them (e.g., *"What's the status of my sites?"* → "read the top section of `status-log.md` and summarise").
|
||||||
|
- **What NOT to do** — e.g., *don't modify `.scarf/config.json` yourself; tell the user to open the Configuration button.*
|
||||||
|
|
||||||
|
Use `{{PROJECT_DIR}}` placeholders in AGENTS.md only if the template will be installed through the installer (which substitutes the token). For a hand-scaffolded local-only project, substitute the absolute path yourself — `{{PROJECT_DIR}}` only resolves at install time.
|
||||||
|
|
||||||
|
### Step 6 — write `README.md`
|
||||||
|
|
||||||
|
User-facing. Keep it short:
|
||||||
|
|
||||||
|
- One-paragraph purpose.
|
||||||
|
- How to install / first run (for an unexported project: "click + in Scarf's Projects sidebar").
|
||||||
|
- How to trigger the cron job manually (Cron sidebar → Run Now).
|
||||||
|
- A pointer at `AGENTS.md` for agents.
|
||||||
|
|
||||||
|
### Step 7 — register the cron job (if any)
|
||||||
|
|
||||||
|
For a local non-exported project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes cron create --name "<descriptive name>" "<schedule>" "<prompt with absolute project dir substituted>"
|
||||||
|
# Then pause it so it doesn't fire until the user's ready:
|
||||||
|
hermes cron pause <newly-created-job-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the id back from `hermes cron list --json` or parse the create output.
|
||||||
|
|
||||||
|
For an exportable template (one you're staging in `templates/<author>/<name>/staging/`): just author `cron/jobs.json` — the installer registers + pauses at install time, and prefixes the name with `[tmpl:<id>]`.
|
||||||
|
|
||||||
|
### Step 8 — register the project with Scarf
|
||||||
|
|
||||||
|
Tell the user: *"I've written the files. Click the **+** button in Scarf's Projects sidebar and pick `<absolute-project-dir>`. The dashboard will appear."*
|
||||||
|
|
||||||
|
Do NOT edit `~/.hermes/scarf/projects.json` directly — Scarf owns that file and reloads it on its own. The UI path is safer.
|
||||||
|
|
||||||
|
### Step 9 (optional) — log to the Template Author project's list
|
||||||
|
|
||||||
|
If the user has the `awizemann/template-author` project installed (the one that shipped this skill), append an entry to its `dashboard.json`'s `Scaffolded Projects` list widget:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "text": "<absolute-project-dir> — <one-line purpose>", "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
This gives the user a running audit trail of everything you've scaffolded for them. Preserve every other field in the dashboard as-is.
|
||||||
|
|
||||||
|
## Testing your scaffold
|
||||||
|
|
||||||
|
### Minimum smoke test
|
||||||
|
|
||||||
|
1. Tell the user to click **+** in Scarf's Projects sidebar and pick the directory.
|
||||||
|
2. Dashboard appears — sanity check every widget renders correctly.
|
||||||
|
3. If there's a cron job: click the job in Scarf's Cron sidebar → **Run Now**. The agent executes the prompt; dashboard updates when it finishes.
|
||||||
|
|
||||||
|
### Configuration-form test (only if schema was declared)
|
||||||
|
|
||||||
|
To verify the Configuration form renders, you need to *install* the project as a template — scaffolded projects don't go through the installer, so the form never runs. Export the project first:
|
||||||
|
|
||||||
|
1. Projects → Templates → **Export "<name>" as Template…** → save the `.scarftemplate` somewhere.
|
||||||
|
2. Projects → Templates → **Install from File…** → pick the bundle → the Configure step should render the form you designed.
|
||||||
|
3. Cancel the install (the preview sheet has a Cancel button) — you just wanted to verify the form shape.
|
||||||
|
|
||||||
|
### Catalog validation (only if publishing)
|
||||||
|
|
||||||
|
If the user plans to submit this to the public catalog at `awizemann.github.io/scarf/templates/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root
|
||||||
|
./scripts/catalog.sh check
|
||||||
|
```
|
||||||
|
|
||||||
|
Validates every template in `templates/<author>/<name>/` against the Python validator — the same one the PR CI uses. Catches schema issues, claim mismatches, size violations, common secret patterns.
|
||||||
|
|
||||||
|
## Common pitfalls
|
||||||
|
|
||||||
|
Things to check before declaring the scaffold done:
|
||||||
|
|
||||||
|
- [ ] Every cron prompt uses `{{PROJECT_DIR}}` (for exported) OR an absolute path (for local-only). Relative paths will fail.
|
||||||
|
- [ ] `contents.config` in the manifest equals the actual field count. Claim mismatch = rejected.
|
||||||
|
- [ ] No `default` on any `secret` field.
|
||||||
|
- [ ] Every enum field has non-empty `options`.
|
||||||
|
- [ ] Every list field has `itemType: "string"`.
|
||||||
|
- [ ] Every table widget has rows of length equal to `columns`.
|
||||||
|
- [ ] Every webview widget has an https URL that renders something meaningful even pre-first-run (Scarf homepage is a decent placeholder).
|
||||||
|
- [ ] `dashboard.json` has `version: 1` at the top.
|
||||||
|
- [ ] `AGENTS.md` documents every config field, every updated widget, and the cron behaviour — the user relies on it as the source of truth when things drift.
|
||||||
|
- [ ] **No raw URLs in field descriptions.** Use `[link text](https://…)` markdown syntax instead — raw URLs read as long unbreakable tokens in the Configuration sheet. Same rule for long paths and other unbreakable strings; wrap in `` ` `` if they must appear verbatim.
|
||||||
|
- [ ] **Leave the `<!-- scarf-project:begin -->` / `<!-- scarf-project:end -->` region alone in the project's `AGENTS.md`.** As of Scarf v2.3, the app auto-injects a project-identity block at chat-start time (project name, directory, template id, configuration field names, cron jobs). Anything you write inside that region will be overwritten on the next chat start. Put template-specific agent instructions BELOW the block so they're preserved across refreshes.
|
||||||
|
|
||||||
|
## Reference — source of truth files
|
||||||
|
|
||||||
|
- **Dashboard widget schema** — `scarf/scarf/Core/Models/ProjectDashboard.swift` in the Scarf repo. If you need exact field types or defaults, read it.
|
||||||
|
- **Config schema + validation** — `scarf/scarf/Core/Models/TemplateConfig.swift` and `scarf/scarf/Core/Services/ProjectConfigService.swift`.
|
||||||
|
- **Exporter behaviour** — `scarf/scarf/Core/Services/ProjectTemplateExporter.swift`. Verifies what files the exporter will pick up from a live project and what it'll carry into a bundle.
|
||||||
|
- **Installer contract** — `scarf/scarf/Core/Services/ProjectTemplateInstaller.swift`. Verifies what `{{PROJECT_DIR}}` substitution covers and where installed files land.
|
||||||
|
- **Catalog validator** — `tools/build-catalog.py` in the Scarf repo. Run with `./scripts/catalog.sh check` for the same rules CI uses.
|
||||||
|
- **Worked example** — `templates/awizemann/site-status-checker/staging/` in the Scarf repo. Complete end-to-end: dashboard with stats + list + webview, a config schema with a list + a number, a cron job, an AGENTS.md that documents every moving part. Read it first whenever you're unsure how a piece should look.
|
||||||
|
- **User-facing docs** — [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates).
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ScarfCore
|
import ScarfCore
|
||||||
|
import os
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ScarfApp: App {
|
struct ScarfApp: App {
|
||||||
@@ -64,6 +65,36 @@ struct ScarfApp: App {
|
|||||||
_ = HermesFileService.enrichedEnvironment()
|
_ = HermesFileService.enrichedEnvironment()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bootstrap built-in skills shipped inside the app bundle into
|
||||||
|
// `~/.hermes/skills/`. Today this is just `scarf-template-author`,
|
||||||
|
// which the "New Project from Scratch" wizard hands off to. The
|
||||||
|
// service is idempotent + version-gated; failures log and don't
|
||||||
|
// block launch — worst case is the wizard still works but the
|
||||||
|
// agent doesn't have the skill loaded for that session.
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
do {
|
||||||
|
try SkillBootstrapService(context: .local).ensureBundledSkillsInstalled()
|
||||||
|
} catch {
|
||||||
|
Logger(subsystem: "com.scarf", category: "scarfApp")
|
||||||
|
.warning("skill bootstrap failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile every registered project's secrets-env block in
|
||||||
|
// ~/.hermes/.env. Catches users upgrading from a pre-mirror
|
||||||
|
// Scarf version (existing projects' Keychain values weren't
|
||||||
|
// mirrored before) and any drift between the Keychain state
|
||||||
|
// and the env file. Idempotent — projects whose blocks are
|
||||||
|
// already current produce no write.
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
do {
|
||||||
|
try KeychainEnvMirror(context: .local).reconcileAll()
|
||||||
|
} catch {
|
||||||
|
Logger(subsystem: "com.scarf", category: "scarfApp")
|
||||||
|
.warning("env-mirror reconcile failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test-mode launch-URL handoff. When XCUITest passes
|
// Test-mode launch-URL handoff. When XCUITest passes
|
||||||
// `--scarf-test-install-url <https-url>`, route the URL
|
// `--scarf-test-install-url <https-url>`, route the URL
|
||||||
// through `TemplateURLRouter` so `ProjectsView`'s onAppear
|
// through `TemplateURLRouter` so `ProjectsView`'s onAppear
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
@testable import scarf
|
||||||
|
|
||||||
|
/// Exercises the file-only seam of `KeychainEnvMirror` —
|
||||||
|
/// `mirror(slug:entries:envPath:)` and `unmirror(slug:envPath:)`.
|
||||||
|
/// These take pre-resolved entries so the suite never touches the
|
||||||
|
/// macOS Keychain or the user's real `~/.hermes/.env`.
|
||||||
|
///
|
||||||
|
/// The Keychain-resolution path (`mirror(project:)`,
|
||||||
|
/// `resolveSecrets(for:)`) is covered by the manual end-to-end
|
||||||
|
/// verification in the project plan — putting that under unit tests
|
||||||
|
/// would either pollute the user's login keychain or require a
|
||||||
|
/// mock-keychain abstraction that's out of scope for v2.8.
|
||||||
|
@Suite("KeychainEnvMirror")
|
||||||
|
struct KeychainEnvMirrorTests {
|
||||||
|
|
||||||
|
// MARK: - derivedSlug
|
||||||
|
|
||||||
|
@Test func derivedSlugSimple() {
|
||||||
|
let project = ProjectEntry(name: "Local News", path: "/tmp/x")
|
||||||
|
#expect(KeychainEnvMirror.derivedSlug(forProject: project) == "local-news")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func derivedSlugCollapsesSpecials() {
|
||||||
|
let project = ProjectEntry(name: "Foo & Bar (2)", path: "/tmp/x")
|
||||||
|
// Anything not [a-z0-9] becomes a separator; runs collapse;
|
||||||
|
// trailing separators stripped.
|
||||||
|
#expect(KeychainEnvMirror.derivedSlug(forProject: project) == "foo-bar-2")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func derivedSlugAllSymbolsFallsBackToProject() {
|
||||||
|
let project = ProjectEntry(name: "!!!", path: "/tmp/x")
|
||||||
|
// Pathological all-symbols name still yields a valid slug.
|
||||||
|
#expect(KeychainEnvMirror.derivedSlug(forProject: project) == "project")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - mirror(slug:entries:envPath:) — file shape
|
||||||
|
|
||||||
|
@Test func mirrorWritesBlockToFreshEnv() throws {
|
||||||
|
let env = try TempEnv()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.mirror(
|
||||||
|
slug: "local-news",
|
||||||
|
entries: [("SCARF_LOCAL_NEWS_API_TOKEN", "abc123")],
|
||||||
|
envPath: env.path
|
||||||
|
)
|
||||||
|
let written = try env.read()
|
||||||
|
#expect(written.contains("# scarf-secrets:begin local-news"))
|
||||||
|
#expect(written.contains("SCARF_LOCAL_NEWS_API_TOKEN=abc123"))
|
||||||
|
#expect(written.contains("# scarf-secrets:end local-news"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func mirrorEnforcesMode0600() throws {
|
||||||
|
let env = try TempEnv()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.mirror(
|
||||||
|
slug: "x",
|
||||||
|
entries: [("KEY", "value")],
|
||||||
|
envPath: env.path
|
||||||
|
)
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: env.path)
|
||||||
|
let perms = attrs[.posixPermissions] as? NSNumber
|
||||||
|
#expect(
|
||||||
|
perms?.intValue == 0o600,
|
||||||
|
"expected mode 0600 on \(env.path); got \(String(describing: perms))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func mirrorPreservesUserContent() throws {
|
||||||
|
let env = try TempEnv()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
try env.write("ANTHROPIC_API_KEY=sk-test\n")
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.mirror(
|
||||||
|
slug: "x",
|
||||||
|
entries: [("KEY", "value")],
|
||||||
|
envPath: env.path
|
||||||
|
)
|
||||||
|
let written = try env.read()
|
||||||
|
// User content stays intact at the top.
|
||||||
|
#expect(written.hasPrefix("ANTHROPIC_API_KEY=sk-test"))
|
||||||
|
// Block landed below it.
|
||||||
|
#expect(written.contains("# scarf-secrets:begin x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func mirrorEmptyEntriesRemovesBlock() throws {
|
||||||
|
// The documented sentinel for "secrets cleared" — empty
|
||||||
|
// entries triggers unmirror, not an empty block with
|
||||||
|
// dangling markers.
|
||||||
|
let env = try TempEnv()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.mirror(
|
||||||
|
slug: "x",
|
||||||
|
entries: [("KEY", "value")],
|
||||||
|
envPath: env.path
|
||||||
|
)
|
||||||
|
try mirror.mirror(
|
||||||
|
slug: "x",
|
||||||
|
entries: [],
|
||||||
|
envPath: env.path
|
||||||
|
)
|
||||||
|
let written = try env.read()
|
||||||
|
#expect(!written.contains("scarf-secrets"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func mirrorMultiProjectIsolated() throws {
|
||||||
|
// Mirroring slug A then slug B then re-mirroring A doesn't
|
||||||
|
// disturb B's block — the most important multi-project
|
||||||
|
// invariant for this file.
|
||||||
|
let env = try TempEnv()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.mirror(slug: "alpha", entries: [("A", "1")], envPath: env.path)
|
||||||
|
try mirror.mirror(slug: "bravo", entries: [("B", "2")], envPath: env.path)
|
||||||
|
let beforeUpdate = try env.read()
|
||||||
|
try mirror.mirror(slug: "alpha", entries: [("A", "1-updated")], envPath: env.path)
|
||||||
|
let afterUpdate = try env.read()
|
||||||
|
#expect(afterUpdate.contains("A=1-updated"))
|
||||||
|
#expect(!afterUpdate.contains("A=1\n"))
|
||||||
|
// Bravo block byte-identical.
|
||||||
|
let beforeBravo = extractBlock(slug: "bravo", from: beforeUpdate)
|
||||||
|
let afterBravo = extractBlock(slug: "bravo", from: afterUpdate)
|
||||||
|
#expect(beforeBravo != nil)
|
||||||
|
#expect(beforeBravo == afterBravo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func mirrorIdempotentWhenUnchanged() async throws {
|
||||||
|
// Reconcile-on-launch fires this every cold start. If the
|
||||||
|
// input hasn't changed, we shouldn't rewrite the file —
|
||||||
|
// that bumps mtime and triggers anything watching `.env`.
|
||||||
|
let env = try TempEnv()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.mirror(
|
||||||
|
slug: "x",
|
||||||
|
entries: [("KEY", "value")],
|
||||||
|
envPath: env.path
|
||||||
|
)
|
||||||
|
let mtimeBefore = try env.modificationDate()
|
||||||
|
// Sleep one second so a real write would advance mtime to a
|
||||||
|
// distinct value (APFS mtime resolution is nanosecond on
|
||||||
|
// modern macOS but we want to be unambiguous).
|
||||||
|
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
try mirror.mirror(
|
||||||
|
slug: "x",
|
||||||
|
entries: [("KEY", "value")],
|
||||||
|
envPath: env.path
|
||||||
|
)
|
||||||
|
let mtimeAfter = try env.modificationDate()
|
||||||
|
#expect(
|
||||||
|
mtimeBefore == mtimeAfter,
|
||||||
|
"mtime advanced from \(mtimeBefore) to \(mtimeAfter) — no-op write fired"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - unmirror(slug:envPath:)
|
||||||
|
|
||||||
|
@Test func unmirrorRemovesBlock() throws {
|
||||||
|
let env = try TempEnv()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.mirror(slug: "x", entries: [("KEY", "value")], envPath: env.path)
|
||||||
|
try mirror.unmirror(slug: "x", envPath: env.path)
|
||||||
|
let written = try env.read()
|
||||||
|
#expect(!written.contains("scarf-secrets"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unmirrorNoOpWhenFileMissing() throws {
|
||||||
|
// Brand-new install with no env file: unmirror on uninstall
|
||||||
|
// shouldn't throw or create an empty file.
|
||||||
|
let env = try TempEnv(initialContents: nil)
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.unmirror(slug: "x", envPath: env.path)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: env.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unmirrorPreservesOtherProjectBlocks() throws {
|
||||||
|
let env = try TempEnv()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.mirror(slug: "alpha", entries: [("A", "1")], envPath: env.path)
|
||||||
|
try mirror.mirror(slug: "bravo", entries: [("B", "2")], envPath: env.path)
|
||||||
|
try mirror.unmirror(slug: "alpha", envPath: env.path)
|
||||||
|
let written = try env.read()
|
||||||
|
#expect(!written.contains("# scarf-secrets:begin alpha"))
|
||||||
|
#expect(written.contains("# scarf-secrets:begin bravo"))
|
||||||
|
#expect(written.contains("B=2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unmirrorNoOpWhenSlugAbsent() throws {
|
||||||
|
// Removing a project that never had secrets shouldn't alter
|
||||||
|
// the file.
|
||||||
|
let env = try TempEnv(initialContents: "USER=x\n")
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let mirror = KeychainEnvMirror(context: .local)
|
||||||
|
try mirror.unmirror(slug: "neverwashere", envPath: env.path)
|
||||||
|
let written = try env.read()
|
||||||
|
#expect(written == "USER=x\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test helpers
|
||||||
|
|
||||||
|
private func extractBlock(slug: String, from text: String) -> String? {
|
||||||
|
let begin = "# scarf-secrets:begin \(slug)"
|
||||||
|
let end = "# scarf-secrets:end \(slug)"
|
||||||
|
guard let beginRange = text.range(of: begin),
|
||||||
|
let endRange = text.range(
|
||||||
|
of: end,
|
||||||
|
range: beginRange.upperBound..<text.endIndex
|
||||||
|
) else { return nil }
|
||||||
|
return String(text[beginRange.lowerBound..<endRange.upperBound])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TempEnv
|
||||||
|
|
||||||
|
/// One-shot wrapper around a temp directory with a `.env` file.
|
||||||
|
/// The basename `.env` is what the LocalTransport's mode-0600
|
||||||
|
/// heuristic keys on, so writes through this path get the same
|
||||||
|
/// permission treatment as the real `~/.hermes/.env`.
|
||||||
|
private struct TempEnv {
|
||||||
|
let directory: URL
|
||||||
|
let path: String
|
||||||
|
|
||||||
|
init(initialContents: String? = "") throws {
|
||||||
|
directory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||||
|
.appendingPathComponent("scarf-keychain-env-mirror-tests-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let envURL = directory.appendingPathComponent(".env")
|
||||||
|
path = envURL.path
|
||||||
|
if let initialContents {
|
||||||
|
try initialContents.write(to: envURL, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func read() throws -> String {
|
||||||
|
try String(contentsOfFile: path, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(_ contents: String) throws {
|
||||||
|
try contents.write(toFile: path, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func modificationDate() throws -> Date {
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: path)
|
||||||
|
guard let date = attrs[.modificationDate] as? Date else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "TempEnv",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "no mtime on \(path)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanup() {
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: scarf-template-author
|
name: scarf-template-author
|
||||||
description: Scaffold a new Scarf project — dashboard, optional configuration schema, optional cron job, and AGENTS.md — from a short conversational interview with the user. Output is immediately usable locally and cleanly exportable as a .scarftemplate bundle.
|
description: Scaffold a new Scarf project — dashboard, optional configuration schema, optional cron job, and AGENTS.md — from a short conversational interview with the user. Output is immediately usable locally and cleanly exportable as a .scarftemplate bundle.
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
author: Alan Wizemann
|
author: Alan Wizemann
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [macos]
|
platforms: [macos]
|
||||||
@@ -319,11 +319,40 @@ Where `cron/jobs.json` is:
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Using secrets in cron prompts
|
||||||
|
|
||||||
|
`secret`-typed config fields land in the macOS Keychain at install time, with `keychain://` URIs in `<project>/.scarf/config.json` (never plaintext on disk). At install + on every config save, **Scarf mirrors the resolved values into `~/.hermes/.env`** under env var names like `SCARF_<UPPERCASE_SLUG>_<UPPERCASE_FIELDKEY>`. Hermes's cron scheduler reloads `~/.hermes/.env` fresh on every tick, so the values are reachable from any tool the agent invokes.
|
||||||
|
|
||||||
|
**The agent reads them via the terminal or code_exec tool — not from prompt-text substitution.** Hermes does not interpolate env vars into prompt bodies. Tool-invoked subprocesses (the only path through which env vars become visible) DO see them via shell-level expansion or `os.environ`. Cron prompts should reference secrets in tool invocations, not in inline text.
|
||||||
|
|
||||||
|
**Naming.** For a template with `slug = "site-status-checker"` and a secret field `api_token`, the env var is `SCARF_SITE_STATUS_CHECKER_API_TOKEN`. Both halves are upper-cased and any non-`[A-Z0-9_]` characters become `_`. Stable across releases — write your prompts using these names and they'll keep working when the user rotates the secret.
|
||||||
|
|
||||||
|
**Example cron prompt (with a secret):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Daily news digest",
|
||||||
|
"schedule": "0 9 * * *",
|
||||||
|
"prompt": "Use the terminal tool to fetch the RSS feed: `curl -sS -H \"Authorization: Bearer $SCARF_LOCAL_NEWS_API_TOKEN\" \"$SCARF_LOCAL_NEWS_RSS_URL\" -o {{PROJECT_DIR}}/.scarf/feed.xml`. Then summarise the top 5 items into {{PROJECT_DIR}}/.scarf/digest.md."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent runs `curl` via the terminal tool; the shell expands the env vars from the cron process's environment (which Hermes populated by loading `~/.hermes/.env`). For Python via the code_exec tool, use `os.environ['SCARF_LOCAL_NEWS_API_TOKEN']`.
|
||||||
|
|
||||||
|
**What NOT to do:**
|
||||||
|
|
||||||
|
- ❌ *"Read `keychain://...` from config.json and call the API with it."* Hermes treats the URI as opaque text — the API call sends `Authorization: Bearer keychain://...` and gets a 401.
|
||||||
|
- ❌ *"Use the API token from values.api_token in config.json."* Same issue — the value in config.json is the URI, not the secret.
|
||||||
|
- ❌ Inlining a secret into the prompt body and asking the agent to use it. Secrets shouldn't appear in prompts; that's why we route them through env vars.
|
||||||
|
|
||||||
|
**What about `~/.hermes/.env` rotation?** The user rotates a secret in Scarf's Configuration sheet → Scarf re-resolves from the Keychain → re-mirrors to `~/.hermes/.env` → next cron tick (Hermes reloads `.env` per tick) sees the new value. No cron-job edit needed.
|
||||||
|
|
||||||
### Gotchas
|
### Gotchas
|
||||||
|
|
||||||
- **Hermes does not set a CWD when firing cron jobs.** Relative paths in the prompt resolve against wherever the Hermes process happens to be running, not the project. Always use `{{PROJECT_DIR}}` in the prompt — the installer substitutes the absolute path at install time. This is THE most common template-author mistake.
|
- **Hermes does not set a CWD when firing cron jobs.** Relative paths in the prompt resolve against wherever the Hermes process happens to be running, not the project. Always use `{{PROJECT_DIR}}` in the prompt — the installer substitutes the absolute path at install time. This is THE most common template-author mistake.
|
||||||
- **Cron jobs created by the installer start paused.** Their name is auto-prefixed with `[tmpl:<template-id>]`. The user enables them from Scarf's Cron sidebar when ready.
|
- **Cron jobs created by the installer start paused.** Their name is auto-prefixed with `[tmpl:<template-id>]`. The user enables them from Scarf's Cron sidebar when ready.
|
||||||
- **Registering a cron job for a user's local (non-exported) project:** run `hermes cron create --name "<descriptive name>" "<schedule>" "<prompt>"` directly, substituting the absolute `<project>` path for `{{PROJECT_DIR}}` yourself. Then `hermes cron pause <id>` so it doesn't run until the user opts in.
|
- **Registering a cron job for a user's local (non-exported) project:** run `hermes cron create --name "<descriptive name>" "<schedule>" "<prompt>"` directly, substituting the absolute `<project>` path for `{{PROJECT_DIR}}` yourself. Then `hermes cron pause <id>` so it doesn't run until the user opts in.
|
||||||
|
- **Hermes does not substitute env vars into prompt text.** `$VAR` references in the prompt body are passed through verbatim. Env vars only become visible when the agent invokes a tool (terminal, code_exec) that runs in a subprocess inheriting the cron process's environment — see the "Using secrets in cron prompts" section above.
|
||||||
|
|
||||||
### Schedule quick reference
|
### Schedule quick reference
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user