mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a7ac21ebe | |||
| 5be67282d8 | |||
| c661945a1f | |||
| f5f8dc30b6 | |||
| 34d315793b | |||
| acd3692faf | |||
| ab615f0c28 | |||
| 982ed7da92 | |||
| cb164f07f9 | |||
| 1dbdf9d079 | |||
| 101488cd0d |
@@ -61,3 +61,8 @@ releases/v*/appcast-entry.xml
|
||||
|
||||
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
|
||||
scripts/wiki-blocklist.txt
|
||||
|
||||
# TestFlight feedback / crash JSONs downloaded for triage. PII (emails,
|
||||
# carriers, locales) and never meant for the public repo — kept local
|
||||
# while a fix round is in progress, deleted afterward.
|
||||
crashes/
|
||||
|
||||
@@ -19,11 +19,43 @@
|
||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
||||
</p>
|
||||
|
||||
## What's New in 2.5
|
||||
## What's New in 2.6
|
||||
|
||||
### ScarfGo — the iPhone companion ships in public TestFlight
|
||||
### Hermes v2026.4.30 (v0.12.0) catch-up
|
||||
|
||||
Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
|
||||
The largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Every new surface is **capability-gated** through `HermesCapabilities` (parses `hermes --version` once per server) — on a v0.11 host, Scarf 2.6 looks identical to Scarf 2.5.2 and the new affordances are hidden.
|
||||
|
||||
- **Autonomous Curator (Mac sidebar + iOS panel).** `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Status panel, **Run Now / Pause / Resume** actions, three leaderboards (least-recently-active / most-active / least-active) with activity / use / view / patch counters, inline pin toggles, restore-archived sheet. Last-run REPORT.md renders inline. New "Curator" sidebar item under Interact (between Memory and Skills); ScarfGo gets a Curator nav row under System.
|
||||
- **Multimodal image input in chat.** Drag/drop, paste, or NSOpenPanel multi-pick on Mac; PhotosPicker on iOS (up to 5 images per message). `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85, **detached only** so encoding never blocks MainActor. Hermes routes the prompt to a vision-capable model automatically. Image-only sends are valid — vision models accept "describe this" with no caption.
|
||||
- **5 new inference providers** in the model picker — GMI Cloud, Azure AI Foundry, LM Studio (now first-class), MiniMax (OAuth), Tencent TokenHub. Provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly.
|
||||
- **Microsoft Teams + Yuanbao** as the 18th and 19th gateway platforms in the Platforms tab.
|
||||
- **Read-only Kanban view (Mac).** Paginated table over `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and 5s polling while foregrounded. Create / claim / dispatch UI is deferred until upstream stabilizes the multi-profile collab layer (which was reverted in v0.12).
|
||||
- **Skills v0.12 surface.** Direct-URL install (`hermes skills install <https-url>`) via a new "Install from URL…" toolbar button on Mac; reload via `hermes skills audit`; `skills.disabled` rendered as strikethrough + an "OFF" pill on Mac and iOS rows; Curator pin badge from `~/.hermes/skills/.curator_state` surfaced as a pin glyph.
|
||||
- **Cron — `--workdir` field (Mac).** Inject `AGENTS.md` / `CLAUDE.md` / `.cursorrules` from a working directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor adds the field; both create and edit paths forward the flag.
|
||||
- **Settings deltas.** New **Caching & Redaction** section under Advanced — prompt cache TTL picker (5m / 1h), redact-secrets-in-patches toggle (now off by default on v0.12; flip back on here), runtime metadata footer toggle. TTS provider list gains **piper** (native local TTS); terminal backend list gains **vercel** (Vercel Sandbox).
|
||||
- **`auxiliary.curator` aux task.** Curator's review fork can run on a separate model from the main agent. `auxiliary.flush_memories` was removed in v0.12 — Scarf preserves the row on v0.11 hosts (inverse gate) and hides it on v0.12.
|
||||
- **ScarfGo catch-up.** Read-only Webhooks / Plugins / Profiles tabs parity-match the Mac surfaces (no mutating CLI verbs on the phone). Yellow Hermes-version banner nudges pre-v0.12 hosts to upgrade; renders only when the connected target is below v0.12.
|
||||
|
||||
### Chat fixes (post-merge round)
|
||||
|
||||
A focused pass over GitHub issue triage:
|
||||
|
||||
- **Typing lag in the chat composer ([#67](https://github.com/awizemann/scarf/issues/67))** — `RichChatInputBar.updateMenuState()` was firing on every keystroke and writing two state vars per `.onChange`, tripping SwiftUI's "action tried to update multiple times per frame" warning. Composer now coalesces writes, short-circuits when the slash menu can't apply, and watches `commands.count` instead of allocating `commands.map(\.id)` per keystroke.
|
||||
- **Chat font-size slider now actually scales rich chat content ([#68](https://github.com/awizemann/scarf/issues/68))** — `\.dynamicTypeSize` couldn't reach the fixed-point ScarfFont tokens. New `\.chatFontScale` env value plumbed through bubbles, markdown, and code blocks.
|
||||
- **Placeholder ghosting on first keystroke ([#65](https://github.com/awizemann/scarf/issues/65))** — `TextEditor`'s NSTextView surfaces a typed glyph one frame before the SwiftUI binding propagates. Pinned an opaque background behind the placeholder rect; switched the conditional to `.opacity(...)` for view-tree stability.
|
||||
- **Draft text leaked between conversations ([#62](https://github.com/awizemann/scarf/issues/62))** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId`.
|
||||
- **Sent message rendered blank after navigating away ([#63](https://github.com/awizemann/scarf/issues/63))** — `loadSessionHistory` atomically replaced messages from a state.db that hadn't yet flushed the user's row. New per-session pending-user-messages cache survives `reset()` and re-injects entries until the DB catches up.
|
||||
- **Background completion notifications ([#64](https://github.com/awizemann/scarf/issues/64))** — new `ChatNotificationService` fires a local UNUserNotificationCenter banner when a prompt completes while Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
|
||||
- **Per-message TTS playback ([#66](https://github.com/awizemann/scarf/issues/66))** — small speaker glyph on each settled assistant bubble. Tap to read aloud through `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice.
|
||||
- **ACP control-message timeout 30s → 60s ([#61](https://github.com/awizemann/scarf/issues/61))** — gives `initialize` / `session/new` / `session/load` headroom against gateway-induced state.db lock contention.
|
||||
|
||||
See the full [v2.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.6.0).
|
||||
|
||||
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.5, v2.3, v2.2, v2.0, v1.6, and earlier.
|
||||
|
||||
## ScarfGo — the iPhone companion
|
||||
|
||||
Same Hermes server you've been running on your Mac — reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
|
||||
|
||||
**[Join the public TestFlight](https://testflight.apple.com/join/qCrRpcTz)** — the link is live now but only accepts new beta testers once Apple's Beta Review approves the first build. If you hit a "not accepting testers" splash, bookmark it and try again in 24–48h.
|
||||
|
||||
@@ -39,21 +71,6 @@ Same Hermes server you've been running on your Mac — now reachable from your p
|
||||
|
||||
See the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the full feature tour, [ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding) for the SSH-key setup walkthrough, and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
|
||||
|
||||
### Everything else in 2.5
|
||||
|
||||
- **Portable project-scoped slash commands.** Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (with optional `default:` fallback) in the body and sends the expanded prompt to Hermes. Mac authoring tab + iOS read-only browser. Templates carry them via the new `slash-commands/` block in `.scarftemplate` bundles (schemaVersion 3). See [Slash Commands](https://github.com/awizemann/scarf/wiki/Slash-Commands) for the full schema.
|
||||
- **Hermes v2026.4.23 chat parity.** `/steer` non-interruptive guidance command, per-turn stopwatch on assistant bubbles, numbered keyboard shortcuts (1–9) on the permission sheet, git branch chip in the chat header. The new `messages.reasoning_content` and `sessions.api_call_count` columns surface as a richer reasoning disclosure + an "API" chip on session rows.
|
||||
- **Spotify + design-md skills.** Mac ships an in-app Spotify OAuth sheet (mirrors the v2.3 Nous Portal pattern); design-md gets a host-side `npx` prereq check on both platforms. SKILL.md frontmatter (`allowed_tools`, `related_skills`, `dependencies`) renders as chip rows. A "What's New" pill on the Skills tab tells you when remote skills changed since you last looked.
|
||||
- **Mac global Sessions: project filter + project badges** — parity with ScarfGo's Sessions tab. The list grows a filter Menu (All projects / Unattributed / each registered project) and each row carries a tinted folder chip with the project name when attributed.
|
||||
- **Human-readable cron schedules everywhere.** New `CronScheduleFormatter` in ScarfCore translates the common cron shapes into English phrases and falls back to the raw expression on anything custom. Mac and iOS render the same.
|
||||
- **Mac design-system overhaul.** Rust palette, typed token bundle (`ScarfColor`, `ScarfFont`, `ScarfSpace`, `ScarfRadius`), reusable components (`ScarfPageHeader`, `ScarfCard`, `ScarfBadge`, `ScarfTextField`, four button styles), redesigned 3-pane chat. iOS adopts the same tokens with a hybrid Dynamic Type policy so accessibility scaling on body text is preserved. See [Design System](https://github.com/awizemann/scarf/wiki/Design-System) for the full reference.
|
||||
- **Under the hood** — `SessionAttributionService`, `ProjectContextBlock`, `CronScheduleFormatter`, `GitBranchService`, `SkillPrereqService`, `SkillSnapshotService`, `ProjectSlashCommandService`, and the ACP error triplet (`acpError` / `acpErrorHint` / `acpErrorDetails`) consolidated into ScarfCore so Mac and iOS consume one source of truth. 179 tests across 13 suites, three consecutive green runs. Several `try?` swallows in iOS lifecycle code now surface real failures (Keychain unlock errors no longer drop people into onboarding; partial Forget operations report what failed).
|
||||
- **iOS push notifications skeleton** — `NotificationRouter` ships with foreground presentation + a lock-screen "Approve / Deny" action category gated by `apnsEnabled = false`. Lights up when Hermes ships a server-side push sender + an APNs cert.
|
||||
|
||||
See the full [v2.5.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.5.0).
|
||||
|
||||
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.3, v2.2, v2.0, v1.6, and earlier.
|
||||
|
||||
## Connect ScarfGo to your Hermes server
|
||||
|
||||
ScarfGo speaks SSH directly — no companion service, no developer-controlled server in between. Onboarding takes about a minute:
|
||||
@@ -145,7 +162,7 @@ Custom, agent-generated dashboards for any project. Define stat boxes, charts, t
|
||||
- macOS 14.6+ (Sonoma) for Scarf
|
||||
- iOS 18.0+ for [ScarfGo](https://github.com/awizemann/scarf/wiki/ScarfGo) (the iPhone companion, public TestFlight from v2.5)
|
||||
- Xcode 16.0+ to build from source
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.11.0+ recommended for full v2.5 feature support — `/steer`, new state.db columns, design-md/spotify skills, SKILL.md frontmatter chips)
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.12.0+ recommended for full v2.6 feature support — autonomous Curator, multimodal image input, 5 new providers, Microsoft Teams + Yuanbao gateways, Kanban, Skills v0.12 surface, cron `--workdir`, prompt-cache TTL, Piper TTS, Vercel terminal)
|
||||
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server. ScarfGo requires the same on every Hermes host it connects to.
|
||||
|
||||
### Compatibility
|
||||
@@ -159,9 +176,10 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
|
||||
| v0.8.0 (2026-04-08) | Verified |
|
||||
| v0.9.0 (2026-04-13) | Verified |
|
||||
| v0.10.0 (2026-04-16) | Verified (Tool Gateway introduced) |
|
||||
| v0.11.0 (2026-04-23) | **Verified — current target (recommended for full v2.5 feature support)** |
|
||||
| v0.11.0 (2026-04-23) | Verified |
|
||||
| v0.12.0 (2026-04-30) | **Verified — current target (recommended for full v2.6 feature support)** |
|
||||
|
||||
Scarf 2.5 targets Hermes v0.11.0 for `/steer`, the new state.db columns (`messages.reasoning_content`, `sessions.api_call_count`), the new skills (design-md, spotify), the SKILL.md frontmatter chip surfaces, and the `hermes memory reset` toolbar action. Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; v0.11-specific behavior degrades gracefully on older agents (`/steer` is harmless, new columns silently nil out).
|
||||
Scarf 2.6 targets Hermes v0.12.0 for the autonomous Curator, multimodal ACP image content blocks, the 5 new inference providers, Microsoft Teams + Yuanbao gateways, the read-only Kanban view, the Skills v0.12 surface (URL install / reload / disable badges / curator pin), cron `--workdir`, `auxiliary.curator`, `prompt_caching.cache_ttl`, the redaction toggle, the runtime metadata footer, Piper TTS, and the Vercel terminal backend. Every v0.12 surface is **capability-gated** — Scarf detects the host's Hermes version once per server connection (`hermes --version` → semver + `YYYY.M.D` parse) and hides v0.12-only UI on older hosts. v0.11.0 hosts keep the full v2.5 surface (`/steer`, `messages.reasoning_content`, `sessions.api_call_count`, design-md/spotify skills, SKILL.md frontmatter chips, `hermes memory reset`). Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; new behavior degrades gracefully on older agents.
|
||||
|
||||
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
## What's in 2.6.5
|
||||
|
||||
A patch release that ships **template discoverability**, **cron observability**, and an **end-to-end UI test harness** that locks the new install path against regression. No breaking changes; every Hermes capability target is unchanged from 2.6.0.
|
||||
|
||||
### In-app Template Catalog
|
||||
|
||||
The catalog is no longer web-only. **Templates → Browse Catalog…** opens a sheet that fetches the live catalog from `awizemann.github.io/scarf/templates/`, renders one row per published template with name + version + tags, and one-click installs through the existing flow. Search filters across name / description / tags; the category picker constrains to whatever categories the loaded catalog actually carries.
|
||||
|
||||
- **Install-state badges** — each row shows "Installed v1.2.0" (green) or "Update v1.3.0" (amber) when the catalog version is newer than what's in `~/.hermes/scarf/projects.json`. Update is "uninstall + reinstall" today; in-place upgrade is on the v3 backlog.
|
||||
- **24h cache** at `~/.hermes/scarf/catalog_cache.json` so opening the sheet repeatedly doesn't re-hit the network. Refresh icon force-fetches.
|
||||
- **Bundled fallback** — fresh-install / offline users still see the official templates as a hardcoded list. Network failures serve stale cache with a "refresh failed" hint.
|
||||
- **Catalog-schema decoder fault tolerance** — one malformed entry on the live catalog can't bring down the whole list. The bad row is dropped with a logged warning; the rest survive.
|
||||
|
||||
### HackerNews Daily Digest template
|
||||
|
||||
First template added under the new dogfooding-templates loop. Configurable `min_score`, `max_items`, `topics`; one daily-at-08:00 cron job (paused on install) that pulls the HN Firebase API, filters, and prepends a markdown digest to the project's `digest.md`. No API keys required. Live at the catalog URL above.
|
||||
|
||||
### Cron observability — auth-error banner + running indicator + log tail
|
||||
|
||||
Cron rows now surface the same OAuth-refresh-revoked recovery flow as Chat instead of a generic red dot, plus three previously-missing observability cues:
|
||||
|
||||
- **OAuth re-auth.** `ACPErrorHint.classify` runs on `job.lastError`; when it returns `oauthRefreshRevoked(provider)` the detail pane shows the human-readable hint + a **Re-authenticate** button that drops the user into Credential Pools — same wiring ChatView's banner uses. Unrecognized errors fall back to the legacy red `lastError` text.
|
||||
- **Running indicator.** The row dot turns blue + pulses when `state == "running"` (precedence over disabled / error / success); the detail header gains a "running…" badge next to active/paused. No new polling — `HermesFileWatcher.lastChangeDate` already drives `CronViewModel.load()`.
|
||||
- **Last run output.** Collapsible panel replacing the inline log: a one-line summary (`<timestamp> — ok|error|running…`) always visible, full monospaced terminal-style scroll on expand, auto-scrolls to bottom when new runs land.
|
||||
|
||||
Also fixes a pre-existing bug in `HermesFileService.loadCronOutput` that returned the wrong file under Hermes's per-job-id output nesting.
|
||||
|
||||
### Layer B install-drive XCUITest harness
|
||||
|
||||
The dogfooding-templates initiative ships its first end-to-end UI test that drives the install pipeline:
|
||||
|
||||
```
|
||||
Launch with --scarf-test-mode → Sidebar → Projects → Install sheet
|
||||
(via --scarf-test-install-url launch arg) → Configure → Open Project
|
||||
→ Right-click → Uninstall Template → Confirm Remove → Done
|
||||
```
|
||||
|
||||
Runs ~30 s green on the dev Mac, validates 9 assertion points across the user journey. Covers the new accessibility identifiers wired in this release: `templateConfig.commitButton`, `projects.row.<name>`, `sidebar.section.<rawValue>`, `projects.contextMenu.uninstallTemplate`, `templateUninstall.confirmRemove`, `templateInstall.success.openProject`, `templateUninstall.success.done`. The `--scarf-test-install-url` launch arg + `TestModeFlags.isTestMode` gating lets XCUITest skip SwiftUI Menu / NSToolbarItem accessibility-bridging quirks that otherwise block toolbar-menu driving.
|
||||
|
||||
Wiki [Test-Harness](https://github.com/awizemann/scarf/wiki/Test-Harness) documents how to extend the harness for the next template.
|
||||
|
||||
### Sentinel-marker test isolation (incident-response hardening)
|
||||
|
||||
`SCARF_HERMES_HOME` override now requires the path to contain a `.scarf-test-home-marker` file to activate. Without the marker, production code falls through to the user's real `~/.hermes/`. Lands belt-and-braces protection for cases where a test crashes mid-teardown leaving the env var set, an env var inherits from a parent shell, or a misconfigured launchctl plist exports the variable. The override remains the seam every E2E test relies on; the marker file ensures it can't accidentally pivot a non-test process off the user's data.
|
||||
|
||||
### Chat fixes
|
||||
|
||||
- **OAuth refresh-revoked surface.** Chat-side error banner now classifies the message via `ACPErrorHint.classify` and offers an in-app **Re-authenticate** button that routes through Credential Pools (#65). Same primitive the new cron banner reuses.
|
||||
- **Placeholder ghosting fix.** TextEditor's placeholder now clips to the editor's bounds and clears on focus instead of bleeding past the cursor area when the user types fast (#67).
|
||||
|
||||
### Profile chip + structured logs
|
||||
|
||||
- **Active-profile chip in the sidebar header.** Click → routes to Profiles. Local contexts only (remote SSH would mislead).
|
||||
- **Switch & Relaunch** flow now writes `~/.hermes/active_profile` and relaunches Scarf in a single click instead of asking the user to quit+reopen.
|
||||
- Profile-resolver logs are now structured (key=value form) so `log show … | grep ProfileResolver` can pull "which profile did Scarf resolve to and why" out of support requests.
|
||||
|
||||
### Swift 6 cleanup
|
||||
|
||||
- `MessageSpeechService` — drop `@preconcurrency` on the AVSpeechSynthesizerDelegate conformance now that the protocol's Sendable annotations are upstreamed.
|
||||
- `ChatView` — `RichChatViewModel.PendingPermission: @retroactive Identifiable`. Quiets the Swift 6 compiler so downstream breakage would be loud if ScarfCore ever adds the conformance upstream.
|
||||
- `CredentialPoolsView` — `.help(Text(verbatim:))` so backticks render literally instead of being treated as markdown inline-code.
|
||||
|
||||
### iOS
|
||||
|
||||
- Composer redesigned with HIG touch targets + clear disabled state.
|
||||
- Portrait lock retained.
|
||||
- Chat-start preflight moved off MainActor.
|
||||
|
||||
### Known caveats
|
||||
|
||||
- **Cron-job-uninstall by name is ambiguous** when two projects share the same template id. The Layer B test surfaced this — manifests as: the test passes, but if you've manually installed the same template before running the test, your real cron job can disappear. Recovery is `hermes cron create`. Fix is queued: store cron-job IDs in `<project>/.scarf/template.lock.json` at install time and resolve by ID at uninstall time.
|
||||
- **Full-suite parallel test runs intermittently hang** — pre-existing flaky test infrastructure unrelated to this release. Individual suites all pass; the hang only manifests on `xcodebuild test` with everything concurrent. The sentinel-marker hardening prevents user-data damage from any race.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- **Hermes target unchanged from 2.6.0**: v2026.4.30 (v0.12.0). Pre-v0.12 Hermes hosts continue to work — no new capability gates added in this release.
|
||||
- **Min macOS unchanged**: 14.6.
|
||||
- **No schema changes** to anything in `~/.hermes/`. The two new Scarf-owned files (`scarf/catalog_cache.json` and the template-installer's `.scarf-test-home-marker` for tests) are additive.
|
||||
@@ -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 }
|
||||
@@ -95,15 +153,20 @@ public enum HermesProfileResolver {
|
||||
let defaultHome = defaultRootHome()
|
||||
let activeFile = defaultHome + "/active_profile"
|
||||
|
||||
// Absent file → default profile. This is the common case for users
|
||||
// who haven't run `hermes profile use ...` and shouldn't generate
|
||||
// any log noise.
|
||||
// Absent file → default profile. Common case for users who
|
||||
// haven't run `hermes profile use ...`. We still log at
|
||||
// `.info` (key=value, not warning) so support requests can
|
||||
// pull `log show … | grep ProfileResolver` and confirm the
|
||||
// resolver IS running and IS resolving to the default —
|
||||
// distinguishing "feature didn't fire" from "feature fired
|
||||
// and chose default" (issue #70).
|
||||
guard FileManager.default.fileExists(atPath: activeFile) else {
|
||||
logger.info("Resolved active Hermes profile: name=default, home=\(defaultHome, privacy: .public), source=default-no-file")
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
guard let raw = try? String(contentsOfFile: activeFile, encoding: .utf8) else {
|
||||
logger.warning("Found active_profile but could not read it; falling back to default profile.")
|
||||
logger.warning("Found active_profile but could not read it; falling back to default. home=\(defaultHome, privacy: .public)")
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
@@ -111,6 +174,7 @@ public enum HermesProfileResolver {
|
||||
|
||||
// Empty file or explicit "default" → default profile.
|
||||
if trimmed.isEmpty || trimmed == "default" {
|
||||
logger.info("Resolved active Hermes profile: name=default, home=\(defaultHome, privacy: .public), source=file-default")
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
@@ -129,7 +193,7 @@ public enum HermesProfileResolver {
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
logger.info("Resolved active Hermes profile to \(trimmed, privacy: .public) at \(profileHome, privacy: .public).")
|
||||
logger.info("Resolved active Hermes profile: name=\(trimmed, privacy: .public), home=\(profileHome, privacy: .public), source=file")
|
||||
return (trimmed, profileHome)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -448,14 +448,15 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
if !controller.attachments.isEmpty || isEncodingAttachment || attachmentError != nil {
|
||||
attachmentStrip
|
||||
}
|
||||
composerRow
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.top, ScarfSpace.s2)
|
||||
.padding(.bottom, ScarfSpace.s2)
|
||||
.background(.regularMaterial)
|
||||
#if canImport(PhotosUI)
|
||||
.photosPicker(
|
||||
@@ -536,18 +537,23 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private var composerRow: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
HStack(alignment: .bottom, spacing: ScarfSpace.s2) {
|
||||
if supportsImagePrompts {
|
||||
Button {
|
||||
showPhotoPicker = true
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.foregroundStyle(
|
||||
attachDisabled
|
||||
? ScarfColor.foregroundFaint
|
||||
: ScarfColor.foregroundMuted
|
||||
)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(controller.state != .ready || controller.attachments.count >= Self.maxAttachments)
|
||||
.disabled(attachDisabled)
|
||||
.accessibilityLabel("Attach image")
|
||||
}
|
||||
TextField(
|
||||
@@ -555,8 +561,19 @@ struct ChatView: View {
|
||||
text: $controller.draft,
|
||||
axis: .vertical
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textFieldStyle(.plain)
|
||||
.lineLimit(1...5)
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.vertical, ScarfSpace.s2)
|
||||
.frame(minHeight: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
|
||||
.fill(ScarfColor.backgroundSecondary)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
|
||||
.strokeBorder(ScarfColor.borderStrong, lineWidth: 1)
|
||||
)
|
||||
.disabled(controller.state != .ready)
|
||||
.submitLabel(.send)
|
||||
.focused($composerFocused)
|
||||
@@ -592,13 +609,32 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Big circular send button. Filled with the brand accent when
|
||||
// ready, swapped to a flat gray when disabled — opacity dims
|
||||
// alone read as "not quite tappable" (issue #69), the explicit
|
||||
// color swap makes the state unambiguous in both light and
|
||||
// dark mode.
|
||||
Button {
|
||||
Task { await controller.send() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(canSendComposer
|
||||
? ScarfColor.accent
|
||||
: ScarfColor.backgroundTertiary)
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(canSendComposer
|
||||
? ScarfColor.onAccent
|
||||
: ScarfColor.foregroundFaint)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Circle())
|
||||
.animation(ScarfAnimation.fast, value: canSendComposer)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSendComposer)
|
||||
.accessibilityLabel("Send message")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,6 +646,12 @@ struct ChatView: View {
|
||||
return !controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// Mirror of the `.disabled(...)` predicate on the paperclip button.
|
||||
/// Pulled out so the button's foreground branch reads cleanly.
|
||||
private var attachDisabled: Bool {
|
||||
controller.state != .ready || controller.attachments.count >= Self.maxAttachments
|
||||
}
|
||||
|
||||
/// Pull JPEG/PNG bytes out of each PhotosPickerItem and feed them
|
||||
/// through ImageEncoder. Detached so the heavyweight resize +
|
||||
/// JPEG-encode work doesn't block MainActor; the resulting
|
||||
@@ -1041,10 +1083,21 @@ final class ChatController {
|
||||
/// the start intent so the preflight sheet can replay it after the
|
||||
/// user picks a model. Reads via `context.readText` (transport-
|
||||
/// aware) and parses with the ScarfCore YAML parser — same path
|
||||
/// `IOSSettingsViewModel.load` uses, just synchronous because the
|
||||
/// preflight runs before any `state = .connecting` UI transition.
|
||||
private func passModelPreflight(intent: PendingStart) -> Bool {
|
||||
let raw = context.readText(context.paths.configYAML) ?? ""
|
||||
/// `IOSSettingsViewModel.load` uses.
|
||||
///
|
||||
/// **Off MainActor.** `context.readText` synchronously calls
|
||||
/// `transport.fileExists` + `transport.readFile`; on a remote
|
||||
/// ScarfGo context that's a blocking SSH round-trip that, before
|
||||
/// this fix, ran on the controller's `@MainActor` and stalled the
|
||||
/// UI for seconds during connect — long enough for iOS's
|
||||
/// non-responsive-app watchdog to kill the process if the user
|
||||
/// kept tapping (the typing TestFlight crash report). Reading
|
||||
/// detached pushes the I/O off MainActor; the result and the
|
||||
/// `pendingStartIntent` / `modelPreflightReason` writes hop back.
|
||||
private func passModelPreflight(intent: PendingStart) async -> Bool {
|
||||
let path = context.paths.configYAML
|
||||
let ctx = context
|
||||
let raw = await Task.detached { ctx.readText(path) ?? "" }.value
|
||||
let config = HermesConfig(yaml: raw)
|
||||
let result = ModelPreflight.check(config)
|
||||
if result.isConfigured { return true }
|
||||
@@ -1138,7 +1191,7 @@ final class ChatController {
|
||||
/// can type and hit send immediately.
|
||||
func start() async {
|
||||
if state == .connecting || state == .ready { return }
|
||||
guard passModelPreflight(intent: .fresh) else { return }
|
||||
guard await passModelPreflight(intent: .fresh) else { return }
|
||||
state = .connecting
|
||||
vm.reset()
|
||||
let client = ACPClient.forIOSApp(
|
||||
@@ -1651,7 +1704,7 @@ final class ChatController {
|
||||
} else {
|
||||
intent = .fresh
|
||||
}
|
||||
guard passModelPreflight(intent: intent) else { return }
|
||||
guard await passModelPreflight(intent: intent) else { return }
|
||||
state = .connecting
|
||||
let client = ACPClient.forIOSApp(
|
||||
context: context,
|
||||
@@ -1735,7 +1788,7 @@ final class ChatController {
|
||||
/// to `session/load` if the remote doesn't support `session/resume`
|
||||
/// (Hermes < 0.9.x).
|
||||
func startResuming(sessionID: String) async {
|
||||
guard passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
|
||||
guard await passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
|
||||
await stop()
|
||||
vm.reset()
|
||||
// Clear eagerly so a lingering project name from a prior
|
||||
|
||||
@@ -529,7 +529,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -540,13 +540,13 @@
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -571,7 +571,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -582,13 +582,13 @@
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -612,7 +612,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -635,7 +635,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -658,7 +658,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -680,7 +680,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -834,7 +834,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -848,7 +848,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -870,7 +870,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -884,7 +884,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -902,12 +902,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -924,12 +924,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -945,11 +945,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -965,11 +965,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
@@ -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,99 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Quits the running app and brings up a fresh instance of the same
|
||||
/// bundle. Used by the Profile-switching flow (issue #70) so the new
|
||||
/// active profile lands in a process that has never observed the old
|
||||
/// one — sidesteps any in-process cache or service-state bug that
|
||||
/// might still be reading from the previous profile's home directory.
|
||||
///
|
||||
/// The pairing is intentional:
|
||||
/// 1. Caller invokes `try AppRelauncher.relaunch()`. That spawns a
|
||||
/// fresh `open -n <bundleURL>`, captures stderr/exitCode, returns
|
||||
/// success once the launcher has acknowledged the dispatch.
|
||||
/// 2. Caller schedules `NSApp.terminate(nil)` 250ms later. The
|
||||
/// 250ms gives macOS time to begin launching the second PID so
|
||||
/// the dock-icon hand-off looks smooth (no flash of missing
|
||||
/// icon). Without the gap, macOS can briefly show zero Scarf
|
||||
/// icons in the dock.
|
||||
///
|
||||
/// Refuses to relaunch when the running bundle is under
|
||||
/// `DerivedData/` or `Build/Products/Debug` — that's an Xcode
|
||||
/// debug session, and `terminate(nil)` would kill the run mid-debug
|
||||
/// without giving the new instance any way to attach. The caller
|
||||
/// surfaces a "restart manually" toast in that case.
|
||||
@MainActor
|
||||
enum AppRelauncher {
|
||||
static let logger = Logger(subsystem: "com.scarf.app", category: "AppRelauncher")
|
||||
|
||||
enum RelaunchError: Error, LocalizedError {
|
||||
case debugBuild
|
||||
case openFailed(exitCode: Int32, stderr: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .debugBuild:
|
||||
return "Refusing to relaunch from an Xcode debug build."
|
||||
case .openFailed(let code, let stderr):
|
||||
return "open(1) exited \(code): \(stderr)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a fresh instance of the running app via `/usr/bin/open -n
|
||||
/// <bundleURL>` and returns once the launcher process has dispatched
|
||||
/// the new instance. The caller is responsible for the subsequent
|
||||
/// `NSApp.terminate(nil)` (deferred ~250ms for a smooth dock hand-off).
|
||||
/// Throws `.debugBuild` when launched from Xcode/DerivedData;
|
||||
/// `.openFailed` when `open` itself errored.
|
||||
static func relaunch() throws {
|
||||
let bundleURL = Bundle.main.bundleURL
|
||||
let path = bundleURL.path
|
||||
if path.contains("/DerivedData/")
|
||||
|| path.contains("/Build/Products/Debug")
|
||||
|| path.contains("/Build/Products/Debug-")
|
||||
{
|
||||
logger.warning("Refusing relaunch — running from Xcode build (\(path, privacy: .public))")
|
||||
throw RelaunchError.debugBuild
|
||||
}
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||
// -n: force a NEW instance (without it, `open` activates the
|
||||
// running app and we'd never get a fresh process).
|
||||
// Pass the bundle URL directly (not -a <bundleId>) so signed
|
||||
// dev clones in `~/Applications` still resolve correctly.
|
||||
// No -W: we want `open` to return immediately after dispatch,
|
||||
// not block until the spawned app exits.
|
||||
proc.arguments = ["-n", path]
|
||||
|
||||
let stderrPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
proc.standardError = stderrPipe
|
||||
proc.standardOutput = stdoutPipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
throw RelaunchError.openFailed(exitCode: -1, stderr: error.localizedDescription)
|
||||
}
|
||||
|
||||
proc.waitUntilExit()
|
||||
|
||||
// Drain both streams BEFORE inspecting exit code so we don't leak fds.
|
||||
let errData = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
_ = try? stdoutPipe.fileHandleForReading.readToEnd()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
|
||||
guard proc.terminationStatus == 0 else {
|
||||
let stderr = String(data: errData, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
logger.warning("open(1) failed (\(proc.terminationStatus)): \(stderr, privacy: .public)")
|
||||
throw RelaunchError.openFailed(exitCode: proc.terminationStatus, stderr: stderr)
|
||||
}
|
||||
|
||||
logger.info("Relaunch dispatched for \(path, privacy: .public)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,12 +490,35 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the most-recent run output for a cron job. Hermes writes
|
||||
/// `~/.hermes/cron/output/<jobId>/<YYYY-MM-DD_HH-MM-SS>.md` per run
|
||||
/// (one file per execution); we resolve the per-job subdir, take
|
||||
/// the lexicographically-last filename (which is the newest given
|
||||
/// the timestamp prefix), and return its contents. Returns nil
|
||||
/// when the subdir is missing, empty, or the read fails — the cron
|
||||
/// detail surface treats nil as "no output yet."
|
||||
///
|
||||
/// A legacy flat-file layout (`<dir>/<filename containing jobId>`)
|
||||
/// is checked as a fallback so older Hermes installs that used a
|
||||
/// non-nested layout still surface their last run.
|
||||
nonisolated func loadCronOutput(jobId: String) -> String? {
|
||||
let dir = context.paths.cronOutputDir
|
||||
guard let files = try? transport.listDirectory(dir) else { return nil }
|
||||
let matching = files.filter { $0.contains(jobId) }.sorted().last
|
||||
guard let filename = matching else { return nil }
|
||||
return readFile(dir + "/" + filename)
|
||||
let perJobDir = dir + "/" + jobId
|
||||
if let runs = try? transport.listDirectory(perJobDir),
|
||||
let latest = runs.sorted().last {
|
||||
if let content = readFile(perJobDir + "/" + latest) {
|
||||
return content
|
||||
}
|
||||
}
|
||||
// Legacy fallback: pre-subdir layouts had files like
|
||||
// `<jobId>-<timestamp>.log` directly under cronOutputDir. Keep
|
||||
// matching them so users on older Hermes versions still see
|
||||
// their tail.
|
||||
if let files = try? transport.listDirectory(dir),
|
||||
let matching = files.filter({ $0.contains(jobId) }).sorted().last {
|
||||
return readFile(dir + "/" + matching)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ struct RichChatInputBar: View {
|
||||
// bare `if text.isEmpty` overlay renders the
|
||||
// translucent placeholder text on top of the
|
||||
// just-typed character — visible as a "behind
|
||||
// or around" ghost. Two mitigations:
|
||||
// or around" ghost. Three mitigations:
|
||||
//
|
||||
// 1. Pin an opaque rectangle behind the
|
||||
// placeholder text. During any single-
|
||||
@@ -125,15 +125,29 @@ struct RichChatInputBar: View {
|
||||
// keystroke (removes the per-keystroke
|
||||
// view-mutation churn the composer was
|
||||
// already paying for).
|
||||
// 3. Constrain to a single line with
|
||||
// `frame(maxWidth: .infinity)` and
|
||||
// `truncationMode(.tail)` so the long-form
|
||||
// hint can't escape the rounded
|
||||
// TextEditor bounds when the sidebar /
|
||||
// detail-pane geometry compresses the
|
||||
// composer (was visibly overflowing).
|
||||
Text(supportsImagePrompts
|
||||
? "Message Hermes… / for commands · drag images to attach"
|
||||
: "Message Hermes… / for commands")
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(ScarfColor.backgroundSecondary)
|
||||
.opacity(text.isEmpty ? 1 : 0)
|
||||
// Hide once the field has any content OR
|
||||
// the user is actively focused — matches
|
||||
// standard NSTextField / UITextField
|
||||
// placeholder semantics.
|
||||
.opacity((text.isEmpty && !isFocused) ? 1 : 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Drag-drop image attachments. Receives both file URLs
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -24,6 +24,16 @@ final class CronViewModel {
|
||||
var editingJob: HermesCronJob?
|
||||
var isLoading = false
|
||||
|
||||
/// Classified hint for the selected job's `lastError`, computed via
|
||||
/// `ACPErrorHint.classify` so cron rows surface the same OAuth-revoked
|
||||
/// affordance that ChatView's banner offers. `nil` when the selected
|
||||
/// job has no error or the error doesn't match a known pattern — the
|
||||
/// detail pane falls back to rendering `lastError` raw.
|
||||
var selectedErrorClassification: ACPErrorHint.Classification? {
|
||||
guard let job = selectedJob, let lastError = job.lastError, !lastError.isEmpty else { return nil }
|
||||
return ACPErrorHint.classify(errorMessage: lastError, stderrTail: "")
|
||||
}
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
let svc = fileService
|
||||
|
||||
@@ -12,7 +12,10 @@ import ScarfDesign
|
||||
struct CronView: View {
|
||||
@State private var viewModel: CronViewModel
|
||||
@State private var pendingDelete: HermesCronJob?
|
||||
@State private var showOutputPanel: Bool = false
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: CronViewModel(context: context))
|
||||
@@ -36,6 +39,13 @@ struct CronView: View {
|
||||
.navigationTitle("Cron Jobs")
|
||||
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
|
||||
.onAppear { viewModel.load() }
|
||||
// Reload on Hermes file mutations — Hermes flips `state` between
|
||||
// "scheduled" and "running" inside `~/.hermes/cron/jobs.json`
|
||||
// when a job starts/finishes, and writes a new run-output file
|
||||
// under `~/.hermes/cron/output/`. The watcher gives us the
|
||||
// running indicator + log tail refresh "for free" without a
|
||||
// polling timer. Same wiring ActivityView uses.
|
||||
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
|
||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in
|
||||
viewModel.createJob(
|
||||
@@ -172,6 +182,13 @@ struct CronView: View {
|
||||
Circle()
|
||||
.fill(statusDotColor(job))
|
||||
.frame(width: 7, height: 7)
|
||||
.opacity(job.state == "running" ? 0.55 : 1.0)
|
||||
.animation(
|
||||
job.state == "running"
|
||||
? .easeInOut(duration: 0.9).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: job.state
|
||||
)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
Text(job.schedule.expression ?? job.schedule.display ?? "—")
|
||||
@@ -221,7 +238,13 @@ struct CronView: View {
|
||||
}
|
||||
|
||||
private func statusDotColor(_ job: HermesCronJob) -> Color {
|
||||
// Order matters: a currently-running job overrides a stale
|
||||
// lastError so the user sees "yes, retrying right now" rather
|
||||
// than "still showing the old failure." Disabled wins over
|
||||
// everything else — a paused job isn't running, regardless
|
||||
// of state-field churn.
|
||||
if !job.enabled { return ScarfColor.foregroundFaint }
|
||||
if job.state == "running" { return ScarfColor.info }
|
||||
if job.lastError != nil { return ScarfColor.danger }
|
||||
return ScarfColor.success
|
||||
}
|
||||
@@ -272,6 +295,9 @@ struct CronView: View {
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
ScarfBadge(job.enabled ? "active" : "paused",
|
||||
kind: job.enabled ? .success : .neutral)
|
||||
if job.state == "running" {
|
||||
ScarfBadge("running…", kind: .info)
|
||||
}
|
||||
}
|
||||
Text(CronScheduleFormatter.humanReadable(from: job.schedule))
|
||||
.scarfStyle(.footnote)
|
||||
@@ -420,26 +446,165 @@ struct CronView: View {
|
||||
}
|
||||
|
||||
if let error = job.lastError {
|
||||
errorBanner(job: job, error: error)
|
||||
}
|
||||
|
||||
outputPanel(job: job)
|
||||
}
|
||||
|
||||
/// Last-error surface. When `ACPErrorHint` recognizes the message
|
||||
/// (OAuth refresh-revoked, missing credentials, SSH failure, etc.),
|
||||
/// it renders the human hint + raw error + a re-auth button when
|
||||
/// applicable. Otherwise falls back to the legacy single-line
|
||||
/// red text — same chrome the view used pre-PR for unrecognized
|
||||
/// errors. Mirrors `ChatView.errorBanner` so the recovery flow is
|
||||
/// identical between cron and chat.
|
||||
@ViewBuilder
|
||||
private func errorBanner(job: HermesCronJob, error: String) -> some View {
|
||||
if let classification = viewModel.selectedErrorClassification {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(classification.hint)
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.textSelection(.enabled)
|
||||
Text(error)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: ScarfSpace.s2)
|
||||
if let provider = classification.oauthProvider {
|
||||
Button("Re-authenticate") {
|
||||
coordinator.pendingOAuthReauth = provider
|
||||
coordinator.selectedSection = .credentialPools
|
||||
}
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.help("Open Credential Pools and re-authenticate \(provider).")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.fill(ScarfColor.warning.opacity(0.08))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.strokeBorder(ScarfColor.warning.opacity(0.25), lineWidth: 1)
|
||||
)
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
Text(error)
|
||||
.scarfStyle(.caption)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.foregroundStyle(ScarfColor.danger)
|
||||
}
|
||||
}
|
||||
|
||||
if let output = viewModel.jobOutput {
|
||||
sectionBlock("LAST OUTPUT") {
|
||||
Text(output)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.textSelection(.enabled)
|
||||
.padding(ScarfSpace.s3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
/// Per-job run-output panel. Always visible; collapsed by default
|
||||
/// with a one-line summary so the detail pane stays scannable when
|
||||
/// the user has dozens of cron jobs. Expanded body mirrors the
|
||||
/// dark monospaced tail layout `LogsView` uses, fed by
|
||||
/// `HermesFileService.loadCronOutput` (Hermes writes per-run files
|
||||
/// under `~/.hermes/cron/output/<jobId>-*`). Reload happens via the
|
||||
/// outer `HermesFileWatcher` `.onChange` — when a fresh run lands a
|
||||
/// new output file, the VM re-reads on the next mtime tick.
|
||||
@ViewBuilder
|
||||
private func outputPanel(job: HermesCronJob) -> some View {
|
||||
let summary = outputSummary(job)
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
Button {
|
||||
showOutputPanel.toggle()
|
||||
} label: {
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
Image(systemName: showOutputPanel ? "chevron.down" : "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Text("LAST RUN OUTPUT")
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Text(summary)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if showOutputPanel {
|
||||
if let output = viewModel.jobOutput, !output.isEmpty {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
Text(output)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(ScarfSpace.s3)
|
||||
.id("cron-output-bottom")
|
||||
}
|
||||
.frame(maxHeight: 320)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.fill(Color(red: 0.07, green: 0.06, blue: 0.05))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.strokeBorder(ScarfColor.border, lineWidth: 1)
|
||||
)
|
||||
// Auto-scroll to the latest line whenever the
|
||||
// output content changes (a new run lands).
|
||||
.onChange(of: output) {
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
proxy.scrollTo("cron-output-bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
proxy.scrollTo("cron-output-bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No output yet — this job hasn't run, or its output file is gone.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(ScarfSpace.s3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.fill(ScarfColor.backgroundSecondary)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.strokeBorder(ScarfColor.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line summary rendered next to the LAST RUN OUTPUT chevron
|
||||
/// when the panel is collapsed. Gives a quick "yes there's content"
|
||||
/// (or "no output yet") read without expanding.
|
||||
private func outputSummary(_ job: HermesCronJob) -> String {
|
||||
let timestamp = job.lastRunAt.map { CronScheduleFormatter.formatNextRun(iso: $0) } ?? "never"
|
||||
let status: String = {
|
||||
if job.state == "running" { return "running…" }
|
||||
if job.lastError != nil { return "error" }
|
||||
if job.lastRunAt != nil { return "ok" }
|
||||
return "no runs yet"
|
||||
}()
|
||||
return "\(timestamp) — \(status)"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionBlock<Content: View>(_ title: String, @ViewBuilder _ content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
@@ -50,8 +51,65 @@ final class ProfilesViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active profile via `hermes profile use <name>` without
|
||||
/// relaunching Scarf. Most users will reach for `switchAndRelaunch`
|
||||
/// instead — kept here so the context-menu "Use" item stays
|
||||
/// functional and so callers that genuinely want a no-relaunch
|
||||
/// switch (tests, scripted setups) have a path. Invalidates the
|
||||
/// resolver cache on success so the next `context.paths` access
|
||||
/// picks up the new home directory.
|
||||
func switchTo(_ profile: HermesProfile) {
|
||||
runAndReload(["profile", "use", profile.name], success: "Active profile set to \(profile.name)")
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["profile", "use", profile.name], timeout: 60)
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
HermesProfileResolver.invalidateCache()
|
||||
self.message = "Active profile set to \(profile.name) — restart Scarf to refresh."
|
||||
} else {
|
||||
self.message = "Failed: \(result.output.prefix(120))"
|
||||
}
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active profile and immediately relaunch Scarf. The
|
||||
/// canonical user-facing switch path (issue #70): a fresh process
|
||||
/// guarantees every service constructs from the new
|
||||
/// `~/.hermes/active_profile` value, sidestepping any in-process
|
||||
/// state that might still be holding the previous profile's
|
||||
/// data. Failures fall back to a "restart manually" toast.
|
||||
@MainActor
|
||||
func switchAndRelaunch(_ profile: HermesProfile) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["profile", "use", profile.name], timeout: 30)
|
||||
await MainActor.run {
|
||||
guard result.exitCode == 0 else {
|
||||
self.message = "Failed: \(result.output.prefix(120))"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
HermesProfileResolver.invalidateCache()
|
||||
do {
|
||||
try AppRelauncher.relaunch()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
} catch AppRelauncher.RelaunchError.debugBuild {
|
||||
self.message = "Profile switched to \(profile.name). Restart Scarf manually (Xcode-launched instance)."
|
||||
self.load()
|
||||
} catch {
|
||||
self.message = "Profile switched to \(profile.name). Please quit and reopen Scarf manually."
|
||||
self.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func create(name: String, cloneConfig: Bool, cloneAll: Bool) {
|
||||
|
||||
@@ -20,6 +20,12 @@ struct ProfilesView: View {
|
||||
@State private var renameTarget: HermesProfile?
|
||||
@State private var renameNewName = ""
|
||||
@State private var pendingDelete: HermesProfile?
|
||||
/// Profile the user has clicked "Switch & Relaunch" on, awaiting
|
||||
/// confirmation before we run `hermes profile use` and exit. The
|
||||
/// confirmation step is load-bearing — relaunching closes every
|
||||
/// open Scarf window in the process, so the user needs an explicit
|
||||
/// agreement.
|
||||
@State private var pendingSwitch: HermesProfile?
|
||||
/// Remote-import sheet visibility. Local imports use `NSOpenPanel`
|
||||
/// inline; remote imports route through `RemoteProfilePathSheet`
|
||||
/// because the zip the user wants to import lives on the remote
|
||||
@@ -63,6 +69,18 @@ struct ProfilesView: View {
|
||||
} message: {
|
||||
Text("This removes the profile directory and all data within it. This cannot be undone.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingSwitch.map { "Switch to '\($0.name)' and relaunch Scarf?" } ?? "",
|
||||
isPresented: Binding(get: { pendingSwitch != nil }, set: { if !$0 { pendingSwitch = nil } })
|
||||
) {
|
||||
Button("Switch & Relaunch") {
|
||||
if let profile = pendingSwitch { viewModel.switchAndRelaunch(profile) }
|
||||
pendingSwitch = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingSwitch = nil }
|
||||
} message: {
|
||||
Text("All Scarf windows will close and reopen. Unsaved chat input may be lost.")
|
||||
}
|
||||
.sheet(isPresented: $showRemoteImportSheet) {
|
||||
RemoteProfilePathSheet(
|
||||
context: viewModel.context,
|
||||
@@ -160,7 +178,9 @@ struct ProfilesView: View {
|
||||
}
|
||||
.tag(profile.id)
|
||||
.contextMenu {
|
||||
Button("Use") { viewModel.switchTo(profile) }
|
||||
Button("Switch & Relaunch") { pendingSwitch = profile }
|
||||
.disabled(profile.isActive)
|
||||
Button("Set Active (no relaunch)") { viewModel.switchTo(profile) }
|
||||
.disabled(profile.isActive)
|
||||
Button("Rename") {
|
||||
renameTarget = profile
|
||||
@@ -215,16 +235,17 @@ struct ProfilesView: View {
|
||||
Spacer()
|
||||
if !profile.isActive {
|
||||
Button {
|
||||
viewModel.switchTo(profile)
|
||||
pendingSwitch = profile
|
||||
} label: {
|
||||
Label("Switch to This Profile", systemImage: "arrow.triangle.swap")
|
||||
Label("Switch & Relaunch", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.help("Set as active profile and relaunch Scarf so every tab loads from \(profile.name)")
|
||||
}
|
||||
}
|
||||
if !profile.isActive {
|
||||
profileSwitchWarning
|
||||
profileSwitchInfo
|
||||
}
|
||||
SettingsSection(title: "Details", icon: "info.circle") {
|
||||
if !profile.path.isEmpty {
|
||||
@@ -255,16 +276,16 @@ struct ProfilesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var profileSwitchWarning: some View {
|
||||
private var profileSwitchInfo: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.")
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("**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.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.orange.opacity(0.1))
|
||||
.background(ScarfColor.backgroundSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ struct ProjectsSidebar: View {
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.tag(project)
|
||||
.accessibilityIdentifier("projects.row.\(project.name)")
|
||||
.contextMenu {
|
||||
projectContextMenu(project)
|
||||
}
|
||||
@@ -190,6 +191,7 @@ struct ProjectsSidebar: View {
|
||||
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||
onUninstallTemplate(project)
|
||||
}
|
||||
.accessibilityIdentifier("projects.contextMenu.uninstallTemplate")
|
||||
Divider()
|
||||
}
|
||||
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||
|
||||
@@ -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,16 @@ struct ProjectsView: View {
|
||||
} label: {
|
||||
Label("Templates", systemImage: "shippingbox")
|
||||
}
|
||||
// `.accessibilityElement(children: .ignore)` collapses
|
||||
// the inner Label's automatic accessibility tree so our
|
||||
// explicit identifier sticks. Without it, SwiftUI uses
|
||||
// the systemImage name (`chevron.down` in macOS toolbar
|
||||
// contexts) as the menu button's accessibility identifier
|
||||
// and our `.accessibilityIdentifier` is silently
|
||||
// overridden — verified via XCUITest tree dump.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Templates")
|
||||
.accessibilityIdentifier("templates.toolbar.menu")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +258,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 +273,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())
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ struct TemplateConfigSheet: View {
|
||||
onCancel()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("templateConfig.cancelButton")
|
||||
Spacer()
|
||||
Button(commitLabel) {
|
||||
if let finalized = viewModel.commit(project: project) {
|
||||
@@ -108,6 +109,7 @@ struct TemplateConfigSheet: View {
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.accessibilityIdentifier("templateConfig.commitButton")
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ struct TemplateInstallSheet: View {
|
||||
Button("Install") { viewModel.confirmInstall() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.accessibilityIdentifier("templateInstall.confirmInstall")
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
@@ -401,6 +402,7 @@ struct TemplateInstallSheet: View {
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.accessibilityIdentifier("templateInstall.success.openProject")
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
@@ -507,6 +509,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 +568,7 @@ private struct ParentDirectoryStep: View {
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(parentPath.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.accessibilityIdentifier("templateInstall.parentDir.continue")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ struct TemplateUninstallSheet: View {
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.tint(.red)
|
||||
.accessibilityIdentifier("templateUninstall.confirmRemove")
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
@@ -298,6 +299,7 @@ struct TemplateUninstallSheet: View {
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.accessibilityIdentifier("templateUninstall.success.done")
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ struct SidebarView: View {
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
|
||||
/// Currently-active Hermes profile name, surfaced as a header
|
||||
/// chip on local contexts so users always see which profile
|
||||
/// Scarf is reading from (issue #70 follow-up). Refreshed on
|
||||
/// every section change as a cheap proxy for "user is
|
||||
/// interacting with the app" — covers the rare case where the
|
||||
/// user runs `hermes profile use` from a terminal mid-session.
|
||||
@State private var activeProfileName: String = HermesProfileResolver.activeProfileName()
|
||||
|
||||
/// Capability-gated sections. Curator is v0.12+ only; older Hermes
|
||||
/// hosts get the same Interact section minus the Curator row.
|
||||
/// Building the list lazily off the env keeps the sidebar honest
|
||||
@@ -62,6 +70,14 @@ struct SidebarView: View {
|
||||
.background(.regularMaterial)
|
||||
.background(ScarfColor.backgroundTertiary.opacity(0.4))
|
||||
.splitViewAutosaveName("ScarfMainSidebar.\(serverContext.id)")
|
||||
.onAppear {
|
||||
HermesProfileResolver.invalidateCache()
|
||||
activeProfileName = HermesProfileResolver.activeProfileName()
|
||||
}
|
||||
.onChange(of: coordinator.selectedSection) { _, _ in
|
||||
HermesProfileResolver.invalidateCache()
|
||||
activeProfileName = HermesProfileResolver.activeProfileName()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -76,6 +92,18 @@ struct SidebarView: View {
|
||||
Text("Scarf")
|
||||
.scarfStyle(.bodyEmph)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
// Active-profile chip — local contexts only. Remote
|
||||
// ServerContexts don't read this Mac's active_profile
|
||||
// file, so the chip would be misleading there.
|
||||
if !serverContext.isRemote {
|
||||
Button {
|
||||
coordinator.selectedSection = .profiles
|
||||
} label: {
|
||||
ScarfBadge("profile: \(activeProfileName)", kind: .brand)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Active Hermes profile — click to manage")
|
||||
}
|
||||
Spacer()
|
||||
Text(serverContext.displayName.lowercased())
|
||||
.font(ScarfFont.caption2)
|
||||
@@ -138,6 +166,7 @@ struct SidebarView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("sidebar.section.\(item.rawValue)")
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
@@ -43,6 +43,22 @@ struct ScarfApp: App {
|
||||
Task.detached(priority: .utility) {
|
||||
_ = HermesFileService.enrichedEnvironment()
|
||||
}
|
||||
|
||||
// Test-mode launch-URL handoff. When XCUITest passes
|
||||
// `--scarf-test-install-url <https-url>`, route the URL
|
||||
// through `TemplateURLRouter` so `ProjectsView`'s onAppear
|
||||
// hook dispatches it as if the user had clicked a
|
||||
// `scarf://install` deep link. Bypasses the SwiftUI/AppKit
|
||||
// Menu accessibility-bridging issues that otherwise block
|
||||
// XCUITest from driving the toolbar menu's "Browse Catalog…"
|
||||
// / "Install from URL…" items reliably. Production launches
|
||||
// (no flag) untouched.
|
||||
if TestModeFlags.shared.isTestMode,
|
||||
let idx = CommandLine.arguments.firstIndex(of: "--scarf-test-install-url"),
|
||||
idx + 1 < CommandLine.arguments.count,
|
||||
let url = URL(string: "scarf://install?url=" + CommandLine.arguments[idx + 1]) {
|
||||
TemplateURLRouter.shared.handle(url)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
@@ -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,76 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises `CronViewModel.selectedErrorClassification` — the bridge
|
||||
/// between Hermes's cron `last_error` field and the in-app re-auth
|
||||
/// affordance. Covers the OAuth-revoked path that motivated the surface
|
||||
/// (real string captured from `~/.hermes/cron/jobs.json` when an
|
||||
/// OAuth-authed provider's refresh session is invalidated) plus the
|
||||
/// "no error" + "unrecognized error" branches the UI relies on.
|
||||
@Suite struct CronViewModelErrorClassificationTests {
|
||||
|
||||
/// The exact `last_error` string Hermes writes to `~/.hermes/cron/jobs.json`
|
||||
/// after an OAuth-authed cron run hits a revoked refresh session.
|
||||
/// Captured from a live failed run on 2026-05-03 — if Hermes ever
|
||||
/// changes the wording, this test breaks loudly so we know to
|
||||
/// update the matcher in `ACPErrorHint.classify`.
|
||||
private static let revokedErrorString =
|
||||
"RuntimeError: Refresh session has been revoked Run `hermes model` to re-authenticate."
|
||||
|
||||
@Test @MainActor func oauthRevokedErrorClassifies() {
|
||||
let vm = CronViewModel()
|
||||
vm.selectedJob = Self.fixtureJob(lastError: Self.revokedErrorString)
|
||||
|
||||
let classification = vm.selectedErrorClassification
|
||||
#expect(classification != nil)
|
||||
#expect(classification?.hint.contains("Re-authenticate") == true
|
||||
|| classification?.hint.contains("re-authenticate") == true
|
||||
|| classification?.hint.contains("revoked") == true
|
||||
|| classification?.hint.contains("expired") == true)
|
||||
// The classifier returns nil oauthProvider when no provider word
|
||||
// is present in the haystack — Hermes's revoked-session line
|
||||
// doesn't always include the provider name. Either result is
|
||||
// acceptable to the UI: a non-nil provider lets the row render
|
||||
// a "Re-authenticate" button; a nil provider still surfaces the
|
||||
// human hint without the button.
|
||||
_ = classification?.oauthProvider
|
||||
}
|
||||
|
||||
@Test @MainActor func noSelectedJobReturnsNil() {
|
||||
let vm = CronViewModel()
|
||||
#expect(vm.selectedErrorClassification == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func selectedJobWithoutErrorReturnsNil() {
|
||||
let vm = CronViewModel()
|
||||
vm.selectedJob = Self.fixtureJob(lastError: nil)
|
||||
#expect(vm.selectedErrorClassification == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func unrecognizedErrorReturnsNil() {
|
||||
// ACPErrorHint returns nil when no pattern matches; the UI
|
||||
// falls back to rendering the raw lastError without the
|
||||
// re-auth banner.
|
||||
let vm = CronViewModel()
|
||||
vm.selectedJob = Self.fixtureJob(
|
||||
lastError: "RuntimeError: cron-specific failure that doesn't match any known pattern"
|
||||
)
|
||||
#expect(vm.selectedErrorClassification == nil)
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func fixtureJob(lastError: String?) -> HermesCronJob {
|
||||
HermesCronJob(
|
||||
id: "test-job",
|
||||
name: "Test Job",
|
||||
prompt: "noop",
|
||||
schedule: CronSchedule(kind: "cron", expression: "0 9 * * *"),
|
||||
enabled: true,
|
||||
state: lastError != nil ? "failed" : "scheduled",
|
||||
lastError: lastError
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,382 @@
|
||||
//
|
||||
// TemplateInstallUITests.swift
|
||||
// scarfUITests
|
||||
//
|
||||
// Layer B of the dogfooding-templates harness — drives Scarf via XCUITest
|
||||
// against the developer Mac's real `~/.hermes/` installation.
|
||||
//
|
||||
// Two tests:
|
||||
// 1. `testAppLaunchesAndSurfacesAWindow` — smoke that proves the
|
||||
// harness can launch the app, send ⌘1, surface a window. Catches
|
||||
// regressions in the test target itself before the install-flow
|
||||
// tests run.
|
||||
// 2. `testFullCatalogToInstallToDashboardJourney` — drives the v2.8
|
||||
// surface end-to-end: Templates → Browse Catalog → tap HN Digest
|
||||
// row → tap Install in detail → fill parent dir → Configure with
|
||||
// defaults → confirm Install → wait for project to appear in
|
||||
// sidebar → uninstall via context menu → confirm uninstall →
|
||||
// verify project gone. Cleanup is the uninstall round-trip; if
|
||||
// the test crashes mid-flow the only orphan is a tagged cron job
|
||||
// `[tmpl:awizemann/hackernews-digest] Daily HN digest` that the
|
||||
// dev can `hermes cron remove` manually.
|
||||
//
|
||||
// ## 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)
|
||||
}
|
||||
|
||||
// MARK: - Full install-flow journey
|
||||
|
||||
/// HTTPS URL for the HN Digest `.scarftemplate` bundle. The
|
||||
/// install pipeline accepts any HTTPS URL pointing at a valid
|
||||
/// `.scarftemplate`; this is the canonical published location
|
||||
/// that the live catalog also references via `installUrl`.
|
||||
private static let hnDigestInstallURL =
|
||||
"https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/hackernews-digest/hackernews-digest.scarftemplate"
|
||||
|
||||
/// The cron job tag prefix the installer attaches to every cron
|
||||
/// job shipped with this template. Used for cleanup if the
|
||||
/// uninstall flow doesn't run (e.g. test crashed). The dev
|
||||
/// recovers by running `hermes cron remove <id>` for any job
|
||||
/// whose name starts with this prefix.
|
||||
private static let cronTagPrefix = "[tmpl:awizemann/hackernews-digest]"
|
||||
|
||||
/// Drives Install (via launch-arg URL handoff) → Configure →
|
||||
/// Open Project → sidebar row → Uninstall → Done in one shot.
|
||||
/// The whole flow exercises the v2.7 and v2.8 accessibility
|
||||
/// identifiers on the install/uninstall path:
|
||||
///
|
||||
/// templates.toolbar.menu → templates.browseCatalog
|
||||
/// catalog.row.<slug> → catalogDetail.installButton
|
||||
/// templateInstall.parentDir.field
|
||||
/// templateInstall.parentDir.continue
|
||||
/// templateConfig.commitButton
|
||||
/// templateInstall.confirmInstall
|
||||
/// projects.row.<name>
|
||||
/// projects.contextMenu.uninstallTemplate
|
||||
/// templateUninstall.confirmRemove
|
||||
///
|
||||
/// **Side effects.** Installs a real project at
|
||||
/// `<runner-tmp>/scarf-uitest-<uuid>/awizemann-hackernews-digest`,
|
||||
/// registers a paused cron job, and registers an entry in
|
||||
/// `~/.hermes/scarf/projects.json` — all of which the test then
|
||||
/// removes via the in-app uninstall flow. Crashes mid-flow leave
|
||||
/// at most one tagged cron job + one tmpdir; both recoverable
|
||||
/// without re-running the test.
|
||||
///
|
||||
/// **Known cohabitation hazard.** If the dev Mac already has a
|
||||
/// project installed from the same template
|
||||
/// (`awizemann/hackernews-digest`), the install pipeline
|
||||
/// uniquifies the new project's name (e.g. "HackerNews Daily
|
||||
/// Digest 2"), but BOTH projects' cron jobs get registered
|
||||
/// under the same `[tmpl:awizemann/hackernews-digest] Daily HN
|
||||
/// digest` name. The uninstaller resolves cron jobs to remove
|
||||
/// by NAME (`ProjectTemplateUninstaller.loadUninstallPlan`,
|
||||
/// circa 2026.5), so it can target the WRONG project's cron
|
||||
/// job. Manifests as: test passes, your real project's cron
|
||||
/// disappears. Track issue: cron-job IDs should be stored in
|
||||
/// the lock file at install time and resolved by ID. Until
|
||||
/// fixed, run this test against a Mac that doesn't already
|
||||
/// have the test template installed manually.
|
||||
@MainActor
|
||||
func testFullCatalogToInstallToDashboardJourney() throws {
|
||||
// `/tmp` is sandbox-protected for the XCUITest runner —
|
||||
// `createDirectory` there throws EPERM. `NSTemporaryDirectory()`
|
||||
// resolves to the runner's own container tmp
|
||||
// (`~/Library/Containers/com.scarfUITests.xctrunner/Data/tmp/`),
|
||||
// which the runner can write AND the unsandboxed Scarf app
|
||||
// can read since the app has full disk access.
|
||||
let parentDir = (NSTemporaryDirectory() as NSString)
|
||||
.appendingPathComponent("scarf-uitest-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: parentDir,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
defer {
|
||||
// Best-effort: uninstall preserves user-added files in
|
||||
// the project dir, so the parent may still exist after
|
||||
// the in-app uninstall ran. Wipe so /tmp dirs don't
|
||||
// leak across runs.
|
||||
try? FileManager.default.removeItem(atPath: parentDir)
|
||||
}
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = [
|
||||
"--scarf-test-mode",
|
||||
// Hand the install URL to ScarfApp.init() via launch
|
||||
// args — see scarfApp.swift's `--scarf-test-install-url`
|
||||
// block. Equivalent to a `scarf://install?url=…` deep
|
||||
// link arriving on cold launch, except XCUITest
|
||||
// doesn't have a clean way to issue those (NSWorkspace
|
||||
// is sandbox-restricted from the runner). The router
|
||||
// stages the URL on the singleton; ProjectsView's
|
||||
// onAppear hook picks it up and presents the install
|
||||
// sheet automatically once the window surfaces.
|
||||
"--scarf-test-install-url",
|
||||
Self.hnDigestInstallURL
|
||||
]
|
||||
app.launch()
|
||||
|
||||
// Surface the window, same dance as the smoke test.
|
||||
app.activate()
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
app.typeKey("1", modifierFlags: .command)
|
||||
let windowAppeared = app.windows.firstMatch.waitForExistence(timeout: 15)
|
||||
XCTAssertTrue(windowAppeared, "Scarf window did not surface within 15s")
|
||||
|
||||
// Click into Projects in the sidebar — the install-sheet
|
||||
// observer lives on `ProjectsView.onChange(pendingInstallURL)`,
|
||||
// so the staged URL only dispatches once Projects is on
|
||||
// screen. Default-launched Scarf opens to Dashboard.
|
||||
let projectsRow = app.descendants(matching: .any)
|
||||
.matching(identifier: "sidebar.section.Projects").firstMatch
|
||||
XCTAssertTrue(projectsRow.waitForExistence(timeout: 5), "sidebar.section.Projects missing")
|
||||
projectsRow.click()
|
||||
|
||||
// 4. Install sheet → parent dir field. The launch-arg URL
|
||||
// handoff stages the URL via TemplateURLRouter; the install
|
||||
// sheet picks it up via ProjectsView's onChange observer.
|
||||
// First visible state is `fetching/inspecting` (network
|
||||
// download of the .scarftemplate, ~few seconds), then
|
||||
// `awaitingParentDirectory` which is when the field appears.
|
||||
// Generous timeout because cold network on a CI Mac can be
|
||||
// slow.
|
||||
let parentField = app.descendants(matching: .any)
|
||||
.matching(identifier: "templateInstall.parentDir.field").firstMatch
|
||||
if !parentField.waitForExistence(timeout: 30) {
|
||||
let snap = XCTAttachment(screenshot: app.screenshot())
|
||||
snap.name = "no-parent-dir-field"
|
||||
snap.lifetime = .keepAlways
|
||||
add(snap)
|
||||
XCTFail("parent-dir field missing — install sheet didn't open or got stuck in fetching/inspecting? See screenshot.")
|
||||
return
|
||||
}
|
||||
parentField.click()
|
||||
parentField.typeKey("a", modifierFlags: .command)
|
||||
parentField.typeText(parentDir)
|
||||
|
||||
let parentContinue = app.descendants(matching: .any)
|
||||
.matching(identifier: "templateInstall.parentDir.continue").firstMatch
|
||||
XCTAssertTrue(parentContinue.waitForExistence(timeout: 3), "parent-dir Continue missing")
|
||||
parentContinue.click()
|
||||
|
||||
// 5. Configure step. Three fields with defaults
|
||||
// (topics=[], min_score=100, max_items=15) — leave them, click
|
||||
// commit.
|
||||
let configCommit = app.descendants(matching: .any)
|
||||
.matching(identifier: "templateConfig.commitButton").firstMatch
|
||||
XCTAssertTrue(
|
||||
configCommit.waitForExistence(timeout: 5),
|
||||
"templateConfig.commitButton missing — configure step didn't render?"
|
||||
)
|
||||
configCommit.click()
|
||||
|
||||
// 6. Confirm Install sheet.
|
||||
let confirmInstall = app.descendants(matching: .any)
|
||||
.matching(identifier: "templateInstall.confirmInstall").firstMatch
|
||||
XCTAssertTrue(
|
||||
confirmInstall.waitForExistence(timeout: 5),
|
||||
"templateInstall.confirmInstall missing — install plan didn't render?"
|
||||
)
|
||||
confirmInstall.click()
|
||||
|
||||
// 6.5. Success view → Open Project. Without this, the
|
||||
// install sheet's onCompleted callback doesn't fire and
|
||||
// ProjectsView never calls `viewModel.load()`, so the new
|
||||
// project row never appears in the sidebar even though
|
||||
// it's in the registry on disk.
|
||||
let openProject = app.descendants(matching: .any)
|
||||
.matching(identifier: "templateInstall.success.openProject").firstMatch
|
||||
XCTAssertTrue(
|
||||
openProject.waitForExistence(timeout: 30),
|
||||
"templateInstall.success.openProject missing — install never completed?"
|
||||
)
|
||||
openProject.click()
|
||||
|
||||
// 7. Project row appears in sidebar. The installer assigns
|
||||
// the human-readable manifest name and uniquifies on
|
||||
// collision — if the dev Mac already has a "HackerNews
|
||||
// Daily Digest" project (e.g. installed manually for v2.8
|
||||
// verification), the test's install lands at "HackerNews
|
||||
// Daily Digest 2" or similar. Match a numbered suffix
|
||||
// explicitly so we don't grab the user's existing project
|
||||
// and right-click-uninstall it (the user's data is sacred —
|
||||
// see the v2.7 sentinel-marker incident report).
|
||||
// The `.tag(project)` accessibility-id propagation has been
|
||||
// flaky in our hands — try BEGINSWITH (works on the matching
|
||||
// Identifiable) and fall back to a tree dump for diagnostics.
|
||||
let projectRow = app.descendants(matching: .any)
|
||||
.matching(NSPredicate(format: "identifier BEGINSWITH 'projects.row.HackerNews Daily Digest '"))
|
||||
.firstMatch
|
||||
if !projectRow.waitForExistence(timeout: 30) {
|
||||
let allProjectRows = app.descendants(matching: .any)
|
||||
.matching(NSPredicate(format: "identifier BEGINSWITH 'projects.row.'"))
|
||||
.allElementsBoundByIndex
|
||||
.map { $0.identifier }
|
||||
print("[Layer B] all projects.row.* identifiers seen:", allProjectRows)
|
||||
XCTFail("Installed project didn't appear in sidebar with a numbered suffix.")
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the post-install screenshot for triage / before
|
||||
// tearing down.
|
||||
let installedShot = XCTAttachment(screenshot: app.screenshot())
|
||||
installedShot.name = "Post-Install Sidebar"
|
||||
installedShot.lifetime = .deleteOnSuccess
|
||||
add(installedShot)
|
||||
|
||||
// 8. Cleanup via UI: right-click → Uninstall Template…
|
||||
// → Remove. The uninstaller drives the cron-remove + registry
|
||||
// delete + project dir wipe through the app's permissions.
|
||||
projectRow.rightClick()
|
||||
let uninstallMenuItem = app.descendants(matching: .any)
|
||||
.matching(identifier: "projects.contextMenu.uninstallTemplate").firstMatch
|
||||
XCTAssertTrue(
|
||||
uninstallMenuItem.waitForExistence(timeout: 5),
|
||||
"Uninstall Template context-menu item missing — was isTemplateInstalled wrong?"
|
||||
)
|
||||
uninstallMenuItem.click()
|
||||
|
||||
let confirmRemove = app.descendants(matching: .any)
|
||||
.matching(identifier: "templateUninstall.confirmRemove").firstMatch
|
||||
XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "Uninstall Remove button missing")
|
||||
confirmRemove.click()
|
||||
|
||||
// 8.5. Uninstall success → Done. Same pattern as install:
|
||||
// the registry write only triggers a sidebar refresh once
|
||||
// the Done button fires onCompleted (see ProjectsView's
|
||||
// showingUninstallSheet handler).
|
||||
let uninstallDone = app.descendants(matching: .any)
|
||||
.matching(identifier: "templateUninstall.success.done").firstMatch
|
||||
XCTAssertTrue(
|
||||
uninstallDone.waitForExistence(timeout: 30),
|
||||
"templateUninstall.success.done missing — uninstall never completed?"
|
||||
)
|
||||
uninstallDone.click()
|
||||
|
||||
// 9. Project row with the numbered suffix disappears from
|
||||
// the sidebar. The base "HackerNews Daily Digest" (the
|
||||
// user's manual install) stays — only the test's uniquified
|
||||
// copy should be gone. Re-query rather than reusing the
|
||||
// earlier handle because XCUITest sometimes caches a
|
||||
// stale snapshot of `.exists`.
|
||||
let removedDeadline = Date().addingTimeInterval(15)
|
||||
var stillThere = true
|
||||
while stillThere && Date() < removedDeadline {
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
stillThere = app.descendants(matching: .any)
|
||||
.matching(NSPredicate(format: "identifier BEGINSWITH 'projects.row.HackerNews Daily Digest '"))
|
||||
.firstMatch.exists
|
||||
}
|
||||
XCTAssertFalse(
|
||||
stillThere,
|
||||
"Project still in sidebar after uninstall — registry write didn't complete?"
|
||||
)
|
||||
|
||||
// 10. Graceful quit. XCTest's implicit teardown auto-terminate
|
||||
// has been observed to fail with "Failed to terminate
|
||||
// com.scarf.app:0" after long journeys involving multiple
|
||||
// sheet open/close cycles. Sending ⌘Q here lets Scarf go
|
||||
// through its normal NSApp.terminate flow (which respects
|
||||
// any save-window-state work the WindowGroup wants to do)
|
||||
// BEFORE the runner tries to force-terminate. Result: clean
|
||||
// green test instead of a phantom-failure-after-success.
|
||||
app.typeKey("q", modifierFlags: .command)
|
||||
// Wait briefly for the app to actually exit. If it doesn't,
|
||||
// the auto-terminate will still try and may still fail —
|
||||
// but at least we gave it the polite-quit chance first.
|
||||
let exitDeadline = Date().addingTimeInterval(5)
|
||||
while app.state != .notRunning && Date() < exitDeadline {
|
||||
Thread.sleep(forTimeInterval: 0.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
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