mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
Dogfooding templates: HN Digest + in-app catalog browser + test harness (#71)
* feat(templates): hackernews-digest template + dogfooding test harness First pass of the dogfooding-templates initiative. Each pre-release cycle ships one new official `.scarftemplate` and uses installing/exercising that template as the regression test. v1 lands the harness scaffolding plus the first template under it. - HackerNews Daily Digest template (`templates/awizemann/hackernews-digest/`): config-driven (min_score / max_items / topics) cron-only template. No secrets — keeps the harness minimal until the fake-Keychain shim lands. Bundle validates against `tools/build-catalog.py`; entry added to `templates/catalog.json`. - `SCARF_HERMES_HOME` env-var override at `HermesProfileResolver` — the seam every Layer-B test relies on to drive Scarf against an isolated Hermes home. Bypasses cache + active_profile lookup; rejects relative paths. 5 unit tests + 3 ServerContext integration tests. - `TestModeFlags.shared.isTestMode` — reads `--scarf-test-mode` once from `CommandLine.arguments`. Wiring only; gating sites (Sparkle, capability probe, first-run walkthrough) land as Layer-B exercises them. - Layer A (`scarf/scarfTests/TemplateE2ETests.swift`): parses + plans the shipped HN bundle the way the app does at install time; asserts manifest, config schema, dashboard widgets, and cron prompt contract. Mirrors the existing site-status-checker coverage. - Layer B scaffold (`scarf/scarfUITests/TemplateInstallUITests.swift`): proves the launch-arg + env-var plumbing reaches Scarf. Full install click-through deferred until fixture-Hermes-home and accessibility IDs land. Wiki pages added separately on the `.wiki-worktree` branch: - `Template-Ideas.md` — backlog of 9 v1-feasible templates + full-spec v3 epic for Project-Site-as-Living-Surface (eBay listings use case). - `Test-Harness.md` — contributor guide for extending the harness. Verification: scarfTests 124/124, ScarfCore 220/220, new Layer A 3/3, Layer B scaffold 1/1, build-catalog.py + its 28 unit tests all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(test-harness): Layer B pivot to real ~/.hermes + a11y IDs + Sparkle gating Discovered during Layer B work that XCUITest runners are sandboxed: they can read ~/.hermes/ but writes throw NSFileWriteNoPermissionError. That kills the SCARF_HERMES_HOME-based isolation pattern for UI tests — snapshot/restore from inside the runner can't work. Pivot: - Layer B drives the real ~/.hermes the dev Mac is already running against. The harness assumes a working Hermes install (XCTSkip if the binary isn't there). Cleanup is via the app's own UI flows (which have full disk access), not direct file I/O. Layer A keeps its env-var seam — those tests run inside the host app's address space and write freely. - SwiftUI's WindowGroup(for: ServerID.self) doesn't auto-surface a window on a fresh XCUIApplication.launch(). The harness sends ⌘1 (the "Open Server → Local" menu shortcut wired in scarfApp.swift's OpenServerCommands) to take the same code path real users hit via Dock click. - Real user home resolved via getpwuid(getuid()) rather than NSHomeDirectory(), which inside the sandboxed runner returns ~/Library/Containers/com.scarfUITests.xctrunner/Data. - 8 accessibility IDs added on the install path so the next iteration can drive the full Templates → Install from URL → Parent dir → Confirm Install flow without depending on view-tree label scraping: templates.toolbar.menu, templates.installFromFile, templates.installFromURL, templates.installURL.field, templates.installURL.confirm, templateInstall.parentDir.field, templateInstall.parentDir.continue, templateInstall.confirmInstall. - TestModeFlags.shared.isTestMode now gates UpdaterService — --scarf-test-mode launches Sparkle inert so update prompts don't pop on top of an XCUITest-driven window. Production launches unchanged. FixtureHermesHome.swift removed — the fixture-tmpdir approach is abandoned in favour of using the real installation. Layer A's SCARF_HERMES_HOME tests still pass; they just don't need a populated home to exercise path derivation. Verification: scarfTests 124/124, ScarfCore 220/220, Layer B smoke 1/1 (after fresh build — XCUITest is sensitive to stale binaries). catalog.py --check still green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(chat): clip placeholder to TextEditor bounds and clear it on focus Two related bugs in the Mac chat composer's placeholder overlay: * The "Message Hermes… / for commands · drag images to attach" hint had no width constraint, so on narrower window geometries it visibly overflowed past the rounded TextEditor boundary. Add `lineLimit(1)`, `truncationMode(.tail)`, and `frame(maxWidth: .infinity, alignment: .leading)` so it ellipsizes inside the field instead. * The opacity formula `text.isEmpty ? 1 : 0` only hid the placeholder once content was typed, not when the field gained focus. Standard NSTextField / UITextField semantics clear the placeholder on focus. Switch to `(text.isEmpty && !isFocused) ? 1 : 0` so the hint disappears the moment the user clicks into the field. The opaque-background ghosting mitigation from #65 is preserved unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(chat): surface OAuth refresh-revoked errors with in-app re-auth When an OAuth provider's refresh token was revoked, Hermes printed "Refresh session has been revoked. Run `hermes model` to re-authenticate." to stderr but Scarf swallowed it — the user saw a typing indicator that silently disappeared with no banner, no system message, no actionable hint. The error classifier had no pattern for OAuth revocation. - `ACPErrorHint.classify` now returns a `Classification` struct carrying the hint plus an optional `oauthProvider` name. New patterns match "Refresh session has been revoked", "re-authenticate", and 401-with-OAuth-provider-name (whole-word so `anthropicapi` doesn't false-match `anthropic`). Provider extraction lets the UI dispatch the right re-auth flow. - Chat error banner ([ChatView.swift]) gains a "Re-authenticate" button when an OAuth provider was identified — sets `AppCoordinator.pendingOAuthReauth` and routes to Credential Pools. - Credential Pools view consumes the hand-off slot to auto-present AddCredentialSheet seeded with the affected provider, AND adds a per-row "Re-authenticate" button on every OAuth provider so users who go straight there don't have to retype the provider name. - `AddCredentialSheet` accepts an optional `initialProvider` that pre-fills providerID + authType=.oauth; the existing Nous-vs-PKCE- vs-CLI gate dispatches re-auth identically to first-time setup — reuses the same `OAuthFlowController` / `NousSignInSheet` plumbing, no new flow code. Verification: ScarfCore 221/221 (incl. new errorHintsClassifyOAuthRefreshRevoked covering the four patterns + word-boundary guard); Mac app builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(catalog): in-app template catalog browser + sentinel-marker test isolation The v2.8 catalog browser surfaces every shipped .scarftemplate from awizemann.github.io/scarf/templates/catalog.json directly in Scarf. Users now discover and install templates without leaving the app. Closes the gap that publishing the catalog updated the website but nothing inside Scarf. Architecture mirrors NousModelCatalogService 1:1: cache-first fetch, 24h TTL at ~/.hermes/scarf/catalog_cache.json, result enum (fresh / cache / fallback) with bundled fallback so a fresh-install / offline user still sees something. Search + category filter + sort (awizemann official first). Detail page renders entry.config schema preview without separate README fetch — what's in catalog.json is what we render. Install hands the HTTPS URL to the existing TemplateInstallerViewModel.openRemoteURL flow; nothing about the installer itself changes. Files: - Core/Models/CatalogEntry.swift — Decodable mirror of catalog.json per-template shape. Identity-based Equatable/Hashable on `id`. - Core/Services/CatalogService.swift — fetch + cache + fallback - Core/Services/InstalledTemplatesIndex.swift — walks projects.json + template.lock.json to build [templateId: version] map; classify() helper for Installed / Update available / Not installed badges - Features/Templates/ViewModels/CatalogViewModel.swift — @Observable - Features/Templates/Views/{CatalogView,CatalogRowView,CatalogDetailView,CatalogCategoryFilter}.swift - Packages/ScarfCore/.../HermesPathSet.swift — adds catalogCache path - Features/Projects/Views/ProjectsView.swift — Templates toolbar menu now opens with "Browse Catalog…"; sheet binding. Tests (20 new, all passing in isolation): - CatalogServiceTests (6) — live catalog.json snapshot, cache lifecycle, staleness boundary, schema-version mismatch rejection, bundled fallback - InstalledTemplatesIndexTests (5) — empty registry, templated project, ad-hoc project skip, corrupt lock skip, classify() branches - CatalogViewModelTests (6) — search filter, category filter, official-first sort, deduped categories, install state, install URL pass-through Accessibility IDs (6, on the catalog path): templates.browseCatalog, catalog.searchField, catalog.refreshButton, catalog.row.<detailSlug>, catalog.categoryFilter, catalogDetail.installButton. ## Sentinel-marker hardening on SCARF_HERMES_HOME (incident response) While iterating on v2.8 tests, the env-var override pattern racing under Swift Testing's parallel-suite scheduler caused ~/.hermes/scarf/projects.json to be overwritten with fixture data from ProjectsViewModelTests. Recovered the user's projects from the on-disk dirs they referenced + cron-job prompt paths (6 projects restored). To make this class of incident impossible going forward: HermesProfileResolver.scarfHermesHomeOverride() now requires the override path to contain a sentinel marker file (`.scarf-test-home-marker`). Without the marker, the override is ignored and Scarf falls through to the real ~/.hermes/. Even if a test crashes mid-teardown leaving the env var set, even if the var leaks to a non-test process, even if a misconfigured launchctl plist exports it — the override only activates against directories that explicitly opt in by carrying the marker. Tests drop the marker in their tmpdir setUp; production never carries it. HermesProfileResolverTests gains overrideIsIgnoredWhenMarkerMissing which verifies the guard is load-bearing. All test files using SCARF_HERMES_HOME (CatalogServiceTests, CatalogViewModelTests, InstalledTemplatesIndexTests, TemplateE2ETests) now drop the marker before setenv. Verification: 20/20 v2.8 + v2.7 hardened tests pass; 45/45 adjacent existing tests pass; ScarfCore package tests pass (221/221); catalog validator clean (3 templates); wiki secret-scan clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(swift6): retroactive conformance + verbatim help text + xcstrings refresh Three small Swift 6 compile-cleanups that landed during the dogfooding-templates iteration: - MessageSpeechService — drop `@preconcurrency` on the AVSpeechSynthesizerDelegate conformance now that the protocol's Sendable annotations are upstreamed. - ChatView — mark `RichChatViewModel.PendingPermission: Identifiable` as `@retroactive`. We don't own either the type or the protocol; the Swift 6 compiler flags this so downstream breakage is loud if ScarfCore ever adds the conformance upstream. - CredentialPoolsView — wrap the `.help(...)` string in `Text(verbatim:)` so the backticks render literally instead of being interpreted as markdown inline-code by the LocalizedStringKey overload (which `.help(_:)` rejects styled). Localizable.xcstrings: auto-generated catalog refresh picking up the new active-profile + chat error-hint strings landed in earlier commits on this branch (acd3692,301806d). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(catalog): error logging + MainActor I/O + semver pre-release + decoder fault tolerance - InstalledTemplatesIndex: replace bare `try?` reads/decodes with logged do/catch so corrupt registry/lock files leave a breadcrumb instead of a silent nil. - InstalledTemplatesIndex.isVersionNewer: handle pre-release suffixes per semver §11 — `1.0.0-beta` no longer reports as newer than `1.0.0`, preventing a ghost "Update available" that would downgrade users. - CatalogViewModel.refresh: dispatch the synchronous index walk through `Task.detached` so registry + N lock-file reads don't run on @MainActor. - Catalog decoder: per-element fault tolerance via custom `init(from:)` — one malformed catalog entry is dropped with a logged warning instead of failing the whole catalog decode (honors the per-entry doc-comment contract). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -593,7 +593,30 @@ public enum ACPClientError: Error, LocalizedError {
|
||||
/// human-readable hint for the chat UI. Pattern-matches the most common
|
||||
/// fresh-install failure modes. Returns nil when no known pattern matches.
|
||||
public enum ACPErrorHint {
|
||||
public static func classify(errorMessage: String, stderrTail: String) -> String? {
|
||||
/// Result of a classifier hit. `hint` is the user-facing copy; when
|
||||
/// the failure is an OAuth refresh-revocation, `oauthProvider` names
|
||||
/// the affected provider (lowercase, matching `auth.json` keys) so
|
||||
/// the UI can offer a one-click re-authenticate affordance. `nil`
|
||||
/// `oauthProvider` means "we matched a non-OAuth failure mode, or
|
||||
/// we matched OAuth but couldn't identify which provider."
|
||||
public struct Classification: Sendable, Equatable {
|
||||
public let hint: String
|
||||
public let oauthProvider: String?
|
||||
|
||||
public init(hint: String, oauthProvider: String? = nil) {
|
||||
self.hint = hint
|
||||
self.oauthProvider = oauthProvider
|
||||
}
|
||||
}
|
||||
|
||||
/// Known OAuth-authed providers Hermes ships. Listed lowercase to
|
||||
/// match `auth.json.providers.<key>` and the values
|
||||
/// `OAuthFlowController.start(provider:)` accepts.
|
||||
private static let oauthProviders = [
|
||||
"nous", "claude", "anthropic", "qwen", "gemini", "google", "copilot", "github",
|
||||
]
|
||||
|
||||
public static func classify(errorMessage: String, stderrTail: String) -> Classification? {
|
||||
let haystack = errorMessage + "\n" + stderrTail
|
||||
|
||||
// SSH-level failures come first — they apply only to remote
|
||||
@@ -603,30 +626,55 @@ public enum ACPErrorHint {
|
||||
// all surface as opaque "ACP process terminated" / "request
|
||||
// timed out", and the user has no idea where to look.
|
||||
if haystack.contains("Connection refused") {
|
||||
return "Couldn't reach the remote host — the SSH port is closed or the droplet is down. Check the host is running and reachable."
|
||||
return Classification(hint: "Couldn't reach the remote host — the SSH port is closed or the droplet is down. Check the host is running and reachable.")
|
||||
}
|
||||
if haystack.localizedCaseInsensitiveContains("Operation timed out")
|
||||
|| haystack.localizedCaseInsensitiveContains("Connection timed out")
|
||||
|| haystack.contains("Network is unreachable")
|
||||
|| haystack.contains("No route to host") {
|
||||
return "Couldn't reach the remote host — the network connection timed out. Check the host is running and your network is up."
|
||||
return Classification(hint: "Couldn't reach the remote host — the network connection timed out. Check the host is running and your network is up.")
|
||||
}
|
||||
if haystack.contains("Permission denied (publickey")
|
||||
|| haystack.contains("Permission denied, please try again") {
|
||||
return "SSH rejected the key. Make sure the right identity file is selected and that ssh-agent has the key loaded — open Terminal and run `ssh-add -l`."
|
||||
return Classification(hint: "SSH rejected the key. Make sure the right identity file is selected and that ssh-agent has the key loaded — open Terminal and run `ssh-add -l`.")
|
||||
}
|
||||
if haystack.contains("Host key verification failed")
|
||||
|| haystack.contains("REMOTE HOST IDENTIFICATION HAS CHANGED") {
|
||||
return "The remote host's SSH key changed. If you just rebuilt the droplet, remove the old entry with `ssh-keygen -R <host>`, then try again."
|
||||
return Classification(hint: "The remote host's SSH key changed. If you just rebuilt the droplet, remove the old entry with `ssh-keygen -R <host>`, then try again.")
|
||||
}
|
||||
if haystack.contains("Could not resolve hostname")
|
||||
|| haystack.contains("Name or service not known") {
|
||||
return "Couldn't resolve the host name. Check the host in this server's settings."
|
||||
return Classification(hint: "Couldn't resolve the host name. Check the host in this server's settings.")
|
||||
}
|
||||
if haystack.localizedCaseInsensitiveContains("command not found")
|
||||
|| haystack.contains("hermes: not found")
|
||||
|| haystack.contains("exit 127") {
|
||||
return "The remote shell couldn't find `hermes`. Either install Hermes on the remote (`pipx install hermes-agent`) or set an absolute binary path in this server's settings."
|
||||
return Classification(hint: "The remote shell couldn't find `hermes`. Either install Hermes on the remote (`pipx install hermes-agent`) or set an absolute binary path in this server's settings.")
|
||||
}
|
||||
|
||||
// OAuth refresh-token revocation. Hermes prints
|
||||
// "Refresh session has been revoked. Run `hermes model` to
|
||||
// re-authenticate." to stderr/stdout when an OAuth-authed
|
||||
// provider's refresh token can no longer mint access tokens
|
||||
// (user revoked, server rotated keys, etc.). We can't drive
|
||||
// `hermes model` interactively, but `hermes auth add <provider>
|
||||
// --type oauth` is the same code path Scarf already drives via
|
||||
// `OAuthFlowController` for first-time setup, so we surface a
|
||||
// re-authenticate affordance instead. Checked BEFORE the
|
||||
// generic "no credentials found" path because the message
|
||||
// contains the word "credentials" via the surrounding context.
|
||||
if haystack.localizedCaseInsensitiveContains("refresh session has been revoked")
|
||||
|| haystack.range(of: #"refresh.*revoked"#, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
|| haystack.localizedCaseInsensitiveContains("re-authenticate")
|
||||
|| haystack.localizedCaseInsensitiveContains("reauthenticate")
|
||||
|| (haystack.contains("401") && oauthProvider(in: haystack) != nil)
|
||||
|| (haystack.localizedCaseInsensitiveContains("unauthorized") && oauthProvider(in: haystack) != nil) {
|
||||
let provider = oauthProvider(in: haystack)
|
||||
let suffix = provider.map { " (affected provider: \($0))." } ?? "."
|
||||
return Classification(
|
||||
hint: "Your OAuth session has expired or been revoked\(suffix) Click Re-authenticate below to sign in again.",
|
||||
oauthProvider: provider
|
||||
)
|
||||
}
|
||||
|
||||
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
|
||||
@@ -635,7 +683,7 @@ public enum ACPErrorHint {
|
||||
|| haystack.contains("ANTHROPIC_TOKEN")
|
||||
|| haystack.contains("claude setup-token")
|
||||
|| haystack.contains("claude /login") {
|
||||
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
|
||||
return Classification(hint: "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf.")
|
||||
}
|
||||
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
||||
options: .regularExpression) {
|
||||
@@ -643,13 +691,31 @@ public enum ACPErrorHint {
|
||||
if let nameStart = matched.range(of: "'"),
|
||||
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
|
||||
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
|
||||
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
|
||||
return Classification(hint: "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf.")
|
||||
}
|
||||
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
|
||||
return Classification(hint: "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf.")
|
||||
}
|
||||
if haystack.localizedCaseInsensitiveContains("rate limit")
|
||||
|| haystack.localizedCaseInsensitiveContains("429") {
|
||||
return "Your AI provider returned a rate-limit error. Try again in a moment."
|
||||
return Classification(hint: "Your AI provider returned a rate-limit error. Try again in a moment.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Best-effort extraction of an OAuth provider name from raw error
|
||||
/// text. Returns the lowercase provider key (`"nous"`, `"claude"`,
|
||||
/// etc.) when one of the known OAuth providers appears as a whole
|
||||
/// word. The first match wins — Hermes typically logs the active
|
||||
/// provider name once, near the failure.
|
||||
private static func oauthProvider(in haystack: String) -> String? {
|
||||
let lowered = haystack.lowercased()
|
||||
for provider in oauthProviders {
|
||||
// Whole-word match so substrings like "anthropicapi" don't
|
||||
// false-trigger on "anthropic".
|
||||
let pattern = "\\b" + NSRegularExpression.escapedPattern(for: provider) + "\\b"
|
||||
if lowered.range(of: pattern, options: .regularExpression) != nil {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -98,6 +98,12 @@ public struct HermesPathSet: Sendable, Hashable {
|
||||
/// on user request from the model picker. Survives offline runs so
|
||||
/// the picker still has something to render.
|
||||
public nonisolated var nousModelsCache: String { scarfDir + "/nous_models_cache.json" }
|
||||
/// Cached `templates/catalog.json` from awizemann.github.io. Populated
|
||||
/// by `CatalogService` on first sheet-open and refreshed on a 24h TTL
|
||||
/// or on explicit user click. Mirrors `nousModelsCache` exactly:
|
||||
/// JSON, scarf-owned, survives offline runs so the catalog browser
|
||||
/// still has something to render. Wiped by a Hermes home reset.
|
||||
public nonisolated var catalogCache: String { scarfDir + "/catalog_cache.json" }
|
||||
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
@@ -51,7 +51,19 @@ public enum HermesProfileResolver {
|
||||
/// Returns the default `~/.hermes` when no profile is active OR when
|
||||
/// the configured profile is invalid (logged) — so the worst-case
|
||||
/// failure mode is "Scarf shows what it always showed before."
|
||||
///
|
||||
/// **Test override.** Setting `SCARF_HERMES_HOME` in the environment
|
||||
/// pins this resolver to the supplied absolute path and bypasses both
|
||||
/// the cache and the `active_profile` lookup. Used by the E2E test
|
||||
/// harness (`TemplateE2ETests`, `TemplateInstallUITests`) to drive
|
||||
/// Scarf against an isolated tmpdir Hermes home so the user's real
|
||||
/// `~/.hermes` is never touched. Read on every call (cheap; a single
|
||||
/// `ProcessInfo` lookup) so tests can flip it across test methods
|
||||
/// without stale-cache surprises.
|
||||
public static func resolveLocalHome() -> String {
|
||||
if let override = scarfHermesHomeOverride() {
|
||||
return override
|
||||
}
|
||||
return refreshIfNeeded().home
|
||||
}
|
||||
|
||||
@@ -60,9 +72,55 @@ public enum HermesProfileResolver {
|
||||
/// reading from (issue #50 follow-up: prevents the next variant
|
||||
/// of "where's my data — wrong profile" by making it visible).
|
||||
public static func activeProfileName() -> String {
|
||||
if scarfHermesHomeOverride() != nil {
|
||||
return "test-override"
|
||||
}
|
||||
return refreshIfNeeded().name
|
||||
}
|
||||
|
||||
/// Sentinel filename that the override path MUST contain for the
|
||||
/// override to be honored. Without it, production code refuses to
|
||||
/// pivot off the user's real `~/.hermes` even if the env var is
|
||||
/// set. This is the "even if a test leaks the env var, even if
|
||||
/// some non-test process inherits it, the user's data is safe"
|
||||
/// belt-and-braces guard. Tests create this marker before
|
||||
/// `setenv("SCARF_HERMES_HOME", ...)`.
|
||||
public static let testHomeMarkerFilename = ".scarf-test-home-marker"
|
||||
|
||||
/// Read `SCARF_HERMES_HOME` from the environment. Returns `nil` when
|
||||
/// unset or empty so production callers fall through to the profile
|
||||
/// resolver. The override must:
|
||||
/// 1. Be an absolute path — relative paths are rejected (they'd
|
||||
/// land relative to the cwd of whatever process happened to
|
||||
/// invoke the resolver, which is not what tests want).
|
||||
/// 2. Contain the sentinel marker file
|
||||
/// `<path>/<testHomeMarkerFilename>`. Without the marker we
|
||||
/// treat the env var as untrusted and ignore it. This protects
|
||||
/// the user's real `~/.hermes/` from any code path that
|
||||
/// accidentally exports `SCARF_HERMES_HOME` to the wrong value
|
||||
/// (e.g. a test crashed mid-teardown, an env var inherited
|
||||
/// from a parent shell, a misconfigured launchctl plist).
|
||||
/// Both checks are cheap — `FileManager.fileExists` against a
|
||||
/// known path is microseconds. The override is hot but not
|
||||
/// hot-hot, so an extra stat per call is negligible.
|
||||
private static func scarfHermesHomeOverride() -> String? {
|
||||
guard let raw = ProcessInfo.processInfo.environment["SCARF_HERMES_HOME"] else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard trimmed.hasPrefix("/") else {
|
||||
logger.warning("SCARF_HERMES_HOME=\(trimmed, privacy: .public) is not absolute; ignoring.")
|
||||
return nil
|
||||
}
|
||||
let markerPath = trimmed + "/" + testHomeMarkerFilename
|
||||
guard FileManager.default.fileExists(atPath: markerPath) else {
|
||||
logger.warning("SCARF_HERMES_HOME=\(trimmed, privacy: .public) lacks sentinel marker (\(testHomeMarkerFilename, privacy: .public)); ignoring to protect real ~/.hermes.")
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/// Force a re-read on the next call, regardless of TTL. Test helper.
|
||||
public static func invalidateCache() {
|
||||
lock.withLock { $0.resolvedAt = .distantPast }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Process-wide toggles for test-mode launches.
|
||||
///
|
||||
/// Read `CommandLine.arguments` once at first access and cache the result so
|
||||
/// any code path can ask `TestModeFlags.shared.isTestMode` without paying for
|
||||
/// a re-scan. The harness sets `--scarf-test-mode` from XCUITest's
|
||||
/// `XCUIApplication.launchArguments` and pairs it with `SCARF_HERMES_HOME`
|
||||
/// (read by `HermesProfileResolver`) to drive Scarf against an isolated
|
||||
/// Hermes home.
|
||||
///
|
||||
/// The flags themselves don't do anything on their own — they're hook points
|
||||
/// for production code paths to gate behavior. v1 lands the wiring; the
|
||||
/// gating sites (Sparkle update prompt, capability live-probe, first-run
|
||||
/// walkthrough) are added incrementally as the harness exercises them and
|
||||
/// surfaces flakes.
|
||||
public struct TestModeFlags: Sendable {
|
||||
/// True when the process was launched with `--scarf-test-mode`. Read
|
||||
/// once from `CommandLine.arguments`; never mutated.
|
||||
public let isTestMode: Bool
|
||||
|
||||
/// Default singleton — cached on first access. Production code reads
|
||||
/// this; tests that need a different shape construct their own value.
|
||||
public static let shared: TestModeFlags = TestModeFlags(
|
||||
arguments: CommandLine.arguments
|
||||
)
|
||||
|
||||
/// Constructor exposed for tests so a synthetic argv can be passed
|
||||
/// without involving the real `CommandLine`. Production callers use
|
||||
/// `.shared`.
|
||||
public init(arguments: [String]) {
|
||||
self.isTestMode = arguments.contains("--scarf-test-mode")
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,12 @@ public final class RichChatViewModel {
|
||||
/// users can copy-paste the raw output into a bug report.
|
||||
public var acpErrorDetails: String?
|
||||
|
||||
/// Lowercase OAuth provider name (`"nous"`, `"claude"`, …) when the
|
||||
/// most recent failure was an OAuth refresh-revocation Hermes asked
|
||||
/// the user to fix via re-authentication. Drives the chat banner's
|
||||
/// "Re-authenticate" button. Nil for any other failure mode.
|
||||
public var acpErrorOAuthProvider: String?
|
||||
|
||||
/// Optional stderr-tail provider the controller can hook up when it
|
||||
/// creates the ACPClient. Used by `handlePromptComplete` to enrich
|
||||
/// the error banner on non-retryable stopReasons. The closure is
|
||||
@@ -134,6 +140,7 @@ public final class RichChatViewModel {
|
||||
acpError = nil
|
||||
acpErrorHint = nil
|
||||
acpErrorDetails = nil
|
||||
acpErrorOAuthProvider = nil
|
||||
}
|
||||
|
||||
/// Populate the error triplet from a thrown Error + the ACPClient
|
||||
@@ -154,10 +161,11 @@ public final class RichChatViewModel {
|
||||
}
|
||||
let msg = error.localizedDescription
|
||||
let stderrTail = await client?.recentStderr ?? ""
|
||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
acpError = msg
|
||||
acpErrorHint = hint
|
||||
acpErrorHint = cls?.hint
|
||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||
acpErrorOAuthProvider = cls?.oauthProvider
|
||||
}
|
||||
|
||||
/// Populate the error triplet when `handlePromptComplete` sees a
|
||||
@@ -168,11 +176,11 @@ public final class RichChatViewModel {
|
||||
public func recordPromptStopFailure(stopReason: String, client: ACPClient?) async {
|
||||
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
|
||||
let stderrTail = await client?.recentStderr ?? ""
|
||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
?? Self.fallbackHint(for: stopReason)
|
||||
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
acpError = msg
|
||||
acpErrorHint = hint
|
||||
acpErrorHint = cls?.hint ?? Self.fallbackHint(for: stopReason)
|
||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||
acpErrorOAuthProvider = cls?.oauthProvider
|
||||
}
|
||||
|
||||
/// Same as `recordPromptStopFailure` but pulls stderr from the
|
||||
@@ -182,11 +190,11 @@ public final class RichChatViewModel {
|
||||
private func recordPromptStopFailureUsingProvider(stopReason: String) async {
|
||||
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
|
||||
let stderrTail = await acpStderrProvider?() ?? ""
|
||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
?? Self.fallbackHint(for: stopReason)
|
||||
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
acpError = msg
|
||||
acpErrorHint = hint
|
||||
acpErrorHint = cls?.hint ?? Self.fallbackHint(for: stopReason)
|
||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||
acpErrorOAuthProvider = cls?.oauthProvider
|
||||
}
|
||||
|
||||
private static func fallbackHint(for stopReason: String) -> String? {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Exercises the `SCARF_HERMES_HOME` test-mode override on `HermesProfileResolver`.
|
||||
/// The override is the seam every E2E test relies on — without it, tests would
|
||||
/// touch the user's real `~/.hermes`. Serialized because we mutate process-wide
|
||||
/// environment.
|
||||
///
|
||||
/// **Marker file requirement.** As of v2.8 the override only activates when the
|
||||
/// path contains the sentinel `HermesProfileResolver.testHomeMarkerFilename`.
|
||||
/// Tests that want the override active drop the marker before `setenv`. Tests
|
||||
/// that want to verify the override is rejected (relative path, missing
|
||||
/// marker, empty value) skip the marker. The hardening prevents a leaked env
|
||||
/// var from ever pivoting Scarf off the user's real `~/.hermes`.
|
||||
@Suite(.serialized)
|
||||
struct HermesProfileResolverOverrideTests {
|
||||
|
||||
private static let envKey = "SCARF_HERMES_HOME"
|
||||
|
||||
@Test func absoluteOverrideTakesPrecedenceWhenMarkerPresent() throws {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
let tmp = NSTemporaryDirectory().appending("scarf-test-home-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(atPath: tmp, withIntermediateDirectories: true)
|
||||
try Data().write(to: URL(fileURLWithPath: tmp + "/" + HermesProfileResolver.testHomeMarkerFilename))
|
||||
defer { try? FileManager.default.removeItem(atPath: tmp) }
|
||||
setenv(Self.envKey, tmp, 1)
|
||||
|
||||
#expect(HermesProfileResolver.resolveLocalHome() == tmp)
|
||||
#expect(HermesProfileResolver.activeProfileName() == "test-override")
|
||||
}
|
||||
|
||||
@Test func overrideIsIgnoredWhenMarkerMissing() throws {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
// Real-looking dir, no marker — exactly the shape a leaked env
|
||||
// var or misconfigured launchctl plist would produce. Must NOT
|
||||
// override; must fall through to the real resolver.
|
||||
let tmp = NSTemporaryDirectory().appending("scarf-no-marker-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(atPath: tmp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(atPath: tmp) }
|
||||
setenv(Self.envKey, tmp, 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(resolved != tmp)
|
||||
#expect(resolved.hasSuffix("/.hermes") || resolved.contains("/.hermes/profiles/"))
|
||||
}
|
||||
|
||||
@Test func emptyOverrideFallsThrough() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
setenv(Self.envKey, "", 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(!resolved.isEmpty)
|
||||
#expect(resolved.hasSuffix("/.hermes") || resolved.contains("/.hermes/profiles/"))
|
||||
}
|
||||
|
||||
@Test func relativeOverrideIsRejected() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
setenv(Self.envKey, "relative/path", 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(!resolved.hasSuffix("relative/path"))
|
||||
}
|
||||
|
||||
@Test func unsetOverrideUsesProfileResolver() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
unsetenv(Self.envKey)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(!resolved.isEmpty)
|
||||
}
|
||||
|
||||
@Test func overrideBypassesCacheWhenMarkerPresent() throws {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
let first = NSTemporaryDirectory().appending("scarf-cache-bypass-1-\(UUID().uuidString)")
|
||||
let second = NSTemporaryDirectory().appending("scarf-cache-bypass-2-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(atPath: first, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(atPath: second, withIntermediateDirectories: true)
|
||||
try Data().write(to: URL(fileURLWithPath: first + "/" + HermesProfileResolver.testHomeMarkerFilename))
|
||||
try Data().write(to: URL(fileURLWithPath: second + "/" + HermesProfileResolver.testHomeMarkerFilename))
|
||||
defer {
|
||||
try? FileManager.default.removeItem(atPath: first)
|
||||
try? FileManager.default.removeItem(atPath: second)
|
||||
}
|
||||
|
||||
setenv(Self.envKey, first, 1)
|
||||
#expect(HermesProfileResolver.resolveLocalHome() == first)
|
||||
|
||||
// Flip env var without invalidating the cache. Override is read
|
||||
// fresh on every call, so the new value takes effect immediately.
|
||||
setenv(Self.envKey, second, 1)
|
||||
#expect(HermesProfileResolver.resolveLocalHome() == second)
|
||||
}
|
||||
|
||||
private func restore(_ saved: String?) {
|
||||
if let saved {
|
||||
setenv(Self.envKey, saved, 1)
|
||||
} else {
|
||||
unsetenv(Self.envKey)
|
||||
}
|
||||
HermesProfileResolver.invalidateCache()
|
||||
}
|
||||
}
|
||||
@@ -265,19 +265,20 @@ import Foundation
|
||||
errorMessage: "No Anthropic credentials found",
|
||||
stderrTail: ""
|
||||
)
|
||||
#expect(noCreds?.contains("ANTHROPIC_API_KEY") == true)
|
||||
#expect(noCreds?.hint.contains("ANTHROPIC_API_KEY") == true)
|
||||
#expect(noCreds?.oauthProvider == nil)
|
||||
|
||||
let missingBinary = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "No such file or directory: 'npx'"
|
||||
)
|
||||
#expect(missingBinary?.contains("npx") == true)
|
||||
#expect(missingBinary?.hint.contains("npx") == true)
|
||||
|
||||
let rateLimit = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "HTTP 429 Too Many Requests: rate limit"
|
||||
)
|
||||
#expect(rateLimit?.contains("rate-limit") == true)
|
||||
#expect(rateLimit?.hint.contains("rate-limit") == true)
|
||||
|
||||
let unknown = ACPErrorHint.classify(
|
||||
errorMessage: "weird thing",
|
||||
@@ -286,6 +287,53 @@ import Foundation
|
||||
#expect(unknown == nil)
|
||||
}
|
||||
|
||||
@Test func errorHintsClassifyOAuthRefreshRevoked() {
|
||||
// Primary trigger — Hermes's verbatim message when an OAuth
|
||||
// refresh token can't mint a new access token. Provider name
|
||||
// appears alongside; classifier should extract it.
|
||||
let revoked = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "Refresh session has been revoked. Run `hermes model` to re-authenticate."
|
||||
)
|
||||
#expect(revoked?.hint.contains("Re-authenticate") == true)
|
||||
|
||||
// With provider context — surfaces the affected provider name
|
||||
// so the chat banner can offer a one-click re-auth that targets
|
||||
// the right OAuth flow.
|
||||
let revokedWithProvider = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "Provider claude: Refresh session has been revoked. Run `hermes model` to re-authenticate."
|
||||
)
|
||||
#expect(revokedWithProvider?.oauthProvider == "claude")
|
||||
|
||||
// 401 + OAuth provider name — broader catchall for providers
|
||||
// that don't print the verbatim "revoked" string.
|
||||
let unauthorized = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "HTTP 401 Unauthorized from nous portal"
|
||||
)
|
||||
#expect(unauthorized?.oauthProvider == "nous")
|
||||
#expect(unauthorized?.hint.contains("OAuth") == true)
|
||||
|
||||
// Unauthorized on a non-OAuth provider (API-key based) should
|
||||
// NOT classify as OAuth revocation — no `oauthProvider` known
|
||||
// to dispatch the re-auth flow against.
|
||||
let unauthorizedNonOAuth = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "HTTP 401 Unauthorized for groq"
|
||||
)
|
||||
#expect(unauthorizedNonOAuth?.oauthProvider == nil)
|
||||
|
||||
// Word-boundary check — "anthropicapi" must not false-trigger
|
||||
// on "anthropic". Without word boundaries this catches the
|
||||
// wrong cases.
|
||||
let substringNoMatch = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "401 unauthorized: anthropicapi.example.com"
|
||||
)
|
||||
#expect(substringNoMatch?.oauthProvider != "anthropic")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Poll `predicate` every ~20ms up to `timeout` seconds. Fails if
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// One template entry as exposed by `awizemann.github.io/scarf/templates/catalog.json`.
|
||||
/// Mirrors the per-template shape `tools/build-catalog.py` emits — the
|
||||
/// validator is the source of truth on the schema, this struct is the
|
||||
/// Swift consumer. **Do not add fields here that aren't in `catalog.json`
|
||||
/// today.** Keeping the surface 1:1 means we can't accidentally render
|
||||
/// something the catalog doesn't actually carry.
|
||||
///
|
||||
/// Most fields are required-from-the-validator's-perspective but
|
||||
/// expressed as optionals here so a single-template typo on the
|
||||
/// website doesn't bring down the whole list — we drop the malformed
|
||||
/// entry and keep going (handled by the decoder in `CatalogService`).
|
||||
struct CatalogEntry: Codable, Sendable, Identifiable, Hashable {
|
||||
|
||||
// Hashable + Equatable conformance is identity-based on `id` —
|
||||
// `TemplateConfigSchema` only conforms to Equatable, so we can't
|
||||
// synthesize Hashable, and a content-based equality wouldn't be
|
||||
// useful anyway (the same template re-fetched from cache vs. fresh
|
||||
// is "the same entry" even if a description was edited upstream).
|
||||
static func == (lhs: CatalogEntry, rhs: CatalogEntry) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
|
||||
/// Stable identifier — `<author>/<template-name>`, e.g.
|
||||
/// `awizemann/hackernews-digest`. Matches the value in
|
||||
/// `template.json`'s `id` field.
|
||||
let id: String
|
||||
|
||||
/// Human-readable name shown in the catalog list.
|
||||
let name: String
|
||||
|
||||
/// Semver. Compared against the installed version from
|
||||
/// `InstalledTemplatesIndex` to detect "Update available".
|
||||
let version: String
|
||||
|
||||
let description: String?
|
||||
let category: String?
|
||||
let tags: [String]
|
||||
|
||||
let author: Author
|
||||
let minScarfVersion: String?
|
||||
let minHermesVersion: String?
|
||||
|
||||
/// HTTPS URL the install flow consumes.
|
||||
/// `TemplateInstallerViewModel.openRemoteURL(_:)` accepts this
|
||||
/// directly. The catalog itself only ships HTTPS URLs (validator
|
||||
/// enforced).
|
||||
let installUrl: String
|
||||
|
||||
/// Bundle metadata for size warnings and integrity checks. Optional
|
||||
/// because pre-v2 catalogs didn't carry these.
|
||||
let bundleSize: Int?
|
||||
let bundleSha256: String?
|
||||
|
||||
/// Slug used by the static-site generator for detail-page URLs.
|
||||
/// Reused as a stable accessibility-ID suffix so XCUITest can find
|
||||
/// rows even if the human-readable id contains slashes.
|
||||
let detailSlug: String?
|
||||
|
||||
/// What's inside the bundle, mirrored from `template.json`'s
|
||||
/// `contents` claim. Drives the "what will be installed" preview
|
||||
/// on the detail page.
|
||||
let contents: Contents?
|
||||
|
||||
/// Config schema + model recommendation if the template declares
|
||||
/// one. Using the existing `TemplateConfigSchema` decoder keeps
|
||||
/// parsing aligned with the install sheet's config form.
|
||||
let config: TemplateConfigSchema?
|
||||
|
||||
struct Author: Codable, Sendable, Equatable {
|
||||
let name: String
|
||||
let url: String?
|
||||
}
|
||||
|
||||
/// `template.json`'s `contents` object. All counts are optional —
|
||||
/// `nil` means "not declared," which the catalog renders as zero.
|
||||
struct Contents: Codable, Sendable, Equatable {
|
||||
let dashboard: Bool?
|
||||
let agentsMd: Bool?
|
||||
let cron: Int?
|
||||
let config: Int?
|
||||
let memory: Bool?
|
||||
let skills: [String]?
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level shape of `catalog.json`. Only carries what the Swift
|
||||
/// catalog browser actually uses — `templates` is the list itself,
|
||||
/// `schemaVersion` lets us reject incompatible future formats.
|
||||
///
|
||||
/// **The validator's `generated` field is intentionally NOT decoded.**
|
||||
/// It ships as a boolean (`true`) per `tools/build-catalog.py`'s
|
||||
/// "human reminder; a timestamp would churn the diff every run"
|
||||
/// comment. The catalog UI uses the cache file's `fetchedAt` for the
|
||||
/// "last refreshed" string, not anything from `catalog.json`.
|
||||
///
|
||||
/// **Per-element fault tolerance.** `templates` is decoded entry by
|
||||
/// entry through an unkeyed container — a single malformed entry
|
||||
/// (missing `tags`, `author`, etc.) is dropped with a logged warning
|
||||
/// rather than failing the whole catalog decode. Honors the contract
|
||||
/// the per-entry doc-comment promises.
|
||||
struct Catalog: Codable, Sendable {
|
||||
let schemaVersion: Int?
|
||||
let templates: [CatalogEntry]
|
||||
|
||||
init(schemaVersion: Int?, templates: [CatalogEntry]) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.templates = templates
|
||||
}
|
||||
|
||||
/// Custom decoder that drops every key other than `schemaVersion`
|
||||
/// and `templates`. Without this, `generated: true` would surface
|
||||
/// as a typeMismatch on `String?`.
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case templates
|
||||
}
|
||||
|
||||
private static let decodeLogger = Logger(subsystem: "com.scarf", category: "CatalogDecoder")
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion)
|
||||
|
||||
var entries: [CatalogEntry] = []
|
||||
if container.contains(.templates) {
|
||||
var unkeyed = try container.nestedUnkeyedContainer(forKey: .templates)
|
||||
entries.reserveCapacity(unkeyed.count ?? 0)
|
||||
while !unkeyed.isAtEnd {
|
||||
do {
|
||||
entries.append(try unkeyed.decode(CatalogEntry.self))
|
||||
} catch {
|
||||
Self.decodeLogger.warning("dropping malformed catalog entry at index \(unkeyed.currentIndex - 1): \(error.localizedDescription, privacy: .public)")
|
||||
// Advance past the bad element so the loop terminates.
|
||||
// Decoding into a permissive `JSONValue` placeholder
|
||||
// would also work, but Foundation's Decoder API has
|
||||
// no built-in skip — `_Skip` consumes one element.
|
||||
_ = try? unkeyed.decode(_Skip.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.templates = entries
|
||||
}
|
||||
|
||||
/// Placeholder type used to consume a malformed array element after
|
||||
/// the real decode threw. Decodes anything by ignoring it.
|
||||
private struct _Skip: Decodable {
|
||||
init(from decoder: Decoder) throws {
|
||||
_ = try decoder.singleValueContainer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// On-disk cache shape. Versioned so a future schema change can lift
|
||||
/// stale caches gracefully — bump `version` and the loader rejects
|
||||
/// anything older without trying to migrate. Stored next to the
|
||||
/// projects registry so a Hermes wipe takes it with the rest of the
|
||||
/// Scarf-owned state.
|
||||
struct CatalogCache: Codable, Sendable {
|
||||
static let currentVersion = 1
|
||||
let version: Int
|
||||
let fetchedAt: Date
|
||||
let catalog: Catalog
|
||||
|
||||
init(version: Int = CatalogCache.currentVersion, fetchedAt: Date, catalog: Catalog) {
|
||||
self.version = version
|
||||
self.fetchedAt = fetchedAt
|
||||
self.catalog = catalog
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a `loadCatalog` call. Distinguishes "fetched fresh" from
|
||||
/// "cache served, network failed" so the catalog UI can surface a
|
||||
/// "could not refresh" hint next to a stale-but-useful list.
|
||||
enum CatalogLoadResult: Sendable {
|
||||
case fresh(catalog: Catalog, fetchedAt: Date)
|
||||
case cache(catalog: Catalog, fetchedAt: Date, refreshError: String?)
|
||||
case fallback(catalog: Catalog, reason: String)
|
||||
}
|
||||
|
||||
enum CatalogServiceError: LocalizedError, Sendable {
|
||||
case transport(String)
|
||||
case http(status: Int)
|
||||
case decode(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .transport(let m): return "Catalog transport: \(m)"
|
||||
case .http(let status): return "Catalog HTTP \(status)"
|
||||
case .decode(let m): return "Catalog decode: \(m)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches + caches the public template catalog from
|
||||
/// awizemann.github.io. Mirrors `NousModelCatalogService` 1:1 in
|
||||
/// shape: cache-first, 24h TTL, fallback when both cache and fetch
|
||||
/// fail. The catalog is unauthenticated (a public static file on
|
||||
/// GitHub Pages), so no bearer-token plumbing.
|
||||
struct CatalogService: Sendable {
|
||||
|
||||
/// Where the catalog lives in production. The static-site builder
|
||||
/// publishes here on `./scripts/catalog.sh publish`. **Versioned
|
||||
/// constant**: if we ever move this URL, every old Scarf install
|
||||
/// pegs at its bundled fallback until the user updates Scarf — so
|
||||
/// keep it stable. Settings-configurable in v2.9 only if anyone
|
||||
/// asks.
|
||||
static let baseURL = URL(string: "https://awizemann.github.io/scarf/templates/catalog.json")!
|
||||
static let cacheTTL: TimeInterval = 24 * 60 * 60 // 24h
|
||||
static let requestTimeout: TimeInterval = 10 // seconds
|
||||
|
||||
/// Hard-coded fallback for offline-with-no-cache. Keeps the picker
|
||||
/// non-empty on a fresh install so the user sees *something* even
|
||||
/// before the first network call. **Update on every release that
|
||||
/// adds a template** — the validator's `tools/check-catalog-fallback-sync.py`
|
||||
/// (TODO) catches drift between this list and `templates/`.
|
||||
static let fallbackCatalog: Catalog = Catalog(
|
||||
schemaVersion: 1,
|
||||
templates: [
|
||||
CatalogEntry(
|
||||
id: "awizemann/site-status-checker",
|
||||
name: "Site Status Checker",
|
||||
version: "1.1.0",
|
||||
description: "Daily uptime check for a list of URLs you configure on install.",
|
||||
category: "monitoring",
|
||||
tags: ["monitoring", "uptime", "cron", "starter"],
|
||||
author: .init(name: "Alan Wizemann", url: "https://github.com/awizemann"),
|
||||
minScarfVersion: "2.3.0",
|
||||
minHermesVersion: "0.9.0",
|
||||
installUrl: "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/site-status-checker/site-status-checker.scarftemplate",
|
||||
bundleSize: nil,
|
||||
bundleSha256: nil,
|
||||
detailSlug: "awizemann-site-status-checker",
|
||||
contents: .init(dashboard: true, agentsMd: true, cron: 1, config: 2, memory: nil, skills: nil),
|
||||
config: nil
|
||||
),
|
||||
CatalogEntry(
|
||||
id: "awizemann/hackernews-digest",
|
||||
name: "HackerNews Daily Digest",
|
||||
version: "1.0.0",
|
||||
description: "A daily digest of HackerNews top stories. No API keys required.",
|
||||
category: "news",
|
||||
tags: ["news", "digest", "hackernews", "cron", "starter"],
|
||||
author: .init(name: "Alan Wizemann", url: "https://github.com/awizemann"),
|
||||
minScarfVersion: "2.3.0",
|
||||
minHermesVersion: "0.9.0",
|
||||
installUrl: "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/hackernews-digest/hackernews-digest.scarftemplate",
|
||||
bundleSize: nil,
|
||||
bundleSha256: nil,
|
||||
detailSlug: "awizemann-hackernews-digest",
|
||||
contents: .init(dashboard: true, agentsMd: true, cron: 1, config: 3, memory: nil, skills: nil),
|
||||
config: nil
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "CatalogService")
|
||||
|
||||
let context: ServerContext
|
||||
private let session: URLSession
|
||||
private let cachePath: String
|
||||
|
||||
init(context: ServerContext = .local, session: URLSession = .shared) {
|
||||
self.context = context
|
||||
self.session = session
|
||||
self.cachePath = context.paths.catalogCache
|
||||
}
|
||||
|
||||
// MARK: - Cache I/O
|
||||
|
||||
/// Read the cache via the active transport so a remote droplet's
|
||||
/// cache lands on the droplet, not the user's Mac. Missing or
|
||||
/// malformed cache → nil; the loader treats that as "no cache" and
|
||||
/// kicks off a fresh fetch.
|
||||
func readCache() -> CatalogCache? {
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(cachePath) else { return nil }
|
||||
do {
|
||||
let data = try transport.readFile(cachePath)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let cache = try decoder.decode(CatalogCache.self, from: data)
|
||||
guard cache.version == CatalogCache.currentVersion else {
|
||||
Self.logger.info("catalog cache schema mismatch (got v\(cache.version), expected v\(CatalogCache.currentVersion)); ignoring")
|
||||
return nil
|
||||
}
|
||||
return cache
|
||||
} catch {
|
||||
Self.logger.warning("couldn't decode catalog cache: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func writeCache(_ cache: CatalogCache) {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(cache)
|
||||
// Make sure the parent dir exists — fresh remote installs
|
||||
// may not yet have `~/.hermes/scarf/`. mkdir -p is cheap
|
||||
// and idempotent on both transports.
|
||||
let parent = (cachePath as NSString).deletingLastPathComponent
|
||||
if !parent.isEmpty {
|
||||
try? transport.createDirectory(parent)
|
||||
}
|
||||
try transport.writeFile(cachePath, data: data)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't write catalog cache: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func isCacheStale(_ cache: CatalogCache) -> Bool {
|
||||
Date().timeIntervalSince(cache.fetchedAt) > Self.cacheTTL
|
||||
}
|
||||
|
||||
// MARK: - Network fetch
|
||||
|
||||
/// Make the catalog GET. Times out after `requestTimeout` so a
|
||||
/// hung network doesn't block the picker indefinitely. Returns the
|
||||
/// parsed catalog on success, throws on any HTTP / decode error.
|
||||
func fetchCatalog() async throws -> Catalog {
|
||||
var request = URLRequest(url: Self.baseURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = Self.requestTimeout
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw CatalogServiceError.transport("non-HTTP response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw CatalogServiceError.http(status: http.statusCode)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(Catalog.self, from: data)
|
||||
} catch {
|
||||
throw CatalogServiceError.decode(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public entry
|
||||
|
||||
/// Top-level "give me the catalog" entry point. Cache-first: serve
|
||||
/// from cache if fresh, fetch + write through if stale or empty,
|
||||
/// fall back to the hard-coded list when both fail. The caller
|
||||
/// renders based on the case so it can show a "could not refresh"
|
||||
/// hint next to a stale-but-still-useful list.
|
||||
func loadCatalog(forceRefresh: Bool = false) async -> CatalogLoadResult {
|
||||
let cached = readCache()
|
||||
|
||||
if let cached, !forceRefresh, !isCacheStale(cached) {
|
||||
return .cache(catalog: cached.catalog, fetchedAt: cached.fetchedAt, refreshError: nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let catalog = try await fetchCatalog()
|
||||
let now = Date()
|
||||
writeCache(CatalogCache(fetchedAt: now, catalog: catalog))
|
||||
return .fresh(catalog: catalog, fetchedAt: now)
|
||||
} catch let error as CatalogServiceError {
|
||||
if let cached {
|
||||
Self.logger.warning("catalog refresh failed (\(error.localizedDescription, privacy: .public)); serving stale cache")
|
||||
return .cache(catalog: cached.catalog, fetchedAt: cached.fetchedAt, refreshError: error.localizedDescription)
|
||||
}
|
||||
Self.logger.warning("catalog refresh failed and no cache; serving fallback (\(error.localizedDescription, privacy: .public))")
|
||||
return .fallback(catalog: Self.fallbackCatalog, reason: error.localizedDescription)
|
||||
} catch {
|
||||
if let cached {
|
||||
return .cache(catalog: cached.catalog, fetchedAt: cached.fetchedAt, refreshError: error.localizedDescription)
|
||||
}
|
||||
return .fallback(catalog: Self.fallbackCatalog, reason: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Maps `templateId → installedVersion` for every project the user has
|
||||
/// installed via a template. Used by the catalog browser to render
|
||||
/// each row's "Installed" / "Update available" / "Not installed" badge.
|
||||
///
|
||||
/// **Read-only.** This service walks the projects registry + each
|
||||
/// project's `.scarf/template.lock.json`. It never writes anything.
|
||||
///
|
||||
/// **Per-call rebuild.** The index is cheap to compute (a registry
|
||||
/// read + N lock-file reads, each a few hundred bytes) and changes
|
||||
/// infrequently from the user's perspective. We rebuild on every
|
||||
/// catalog-sheet open instead of caching with invalidation rules —
|
||||
/// the cost of a stale "Installed" badge would surprise users far more
|
||||
/// than the cost of one extra `[String:Data]` walk on each refresh.
|
||||
struct InstalledTemplatesIndex: Sendable {
|
||||
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "InstalledTemplatesIndex")
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Build the index. Returns `[templateId: version]`. Projects
|
||||
/// without a lock file (ad-hoc projects added via "Add Project")
|
||||
/// are skipped silently — they aren't template-installed and don't
|
||||
/// belong in the index.
|
||||
func build() -> [String: String] {
|
||||
let transport = context.makeTransport()
|
||||
let registryPath = context.paths.projectsRegistry
|
||||
guard transport.fileExists(registryPath) else { return [:] }
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try transport.readFile(registryPath)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't read projects registry at \(registryPath, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return [:]
|
||||
}
|
||||
|
||||
let registry: ProjectRegistry
|
||||
do {
|
||||
registry = try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't decode projects registry: \(error.localizedDescription, privacy: .public)")
|
||||
return [:]
|
||||
}
|
||||
|
||||
var index: [String: String] = [:]
|
||||
for project in registry.projects {
|
||||
guard let lock = readLock(for: project) else { continue }
|
||||
// Last-write-wins on duplicates. Two installs of the same
|
||||
// template id at different versions is rare but possible
|
||||
// (user installed it in two project dirs); the catalog
|
||||
// doesn't need to render which version, just that
|
||||
// *something* is installed.
|
||||
index[lock.templateId] = lock.templateVersion
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/// Update-availability classification for a single catalog entry.
|
||||
/// `installedVersion == nil` → not installed. Equal versions →
|
||||
/// `.installed`. Catalog version newer than installed → `.updateAvailable`.
|
||||
/// Catalog version older or equal-but-different format → `.installed`
|
||||
/// (we trust the catalog; semver-noise comparisons aren't worth a
|
||||
/// full parse here).
|
||||
static func classify(catalogVersion: String, installedVersion: String?) -> InstallState {
|
||||
guard let installedVersion else { return .notInstalled }
|
||||
if catalogVersion == installedVersion {
|
||||
return .installed(version: installedVersion)
|
||||
}
|
||||
if isVersionNewer(catalogVersion, than: installedVersion) {
|
||||
return .updateAvailable(installedVersion: installedVersion, catalogVersion: catalogVersion)
|
||||
}
|
||||
return .installed(version: installedVersion)
|
||||
}
|
||||
|
||||
enum InstallState: Sendable, Equatable {
|
||||
case notInstalled
|
||||
case installed(version: String)
|
||||
case updateAvailable(installedVersion: String, catalogVersion: String)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Read `<project>/.scarf/template.lock.json`. Returns nil for
|
||||
/// ad-hoc (non-templated) projects, malformed JSON, or any I/O
|
||||
/// failure — the catalog shouldn't crash because one project's
|
||||
/// lock file got corrupted.
|
||||
private func readLock(for project: ProjectEntry) -> TemplateLock? {
|
||||
let path = project.path + "/.scarf/template.lock.json"
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(path) else { return nil }
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try transport.readFile(path)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't read template lock at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(TemplateLock.self, from: data)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't decode template lock at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain semver-ish comparison: split on `.`, compare numerically
|
||||
/// from major down. Pre-release suffixes (anything after `-` in a
|
||||
/// segment) make that release *older* than the same numeric prefix
|
||||
/// without a suffix — matches semver §11 ("a pre-release version has
|
||||
/// lower precedence than the associated normal version"), so
|
||||
/// `1.0.0-beta` is *not* newer than `1.0.0`. Two pre-releases on the
|
||||
/// same numeric prefix fall back to lexicographic compare on the
|
||||
/// suffix. Good enough for "is the catalog ahead?" — this isn't a
|
||||
/// package manager.
|
||||
static func isVersionNewer(_ candidate: String, than other: String) -> Bool {
|
||||
let (aCore, aPre) = splitPrerelease(candidate)
|
||||
let (bCore, bPre) = splitPrerelease(other)
|
||||
let a = aCore.split(separator: ".").map(String.init)
|
||||
let b = bCore.split(separator: ".").map(String.init)
|
||||
for i in 0..<max(a.count, b.count) {
|
||||
let ai = i < a.count ? a[i] : "0"
|
||||
let bi = i < b.count ? b[i] : "0"
|
||||
if let an = Int(ai), let bn = Int(bi) {
|
||||
if an != bn { return an > bn }
|
||||
} else if ai != bi {
|
||||
return ai > bi
|
||||
}
|
||||
}
|
||||
// Numeric cores match. Pre-release tiebreak: an absent pre-release
|
||||
// outranks any present pre-release.
|
||||
switch (aPre, bPre) {
|
||||
case (nil, nil): return false
|
||||
case (nil, _): return true // candidate has no pre-release; older has one → newer
|
||||
case (_, nil): return false // candidate has pre-release; other is the release → older
|
||||
case (let ap?, let bp?): return ap > bp
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a version string into its numeric core and pre-release
|
||||
/// suffix on the first `-`. `"1.0.0-beta.2"` → `("1.0.0", "beta.2")`.
|
||||
/// `"1.0.0"` → `("1.0.0", nil)`.
|
||||
private static func splitPrerelease(_ version: String) -> (core: String, pre: String?) {
|
||||
if let dash = version.firstIndex(of: "-") {
|
||||
return (String(version[..<dash]), String(version[version.index(after: dash)...]))
|
||||
}
|
||||
return (version, nil)
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ final class MessageSpeechService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageSpeechService: @preconcurrency AVSpeechSynthesizerDelegate {
|
||||
extension MessageSpeechService: AVSpeechSynthesizerDelegate {
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in
|
||||
self.playingMessageId = nil
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import Sparkle
|
||||
|
||||
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
|
||||
@@ -24,9 +25,15 @@ final class UpdaterService: NSObject {
|
||||
|
||||
override init() {
|
||||
// startingUpdater: true → Sparkle scans for updates on launch per Info.plist schedule.
|
||||
// Default delegates are sufficient for a non-sandboxed app.
|
||||
// Under `--scarf-test-mode` we keep Sparkle inert so XCUITest runs
|
||||
// never see a "an update is available" sheet pop on top of the
|
||||
// window the test is trying to drive. The controller still
|
||||
// initializes — `automaticallyChecksForUpdates` reads/writes
|
||||
// continue to work — it just doesn't fire the on-launch check
|
||||
// or surface UI.
|
||||
let startUpdater = !TestModeFlags.shared.isTestMode
|
||||
self.controller = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
startingUpdater: startUpdater,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
|
||||
@@ -139,6 +139,10 @@ final class ChatViewModel {
|
||||
get { richChatViewModel.acpErrorDetails }
|
||||
set { richChatViewModel.acpErrorDetails = newValue }
|
||||
}
|
||||
var acpErrorOAuthProvider: String? {
|
||||
get { richChatViewModel.acpErrorOAuthProvider }
|
||||
set { richChatViewModel.acpErrorOAuthProvider = newValue }
|
||||
}
|
||||
/// True when `hasAnyAICredential()` returned false at last preflight.
|
||||
var missingCredentials: Bool = false
|
||||
|
||||
|
||||
@@ -116,6 +116,15 @@ struct ChatView: View {
|
||||
.lineLimit(showErrorDetails ? nil : 2)
|
||||
}
|
||||
Spacer()
|
||||
if let provider = viewModel.acpErrorOAuthProvider {
|
||||
Button("Re-authenticate") {
|
||||
coordinator.pendingOAuthReauth = provider
|
||||
coordinator.selectedSection = .credentialPools
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.help("Open Credential Pools and re-authenticate \(provider).")
|
||||
}
|
||||
if viewModel.acpErrorDetails != nil {
|
||||
Button(showErrorDetails ? "Hide details" : "Show details") {
|
||||
showErrorDetails.toggle()
|
||||
@@ -457,7 +466,11 @@ struct ChatView: View {
|
||||
|
||||
// MARK: - Permission Approval View
|
||||
|
||||
extension RichChatViewModel.PendingPermission: Identifiable {
|
||||
// `@retroactive` acknowledges that we're declaring conformance for a
|
||||
// type (`PendingPermission`) and protocol (`Identifiable`) we don't own
|
||||
// — the Swift 6 compiler flags this otherwise so that downstream
|
||||
// breakage is loud if `ScarfCore` ever adds the conformance upstream.
|
||||
extension RichChatViewModel.PendingPermission: @retroactive Identifiable {
|
||||
public var id: Int { requestId }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,14 @@ struct CredentialPoolsView: View {
|
||||
@State private var viewModel: CredentialPoolsViewModel
|
||||
@State private var showAddSheet = false
|
||||
@State private var pendingRemove: HermesCredential?
|
||||
/// When non-nil, `AddCredentialSheet` opens pre-seeded with this
|
||||
/// provider name + OAuth type — driven by the chat banner's
|
||||
/// "Re-authenticate" button via `AppCoordinator.pendingOAuthReauth`,
|
||||
/// or by clicking the per-row "Re-authenticate" button in this
|
||||
/// view. Reset to nil when the sheet dismisses so the next plain
|
||||
/// "Add Credential" press doesn't accidentally inherit it.
|
||||
@State private var reauthInitialProvider: String?
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: CredentialPoolsViewModel(context: context))
|
||||
@@ -42,9 +50,15 @@ struct CredentialPoolsView: View {
|
||||
label: "Loading credentials…",
|
||||
isEmpty: viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty
|
||||
)
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddCredentialSheet(viewModel: viewModel) {
|
||||
.onAppear {
|
||||
viewModel.load()
|
||||
consumePendingReauth()
|
||||
}
|
||||
.onChange(of: coordinator.pendingOAuthReauth) { _, _ in
|
||||
consumePendingReauth()
|
||||
}
|
||||
.sheet(isPresented: $showAddSheet, onDismiss: { reauthInitialProvider = nil }) {
|
||||
AddCredentialSheet(viewModel: viewModel, initialProvider: reauthInitialProvider) {
|
||||
showAddSheet = false
|
||||
}
|
||||
}
|
||||
@@ -64,6 +78,19 @@ struct CredentialPoolsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any pending re-auth hand-off from the chat banner: the
|
||||
/// banner's "Re-authenticate" button writes to
|
||||
/// `coordinator.pendingOAuthReauth` and switches to this view; we
|
||||
/// pick the value up here, seed the sheet's initial provider, and
|
||||
/// clear the slot so navigating back to this view doesn't re-open
|
||||
/// the sheet.
|
||||
private func consumePendingReauth() {
|
||||
guard let pending = coordinator.pendingOAuthReauth else { return }
|
||||
reauthInitialProvider = pending
|
||||
showAddSheet = true
|
||||
coordinator.pendingOAuthReauth = nil
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
ScarfPageHeader(
|
||||
"Credential Pools",
|
||||
@@ -166,13 +193,24 @@ struct CredentialPoolsView: View {
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Re-authenticate") {
|
||||
reauthInitialProvider = provider.provider
|
||||
showAddSheet = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
// `Text(verbatim:)` skips the LocalizedStringKey
|
||||
// overload that would interpret the backticks as
|
||||
// markdown inline-code styling — `.help(_:)` rejects
|
||||
// styled Text. Plain string preserves the backticks
|
||||
// literally.
|
||||
.help(Text(verbatim: "Run `hermes auth add \(provider.provider) --type oauth` again to refresh this provider's tokens."))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Text("Managed by `hermes auth add <provider>` — Scarf is read-only here.")
|
||||
Text("Click Re-authenticate to refresh tokens. Removing or rotating providers is still done via `hermes auth …` in a terminal.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
@@ -337,8 +375,25 @@ struct CredentialPoolsView: View {
|
||||
/// OAuth flow so the user can paste the authorization code back.
|
||||
private struct AddCredentialSheet: View {
|
||||
@Bindable var viewModel: CredentialPoolsViewModel
|
||||
/// Optional pre-fill from the re-auth path. When non-nil, the sheet
|
||||
/// opens with this provider name + OAuth selected, mirroring the
|
||||
/// state the user would otherwise have to type. Plain "Add
|
||||
/// Credential" presses leave it nil.
|
||||
let initialProvider: String?
|
||||
let onDismiss: () -> Void
|
||||
|
||||
init(
|
||||
viewModel: CredentialPoolsViewModel,
|
||||
initialProvider: String? = nil,
|
||||
onDismiss: @escaping () -> Void
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.initialProvider = initialProvider
|
||||
self.onDismiss = onDismiss
|
||||
_providerID = State(initialValue: initialProvider ?? "")
|
||||
_authType = State(initialValue: initialProvider == nil ? .apiKey : .oauth)
|
||||
}
|
||||
|
||||
enum AuthType: String, CaseIterable, Identifiable {
|
||||
case apiKey = "API Key"
|
||||
case oauth = "OAuth"
|
||||
@@ -352,8 +407,8 @@ private struct AddCredentialSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
@State private var providerID: String = ""
|
||||
@State private var authType: AuthType = .apiKey
|
||||
@State private var providerID: String
|
||||
@State private var authType: AuthType
|
||||
@State private var apiKey: String = ""
|
||||
@State private var label: String = ""
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
|
||||
@@ -40,6 +40,7 @@ struct ProjectsView: View {
|
||||
@State private var exportSheetProject: ProjectEntry?
|
||||
@State private var showingInstallURLPrompt = false
|
||||
@State private var installURLInput = ""
|
||||
@State private var showingCatalogSheet = false
|
||||
@State private var showingUninstallSheet = false
|
||||
@State private var configEditorProject: ProjectEntry?
|
||||
/// Project queued for the "remove from list" confirmation dialog.
|
||||
@@ -132,6 +133,17 @@ struct ProjectsView: View {
|
||||
.sheet(isPresented: $showingInstallURLPrompt) {
|
||||
installURLSheet
|
||||
}
|
||||
.sheet(isPresented: $showingCatalogSheet) {
|
||||
CatalogView { url in
|
||||
// Hand the catalog's HTTPS URL to the existing install
|
||||
// flow — no new entry-point logic, just a different
|
||||
// way to surface the URL. The install sheet's
|
||||
// `awaitingParentDirectory` stage takes over from here.
|
||||
installerViewModel.openRemoteURL(url)
|
||||
showingCatalogSheet = false
|
||||
showingInstallSheet = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingUninstallSheet) {
|
||||
TemplateUninstallSheet(viewModel: uninstallerViewModel) { removed in
|
||||
// Refresh the registry and clear selection if we just
|
||||
@@ -198,13 +210,20 @@ struct ProjectsView: View {
|
||||
private var templatesToolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button("Browse Catalog…", systemImage: "books.vertical") {
|
||||
showingCatalogSheet = true
|
||||
}
|
||||
.accessibilityIdentifier("templates.browseCatalog")
|
||||
Divider()
|
||||
Button("Install from File…", systemImage: "tray.and.arrow.down") {
|
||||
openInstallFilePicker()
|
||||
}
|
||||
.accessibilityIdentifier("templates.installFromFile")
|
||||
Button("Install from URL…", systemImage: "link") {
|
||||
installURLInput = ""
|
||||
showingInstallURLPrompt = true
|
||||
}
|
||||
.accessibilityIdentifier("templates.installFromURL")
|
||||
Divider()
|
||||
if let selected = viewModel.selectedProject {
|
||||
Button("Export \"\(selected.name)\" as Template…", systemImage: "tray.and.arrow.up") {
|
||||
@@ -217,6 +236,7 @@ struct ProjectsView: View {
|
||||
} label: {
|
||||
Label("Templates", systemImage: "shippingbox")
|
||||
}
|
||||
.accessibilityIdentifier("templates.toolbar.menu")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +249,7 @@ struct ProjectsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("https://example.com/my.scarftemplate", text: $installURLInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("templates.installURL.field")
|
||||
HStack {
|
||||
Button("Cancel") { showingInstallURLPrompt = false }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
@@ -243,6 +264,7 @@ struct ProjectsView: View {
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(URL(string: installURLInput)?.scheme?.lowercased() != "https")
|
||||
.accessibilityIdentifier("templates.installURL.confirm")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// VM for the in-app catalog browser. Owns the load lifecycle (fresh /
|
||||
/// cache / fallback), the install-state index, and the search +
|
||||
/// category filter. Hands off to the existing
|
||||
/// `TemplateInstallerViewModel` for actual install — there is no
|
||||
/// alternate install path here, which is the whole point: the catalog
|
||||
/// is just a discovery surface that feeds the existing flow.
|
||||
///
|
||||
/// Single observable for the whole sheet. Views read filtered entries
|
||||
/// via `displayedEntries`, refresh via `refresh()`, and install via
|
||||
/// `installAction(for:)`.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CatalogViewModel {
|
||||
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "CatalogViewModel")
|
||||
|
||||
// MARK: - State
|
||||
|
||||
enum LoadState: Sendable, Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded(LoadKind)
|
||||
case failed(message: String)
|
||||
|
||||
enum LoadKind: Sendable, Equatable {
|
||||
case fresh(fetchedAt: Date)
|
||||
case cache(fetchedAt: Date, refreshError: String?)
|
||||
case fallback(reason: String)
|
||||
}
|
||||
}
|
||||
|
||||
/// Catalog entries the loader returned. UI filters/sorts off this
|
||||
/// — never mutated except by `refresh()`.
|
||||
private(set) var entries: [CatalogEntry] = []
|
||||
|
||||
/// `[templateId: installedVersion]`. Drives "Installed" /
|
||||
/// "Update available" badges. Rebuilt on every `refresh()`.
|
||||
private(set) var installedIndex: [String: String] = [:]
|
||||
|
||||
private(set) var loadState: LoadState = .idle
|
||||
|
||||
/// User-typed search string. Matches against name + description +
|
||||
/// tags case-insensitively. Empty = no filter.
|
||||
var searchText: String = ""
|
||||
|
||||
/// `nil` = "All categories." Otherwise the picker constrains to
|
||||
/// entries whose `category` matches.
|
||||
var selectedCategory: String?
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let catalogService: CatalogService
|
||||
private let indexService: InstalledTemplatesIndex
|
||||
|
||||
/// Defaults are nil-coalesced inside the body rather than declared
|
||||
/// as `= CatalogService()` / `= InstalledTemplatesIndex()` defaults
|
||||
/// on the parameter list. Both backing types are `@MainActor`-isolated
|
||||
/// (project default), so evaluating their initializers as default
|
||||
/// parameter values runs in a synchronous nonisolated context and
|
||||
/// the Swift 6 compiler rejects it. Constructing inside the
|
||||
/// `@MainActor` init body sidesteps the diagnostic without changing
|
||||
/// behavior.
|
||||
init(
|
||||
catalogService: CatalogService? = nil,
|
||||
indexService: InstalledTemplatesIndex? = nil
|
||||
) {
|
||||
self.catalogService = catalogService ?? CatalogService()
|
||||
self.indexService = indexService ?? InstalledTemplatesIndex()
|
||||
}
|
||||
|
||||
/// Test-only seam. Production constructs via `init(catalogService:indexService:)`
|
||||
/// then calls `refresh()` to populate. Tests can short-circuit the
|
||||
/// load lifecycle by handing fixture entries directly. Marked
|
||||
/// `internal` (default) so it's invisible to other modules; the
|
||||
/// test target's `@testable import scarf` is what unlocks it.
|
||||
func _seedForTesting(entries: [CatalogEntry], installedIndex: [String: String] = [:]) {
|
||||
self.entries = entries
|
||||
self.installedIndex = installedIndex
|
||||
}
|
||||
|
||||
// MARK: - Public surface
|
||||
|
||||
/// All categories present in the loaded entries, sorted. Used to
|
||||
/// populate the category picker chrome.
|
||||
var availableCategories: [String] {
|
||||
let cats = entries.compactMap(\.category).filter { !$0.isEmpty }
|
||||
return Array(Set(cats)).sorted()
|
||||
}
|
||||
|
||||
/// Apply search + category filters to `entries`. Sort: shipped
|
||||
/// awizemann templates first (so the official ones don't get
|
||||
/// buried), then alphabetical by name.
|
||||
var displayedEntries: [CatalogEntry] {
|
||||
let filtered = entries.filter { entry in
|
||||
if let selectedCategory, entry.category != selectedCategory {
|
||||
return false
|
||||
}
|
||||
return matchesSearch(entry)
|
||||
}
|
||||
return filtered.sorted(by: Self.sortRule)
|
||||
}
|
||||
|
||||
/// Trigger a load. `forceRefresh: true` skips the fresh-cache
|
||||
/// short-circuit and always tries the network. Always rebuilds
|
||||
/// the installed index, since the user may have installed/uninstalled
|
||||
/// since the last load.
|
||||
///
|
||||
/// `indexService.build()` walks the projects registry + every
|
||||
/// project's lock file synchronously, so we run it on a detached
|
||||
/// task — sync file I/O on `@MainActor` would jank the catalog
|
||||
/// sheet during refresh on hosts with many projects.
|
||||
func refresh(forceRefresh: Bool = false) async {
|
||||
loadState = .loading
|
||||
let result = await catalogService.loadCatalog(forceRefresh: forceRefresh)
|
||||
let indexService = self.indexService
|
||||
let index = await Task.detached { indexService.build() }.value
|
||||
await applyLoad(result: result, index: index)
|
||||
}
|
||||
|
||||
/// Classify a row's install state from the current index. Used by
|
||||
/// `CatalogRowView` to render the badge.
|
||||
func installState(for entry: CatalogEntry) -> InstalledTemplatesIndex.InstallState {
|
||||
InstalledTemplatesIndex.classify(
|
||||
catalogVersion: entry.version,
|
||||
installedVersion: installedIndex[entry.id]
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the URL for the install flow. The catalog ships HTTPS
|
||||
/// install URLs; we hand the URL straight to the existing installer
|
||||
/// VM via `TemplateInstallerViewModel.openRemoteURL(_:)`.
|
||||
func installURL(for entry: CatalogEntry) -> URL? {
|
||||
URL(string: entry.installUrl)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func applyLoad(result: CatalogLoadResult, index: [String: String]) {
|
||||
installedIndex = index
|
||||
switch result {
|
||||
case .fresh(let catalog, let fetchedAt):
|
||||
entries = catalog.templates
|
||||
loadState = .loaded(.fresh(fetchedAt: fetchedAt))
|
||||
case .cache(let catalog, let fetchedAt, let refreshError):
|
||||
entries = catalog.templates
|
||||
loadState = .loaded(.cache(fetchedAt: fetchedAt, refreshError: refreshError))
|
||||
case .fallback(let catalog, let reason):
|
||||
entries = catalog.templates
|
||||
loadState = .loaded(.fallback(reason: reason))
|
||||
}
|
||||
}
|
||||
|
||||
private func matchesSearch(_ entry: CatalogEntry) -> Bool {
|
||||
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !q.isEmpty else { return true }
|
||||
let needle = q.lowercased()
|
||||
if entry.name.lowercased().contains(needle) { return true }
|
||||
if (entry.description ?? "").lowercased().contains(needle) { return true }
|
||||
if entry.tags.contains(where: { $0.lowercased().contains(needle) }) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Sort: official `awizemann/...` templates first, then alphabetical
|
||||
/// by name. Keeps the curated subset visible at the top while a
|
||||
/// growing community catalog stays browsable.
|
||||
private static func sortRule(_ a: CatalogEntry, _ b: CatalogEntry) -> Bool {
|
||||
let aOfficial = a.id.hasPrefix("awizemann/")
|
||||
let bOfficial = b.id.hasPrefix("awizemann/")
|
||||
if aOfficial != bOfficial { return aOfficial && !bOfficial }
|
||||
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
import SwiftUI
|
||||
|
||||
/// Compact category picker for the catalog sheet. Renders the
|
||||
/// available categories the loaded catalog actually carries (NOT a
|
||||
/// hard-coded list — keeps the picker honest as the catalog grows or
|
||||
/// shrinks). `nil` selection means "All categories."
|
||||
struct CatalogCategoryFilter: View {
|
||||
@Binding var selected: String?
|
||||
let availableCategories: [String]
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Button {
|
||||
selected = nil
|
||||
} label: {
|
||||
Label("All", systemImage: selected == nil ? "checkmark" : "")
|
||||
}
|
||||
if !availableCategories.isEmpty {
|
||||
Divider()
|
||||
}
|
||||
ForEach(availableCategories, id: \.self) { category in
|
||||
Button {
|
||||
selected = category
|
||||
} label: {
|
||||
Label(category.capitalized, systemImage: selected == category ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: ScarfSpace.s1) {
|
||||
Image(systemName: "line.horizontal.3.decrease.circle")
|
||||
Text(selected.map { $0.capitalized } ?? "All")
|
||||
.scarfStyle(.body)
|
||||
}
|
||||
.padding(.horizontal, ScarfSpace.s2)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.accessibilityIdentifier("catalog.categoryFilter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
import SwiftUI
|
||||
|
||||
/// Detail page for a single catalog entry. Surfaces what's already in
|
||||
/// `catalog.json` — name, version, author, description, contents
|
||||
/// claim, config schema preview. Deliberately does NOT fetch a
|
||||
/// separate README from the network; the catalog's `description` is
|
||||
/// the single source of truth at v2.8 to keep the sheet snappy and
|
||||
/// offline-friendly.
|
||||
struct CatalogDetailView: View {
|
||||
let entry: CatalogEntry
|
||||
let installState: InstalledTemplatesIndex.InstallState
|
||||
let onInstall: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
|
||||
header
|
||||
Divider()
|
||||
if let description = entry.description, !description.isEmpty {
|
||||
Text(description)
|
||||
.scarfStyle(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if !entry.tags.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(entry.tags, id: \.self) { tag in
|
||||
ScarfBadge(tag, kind: .neutral)
|
||||
}
|
||||
}
|
||||
}
|
||||
contentsBlock
|
||||
if let config = entry.config, !config.fields.isEmpty {
|
||||
configBlock(config: config)
|
||||
}
|
||||
Spacer(minLength: ScarfSpace.s4)
|
||||
installRow
|
||||
}
|
||||
.padding(ScarfSpace.s5)
|
||||
}
|
||||
.navigationTitle(entry.name)
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s1) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: ScarfSpace.s2) {
|
||||
Text(entry.name)
|
||||
.scarfStyle(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text("v\(entry.version)")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Spacer(minLength: 0)
|
||||
installStateBadge
|
||||
}
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
Text("by \(entry.author.name)")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
if let category = entry.category, !category.isEmpty {
|
||||
Text("·")
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Text(category.capitalized)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contentsBlock: some View {
|
||||
if let contents = entry.contents {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s1) {
|
||||
Text("What's inside")
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if contents.dashboard == true { contentRow(icon: "rectangle.grid.2x2", text: "Dashboard") }
|
||||
if contents.agentsMd == true { contentRow(icon: "doc.text", text: "AGENTS.md (cross-agent contract)") }
|
||||
if let cron = contents.cron, cron > 0 {
|
||||
contentRow(icon: "clock.badge.checkmark", text: "\(cron) cron job\(cron == 1 ? "" : "s") (paused on install)")
|
||||
}
|
||||
if let config = contents.config, config > 0 {
|
||||
contentRow(icon: "slider.horizontal.3", text: "\(config) configuration field\(config == 1 ? "" : "s")")
|
||||
}
|
||||
if contents.memory == true {
|
||||
contentRow(icon: "memorychip", text: "Memory appendix")
|
||||
}
|
||||
if let skills = contents.skills, !skills.isEmpty {
|
||||
contentRow(icon: "wand.and.rays", text: "\(skills.count) skill\(skills.count == 1 ? "" : "s"): \(skills.joined(separator: ", "))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configBlock(config: TemplateConfigSchema) -> some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s1) {
|
||||
Text("Configuration")
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(config.fields, id: \.key) { field in
|
||||
HStack(alignment: .top, spacing: ScarfSpace.s2) {
|
||||
Image(systemName: field.type == .secret ? "lock.shield" : "circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(field.label)
|
||||
.scarfStyle(.body)
|
||||
if let description = field.description, !description.isEmpty {
|
||||
Text(description)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let recommendation = config.modelRecommendation {
|
||||
Text("Recommended model: \(recommendation.preferred). \(recommendation.rationale ?? "")")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var installRow: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(installButtonLabel) {
|
||||
onInstall()
|
||||
}
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.accessibilityIdentifier("catalogDetail.installButton")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func contentRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.frame(width: 16)
|
||||
Text(text)
|
||||
.scarfStyle(.body)
|
||||
}
|
||||
}
|
||||
|
||||
private var installButtonLabel: String {
|
||||
switch installState {
|
||||
case .notInstalled: return "Install"
|
||||
case .installed: return "Reinstall"
|
||||
case .updateAvailable: return "Update"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var installStateBadge: some View {
|
||||
switch installState {
|
||||
case .notInstalled:
|
||||
EmptyView()
|
||||
case .installed(let version):
|
||||
ScarfBadge("Installed v\(version)", kind: .success)
|
||||
case .updateAvailable(let installedVersion, _):
|
||||
ScarfBadge("v\(installedVersion) installed", kind: .warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
import SwiftUI
|
||||
|
||||
/// One row in the catalog list. Renders an SF Symbol icon (category-coded),
|
||||
/// the template name + version, a one-line description, tag chips, and
|
||||
/// the install-state badge. Tapping a row pushes `CatalogDetailView`;
|
||||
/// the row itself doesn't own that navigation — `CatalogView` handles
|
||||
/// it via `NavigationLink` wrapping.
|
||||
struct CatalogRowView: View {
|
||||
let entry: CatalogEntry
|
||||
let installState: InstalledTemplatesIndex.InstallState
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: ScarfSpace.s3) {
|
||||
categoryIcon
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(ScarfColor.accent)
|
||||
.frame(width: 32, height: 32, alignment: .center)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: ScarfSpace.s2) {
|
||||
Text(entry.name)
|
||||
.scarfStyle(.body)
|
||||
.fontWeight(.semibold)
|
||||
Text("v\(entry.version)")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Spacer(minLength: 0)
|
||||
installStateBadge
|
||||
}
|
||||
if let description = entry.description, !description.isEmpty {
|
||||
Text(description)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.lineLimit(2)
|
||||
}
|
||||
if !entry.tags.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(entry.tags.prefix(4), id: \.self) { tag in
|
||||
ScarfBadge(tag, kind: .neutral)
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, ScarfSpace.s2)
|
||||
.accessibilityIdentifier("catalog.row.\(entry.detailSlug ?? entry.id)")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var installStateBadge: some View {
|
||||
switch installState {
|
||||
case .notInstalled:
|
||||
// Default state — no badge, keeps the row visually quiet.
|
||||
EmptyView()
|
||||
case .installed:
|
||||
ScarfBadge("Installed", kind: .success)
|
||||
case .updateAvailable(_, let catalogVersion):
|
||||
ScarfBadge("Update v\(catalogVersion)", kind: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the freeform `category` string to an SF Symbol. Anything we
|
||||
/// haven't seen falls through to a generic puzzle-piece. Keep
|
||||
/// in sync with `availableCategories` from the live catalog —
|
||||
/// `tools/build-catalog.py` doesn't constrain the field.
|
||||
private var categoryIcon: Image {
|
||||
switch entry.category?.lowercased() ?? "" {
|
||||
case "monitoring": return Image(systemName: "checkmark.shield")
|
||||
case "news": return Image(systemName: "newspaper")
|
||||
case "dev": return Image(systemName: "hammer")
|
||||
case "ops": return Image(systemName: "gauge.with.dots.needle.bottom.50percent")
|
||||
case "personal": return Image(systemName: "person.crop.circle")
|
||||
case "finance": return Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
default: return Image(systemName: "shippingbox")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
import SwiftUI
|
||||
|
||||
/// The catalog sheet's outer shell. Top: search field + category
|
||||
/// filter + refresh button + "last refreshed" timestamp. Body: a list
|
||||
/// of `CatalogRowView`s wrapped in `NavigationLink`s pushing
|
||||
/// `CatalogDetailView`. The whole sheet is one `NavigationStack` so
|
||||
/// the row → detail push uses native macOS behaviour.
|
||||
struct CatalogView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel = CatalogViewModel()
|
||||
|
||||
/// Closure the host (ProjectsView) provides — invoked when the
|
||||
/// user clicks Install on a detail page. Hands the URL to the
|
||||
/// existing `TemplateInstallerViewModel.openRemoteURL(_:)` flow.
|
||||
let onInstall: (URL) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
.navigationTitle("Template Catalog")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 520)
|
||||
.task {
|
||||
// Initial load on first present. `refresh()` honours the
|
||||
// 24h TTL — repeat opens within a day reuse the cache.
|
||||
await viewModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
ScarfTextField("Search templates", text: $viewModel.searchText)
|
||||
.frame(maxWidth: 280)
|
||||
.accessibilityIdentifier("catalog.searchField")
|
||||
CatalogCategoryFilter(
|
||||
selected: $viewModel.selectedCategory,
|
||||
availableCategories: viewModel.availableCategories
|
||||
)
|
||||
Spacer()
|
||||
refreshButton
|
||||
}
|
||||
.padding(ScarfSpace.s3)
|
||||
}
|
||||
|
||||
private var refreshButton: some View {
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
lastRefreshedLabel
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Button {
|
||||
Task { await viewModel.refresh(forceRefresh: true) }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Refresh catalog")
|
||||
.accessibilityIdentifier("catalog.refreshButton")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var lastRefreshedLabel: some View {
|
||||
switch viewModel.loadState {
|
||||
case .idle, .loading:
|
||||
Text("")
|
||||
case .loaded(let kind):
|
||||
switch kind {
|
||||
case .fresh(let fetchedAt):
|
||||
Text("Refreshed \(relative(fetchedAt))")
|
||||
case .cache(let fetchedAt, let refreshError):
|
||||
if refreshError != nil {
|
||||
Text("Cached • refresh failed")
|
||||
} else {
|
||||
Text("Cached \(relative(fetchedAt))")
|
||||
}
|
||||
case .fallback:
|
||||
Text("Offline • bundled list")
|
||||
}
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.foregroundStyle(ScarfColor.danger)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch viewModel.loadState {
|
||||
case .idle, .loading:
|
||||
VStack {
|
||||
Spacer()
|
||||
ProgressView("Loading catalog…")
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .failed(let message):
|
||||
VStack(spacing: ScarfSpace.s2) {
|
||||
Spacer()
|
||||
Text(message)
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.danger)
|
||||
Button("Retry") {
|
||||
Task { await viewModel.refresh(forceRefresh: true) }
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .loaded:
|
||||
entriesList
|
||||
}
|
||||
}
|
||||
|
||||
private var entriesList: some View {
|
||||
let entries = viewModel.displayedEntries
|
||||
return Group {
|
||||
if entries.isEmpty {
|
||||
VStack(spacing: ScarfSpace.s2) {
|
||||
Spacer()
|
||||
Text("No templates match your filters.")
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
List(entries) { entry in
|
||||
NavigationLink(value: entry) {
|
||||
CatalogRowView(
|
||||
entry: entry,
|
||||
installState: viewModel.installState(for: entry)
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.navigationDestination(for: CatalogEntry.self) { entry in
|
||||
CatalogDetailView(
|
||||
entry: entry,
|
||||
installState: viewModel.installState(for: entry),
|
||||
onInstall: {
|
||||
if let url = viewModel.installURL(for: entry) {
|
||||
onInstall(url)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func relative(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,7 @@ struct TemplateInstallSheet: View {
|
||||
Button("Install") { viewModel.confirmInstall() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.accessibilityIdentifier("templateInstall.confirmInstall")
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
@@ -507,6 +508,7 @@ private struct ParentDirectoryStep: View {
|
||||
TextField("Parent directory", text: $parentPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("templateInstall.parentDir.field")
|
||||
.onChange(of: parentPath) { _, _ in
|
||||
if remoteVerification != .idle {
|
||||
remoteVerification = .idle
|
||||
@@ -565,6 +567,7 @@ private struct ParentDirectoryStep: View {
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(parentPath.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.accessibilityIdentifier("templateInstall.parentDir.continue")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@
|
||||
"comment" : "A required asterisk.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"**Switch & Relaunch** sets this as the active profile (writes `~/.hermes/active_profile`) and relaunches Scarf so every tab — Webhooks, Sessions, SOUL.md, Memory — reloads from the new profile's `~/.hermes/profiles/<name>/` directory." : {
|
||||
"comment" : "A description of how to switch profiles.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"/%@" : {
|
||||
|
||||
},
|
||||
@@ -1330,6 +1334,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Active Hermes profile — click to manage" : {
|
||||
"comment" : "A tooltip for the active Hermes profile button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Active Personality" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2195,7 +2203,6 @@
|
||||
|
||||
},
|
||||
"All" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -2319,6 +2326,10 @@
|
||||
"comment" : "A label that shows all sessions",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"All Scarf windows will close and reopen. Unsaved chat input may be lost." : {
|
||||
"comment" : "A confirmation dialog warning message.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"All sessions" : {
|
||||
"comment" : "A label for a filter that shows all sessions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -3514,6 +3525,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Browse Catalog…" : {
|
||||
"comment" : "A button that opens a dialog to browse the catalog of available templates.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Browse Hub" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3721,6 +3736,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"by %@" : {
|
||||
"comment" : "A subheading displaying the author of a template. The argument is the name of the author.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"By Day" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3800,6 +3819,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cached • refresh failed" : {
|
||||
|
||||
},
|
||||
"Caching & Redaction" : {
|
||||
"comment" : "Section title for the advanced tab's \"Caching & Redaction\" section.",
|
||||
@@ -4777,6 +4803,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Click Re-authenticate to refresh tokens. Removing or rotating providers is still done via `hermes auth …` in a terminal." : {
|
||||
"comment" : "A description of how to refresh OAuth-authed",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Click to inspect this tool call" : {
|
||||
"comment" : "A tooltip for a tool call button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -5349,6 +5379,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Configuration" : {
|
||||
"comment" : "A heading for the configuration of a catalog entry.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Configuration saved" : {
|
||||
"comment" : "A title displayed when a configuration is saved.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -11772,6 +11806,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Loading catalog…" : {
|
||||
"comment" : "A placeholder text that appears when the catalog is loading.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loading commands…" : {
|
||||
"comment" : "A placeholder text that appears when loading slash commands.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -12192,10 +12230,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Managed by `hermes auth add <provider>` — Scarf is read-only here." : {
|
||||
"comment" : "A footer describing how OAuth providers are managed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Mark as seen" : {
|
||||
"comment" : "A button that marks the current skill set as seen and dismisses the \"What's New\" pill.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -14307,6 +14341,9 @@
|
||||
"No template loaded." : {
|
||||
"comment" : "A message displayed when no template is loaded.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No templates match your filters." : {
|
||||
|
||||
},
|
||||
"No tool calls found" : {
|
||||
"extractionState" : "stale",
|
||||
@@ -14586,6 +14623,9 @@
|
||||
"OFF" : {
|
||||
"comment" : "A label for a disabled skill.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Offline • bundled list" : {
|
||||
|
||||
},
|
||||
"OK" : {
|
||||
"localizations" : {
|
||||
@@ -14679,6 +14719,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Open Credential Pools and re-authenticate %@." : {
|
||||
"comment" : "A button that opens the Credential Pools pane and re-authenticates with the given OAuth provider.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open Developer Portal" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16927,6 +16971,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Re-authenticate" : {
|
||||
"comment" : "A button that opens the Credential Pools pane and re-authenticates with a given OAuth provider.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Re-authenticate AI providers and any MCP servers from Settings if those weren't included in the backup." : {
|
||||
"comment" : "A message that instructs the user to re-authenticate AI providers and MCP servers if they weren't included in the backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -17020,6 +17068,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Read this reply aloud" : {
|
||||
"comment" : "A label for a button that reads a message aloud.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Reading template.lock.json…" : {
|
||||
"comment" : "Text displayed in a progress view while the template is being read.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -17121,6 +17173,18 @@
|
||||
"comment" : "A label that indicates a recommended model.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Recommended model: %@. %@" : {
|
||||
"comment" : "A footnote that provides a recommendation for the model to use with a given configuration. The argument is the recommended model, and the second argument is an optional rationale for the recommendation.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Recommended model: %1$@. %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Reconnect" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -17281,10 +17345,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Refresh catalog" : {
|
||||
"comment" : "A button that refreshes the list of templates.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"refresh-only" : {
|
||||
"comment" : "A label for a refresh-only OAuth provider.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Refreshed %@" : {
|
||||
"comment" : "A label that shows when the",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Refreshing…" : {
|
||||
"comment" : "A message that appears when the app is refreshing",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -20450,6 +20522,14 @@
|
||||
"comment" : "A heading for the list of sessions in a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Set Active (no relaunch)" : {
|
||||
"comment" : "A button that sets a profile as active without relaunching Hermes.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Set as active profile and relaunch Scarf so every tab loads from %@" : {
|
||||
"comment" : "A button that sets a profile as the active profile and relaunches Scarf.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Set as default — open this server when Scarf launches." : {
|
||||
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -21978,6 +22058,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Stop speaking" : {
|
||||
"comment" : "A button that stops reading a message aloud.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Stored under `quick_commands:` in config.yaml." : {
|
||||
"comment" : "A description of the quick commands feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -22193,7 +22277,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Switch & Relaunch" : {
|
||||
"comment" : "A button that switches to a profile and relaunches Scarf.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Switch to '%@' and relaunch Scarf?" : {
|
||||
"comment" : "A confirmation dialog asking the user to confirm switching to a new profile and relaunching Scarf. The argument is the name of the profile to switch to.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Switch to This Profile" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -22234,6 +22327,7 @@
|
||||
}
|
||||
},
|
||||
"Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -22283,6 +22377,10 @@
|
||||
},
|
||||
"Telegram Setup Docs" : {
|
||||
|
||||
},
|
||||
"Template Catalog" : {
|
||||
"comment" : "The title of the template catalog screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Template ID" : {
|
||||
|
||||
@@ -24205,6 +24303,7 @@
|
||||
}
|
||||
},
|
||||
"Use" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -25155,6 +25254,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"What's inside" : {
|
||||
"comment" : "A heading for the contents of a catalog entry.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"WhatsApp Setup Docs" : {
|
||||
|
||||
},
|
||||
|
||||
@@ -108,4 +108,12 @@ final class AppCoordinator {
|
||||
/// session) — a new session needs a cwd override Scarf doesn't
|
||||
/// yet have an id for.
|
||||
var pendingProjectChat: String?
|
||||
|
||||
/// Lowercase OAuth provider name to re-authenticate. Set by the
|
||||
/// chat error banner's "Re-authenticate" button, consumed by
|
||||
/// CredentialPoolsView, which auto-presents the OAuth sheet seeded
|
||||
/// to this provider. Cleared by the consumer once handled. Sister
|
||||
/// of `pendingProjectChat` — a hand-off slot, not a long-lived
|
||||
/// state value.
|
||||
var pendingOAuthReauth: String?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the catalog browser's fetch + cache path. Six suites
|
||||
/// covering the result-enum surface plus a snapshot test that catches
|
||||
/// catalog-schema drift between the Python validator
|
||||
/// (`tools/build-catalog.py`) and the Swift `Catalog` decoder.
|
||||
///
|
||||
/// All tests run against an isolated `SCARF_HERMES_HOME` tmpdir so the
|
||||
/// user's real `~/.hermes/scarf/catalog_cache.json` is never touched.
|
||||
/// Serialized because we mutate process-wide env.
|
||||
@Suite(.serialized)
|
||||
struct CatalogServiceTests {
|
||||
|
||||
private static let envKey = "SCARF_HERMES_HOME"
|
||||
|
||||
// MARK: - Snapshot
|
||||
|
||||
/// Decode the live `templates/catalog.json` shipped in the repo
|
||||
/// against the Swift `Catalog` decoder. If this fails, the validator
|
||||
/// emitted a field shape the Swift side doesn't accept — fix
|
||||
/// whichever side is wrong (usually the Swift side: catch up on a
|
||||
/// field the Python validator added).
|
||||
@Test func liveCatalogJSONDecodesAgainstSwiftModel() throws {
|
||||
let catalogURL = try Self.locateRepoCatalog()
|
||||
let data = try Data(contentsOf: catalogURL)
|
||||
let catalog = try JSONDecoder().decode(Catalog.self, from: data)
|
||||
#expect(catalog.templates.count >= 1)
|
||||
let hn = try #require(catalog.templates.first(where: { $0.id == "awizemann/hackernews-digest" }))
|
||||
#expect(hn.name == "HackerNews Daily Digest")
|
||||
#expect(hn.installUrl.hasPrefix("https://"))
|
||||
#expect(hn.config?.fields.count == 3)
|
||||
}
|
||||
|
||||
// MARK: - Cache lifecycle
|
||||
|
||||
@Test func freshCacheIsServedWithoutNetwork() async throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let context = ServerContext.local
|
||||
let service = CatalogService(context: context)
|
||||
let now = Date()
|
||||
// Seed a fresh cache.
|
||||
try writeCacheFixture(at: context.paths.catalogCache, fetchedAt: now)
|
||||
|
||||
let result = await service.loadCatalog(forceRefresh: false)
|
||||
switch result {
|
||||
case .cache(let catalog, let fetchedAt, let refreshError):
|
||||
#expect(catalog.templates.count == 1)
|
||||
#expect(catalog.templates.first?.id == "test/cached")
|
||||
#expect(refreshError == nil)
|
||||
#expect(abs(fetchedAt.timeIntervalSince(now)) < 1)
|
||||
case .fresh, .fallback:
|
||||
Issue.record("expected cache result, got \(result)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func corruptCacheIsIgnored() throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let context = ServerContext.local
|
||||
let cachePath = context.paths.catalogCache
|
||||
let parent = (cachePath as NSString).deletingLastPathComponent
|
||||
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||
// Write garbage where we expect a valid cache.
|
||||
try "not-json-at-all".data(using: .utf8)!
|
||||
.write(to: URL(fileURLWithPath: cachePath))
|
||||
|
||||
let service = CatalogService(context: context)
|
||||
// Cache is unreadable → readCache returns nil; loadCatalog will
|
||||
// attempt a network fetch which fails (no internet stub here)
|
||||
// and falls through to the bundled fallback. We don't assert
|
||||
// *which* of fresh/cache/fallback we get because that depends
|
||||
// on the dev Mac's network state — only that the corrupt
|
||||
// cache didn't crash the process.
|
||||
#expect(service.readCache() == nil)
|
||||
}
|
||||
|
||||
@Test func cacheSchemaVersionMismatchIsIgnored() throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let context = ServerContext.local
|
||||
let cachePath = context.paths.catalogCache
|
||||
let parent = (cachePath as NSString).deletingLastPathComponent
|
||||
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||
// v999 cache — far ahead of currentVersion. Loader rejects.
|
||||
let payload = #"{"version":999,"fetchedAt":"2026-05-03T00:00:00Z","catalog":{"templates":[]}}"#
|
||||
try payload.data(using: .utf8)!.write(to: URL(fileURLWithPath: cachePath))
|
||||
|
||||
let service = CatalogService(context: context)
|
||||
#expect(service.readCache() == nil)
|
||||
}
|
||||
|
||||
// MARK: - Staleness
|
||||
|
||||
@Test func isCacheStaleHonorsTTL() throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let service = CatalogService(context: .local)
|
||||
let twentyThreeHoursAgo = Date().addingTimeInterval(-23 * 60 * 60)
|
||||
let twentyFiveHoursAgo = Date().addingTimeInterval(-25 * 60 * 60)
|
||||
let fresh = CatalogCache(fetchedAt: twentyThreeHoursAgo, catalog: Self.minimalCatalog)
|
||||
let stale = CatalogCache(fetchedAt: twentyFiveHoursAgo, catalog: Self.minimalCatalog)
|
||||
#expect(!service.isCacheStale(fresh))
|
||||
#expect(service.isCacheStale(stale))
|
||||
}
|
||||
|
||||
// MARK: - Fallback
|
||||
|
||||
/// One malformed catalog entry must NOT fail the whole list — the
|
||||
/// per-entry doc-comment promises this so a single typo on the live
|
||||
/// catalog doesn't leave every Scarf user with an empty picker.
|
||||
/// Decoder drops the bad entry with a logged warning and keeps the
|
||||
/// rest.
|
||||
@Test func malformedEntryIsDroppedRestSurvive() throws {
|
||||
// First entry has every required field; second is missing
|
||||
// `tags` (required by `CatalogEntry`); third is well-formed.
|
||||
let json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"templates": [
|
||||
{
|
||||
"id": "good/one",
|
||||
"name": "Good One",
|
||||
"version": "1.0.0",
|
||||
"tags": ["a"],
|
||||
"author": {"name": "T"},
|
||||
"installUrl": "https://example.invalid/one.scarftemplate"
|
||||
},
|
||||
{
|
||||
"id": "bad/missing-tags",
|
||||
"name": "Missing Tags",
|
||||
"version": "1.0.0",
|
||||
"author": {"name": "T"},
|
||||
"installUrl": "https://example.invalid/bad.scarftemplate"
|
||||
},
|
||||
{
|
||||
"id": "good/three",
|
||||
"name": "Good Three",
|
||||
"version": "1.0.0",
|
||||
"tags": ["b"],
|
||||
"author": {"name": "T"},
|
||||
"installUrl": "https://example.invalid/three.scarftemplate"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
let catalog = try JSONDecoder().decode(Catalog.self, from: Data(json.utf8))
|
||||
let ids = catalog.templates.map(\.id)
|
||||
#expect(ids == ["good/one", "good/three"])
|
||||
}
|
||||
|
||||
@Test func bundledFallbackIsNonEmpty() {
|
||||
// The fallback ships with the catalog as a hardcoded list so
|
||||
// a fresh-install / offline user still sees something on first
|
||||
// open. Drift between this list and the live catalog is a
|
||||
// separate concern (TODO: tools/check-catalog-fallback-sync.py).
|
||||
#expect(!CatalogService.fallbackCatalog.templates.isEmpty)
|
||||
let ids = CatalogService.fallbackCatalog.templates.map(\.id)
|
||||
#expect(ids.contains("awizemann/site-status-checker"))
|
||||
#expect(ids.contains("awizemann/hackernews-digest"))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Bundle returned by `makeTmpHome()` so each `@Test` func can
|
||||
/// capture both the tmpdir and the registry-lock snapshot in a
|
||||
/// `let` without `mutating` (Swift Testing's `@Test` macros
|
||||
/// disallow mutating instance methods on `@Suite struct`s).
|
||||
/// `TestRegistryLock` serializes us against
|
||||
/// `SessionAttributionServiceTests`, `ProjectsViewModelTests`, and
|
||||
/// every other suite that mutates `ServerContext.local.paths` —
|
||||
/// without it, Swift Testing's parallel-suite scheduler lets one
|
||||
/// suite's `setenv("SCARF_HERMES_HOME", ...)` leak into another
|
||||
/// suite's reads and cause non-deterministic failures.
|
||||
private struct HomeFixture {
|
||||
let homeURL: URL
|
||||
let registrySnapshot: Data?
|
||||
}
|
||||
|
||||
private func makeTmpHome() throws -> HomeFixture {
|
||||
let registrySnapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let path = base.appendingPathComponent("scarf-catalog-test-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
// Drop the sentinel marker BEFORE setenv. Without the marker,
|
||||
// `HermesProfileResolver.scarfHermesHomeOverride()` ignores
|
||||
// the env var and falls through to the real `~/.hermes/` —
|
||||
// protecting the user's real home from any test that crashes
|
||||
// mid-teardown or leaks the env var to another process.
|
||||
try Data().write(to: path.appendingPathComponent(HermesProfileResolver.testHomeMarkerFilename))
|
||||
setenv(Self.envKey, path.path, 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
return HomeFixture(homeURL: path, registrySnapshot: registrySnapshot)
|
||||
}
|
||||
|
||||
private func teardown(_ fixture: HomeFixture) {
|
||||
unsetenv(Self.envKey)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
try? FileManager.default.removeItem(at: fixture.homeURL)
|
||||
TestRegistryLock.restore(fixture.registrySnapshot)
|
||||
}
|
||||
|
||||
private func writeCacheFixture(at path: String, fetchedAt: Date) throws {
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||
let cache = CatalogCache(fetchedAt: fetchedAt, catalog: Self.minimalCatalog)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(cache)
|
||||
try data.write(to: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
private static let minimalCatalog = Catalog(
|
||||
schemaVersion: 1,
|
||||
templates: [
|
||||
CatalogEntry(
|
||||
id: "test/cached",
|
||||
name: "Cached Test Template",
|
||||
version: "1.0.0",
|
||||
description: "Fixture entry used in CatalogServiceTests.",
|
||||
category: "test",
|
||||
tags: ["fixture"],
|
||||
author: .init(name: "Tester", url: nil),
|
||||
minScarfVersion: nil,
|
||||
minHermesVersion: nil,
|
||||
installUrl: "https://example.invalid/cached.scarftemplate",
|
||||
bundleSize: nil,
|
||||
bundleSha256: nil,
|
||||
detailSlug: "test-cached",
|
||||
contents: nil,
|
||||
config: nil
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
/// Walk up from the test source file until we find the repo's
|
||||
/// `templates/catalog.json`. Working dirs differ between
|
||||
/// `xcodebuild test` and an Xcode IDE run, so the fixed
|
||||
/// "../templates/catalog.json" relative path doesn't survive both.
|
||||
private static func locateRepoCatalog() throws -> URL {
|
||||
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
for _ in 0..<6 {
|
||||
let candidate = dir.appendingPathComponent("templates/catalog.json")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
throw CocoaError(.fileReadNoSuchFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the catalog browser's view model. Most coverage is on
|
||||
/// the filtering / sorting / install-state classification logic — the
|
||||
/// load lifecycle is exercised by `CatalogServiceTests`. Serialized
|
||||
/// because the underlying `loadCatalog` walks `SCARF_HERMES_HOME`
|
||||
/// state.
|
||||
@MainActor
|
||||
@Suite(.serialized)
|
||||
struct CatalogViewModelTests {
|
||||
|
||||
private static let envKey = "SCARF_HERMES_HOME"
|
||||
|
||||
@Test func displayedEntriesAppliesSearchFilter() async throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let vm = CatalogViewModel()
|
||||
vm._seedForTesting(entries: Self.fixtureEntries)
|
||||
vm.searchText = "digest"
|
||||
|
||||
let visible = vm.displayedEntries
|
||||
#expect(visible.count == 1)
|
||||
#expect(visible.first?.id == "awizemann/hackernews-digest")
|
||||
}
|
||||
|
||||
@Test func displayedEntriesAppliesCategoryFilter() async throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let vm = CatalogViewModel()
|
||||
vm._seedForTesting(entries: Self.fixtureEntries)
|
||||
vm.selectedCategory = "monitoring"
|
||||
|
||||
let visible = vm.displayedEntries
|
||||
#expect(visible.count == 1)
|
||||
#expect(visible.first?.id == "awizemann/site-status-checker")
|
||||
}
|
||||
|
||||
@Test func sortPutsOfficialAwizemannFirst() async throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let vm = CatalogViewModel()
|
||||
// `community/zzzz` is alphabetically first by name; awizemann
|
||||
// entries should still rank above it because of the official
|
||||
// prefix.
|
||||
vm._seedForTesting(entries: [
|
||||
Self.makeEntry(id: "community/zebra", name: "AAAA Community"),
|
||||
Self.makeEntry(id: "awizemann/hackernews-digest", name: "HackerNews Daily Digest"),
|
||||
Self.makeEntry(id: "awizemann/site-status-checker", name: "Site Status Checker")
|
||||
])
|
||||
|
||||
let visible = vm.displayedEntries
|
||||
#expect(visible.count == 3)
|
||||
#expect(visible[0].id.hasPrefix("awizemann/"))
|
||||
#expect(visible[1].id.hasPrefix("awizemann/"))
|
||||
#expect(visible[2].id == "community/zebra")
|
||||
}
|
||||
|
||||
@Test func availableCategoriesDeduplicatesAndSorts() async throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let vm = CatalogViewModel()
|
||||
vm._seedForTesting(entries: [
|
||||
Self.makeEntry(id: "x/a", name: "A", category: "news"),
|
||||
Self.makeEntry(id: "x/b", name: "B", category: "monitoring"),
|
||||
Self.makeEntry(id: "x/c", name: "C", category: "monitoring"),
|
||||
Self.makeEntry(id: "x/d", name: "D", category: nil)
|
||||
])
|
||||
|
||||
#expect(vm.availableCategories == ["monitoring", "news"])
|
||||
}
|
||||
|
||||
@Test func installStateReportsNotInstalledForUnknown() async throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let vm = CatalogViewModel()
|
||||
vm._seedForTesting(entries: Self.fixtureEntries)
|
||||
// installedIndex stays empty.
|
||||
let state = vm.installState(for: Self.fixtureEntries[0])
|
||||
#expect(state == .notInstalled)
|
||||
}
|
||||
|
||||
@Test func installURLPassesThroughHTTPS() async throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let vm = CatalogViewModel()
|
||||
let url = vm.installURL(for: Self.fixtureEntries[0])
|
||||
#expect(url?.scheme == "https")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Cross-suite serialization. See `CatalogServiceTests` for rationale.
|
||||
private struct HomeFixture {
|
||||
let homeURL: URL
|
||||
let registrySnapshot: Data?
|
||||
}
|
||||
|
||||
private func makeTmpHome() throws -> HomeFixture {
|
||||
let registrySnapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let path = base.appendingPathComponent("scarf-vm-test-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: path.path + "/scarf",
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
// Sentinel marker — see CatalogServiceTests for rationale.
|
||||
try Data().write(to: path.appendingPathComponent(HermesProfileResolver.testHomeMarkerFilename))
|
||||
setenv(Self.envKey, path.path, 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
return HomeFixture(homeURL: path, registrySnapshot: registrySnapshot)
|
||||
}
|
||||
|
||||
private func teardown(_ fixture: HomeFixture) {
|
||||
unsetenv(Self.envKey)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
try? FileManager.default.removeItem(at: fixture.homeURL)
|
||||
TestRegistryLock.restore(fixture.registrySnapshot)
|
||||
}
|
||||
|
||||
private static let fixtureEntries: [CatalogEntry] = [
|
||||
makeEntry(id: "awizemann/hackernews-digest", name: "HackerNews Daily Digest", category: "news", tags: ["digest", "hackernews"]),
|
||||
makeEntry(id: "awizemann/site-status-checker", name: "Site Status Checker", category: "monitoring", tags: ["uptime"])
|
||||
]
|
||||
|
||||
private static func makeEntry(
|
||||
id: String,
|
||||
name: String,
|
||||
category: String? = "test",
|
||||
tags: [String] = []
|
||||
) -> CatalogEntry {
|
||||
CatalogEntry(
|
||||
id: id,
|
||||
name: name,
|
||||
version: "1.0.0",
|
||||
description: "Fixture for CatalogViewModelTests.",
|
||||
category: category,
|
||||
tags: tags,
|
||||
author: .init(name: "Tester", url: nil),
|
||||
minScarfVersion: nil,
|
||||
minHermesVersion: nil,
|
||||
installUrl: "https://example.invalid/\(id).scarftemplate",
|
||||
bundleSize: nil,
|
||||
bundleSha256: nil,
|
||||
detailSlug: id.replacingOccurrences(of: "/", with: "-"),
|
||||
contents: nil,
|
||||
config: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the catalog browser's install-state lookup. Five suites
|
||||
/// covering the build path's empty / templated / ad-hoc / version-diff
|
||||
/// branches plus the `classify` helper's semver-ish comparison.
|
||||
@Suite(.serialized)
|
||||
struct InstalledTemplatesIndexTests {
|
||||
|
||||
private static let envKey = "SCARF_HERMES_HOME"
|
||||
|
||||
@Test func emptyRegistryYieldsEmptyIndex() throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let index = InstalledTemplatesIndex(context: .local).build()
|
||||
#expect(index.isEmpty)
|
||||
}
|
||||
|
||||
@Test func templatedProjectAppearsInIndex() throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let projectDir = fixture.homeURL.appendingPathComponent("project-1", isDirectory: true).path
|
||||
try seedTemplatedProject(
|
||||
at: projectDir,
|
||||
registryPath: ServerContext.local.paths.projectsRegistry,
|
||||
projectName: "Test Project",
|
||||
templateId: "alan/example",
|
||||
templateVersion: "1.2.3"
|
||||
)
|
||||
|
||||
let index = InstalledTemplatesIndex(context: .local).build()
|
||||
#expect(index["alan/example"] == "1.2.3")
|
||||
}
|
||||
|
||||
@Test func adHocProjectWithoutLockIsSkipped() throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
// Project lives in registry but has no `.scarf/template.lock.json`.
|
||||
let projectDir = fixture.homeURL.appendingPathComponent("ad-hoc", isDirectory: true).path
|
||||
try FileManager.default.createDirectory(atPath: projectDir, withIntermediateDirectories: true)
|
||||
let registry = ProjectRegistry(projects: [
|
||||
ProjectEntry(name: "Ad Hoc", path: projectDir)
|
||||
])
|
||||
try writeRegistry(registry, at: ServerContext.local.paths.projectsRegistry)
|
||||
|
||||
let index = InstalledTemplatesIndex(context: .local).build()
|
||||
#expect(index.isEmpty)
|
||||
}
|
||||
|
||||
@Test func corruptLockIsSkippedNotCrashing() throws {
|
||||
let fixture = try makeTmpHome()
|
||||
defer { teardown(fixture) }
|
||||
|
||||
let projectDir = fixture.homeURL.appendingPathComponent("corrupt", isDirectory: true).path
|
||||
let scarfDir = projectDir + "/.scarf"
|
||||
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
|
||||
try "not-json".data(using: .utf8)!
|
||||
.write(to: URL(fileURLWithPath: scarfDir + "/template.lock.json"))
|
||||
|
||||
let registry = ProjectRegistry(projects: [
|
||||
ProjectEntry(name: "Corrupt", path: projectDir)
|
||||
])
|
||||
try writeRegistry(registry, at: ServerContext.local.paths.projectsRegistry)
|
||||
|
||||
let index = InstalledTemplatesIndex(context: .local).build()
|
||||
#expect(index.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - classify(catalogVersion:installedVersion:)
|
||||
|
||||
@Test func classifyBranches() {
|
||||
// Not installed.
|
||||
#expect(
|
||||
InstalledTemplatesIndex.classify(catalogVersion: "1.0.0", installedVersion: nil)
|
||||
== .notInstalled
|
||||
)
|
||||
// Equal versions.
|
||||
#expect(
|
||||
InstalledTemplatesIndex.classify(catalogVersion: "1.0.0", installedVersion: "1.0.0")
|
||||
== .installed(version: "1.0.0")
|
||||
)
|
||||
// Catalog ahead.
|
||||
#expect(
|
||||
InstalledTemplatesIndex.classify(catalogVersion: "1.1.0", installedVersion: "1.0.0")
|
||||
== .updateAvailable(installedVersion: "1.0.0", catalogVersion: "1.1.0")
|
||||
)
|
||||
// Catalog behind installed (downgrade or stale catalog) — treat
|
||||
// as installed, not "update available." User shouldn't see a
|
||||
// ghost update prompt that takes them backwards.
|
||||
#expect(
|
||||
InstalledTemplatesIndex.classify(catalogVersion: "0.9.0", installedVersion: "1.0.0")
|
||||
== .installed(version: "1.0.0")
|
||||
)
|
||||
// Multi-component compare.
|
||||
#expect(
|
||||
InstalledTemplatesIndex.classify(catalogVersion: "2.0.0", installedVersion: "1.99.99")
|
||||
== .updateAvailable(installedVersion: "1.99.99", catalogVersion: "2.0.0")
|
||||
)
|
||||
}
|
||||
|
||||
/// Pre-release versions outrank by being *older*: a `1.0.0-beta`
|
||||
/// catalog entry must NOT surface as "Update available" against a
|
||||
/// stable `1.0.0` installation, otherwise the upgrade flow would
|
||||
/// silently downgrade the user. See semver §11.
|
||||
@Test func prereleaseDoesNotShadowStable() {
|
||||
// Catalog ships pre-release; user already on the matching stable.
|
||||
// Should classify as installed (not update-available).
|
||||
#expect(
|
||||
InstalledTemplatesIndex.classify(catalogVersion: "1.0.0-beta", installedVersion: "1.0.0")
|
||||
== .installed(version: "1.0.0")
|
||||
)
|
||||
// The reverse: user on pre-release, catalog ships stable. Stable
|
||||
// is genuinely newer.
|
||||
#expect(
|
||||
InstalledTemplatesIndex.classify(catalogVersion: "1.0.0", installedVersion: "1.0.0-beta")
|
||||
== .updateAvailable(installedVersion: "1.0.0-beta", catalogVersion: "1.0.0")
|
||||
)
|
||||
// Two pre-releases on the same numeric core: lexicographic
|
||||
// tiebreak on the suffix. `beta.2` > `beta.1`.
|
||||
#expect(
|
||||
InstalledTemplatesIndex.classify(catalogVersion: "1.0.0-beta.2", installedVersion: "1.0.0-beta.1")
|
||||
== .updateAvailable(installedVersion: "1.0.0-beta.1", catalogVersion: "1.0.0-beta.2")
|
||||
)
|
||||
// Direct probe of the comparator for the historical bug case.
|
||||
#expect(InstalledTemplatesIndex.isVersionNewer("1.0.0-beta", than: "1.0.0") == false)
|
||||
#expect(InstalledTemplatesIndex.isVersionNewer("1.0.0", than: "1.0.0-beta") == true)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Helper bundle returned by `makeTmpHome()` so each `@Test`
|
||||
/// func can capture both the tmpdir and the registry snapshot in
|
||||
/// `let`s without needing `mutating` (which Swift Testing's
|
||||
/// `@Test` macros disallow).
|
||||
private struct HomeFixture {
|
||||
let homeURL: URL
|
||||
let registrySnapshot: Data?
|
||||
}
|
||||
|
||||
private func makeTmpHome() throws -> HomeFixture {
|
||||
// Cross-suite serialization against any other test that reads
|
||||
// `ServerContext.local.paths`. See the matching block in
|
||||
// `CatalogServiceTests` for the rationale.
|
||||
let registrySnapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let path = base.appendingPathComponent("scarf-index-test-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: path.path + "/scarf",
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
// Sentinel marker — see CatalogServiceTests for rationale.
|
||||
try Data().write(to: path.appendingPathComponent(HermesProfileResolver.testHomeMarkerFilename))
|
||||
setenv(Self.envKey, path.path, 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
return HomeFixture(homeURL: path, registrySnapshot: registrySnapshot)
|
||||
}
|
||||
|
||||
private func teardown(_ fixture: HomeFixture) {
|
||||
unsetenv(Self.envKey)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
try? FileManager.default.removeItem(at: fixture.homeURL)
|
||||
TestRegistryLock.restore(fixture.registrySnapshot)
|
||||
}
|
||||
|
||||
private func writeRegistry(_ registry: ProjectRegistry, at path: String) throws {
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(registry)
|
||||
try data.write(to: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
private func seedTemplatedProject(
|
||||
at projectDir: String,
|
||||
registryPath: String,
|
||||
projectName: String,
|
||||
templateId: String,
|
||||
templateVersion: String
|
||||
) throws {
|
||||
let scarfDir = projectDir + "/.scarf"
|
||||
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
|
||||
|
||||
// Lock file matching what ProjectTemplateInstaller.writeLockFile would produce.
|
||||
let lockJSON = """
|
||||
{
|
||||
"template_id": "\(templateId)",
|
||||
"template_version": "\(templateVersion)",
|
||||
"template_name": "Test Template",
|
||||
"installed_at": "2026-05-03T00:00:00Z",
|
||||
"project_files": [],
|
||||
"skills_namespace_dir": null,
|
||||
"skills_files": [],
|
||||
"cron_job_names": [],
|
||||
"memory_block_id": null
|
||||
}
|
||||
"""
|
||||
try lockJSON.data(using: .utf8)!
|
||||
.write(to: URL(fileURLWithPath: scarfDir + "/template.lock.json"))
|
||||
|
||||
let registry = ProjectRegistry(projects: [
|
||||
ProjectEntry(name: projectName, path: projectDir)
|
||||
])
|
||||
try writeRegistry(registry, at: registryPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// End-to-end coverage for the dogfooding-templates harness.
|
||||
///
|
||||
/// Two suites live here:
|
||||
///
|
||||
/// 1. `HackerNewsDigestTemplateE2ETests` — exercises the shipped
|
||||
/// `awizemann/hackernews-digest` bundle the way Scarf will at install
|
||||
/// time: unpack, parse, validate the manifest + dashboard + cron
|
||||
/// against the same `ProjectTemplateService` the app uses, then build
|
||||
/// a `TemplateInstallPlan` and assert the resulting plan would write
|
||||
/// the right files in the right places. Mirrors
|
||||
/// `ProjectTemplateExampleTemplateTests.siteStatusCheckerParsesAndPlans`
|
||||
/// so each shipped template gets the same regression net.
|
||||
///
|
||||
/// 2. `ScarfHermesHomeOverrideE2ETests` — proves the `SCARF_HERMES_HOME`
|
||||
/// env-var override (added in `HermesProfileResolver`) actually steers
|
||||
/// `ServerContext.local.paths`. This is the seam the Layer-B XCUITest
|
||||
/// relies on to drive Scarf against an isolated Hermes home; if it
|
||||
/// silently regresses, UI tests would suddenly start writing into the
|
||||
/// user's real `~/.hermes`. Running it here keeps that invariant
|
||||
/// visible from the unit-test target.
|
||||
@Suite struct HackerNewsDigestTemplateE2ETests {
|
||||
|
||||
/// Parse + plan the shipped HN Digest bundle, assert its shape, and
|
||||
/// confirm the cron prompt + dashboard contract are intact.
|
||||
@Test func hackernewsDigestParsesAndPlans() throws {
|
||||
let bundle = try Self.locateExample(author: "awizemann", name: "hackernews-digest")
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
|
||||
// Manifest shape — mirror the install-time invariants the catalog
|
||||
// validator enforces, so this test fails locally before a bad
|
||||
// bundle escapes to PR.
|
||||
#expect(inspection.manifest.id == "awizemann/hackernews-digest")
|
||||
#expect(inspection.manifest.name == "HackerNews Daily Digest")
|
||||
#expect(inspection.manifest.schemaVersion == 2)
|
||||
#expect(inspection.manifest.contents.dashboard)
|
||||
#expect(inspection.manifest.contents.agentsMd)
|
||||
#expect(inspection.manifest.contents.cron == 1)
|
||||
#expect(inspection.manifest.contents.config == 3)
|
||||
#expect(inspection.manifest.contents.skills == nil)
|
||||
#expect(inspection.manifest.contents.memory == nil)
|
||||
#expect(inspection.cronJobs.count == 1)
|
||||
#expect(inspection.cronJobs.first?.name == "Daily HN digest")
|
||||
#expect(inspection.cronJobs.first?.schedule == "0 8 * * *")
|
||||
|
||||
// Config schema — three fields with the constraints the README
|
||||
// promises. The validator catches missing fields; this catches
|
||||
// wrong constraints (e.g. a default that drifts away from the
|
||||
// text in README.md, or a maxItems someone bumped without
|
||||
// updating the surrounding docs).
|
||||
let schema = try #require(inspection.manifest.config)
|
||||
#expect(schema.fields.count == 3)
|
||||
let topicsField = try #require(schema.field(for: "topics"))
|
||||
#expect(topicsField.type == .list)
|
||||
#expect(topicsField.itemType == "string")
|
||||
#expect(topicsField.required == false)
|
||||
#expect(topicsField.maxItems == 20)
|
||||
let minScoreField = try #require(schema.field(for: "min_score"))
|
||||
#expect(minScoreField.type == .number)
|
||||
#expect(minScoreField.minNumber == 1)
|
||||
#expect(minScoreField.maxNumber == 1000)
|
||||
let maxItemsField = try #require(schema.field(for: "max_items"))
|
||||
#expect(maxItemsField.type == .number)
|
||||
#expect(maxItemsField.minNumber == 5)
|
||||
#expect(maxItemsField.maxNumber == 50)
|
||||
#expect(schema.modelRecommendation?.preferred == "claude-haiku-4")
|
||||
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||
|
||||
#expect(plan.projectDir.hasSuffix("awizemann-hackernews-digest"))
|
||||
#expect(plan.skillsFiles.isEmpty)
|
||||
#expect(plan.memoryAppendix == nil)
|
||||
#expect(plan.cronJobs.count == 1)
|
||||
#expect(plan.configSchema?.fields.count == 3)
|
||||
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||||
|
||||
let destinations = plan.projectFiles.map(\.destinationPath)
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/dashboard.json") })
|
||||
|
||||
// Cron-job name gets the template tag prefix so users can
|
||||
// identify + remove it from the Cron sidebar later.
|
||||
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/hackernews-digest] Daily HN digest")
|
||||
|
||||
// The bundled dashboard.json must decode cleanly against the
|
||||
// same struct the app renders with — catches drift between
|
||||
// template-author conventions and the runtime renderer.
|
||||
let dashboardPath = inspection.unpackedDir + "/dashboard.json"
|
||||
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
|
||||
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
|
||||
#expect(dashboard.title == "HackerNews Digest")
|
||||
#expect(dashboard.theme?.accent == "orange")
|
||||
// Three sections: Today's Digest (3 stat widgets), Top Stories
|
||||
// (1 list widget), How to Use (1 text widget). No webview —
|
||||
// this template intentionally doesn't expose a Site tab.
|
||||
#expect(dashboard.sections.count == 3)
|
||||
|
||||
let statsSection = dashboard.sections[0]
|
||||
#expect(statsSection.title == "Today's Digest")
|
||||
let statTitles = statsSection.widgets.filter { $0.type == "stat" }.map(\.title)
|
||||
#expect(statTitles.contains("Top Story Score"))
|
||||
#expect(statTitles.contains("Items Tracked"))
|
||||
#expect(statTitles.contains("Last Run"))
|
||||
|
||||
// The agent's contract: cron prompt references the four nouns
|
||||
// the dashboard + log files depend on. If any reference goes
|
||||
// missing, AGENTS.md and the prompt have desynced and the
|
||||
// agent will run against stale assumptions.
|
||||
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
||||
#expect(cronPrompt.contains("config.json"))
|
||||
#expect(cronPrompt.contains("min_score"))
|
||||
#expect(cronPrompt.contains("max_items"))
|
||||
#expect(cronPrompt.contains("topics"))
|
||||
#expect(cronPrompt.contains("dashboard.json"))
|
||||
#expect(cronPrompt.contains("digest.md"))
|
||||
#expect(cronPrompt.contains("hacker-news.firebaseio.com"))
|
||||
// {{PROJECT_DIR}} stays unresolved in the bundle — the installer
|
||||
// substitutes it at install time. A baked absolute path here
|
||||
// would follow every install to every user's machine.
|
||||
#expect(cronPrompt.contains("{{PROJECT_DIR}}"))
|
||||
}
|
||||
|
||||
nonisolated private static func locateExample(author: String, name: String) throws -> String {
|
||||
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
for _ in 0..<6 {
|
||||
let candidate = dir.appendingPathComponent("templates/\(author)/\(name)/\(name).scarftemplate")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return candidate.path
|
||||
}
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
throw ProjectTemplateError.requiredFileMissing("templates/\(author)/\(name)/\(name).scarftemplate")
|
||||
}
|
||||
}
|
||||
|
||||
/// Smoke-tests the SCARF_HERMES_HOME override at the `ServerContext.local`
|
||||
/// integration point. The unit-level resolver tests live in
|
||||
/// `HermesProfileResolverOverrideTests`; this exercises the same seam from
|
||||
/// the surface every Scarf service actually reads — `ServerContext.paths`.
|
||||
@Suite(.serialized)
|
||||
struct ScarfHermesHomeOverrideE2ETests {
|
||||
|
||||
private static let envKey = "SCARF_HERMES_HOME"
|
||||
|
||||
@Test func overrideSteersServerContextPaths() throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer {
|
||||
restore(saved)
|
||||
TestRegistryLock.restore(snapshot)
|
||||
}
|
||||
|
||||
let tmp = NSTemporaryDirectory().appending("scarf-e2e-home-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(atPath: tmp, withIntermediateDirectories: true)
|
||||
// Sentinel marker so the override is honored. Without this,
|
||||
// `HermesProfileResolver.scarfHermesHomeOverride()` ignores the
|
||||
// env var to protect the user's real `~/.hermes`.
|
||||
try Data().write(to: URL(fileURLWithPath: tmp + "/" + HermesProfileResolver.testHomeMarkerFilename))
|
||||
defer { try? FileManager.default.removeItem(atPath: tmp) }
|
||||
setenv(Self.envKey, tmp, 1)
|
||||
|
||||
// Every derived path in HermesPathSet is computed off `home`, so
|
||||
// proving `home` flips is enough to guarantee state.db, config.yaml,
|
||||
// sessions/, cron/, scarf/projects.json, et al. all redirect.
|
||||
// We assert the registry path explicitly because that's the one
|
||||
// most likely to clobber the user's real ~/.hermes if the
|
||||
// override regresses.
|
||||
let paths = ServerContext.local.paths
|
||||
#expect(paths.home == tmp)
|
||||
#expect(paths.projectsRegistry == tmp + "/scarf/projects.json")
|
||||
#expect(paths.cronJobsJSON == tmp + "/cron/jobs.json")
|
||||
#expect(paths.configYAML == tmp + "/config.yaml")
|
||||
}
|
||||
|
||||
@Test func overrideUnsetReturnsToProductionHome() {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer {
|
||||
restore(saved)
|
||||
TestRegistryLock.restore(snapshot)
|
||||
}
|
||||
|
||||
unsetenv(Self.envKey)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
// Without the override, `paths.home` resolves to the user's real
|
||||
// Hermes home (or the active profile under it). We don't assert
|
||||
// an exact path — we'd be encoding the test machine's username —
|
||||
// but we do assert the shape: an absolute path ending in
|
||||
// `/.hermes` (default profile) or containing `/profiles/`
|
||||
// (named profile).
|
||||
let paths = ServerContext.local.paths
|
||||
#expect(paths.home.hasPrefix("/"))
|
||||
#expect(paths.home.hasSuffix("/.hermes") || paths.home.contains("/.hermes/profiles/"))
|
||||
}
|
||||
|
||||
private func restore(_ saved: String?) {
|
||||
if let saved {
|
||||
setenv(Self.envKey, saved, 1)
|
||||
} else {
|
||||
unsetenv(Self.envKey)
|
||||
}
|
||||
HermesProfileResolver.invalidateCache()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// TemplateInstallUITests.swift
|
||||
// scarfUITests
|
||||
//
|
||||
// Layer B of the dogfooding-templates harness — drives Scarf via XCUITest
|
||||
// against the developer Mac's real `~/.hermes/` installation. v1 is
|
||||
// intentionally small: a single smoke test that proves the harness can
|
||||
// launch the app, surface a window, and read state. The install-flow
|
||||
// drive (Templates → Install → Configure → Dashboard) lands in v2 once
|
||||
// accessibility identifiers are wired across the install path.
|
||||
//
|
||||
// ## Sandbox shape (load-bearing)
|
||||
//
|
||||
// XCUITest runners on macOS are sandboxed even when the app under test
|
||||
// isn't. Concretely:
|
||||
//
|
||||
// - The runner CAN read `~/.hermes/` (verified — `Data(contentsOf:)`
|
||||
// succeeds on `~/.hermes/scarf/projects.json`).
|
||||
// - The runner CANNOT write to `~/.hermes/` — attempting `try data.write(...)`
|
||||
// throws `NSCocoaErrorDomain Code=513 (NSFileWriteNoPermissionError)`
|
||||
// with underlying EPERM.
|
||||
// - The Mac app under test runs unsandboxed and writes there freely.
|
||||
//
|
||||
// Implication for the harness: the install/uninstall round-trip MUST
|
||||
// happen via the app's own UI (which has the permissions), not via
|
||||
// direct file I/O from the runner. setUp can read state for assertions;
|
||||
// it can't snapshot-and-restore.
|
||||
//
|
||||
// ## SwiftUI scene wiring
|
||||
//
|
||||
// Scarf's main window is `WindowGroup(for: ServerID.self)`. On a fresh
|
||||
// `XCUIApplication.launch()` call, SwiftUI doesn't auto-surface a window
|
||||
// — real users get the window via Dock click → AppKit
|
||||
// `applicationOpenUntitledFile`, which XCUITest skips. The harness
|
||||
// nudges the same code path users hit by sending ⌘1 (the "Open Server →
|
||||
// Local" menu shortcut from `scarfApp.swift`'s `OpenServerCommands`).
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class TemplateInstallUITests: XCTestCase {
|
||||
|
||||
/// Real user home — NOT `NSHomeDirectory()`, which inside the
|
||||
/// XCUITest runner sandbox returns
|
||||
/// `~/Library/Containers/com.scarfUITests.xctrunner/Data`. The Mac
|
||||
/// app itself runs unsandboxed and reads from `~/.hermes/`, so any
|
||||
/// path the harness checks against the same data must point at the
|
||||
/// un-sandboxed home. `getpwuid(getuid()).pw_dir` is the canonical
|
||||
/// UNIX answer.
|
||||
private static let realHome: String = {
|
||||
guard let pw = getpwuid(getuid()), let dir = pw.pointee.pw_dir else {
|
||||
return NSHomeDirectory()
|
||||
}
|
||||
return String(cString: dir)
|
||||
}()
|
||||
|
||||
private static let hermesBinary = (realHome as NSString)
|
||||
.appendingPathComponent(".local/bin/hermes")
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
|
||||
// Refuse to run if `hermes` isn't on the dev Mac. The harness's
|
||||
// whole premise is "validate against the real Hermes install
|
||||
// pre-release"; failing here is friendlier than letting tests
|
||||
// crash later in the install flow.
|
||||
guard FileManager.default.isExecutableFile(atPath: Self.hermesBinary) else {
|
||||
throw XCTSkip("Hermes binary not found at \(Self.hermesBinary) — Layer B requires a real Hermes install on the dev Mac.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Smoke test: Scarf launches normally against the real Hermes home,
|
||||
/// the harness pushes ⌘1 (the "Open Server → Local" menu shortcut),
|
||||
/// and a window surfaces. This is the regression net for the test
|
||||
/// target itself — if a future change breaks XCUITest's ability to
|
||||
/// drive Scarf at all, this fails before any of the install-flow
|
||||
/// tests do.
|
||||
@MainActor
|
||||
func testAppLaunchesAndSurfacesAWindow() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["--scarf-test-mode"]
|
||||
app.launch()
|
||||
defer { app.terminate() }
|
||||
|
||||
// Activate first — without this, ⌘1 is delivered to whatever
|
||||
// app currently owns the keyboard focus (often Xcode), and the
|
||||
// menu shortcut is silently dropped by Scarf.
|
||||
app.activate()
|
||||
// Brief pause for activation to settle. We sleep up to 1s; if
|
||||
// the app is already responsive sooner, the ⌘1 send is harmless.
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
app.typeKey("1", modifierFlags: .command)
|
||||
|
||||
let windowAppeared = app.windows.firstMatch.waitForExistence(timeout: 15)
|
||||
XCTAssertTrue(
|
||||
windowAppeared,
|
||||
"Scarf did not surface a window within 15s of ⌘1 nudge. Crash logs land under derivedData/Logs/Test/."
|
||||
)
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "App Launch"
|
||||
attachment.lifetime = .deleteOnSuccess
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,81 @@
|
||||
# HackerNews Daily Digest — Agent Instructions
|
||||
|
||||
This project keeps a daily digest of HackerNews top stories filtered to the score threshold and (optional) topic keywords the user configured. The same instructions apply whether you're Hermes, Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
|
||||
|
||||
## Project layout
|
||||
|
||||
- `.scarf/config.json` — **the source of truth for filter settings.** Written by Scarf's install/configure UI. Holds:
|
||||
- `values.min_score` (number, default 100) — minimum HN score to include.
|
||||
- `values.max_items` (number, default 15) — cap on items per digest run.
|
||||
- `values.topics` (array of strings, default `[]`) — keywords to mark in the digest. Empty array means "no topic highlighting; include every story above the score threshold."
|
||||
- `.scarf/manifest.json` — cached copy of `template.json`. Don't modify.
|
||||
- `digest.md` — append-only markdown log. Newest run at the top. Each run is a section with the ISO-8601 timestamp as the heading. Created on the first run if it doesn't exist.
|
||||
- `.scarf/dashboard.json` — Scarf dashboard. **Only the `value` fields of the three stat widgets and the `items` array of the "Top Stories" list widget should be updated.** The section titles, widget types, and structure must stay intact.
|
||||
|
||||
## How configuration works
|
||||
|
||||
The user configures this project through Scarf's UI — not by editing files directly. On install, a form asked them for the score threshold, item cap, and any topic keywords; those values landed in `.scarf/config.json`. They can edit those values any time via the **Configuration** button on the project dashboard header.
|
||||
|
||||
Read configuration like this (JSON, via whatever file-read tool you have):
|
||||
|
||||
```
|
||||
cat .scarf/config.json
|
||||
# → { "values": { "min_score": 100, "max_items": 15, "topics": ["rust", "ai"] }, ... }
|
||||
```
|
||||
|
||||
**Never** edit `.scarf/config.json` yourself. If the user asks "raise the score threshold" or "add a topic" in chat, tell them to open the Configuration button on the dashboard.
|
||||
|
||||
## First-run bootstrap
|
||||
|
||||
If `digest.md` doesn't exist, create it with a one-line header:
|
||||
|
||||
```
|
||||
# HackerNews Daily Digest
|
||||
|
||||
Newest run at the top. Each section is a single digest.
|
||||
```
|
||||
|
||||
## What to do when the cron job fires
|
||||
|
||||
The cron prompt Scarf registers for this project carries **absolute paths** (the installer substitutes `{{PROJECT_DIR}}` at install time) — you don't need to figure out the project's location yourself. Use whatever absolute paths appear in the prompt you received; if you're working in the project's interactive chat instead, the paths below are relative to the project root.
|
||||
|
||||
1. Read `.scarf/config.json`. Extract `values.min_score` (number), `values.max_items` (number), and `values.topics` (array). Apply defaults (100 / 15 / `[]`) for any missing field.
|
||||
2. Fetch `https://hacker-news.firebaseio.com/v0/topstories.json`. Take the first `max_items * 3` IDs — that gives headroom for the score filter to drop low-scorers without re-fetching.
|
||||
3. For each ID, fetch `https://hacker-news.firebaseio.com/v0/item/<id>.json`. Keep only items where:
|
||||
- `type == "story"`,
|
||||
- `score >= min_score`,
|
||||
- either `url` or `text` is non-null.
|
||||
4. Truncate the surviving list to `max_items`.
|
||||
5. If `topics` is non-empty, walk each surviving item and find the first keyword whose lowercase form is a substring of the lowercase title. Tag the item with that keyword in `[brackets]`. If no keyword matches, leave the item un-tagged.
|
||||
6. Build a digest section:
|
||||
```
|
||||
## <ISO-8601 timestamp>
|
||||
|
||||
- [<score>] <title> [<topic>]? — <url or https://news.ycombinator.com/item?id=<id>>
|
||||
- …
|
||||
```
|
||||
Use the HN comments URL when the item has no external `url`.
|
||||
7. Prepend the section to `digest.md` (newest at top).
|
||||
8. Update `.scarf/dashboard.json`:
|
||||
- `Top Story Score` stat widget: `value` = the highest score in your filtered list (or `0` if the list is empty).
|
||||
- `Items Tracked` stat widget: `value` = number of items in the filtered list.
|
||||
- `Last Run` stat widget: `value` = the ISO-8601 timestamp.
|
||||
- `Top Stories` list widget `items`: one entry per filtered story:
|
||||
- `text`: `"[<score>] <title>"`
|
||||
- `status`: `"ok"` if the story matched a topic, otherwise `"pending"`.
|
||||
9. If the cron job has a `deliver` target set, emit a one-line summary (`12 items, top score 487 — "<title>"`) as the agent's final response so the delivery mechanism picks it up.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Don't modify the structure of `dashboard.json` (section titles, widget types, widget titles, `columns`). Only the values listed above are writable.
|
||||
- Don't edit `.scarf/config.json` — that's the user's responsibility via the Configuration UI.
|
||||
- Don't truncate `digest.md` — it's the historical record. If it grows past 1 MB, add a one-line note at the top of the file asking the user to archive it.
|
||||
- Don't fetch any URL other than `hacker-news.firebaseio.com` (the digest source) or the items the user explicitly asks about. No scraping, no other news sources.
|
||||
- Don't paginate past the first `max_items * 3` IDs. If the score filter eats all of them, write an empty digest section noting "no stories above threshold today" and update widgets to zero.
|
||||
|
||||
## When the user asks you things
|
||||
|
||||
- "What's in today's digest?" — read the top section of `digest.md` and summarize.
|
||||
- "Run the digest now" — do everything in the cron flow above, then summarize the results in chat.
|
||||
- "Why is [story] not in the digest?" — read the last 3–5 sections of `digest.md` and check whether the story appeared. If not, suggest the most likely cause (score below threshold, item type wasn't `story`, item appeared after the most recent run).
|
||||
- "Change the threshold" / "add a topic" — tell them: *"Click the Configuration button on the dashboard header (the slider icon, next to the folder). Adjust the values there and save. The next cron run will pick it up."* Don't try to edit config.json yourself.
|
||||
@@ -0,0 +1,40 @@
|
||||
# HackerNews Daily Digest
|
||||
|
||||
A minimal news-aggregation project that fetches HackerNews top stories once a day, filters them by score and (optional) topic keywords, and keeps a rolling markdown log + a live Scarf dashboard.
|
||||
|
||||
**Requires Scarf 2.3+** — uses the Configuration form during install and on-demand re-edit.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Configurable score threshold** — only stories at or above this score show up. HN front page averages ~150; lower it to widen the net, raise it to focus on the truly viral.
|
||||
- **Configurable item cap** — keeps each digest from sprawling. Default 15.
|
||||
- **Optional topic keywords** — a list of keywords (case-insensitive substring match against titles). Items that match a keyword get a `[topic]` tag in the digest and `"ok"` status in the dashboard list. Empty list = include every story above threshold, no highlighting.
|
||||
- **No API keys** — HackerNews' Firebase API is fully public. Nothing in this project's `.scarf/config.json` is secret; no Keychain entries are created.
|
||||
- **`digest.md`** — agent's append-only log. New runs prepend at the top. Created automatically on first run.
|
||||
- **`.scarf/dashboard.json`** — live dashboard with stat widgets (top score, items tracked, last run) and a Top Stories list.
|
||||
- **Cron job `Daily HN digest`** — registered (paused) by the installer; tag `[tmpl:awizemann/hackernews-digest]`. Runs daily at 8:00 AM when enabled.
|
||||
|
||||
## First steps
|
||||
|
||||
1. During install, fill in the Configuration form — set `min_score`, `max_items`, and any topic keywords you care about. (All have sensible defaults if you just want to skip it.) Hit Continue, then Install.
|
||||
2. After install, open the **Cron** sidebar and enable the `[tmpl:awizemann/hackernews-digest] Daily HN digest` job. It's paused on install so nothing runs without your explicit say-so.
|
||||
3. From the project's dashboard, ask your agent to run the job now: *"Run the HN digest and update the dashboard."*
|
||||
4. Future runs happen automatically at 8 AM daily.
|
||||
|
||||
## Changing filters later
|
||||
|
||||
Click the **Configuration** button (slider icon, dashboard toolbar) to re-open the form pre-filled with your current values. Adjust score, max items, or topics. Save. The next cron run picks up the changes.
|
||||
|
||||
## Customizing
|
||||
|
||||
- **Change the schedule.** Edit the cron job in the Cron sidebar — accepts `30m`, `every 2h`, or standard cron expressions like `0 8 * * *`.
|
||||
- **Switch sources.** This template is HN-only by design. To pull from Lobsters, Reddit, or RSS, fork it (export from a Scarf project, edit `cron/jobs.json`'s prompt, re-import) — most of the agent contract is generic.
|
||||
- **Add alerting.** Set a `deliver` target on the cron job (Discord, Slack, Telegram) — the agent will post the run summary there instead of just writing to `digest.md`.
|
||||
|
||||
## Recommended model
|
||||
|
||||
`claude-haiku-4` works well — this is a simple HTTP-fetch + filter + markdown task. Haiku keeps costs low when the cron runs daily. The recommendation appears in the Configuration form; Scarf doesn't auto-switch your active model, so adjust via Settings if you'd like.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Right-click the project in the sidebar → **Uninstall Template…** (or click the shippingbox icon on the dashboard header). Scarf walks you through exactly what's about to be removed: template-installed files in the project dir, the `[tmpl:…]` cron job, and the configuration values you entered (`config.json`; this template stores no secrets so there's nothing in Keychain to clean up). User-created files (like `digest.md`) are preserved.
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "Daily HN digest",
|
||||
"schedule": "0 8 * * *",
|
||||
"prompt": "Generate the HackerNews daily digest for the Scarf project at {{PROJECT_DIR}}. Read {{PROJECT_DIR}}/.scarf/config.json to get `values.min_score` (number, default 100), `values.max_items` (number, default 15), and `values.topics` (array of strings, default []). Fetch the top story IDs from https://hacker-news.firebaseio.com/v0/topstories.json and take the first `max_items * 3` IDs (gives headroom for the score filter to drop low-scorers). For each ID, fetch https://hacker-news.firebaseio.com/v0/item/<id>.json and keep only `type==\"story\"` items with `score >= min_score` and a non-null `url` or `text`. Cap the surviving list at `max_items`. If `topics` is non-empty, mark each surviving item with a `[topic]` tag for the first matching keyword (case-insensitive substring match against the title). Build a markdown digest section with the ISO-8601 timestamp as the heading and one bullet per item (`- [<score>] <title> [<topic>]? — <url or HN comments link>`). Prepend that section to {{PROJECT_DIR}}/digest.md (create the file with a one-line header if it doesn't exist). Update {{PROJECT_DIR}}/.scarf/dashboard.json: set the `Top Story Score` stat widget's `value` to the highest score, the `Items Tracked` stat widget's `value` to the count of items, and the `Last Run` stat widget's `value` to the ISO-8601 timestamp. Replace the `Top Stories` list widget's `items` array with one entry per item (text = `[<score>] <title>`, status = `\"ok\"` if the item has a topic match else `\"pending\"`). Preserve every other field in dashboard.json as-is. Reply with a one-line summary like '12 items, top score 487 — \"<title>\"'."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "HackerNews Digest",
|
||||
"description": "A daily roll-up of HackerNews top stories above your configured score threshold. The stat widgets and Top Stories list update each time the cron job runs; the digest itself is prepended to `digest.md` in the project root.",
|
||||
"theme": { "accent": "orange" },
|
||||
"sections": [
|
||||
{
|
||||
"title": "Today's Digest",
|
||||
"columns": 3,
|
||||
"widgets": [
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Top Story Score",
|
||||
"value": 0,
|
||||
"icon": "flame.fill",
|
||||
"color": "orange",
|
||||
"subtitle": "highest-scoring item"
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Items Tracked",
|
||||
"value": 0,
|
||||
"icon": "list.bullet.rectangle",
|
||||
"color": "blue",
|
||||
"subtitle": "above your score threshold"
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Last Run",
|
||||
"value": "never",
|
||||
"icon": "clock",
|
||||
"color": "gray",
|
||||
"subtitle": "ISO-8601 timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Top Stories",
|
||||
"columns": 1,
|
||||
"widgets": [
|
||||
{
|
||||
"type": "list",
|
||||
"title": "Top Stories (populated after first run)",
|
||||
"items": [
|
||||
{ "text": "Run the digest once to populate — the agent reads your Configuration, fetches HackerNews' top stories, and fills this list.", "status": "pending" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "How to Use",
|
||||
"columns": 1,
|
||||
"widgets": [
|
||||
{
|
||||
"type": "text",
|
||||
"title": "Quick Start",
|
||||
"format": "markdown",
|
||||
"content": "**1.** Review your configuration — click the **slider icon** (top-right of this dashboard) to open Configuration. Set `min_score`, `max_items`, and any `topics` keywords you want highlighted.\n\n**2.** Enable the `[tmpl:awizemann/hackernews-digest] Daily HN digest` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**3.** Ask your agent: *\"Run the HN digest now.\"* The Top Stories list populates, the stat widgets update, and a fresh entry lands at the top of `digest.md`.\n\n**4.** Daily at 8 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"id": "awizemann/hackernews-digest",
|
||||
"name": "HackerNews Daily Digest",
|
||||
"version": "1.0.0",
|
||||
"minScarfVersion": "2.3.0",
|
||||
"minHermesVersion": "0.9.0",
|
||||
"author": {
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann"
|
||||
},
|
||||
"description": "A daily digest of HackerNews top stories. Pulls Hacker News' Firebase API, filters by minimum score and optional topics, prepends a markdown digest to digest.md, and keeps the dashboard's top stories list current. No API keys required.",
|
||||
"category": "news",
|
||||
"tags": ["news", "digest", "hackernews", "cron", "starter", "configurable"],
|
||||
"contents": {
|
||||
"dashboard": true,
|
||||
"agentsMd": true,
|
||||
"cron": 1,
|
||||
"config": 3
|
||||
},
|
||||
"config": {
|
||||
"schema": [
|
||||
{
|
||||
"key": "topics",
|
||||
"type": "list",
|
||||
"itemType": "string",
|
||||
"label": "Highlight Topics (optional)",
|
||||
"description": "Keywords or phrases to highlight in the digest (case-insensitive substring match against story titles). Leave empty to include every top story above the score threshold.",
|
||||
"required": false,
|
||||
"minItems": 0,
|
||||
"maxItems": 20,
|
||||
"default": []
|
||||
},
|
||||
{
|
||||
"key": "min_score",
|
||||
"type": "number",
|
||||
"label": "Minimum Score",
|
||||
"description": "Only include stories at or above this point score. HN's front page averages ~150; lower this to widen the net, raise it to focus on viral-only items.",
|
||||
"required": false,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"default": 100
|
||||
},
|
||||
{
|
||||
"key": "max_items",
|
||||
"type": "number",
|
||||
"label": "Maximum Items",
|
||||
"description": "Cap on how many stories appear in each digest. Avoids blowing up the dashboard list when HN has a busy day.",
|
||||
"required": false,
|
||||
"min": 5,
|
||||
"max": 50,
|
||||
"default": 15
|
||||
}
|
||||
],
|
||||
"modelRecommendation": {
|
||||
"preferred": "claude-haiku-4",
|
||||
"rationale": "Simple HTTP fetch + filter + markdown render. Haiku is plenty fast and the cheapest option for a daily run."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,76 @@
|
||||
"generated": true,
|
||||
"schemaVersion": 1,
|
||||
"templates": [
|
||||
{
|
||||
"author": {
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann"
|
||||
},
|
||||
"bundleSha256": "4889bc63c25e928ce96cf4032f248435348ee72d3b9c30ae5282361605a8616d",
|
||||
"bundleSize": 8049,
|
||||
"category": "news",
|
||||
"config": {
|
||||
"modelRecommendation": {
|
||||
"preferred": "claude-haiku-4",
|
||||
"rationale": "Simple HTTP fetch + filter + markdown render. Haiku is plenty fast and the cheapest option for a daily run."
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"default": [],
|
||||
"description": "Keywords or phrases to highlight in the digest (case-insensitive substring match against story titles). Leave empty to include every top story above the score threshold.",
|
||||
"itemType": "string",
|
||||
"key": "topics",
|
||||
"label": "Highlight Topics (optional)",
|
||||
"maxItems": 20,
|
||||
"minItems": 0,
|
||||
"required": false,
|
||||
"type": "list"
|
||||
},
|
||||
{
|
||||
"default": 100,
|
||||
"description": "Only include stories at or above this point score. HN's front page averages ~150; lower this to widen the net, raise it to focus on viral-only items.",
|
||||
"key": "min_score",
|
||||
"label": "Minimum Score",
|
||||
"max": 1000,
|
||||
"min": 1,
|
||||
"required": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"default": 15,
|
||||
"description": "Cap on how many stories appear in each digest. Avoids blowing up the dashboard list when HN has a busy day.",
|
||||
"key": "max_items",
|
||||
"label": "Maximum Items",
|
||||
"max": 50,
|
||||
"min": 5,
|
||||
"required": false,
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"contents": {
|
||||
"agentsMd": true,
|
||||
"config": 3,
|
||||
"cron": 1,
|
||||
"dashboard": true
|
||||
},
|
||||
"description": "A daily digest of HackerNews top stories. Pulls Hacker News' Firebase API, filters by minimum score and optional topics, prepends a markdown digest to digest.md, and keeps the dashboard's top stories list current. No API keys required.",
|
||||
"detailSlug": "awizemann-hackernews-digest",
|
||||
"id": "awizemann/hackernews-digest",
|
||||
"installUrl": "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/hackernews-digest/hackernews-digest.scarftemplate",
|
||||
"minHermesVersion": "0.9.0",
|
||||
"minScarfVersion": "2.3.0",
|
||||
"name": "HackerNews Daily Digest",
|
||||
"tags": [
|
||||
"news",
|
||||
"digest",
|
||||
"hackernews",
|
||||
"cron",
|
||||
"starter",
|
||||
"configurable"
|
||||
],
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"author": {
|
||||
"name": "Alan Wizemann",
|
||||
|
||||
Reference in New Issue
Block a user