Merge branch 'main' into scarf-mobile-development (v2.3.0)

Brings the iOS companion branch current with main's v2.2.0, v2.2.1,
and v2.3.0 landings — templates + configuration + catalog (v2.2),
projects folder hierarchy + per-project Sessions sidecar + AGENTS.md
context block + Tool Gateway + Nous Portal OAuth + hermes dashboard
webview (v2.3), and credential-pool OAuth expiry + Nous agent-key
rotation (post-v2.3).

Resolutions:
- ScarfCore Models (HermesConfig, ProjectDashboard, HermesPathSet) —
  forward-ported Tool Gateway's platformToolsets, project-registry v2
  folder/archived fields, and sessionProjectMap path into the moved
  ScarfCore copies. Deleted the old Mac-target paths.
- ScarfCore ModelCatalogService — merged main's overlay-only provider
  support (Nous Portal + OpenAI Codex + Qwen OAuth + …) so iOS and
  macOS pickers see the same provider list. Widened HermesProviderInfo
  / HermesProviderOverlay APIs to public.
- ScarfCore ProjectsViewModel — layered main's v2.3 registry verbs
  (moveProject / renameProject / archive / unarchive / folders) onto
  the M0d-extracted VM, keeping public surface for the Mac target.
- ScarfCore ConnectionStatusViewModel / RichChatViewModel — widened
  `private(set)` to `public private(set)` so Mac views can read
  status, lastSuccess, acp*Tokens, originSessionId, acpCommands,
  quickCommands.
- ScarfCore HermesConfig+YAML — added platform_toolsets parsing to
  the iOS YAML path so config.yaml round-trips the same as macOS.
- RichChatViewModel quick-commands — inlined the Mac-target's
  QuickCommandsViewModel.loadQuickCommands into ScarfCore using the
  existing HermesYAML parser, removing the cross-module dependency.
- HealthViewModel — took main's Tool Gateway + hermes-dashboard
  webview sections wholesale; file stays macOS-only.
- ChatView auto-merge — confirmed resume-session fix (5ae8db2) is
  present; made the PendingPermission.id extension public to satisfy
  Identifiable conformance across module boundary.
- ProjectSessionsViewModel — moved back to the Mac target since it
  depends on SessionAttributionService (also Mac-target). Defer the
  iOS SFTP parity of attribution to M7.
- LocalTransport.runProcess + SSHTransport.runLocal — wrapped the
  Process body in `#if !os(iOS)` with an explicit throw on iOS so
  ScarfCore compiles under the iOS SDK. iOS uses
  CitadelServerTransport (ScarfIOS) as the real implementation.
- CitadelServerTransport — updated `sftp.remove(atPath:)` to
  `sftp.remove(at:)` for the current Citadel API shape.

Cross-module imports: added `import ScarfCore` to 25 Mac-target files
that consumed ScarfCore types (13 v2.3 additions + 12 post-merge
errors caught by MemberImportVisibility: Settings tabs, SidebarView,
MCPServerEditorView, TemplateExportSheet, tests).

Version lockstep: bumped `scarf mobile` target to
MARKETING_VERSION=2.3.0, CURRENT_PROJECT_VERSION=25 to match main.

Builds green for both schemes:
- swift build (ScarfCore standalone)
- xcodebuild scarf -destination platform=macOS
- xcodebuild 'scarf mobile' -destination generic/platform=iOS

Deferred to M7 (iOS SFTP parity):
- NousSubscriptionService auth.json reader
- ProjectAgentContextService AGENTS.md write-before-chat
- SessionAttributionService session_project_map.json read/watch
All currently Mac-target-gated; iOS still builds without them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 10:53:23 +02:00
98 changed files with 5728 additions and 195 deletions
+47 -1
View File
@@ -84,7 +84,17 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
## Hermes Version ## Hermes Version
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse. Targets Hermes v0.10.0 (v2026.4.16). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
v0.10.0 introduced the **Tool Gateway** — paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription without separate API keys. In Scarf:
- **Provider picker** ([ModelCatalogService.swift](scarf/scarf/Core/Services/ModelCatalogService.swift)) merges Hermes's `HERMES_OVERLAYS` so Nous Portal and other overlay-only providers (OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, Arcee) appear alongside the models.dev catalog. Subscription-gated providers sort first and render a "Subscription" pill.
- **Subscription detection** ([NousSubscriptionService.swift](scarf/scarf/Core/Services/NousSubscriptionService.swift)) reads `~/.hermes/auth.json``providers.nous`. Read-only; Hermes owns the write path.
- **Per-task routing** (Auxiliary tab) toggles `auxiliary.<task>.provider` between `nous` and `auto`. Hermes derives gateway routing from provider selection — there is no separate `use_gateway` key.
- **Health surface** ([HealthViewModel.swift](scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift)) adds a synthetic "Tool Gateway" section showing subscription state + `platform_toolsets` mappings + which aux tasks are routed through Nous.
- **Scarf's existing `Gateway` feature is renamed to "Messaging Gateway"** everywhere user-facing to disambiguate from the new Tool Gateway. The `SidebarSection.gateway` enum case and `gateway_state.json` / `gateway.log` paths are unchanged (not user-facing strings).
**Keep `ModelCatalogService.overlayOnlyProviders` in sync** with `HERMES_OVERLAYS` in `~/.hermes/hermes-agent/hermes_cli/providers.py`. When Hermes adds a new overlay-only provider, mirror the entry (display name, base URL, auth type, subscription-gated flag, doc URL) or the picker won't reach it.
## Project Templates ## Project Templates
@@ -142,6 +152,42 @@ Key services: [TemplateConfig.swift](scarf/scarf/Core/Models/TemplateConfig.swif
**Schema is Swift-primary.** If `TemplateConfigField.FieldType` gains a new case, update in order: `TemplateConfig.swift` (model + validation), `tools/build-catalog.py` (`SUPPORTED_CONFIG_FIELD_TYPES` + type-specific rules), `widgets.js` (`summariseConstraint`), `TemplateConfigSheet.swift` (new control subview), tests on both sides. Schema drift between validator + installer is the kind of bug users only notice after shipping. **Schema is Swift-primary.** If `TemplateConfigField.FieldType` gains a new case, update in order: `TemplateConfig.swift` (model + validation), `tools/build-catalog.py` (`SUPPORTED_CONFIG_FIELD_TYPES` + type-specific rules), `widgets.js` (`summariseConstraint`), `TemplateConfigSheet.swift` (new control subview), tests on both sides. Schema drift between validator + installer is the kind of bug users only notice after shipping.
### Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)
v2.3 adds a per-project Sessions tab and a "New Chat" button that spawns `hermes acp` with `cwd = project.path`. Session-to-project attribution is persisted in a Scarf-owned sidecar at `~/.hermes/scarf/session_project_map.json` — the ACP wire protocol has no project-metadata hook (extra params are silently dropped), and `state.db` has no cwd column, so the sidecar is Scarf's source of truth for "which project does this session belong to?" Managed by [SessionAttributionService.swift](scarf/scarf/Core/Services/SessionAttributionService.swift); read by the per-project [ProjectSessionsView.swift](scarf/scarf/Features/Projects/Views/ProjectSessionsView.swift).
**Giving the agent project awareness.** Hermes auto-reads a context file from the session's cwd at startup — priority order `.hermes.md``HERMES.md``AGENTS.md``CLAUDE.md``.cursorrules`, first match wins, 20KB cap. We lean on that by writing a Scarf-managed block into `<project>/AGENTS.md` before opening the session. Service: [ProjectAgentContextService.swift](scarf/scarf/Core/Services/ProjectAgentContextService.swift). Block shape:
```
<!-- scarf-project:begin -->
## Scarf project context
_Auto-generated by Scarf — do not edit between the begin/end markers._
You are operating inside a Scarf project named **"<Project Name>"**. …
- **Project directory:** `<absolute path>`
- **Dashboard:** `<path>/.scarf/dashboard.json`
- **Template:** `<author/id>` v<version> <!-- template-installed only -->
- **Configuration fields:** `field_a`, `field_b (secret — name only, value stored in Keychain)`
- **Registered cron jobs:** `[tmpl:<id>] <name>` — schedule …, currently paused|enabled
- **Uninstall manifest:** `<path>/.scarf/template.lock.json` <!-- when present -->
Any content below this block is template- or user-authored; preserve and defer to it.
<!-- scarf-project:end -->
```
**Invariants.**
- **Secret-safe.** Block surfaces field NAMES, never VALUES. A project with a Keychain-stored secret shows `api_token (secret — name only, …)`; the Keychain ref URI and any plaintext value never appear. Auditable by `refreshListsFieldNamesNotValues` in `ProjectAgentContextServiceTests`.
- **Idempotent.** Two refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta, avoiding file-watcher churn.
- **Bounded.** Everything outside the markers is preserved on every refresh. Template-author AGENTS.md content lives safely below the block.
- **Non-fatal.** `ChatViewModel.startACPSession` calls refresh with `try?` + log — a failed write doesn't block the chat from starting; worst case is the session loses project awareness.
- **Refresh timing.** Called BEFORE `client.start()` so the block lands before Hermes's session-boot context scan. Skipping this ordering = the agent sees stale context from the previous refresh (or nothing, on fresh projects).
**Template-author contract.** A template shipped via the catalog should include an `AGENTS.md` with the template's operational instructions. Authors leave the `<!-- scarf-project -->` region alone — Scarf populates it at chat-start time. Everything below is template-owned and preserved.
**Known caveat.** If any parent directory of the project contains `.hermes.md` or `HERMES.md`, those shadow the project's `AGENTS.md` (higher in Hermes's priority order). No fix in v2.3 — deferred to v2.4 pending user input on how to handle authored `.hermes.md` files.
## Template Catalog ## Template Catalog
Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths. Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths.
+17 -8
View File
@@ -19,16 +19,25 @@
<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> <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> </p>
## What's New in 2.2 ## What's New in 2.3
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the new **Templates** menu in the Projects toolbar. - **Projects sidebar grows up** — group projects into folders, rename / archive / unarchive in place, filter the list with ⌘F, jump to the first nine with ⌘1–⌘9. Archived projects hide by default; a toggle in the bottom bar surfaces them. Non-destructive on the v2.2 registry file — downgrade stays clean.
- **Typed configuration with Keychain-backed secrets** — Templates declare a schema with seven field types (`string`, `text`, `number`, `bool`, `enum`, `list`, `secret`). A **Configure** step in the install flow renders the form, routes secrets to the macOS Keychain, and drops non-secret values into `<project>/.scarf/config.json`. A slider icon in the dashboard header opens the same form post-install for edits — rotate a token, change a site, toggle a feature, and the next cron run picks it up. - **Per-project Sessions tab** — alongside Dashboard and Site. Shows chats attributed to the project, with a **New Chat** button that spawns `hermes acp` with the project's directory as the session cwd and attributes the result via a Scarf-owned sidecar (`~/.hermes/scarf/session_project_map.json`). Click any listed session to resume it with project context automatically restored.
- **Public template catalog** — [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) is a static catalog site generated from `templates/<author>/<name>/` in this repo. Each template has a detail page with a live dashboard preview, the schema rendered with constraint summaries, and a one-click install button. Community submissions go through a CI-enforced Python validator that mirrors the Swift-side invariants. - **Agent actually knows what project it's in** — the architectural headline. Every project-scoped chat gets a Scarf-managed block auto-injected into the project's `AGENTS.md` before the session starts. Hermes reads AGENTS.md from the session's cwd at startup and picks up the block as part of its system prompt. Ask the agent *"what project am I in?"* and it answers with the project name, directory, template id + version, configuration field names, and registered cron jobs — pulled from the injected block. Secret-safe (field names only, never values), idempotent, bounded to `<!-- scarf-project:begin/end -->` markers so template-author content outside the block is preserved across refreshes.
- **Preview-before-apply** — Every install shows a preview sheet listing the exact project directory that will be created, every file inside it, every skill that will be namespaced, every cron job that will be registered (paused by default), every Keychain secret that will be written, and a live diff of any memory appendix. Markdown fields render inline. Nothing writes until you click Install. - **Project indicator in Chat** — folder chip in `SessionInfoBar` and `Chat · <ProjectName>` in the nav title when you're in a project-scoped chat. Resumed sessions keep the indicator by looking up the attribution sidecar at resume time.
- **Site tab** — A dashboard with at least one `webview` widget gets a second tab next to Dashboard. The example `awizemann/site-status-checker` template uses this to render whatever URL you configured as your first watched site, updating on every cron run. - **Tool Gateway — Nous Portal support** — Hermes v0.10.0 introduced subscription-routed tools (web search, image gen, TTS, browser automation). Scarf 2.3 merges Hermes's provider-overlay table into the model picker so **Nous Portal + 5 other previously-invisible providers** now appear, and ships a dedicated **Sign in to Nous Portal** sheet that runs the device-code flow end-to-end in-app — no terminal. Each of the 8 auxiliary sub-model tasks gets a per-task Nous toggle, a Tool Gateway card lands in Health, and Credential Pools' silent-fail dead-end for device-code providers is closed. Scarf's existing messaging-gateway section is renamed **Messaging Gateway** to disambiguate from the new Tool Gateway.
- **Safe-by-design** — Skills install into `~/.hermes/skills/templates/<slug>/` so they never collide with your own. Cron jobs carry a `[tmpl:<id>]` tag and start paused. A `template.lock.json` records every file, cron job, Keychain ref, and memory block for one-click uninstall. Exports carry the configuration schema but never the user's values — safe on projects with live config. Templates **never** touch `config.yaml`, `auth.json`, sessions, or credentials. - **Window-layout cleanup** — switching to Chat or a Sessions tab no longer grows the window past the screen. `.windowResizability(.contentMinSize)` + targeted `idealHeight` caps keep the window's floor at a sensible content minimum while letting users freely drag larger or smaller.
See the full [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0) and the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates). See the full [v2.3.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.3.0), the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates), and the [Hermes Version Compatibility page](https://github.com/awizemann/scarf/wiki/Hermes-Version-Compatibility) for the Tool Gateway's Hermes v0.10.0 requirement.
### Previously, in 2.2
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the **Templates** menu in the Projects toolbar.
- **Typed configuration with Keychain-backed secrets** — templates declare a schema with seven field types (`string`, `text`, `number`, `bool`, `enum`, `list`, `secret`). A **Configure** step in the install flow renders the form, routes secrets to the macOS Keychain, and drops non-secret values into `<project>/.scarf/config.json`.
- **Public template catalog** — [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) with live dashboard previews + schema rendering. CI-enforced Python validator mirrors the Swift-side invariants on every PR.
- **Safe-by-design** — skills namespaced, cron jobs tagged and paused-on-install, lock-file-driven uninstall, exports carry schema but never values.
See the [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0) for the full 2.2 series.
### Previously, in 2.1 ### Previously, in 2.1
+38
View File
@@ -0,0 +1,38 @@
## What's New in 2.2.1
A patch release covering Template Configuration rendering fixes reported against v2.2.0, plus a new catalog template that packages a Hermes skill for scaffolding new Scarf projects.
### Configuration sheet — no more clipping
Two independent rendering fixes to the post-install Configuration editor and the install-flow Configure step:
- **Enum fields with long option labels.** An enum with three or four options whose labels exceeded ~20 characters — e.g. a Claude-model picker with labels like *"Claude Opus 4 (Recommended - Most Capable)"* — rendered as a segmented picker that sized to the intrinsic width of all labels concatenated. On macOS, `.pickerStyle(.segmented)` refuses to respect offered width, refuses to wrap, refuses to truncate. The result was a ~650pt picker that overflowed the sheet's 560pt viewport and clipped the entire form on both sides. Enum fields now always render as a dropdown Menu picker, which surfaces long labels in the popup list and respects the parent's offered width regardless of option count or label length.
- **Descriptions with unbreakable content.** Field descriptions rendered via inline AttributedString markdown can contain tokens SwiftUI's `Text` refuses to break mid-token (raw URLs, long paths). Added `.frame(maxWidth: .infinity, alignment: .leading)` on the sheet's inner VStack and on each field row as a secondary constraint, so description text wraps at whitespace boundaries instead of expanding the sheet width. Applied the same modifier to `TemplateInstallSheet`'s main preview VStack for symmetry — installs with README blocks or cron prompts containing long URLs now wrap cleanly too.
### New catalog entry — `awizemann/template-author`
A `.scarftemplate` whose only content is a Hermes skill (`scarf-template-author`) plus a minimal dashboard that points users at it. Installing the template drops the skill at `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`, discoverable by Claude Code, Cursor, Codex, Aider, and every other agent that reads the standard `~/.hermes/skills/` directory.
The skill teaches agents how to scaffold a new Scarf-compatible project through a short interview — purpose, data source, cadence, widgets, config, secrets — then write `<project>/.scarf/dashboard.json`, `<project>/.scarf/manifest.json`, `<project>/AGENTS.md`, and `<project>/README.md`. Scaffolded projects are usable locally and cleanly exportable as `.scarftemplate` bundles via Scarf's Export flow later. [Catalog detail page →](https://awizemann.github.io/scarf/templates/awizemann-template-author/)
v1 is fully conversational / blank-slate. Pre-baked archetypes (monitor, dev-dashboard, personal-log) are deferred to a future release pending real usage data.
### Authoring guidance — SKILL.md
The `scarf-template-author` skill now tells scaffolding agents to prefer markdown link syntax (`[label](https://…)`) over raw URLs in schema field descriptions. Raw URLs work now (v2.2.1's description wrap fix above handles them gracefully), but `[Anthropic console](https://console.anthropic.com)` reads cleaner in the form than a dumped URL. Same rule extended to long paths or other unbreakable strings — wrap in inline code if they have to appear verbatim, prefer markdown links otherwise.
### Under the hood
- **`scripts/catalog.sh publish` fix.** The pre-flight `need_ghpages` check tested `[[ -d "$GHPAGES_DIR/.git" ]]` — "is `.git` a directory?" — which is true for a regular clone but false for a `git worktree add` worktree (where `.git` is a pointer file). `release.sh` creates and leaves the gh-pages worktree around, so after any release the subsequent catalog-publish call was rejected with a misleading "run `git worktree add`" error on a worktree that was already there and valid. Switched to `-e` (exists, either file or directory). Unblocks publishing the catalog immediately after a release.
### Migrating from 2.2.0
Sparkle will offer the update automatically. No config migration needed. Existing template installs are untouched.
If you've already installed `awizemann/template-author` from a pre-release build, no action needed — the catalog and bundle content are forward-compatible.
### Documentation
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — installing, exporting, configuring, authoring, uninstalling.
- [Catalog site](https://awizemann.github.io/scarf/templates/) — two templates live: `awizemann/site-status-checker` and `awizemann/template-author`.
- [`templates/CONTRIBUTING.md`](https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md) — how to submit a template via PR.
+124
View File
@@ -0,0 +1,124 @@
## What's New in 2.3.0
Two themes land together in this release. The projects sidebar stops being a flat list and becomes a workspace — folders, rename + archive + search + keyboard jumps, a per-project Sessions tab, and every project-scoped chat now automatically carries Scarf-managed context into the agent itself. And Scarf catches up to **Hermes v0.10.0's Tool Gateway**: paid Nous Portal subscribers can now route web search, image generation, TTS, and browser automation through their subscription without separate API keys — and they can sign in entirely from Scarf, no terminal needed.
### Projects sidebar grows up
- **Folders.** Group related projects with folders. Right-click any project → *Move to Folder…* — pick an existing folder or create a new one on the fly. Folders are soft: any folder name that isn't referenced by at least one project just disappears, so there's no "empty folder" state to clean up.
- **Rename** a project from the context menu. Preserves everything else — the path, folder assignment, archive flag, and any running cron attribution stay intact. Rejects duplicate names + empty input with an inline warning.
- **Archive / Unarchive.** Hide projects you don't actively use without deleting anything. The sidebar's bottom bar gains a Show Archived toggle so they're one click away when you need them.
- **Search.** ⌘F focuses a filter field at the top of the sidebar. Fuzzy-matches on name, path, and folder label, live as you type.
- **Keyboard jumps.** ⌘1 through ⌘9 jump to the first nine top-level projects. Pairs cleanly with Scarf's existing window-level shortcuts.
Registry migration is non-destructive — `~/.hermes/scarf/projects.json` gains two optional fields (`folder`, `archived`), and a file written by v2.3 is still parseable by v2.2.1 (unknown-keys are ignored), so downgrade works if you ever need it.
### Per-project Sessions tab
Every project now has a **Sessions** tab alongside Dashboard and Site. It shows chats attributed to this specific project — the sidecar at `~/.hermes/scarf/session_project_map.json` maintains the session-to-project mapping (Hermes's `state.db` has no column for this, so Scarf owns the record).
- **New Chat** — spawns `hermes acp` with the project's directory as the session's working directory, attributes the resulting session to the project, and takes you straight into the chat view.
- **Click any listed session to resume it** in the Chat tab; the project indicator comes along automatically.
- Forward-only attribution: sessions you've already started via the CLI or via the global Chat sidebar section continue to live in the global Sessions view unchanged; they simply aren't attributed to any project.
File descriptors are released cleanly on tab-disappear, matching Scarf's other Hermes-DB-reading VMs.
### Agent context injection via AGENTS.md
The architectural headline of this release. Hermes has no native "project" concept and ACP's wire protocol drops extra session params. But Hermes DOES auto-read `AGENTS.md` from the session's cwd at startup (priority: `.hermes.md``HERMES.md``AGENTS.md``CLAUDE.md``.cursorrules`, first match wins, 20KB cap). So Scarf leans on that.
Every time you start a project-scoped chat, Scarf writes a managed block into `<project>/AGENTS.md`:
```
<!-- scarf-project:begin -->
## Scarf project context
You are operating inside a Scarf project named "<Project Name>". …
- Project directory: …
- Dashboard: …
- Template: <id> v<version>
- Configuration fields: field_a, api_token (secret — name only, value stored in Keychain)
- Registered cron jobs: [tmpl:<id>] <name> — schedule …
<!-- scarf-project:end -->
```
Ask a fresh chat *"what project am I in?"* and the agent answers with the project name, dashboard path, template id, and current cron schedule — pulled from the block Hermes injected into its system prompt automatically.
**Invariants the block guarantees:**
- **Secret-safe.** Surfaces config field *names* with type hints; never values. A project whose config.json has Keychain-ref URIs renders the fields as `api_token (secret — name only, value stored in Keychain)`. Keychain URIs and plaintext values never appear in the block. Locked in by an explicit test (`refreshListsFieldNamesNotValues`).
- **Idempotent.** Two consecutive refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta — no unnecessary file-watcher churn.
- **Bounded.** Everything outside the `<!-- scarf-project -->` markers is preserved across every refresh. Template-author AGENTS.md content lives safely below the block; hand-edits are never clobbered.
- **Non-fatal.** A failed block refresh doesn't block the chat from starting — logged + the session proceeds without the extra context.
- **Bare-project friendly.** Projects without an AGENTS.md (plain directories added via the + button) get one created with just the block. Agent awareness works even without template scaffolding.
**Template-author contract:** leave the `<!-- scarf-project -->` region alone in your bundled `AGENTS.md`. Put template-specific instructions below it so they're preserved across refreshes. The `scarf-template-author` scaffolding skill already teaches this pattern to future agents doing project scaffolding.
**Known caveat:** if any parent directory of your project contains a `.hermes.md` or `HERMES.md`, that file takes priority over the project's AGENTS.md in Hermes's discovery order — the Scarf block gets shadowed. No fix in 2.3 — planned for 2.4 pending design input on handling authored `.hermes.md` files.
### Chat UI — project awareness everywhere
Once the cwd, attribution, and AGENTS.md pieces land, the UI follows:
- **Folder chip in `SessionInfoBar`** at the start of the bar (before the working dot + title) shows the active project name with a folder icon.
- **Navigation title** reads `Chat · <ProjectName>` when scoped, plain `Chat` otherwise — macOS `Subject — Detail` convention.
- **Resumed sessions keep the indicator.** Whether you click a session in the project's Sessions tab or come in from a future deep-link, the attribution is looked up at resume time and the chip renders from the same state.
### Window-layout fixes
A pre-existing issue — untracked until v2.3's heavier Chat/Sessions content exposed it — where the window grew past the screen when you switched to content-heavy sections. Fixed by:
- Setting `WindowGroup.windowResizability(.contentMinSize)` so the window's floor (not ceiling) is derived from content.
- Capping `idealHeight` on `RichChatView` and `ProjectSessionsView` so their plain-VStack children (deliberate choice to dodge a LazyVStack whitespace bug) don't report screen-exceeding ideals upward through `NavigationSplitView.detail`.
Window now stays at a user-draggable size and persists across section switches.
### Under the hood
- New models: `SessionProjectMap``~/.hermes/scarf/session_project_map.json` serialization (`SessionAttributionService` manages it).
- New services: `SessionAttributionService` (reads + writes the sidecar), `ProjectAgentContextService` (writes the AGENTS.md marker block, tests cover prepend/replace/idempotency/secret-redaction).
- New view models: `ProjectSessionsViewModel` (per-project session list with attribution filter), `ChatViewModel` gains `currentProjectPath` + `currentProjectName`.
- `HermesFileWatcher` now watches the attribution sidecar — file-system events propagate through the VMs as they do for every other Scarf-written file.
- `ProjectsViewModel` gains `moveProject / renameProject / archiveProject / unarchiveProject / folders` — rename preserves selection; archive clears it; reorders driven by `localizedCaseInsensitiveCompare` for locale-aware ordering.
- **Tool Gateway services.** `NousSubscriptionService` reads `~/.hermes/auth.json` to detect the subscription state. `NousAuthFlow` spawns `hermes auth add nous --no-browser` (with `PYTHONUNBUFFERED=1` so the device-code block surfaces immediately — Python block-buffers otherwise), parses the verification URL + user code with two line-anchored regexes, auto-opens the approval page via `NSWorkspace`, and confirms success by re-reading `auth.json`. `NousSignInSheet` drives the four-state UI (starting / waiting-for-approval / success / failure-with-billing-link). `CredentialPoolsOAuthGate` is the testable helper that routes providers to the right OAuth flow based on their overlay auth-type.
- **Catalog overlay merge.** `ModelCatalogService` gains a static `overlayOnlyProviders` table mirroring the 6 entries from `HERMES_OVERLAYS` in `hermes-agent/hermes_cli/providers.py`. `HermesProviderInfo` carries `isOverlay` and `subscriptionGated` flags so the picker can render them distinctly.
- **Config parsing.** `HermesConfig` gains `platformToolsets: [String: [String]]`; `HermesFileService` parses the `platform_toolsets.<platform>` block from `config.yaml` as written by `hermes setup tools`.
- **36 new Swift tests** across `ProjectRegistryMigrationTests`, `ProjectsViewModelTests`, `SessionAttributionServiceTests`, `ProjectAgentContextServiceTests` (22 for v2.3 projects work) + `ToolGatewayTests`, `NousAuthFlowParserTests`, `CredentialPoolsGatingTests` (14 for Tool Gateway). Total: 120 tests, all green against v2.3-projects + Tool Gateway combined.
### Icon tweak
App icon files renamed from iOS-template suffixes to macOS-native filenames + paired `Contents.json` update. Pure naming; no visual change at any rendered size.
### Tool Gateway — Nous Portal support
Hermes v0.10.0 introduced a **Tool Gateway**: paid [Nous Portal](https://portal.nousresearch.com) subscribers route web search (Firecrawl), image generation (FAL / FLUX 2 Pro), text-to-speech (OpenAI TTS), and browser automation (Browser Use) through their subscription. No separate API keys, no credential pool juggling. Scarf 2.3 surfaces the whole flow natively.
- **Nous Portal appears in the model picker.** Our picker used to read only the models.dev cache, which doesn't list Nous — so it was invisible. Scarf now merges Hermes's `HERMES_OVERLAYS` table on top of the cache, surfacing **six previously-hidden providers**: Nous Portal, OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, and Arcee. Subscription-gated providers sort first, with a **Subscription** pill so they're visually distinct from BYO-key providers.
- **In-app sign-in.** Click *Sign in to Nous Portal* in the picker (or in the Auxiliary tab's fallback, or Credential Pools for the `nous` provider) and Scarf runs the device-code flow: opens the approval page in your browser, shows the device code in a large monospaced badge you can copy, and auto-detects success by re-reading `~/.hermes/auth.json`. No six-step terminal dance. Subscription-required failures surface a **Subscribe** button that opens the portal's billing page directly.
- **Per-task gateway routing.** The Auxiliary tab's 8 sub-model tasks (vision, web_extract, compression, session_search, skills_hub, approval, mcp, flush_memories) each gain a "Nous Portal" toggle. Enabling it flips `auxiliary.<task>.provider` to `nous` — Hermes derives gateway routing from that, no separate `use_gateway` key needed.
- **Health surface.** A new **Tool Gateway** card in Health shows subscription state, `platform_toolsets` wiring, and which aux tasks are currently routed through Nous.
- **Credential Pools dead-end fixed.** Before: selecting `nous` in the Add Credential sheet and clicking *Start OAuth* silently stalled (the PKCE URL regex never matched the device-code output). Now the sheet detects Nous and routes to the dedicated sign-in flow. For the other non-PKCE providers (OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP), the button disables with an inline hint pointing to `hermes auth add <provider>` — no more silent failures. PKCE providers (Anthropic, etc.) behave exactly as before.
- **Messaging Gateway rename.** Scarf's pre-existing "Gateway" section (Slack / Discord / inbound messaging) is renamed throughout to **Messaging Gateway** to disambiguate from the new Tool Gateway. Same feature, clearer name. Sidebar, dashboard card, menu-bar status, log-source filter, and Settings → Agent section header all updated. Internal enum cases and file paths (`gateway_state.json`, `gateway.log`) are unchanged.
If you don't use Hermes v0.10.0 or don't have a Nous subscription, nothing in your flow changes — the Tool Gateway surface only activates when it's relevant. Sign-in state reads `~/.hermes/auth.json` in read-only mode; Scarf never writes to the credential file.
### Migrating from 2.2.x
Sparkle will offer the update automatically. No config migration needed. Existing template installs are untouched — the v2.3 additions (folders, archive, sidecar) are purely additive; a v2.2.1 projects.json loads cleanly.
If you had any chat sessions attributed to projects in a pre-release v2.3 build, the forward-only attribution model means those sidecar entries surface correctly in the new Sessions tab on first launch.
**Hermes version.** The Tool Gateway features target [Hermes v0.10.0](https://github.com/NousResearch/hermes-agent/releases/tag/v2026.4.16) or newer. If you're on v0.9.0 the rest of Scarf 2.3 works, but Nous Portal won't appear in the picker (it's sourced from `HERMES_OVERLAYS` in v0.10.0+) and the Tool Gateway card won't have subscription data to show. Updating Hermes is `pipx upgrade hermes-agent` or the equivalent for your install method.
### Documentation
- **[Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates)** — gained a "How the agent sees the project" section covering the AGENTS.md injection pattern.
- **[Hermes Version Compatibility](https://github.com/awizemann/scarf/wiki/Hermes-Version-Compatibility)** — bumped recommended minimum to v0.10.0, new subsection covering Tool Gateway feature gating.
- **[Core Services](https://github.com/awizemann/scarf/wiki/Core-Services)** — new rows for `NousSubscriptionService` and `NousAuthFlow`, updated `ModelCatalogService` entry noting overlay merge.
- **Root `CLAUDE.md`** — new subsection "Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)" under Project Templates, plus the Tool Gateway subsection under Hermes Version covering the overlay table and per-task gateway contract.
- **`scarf-template-author` skill** — pitfall bullet added so future scaffolding agents preserve the marker region when authoring new templates.
### Thanks
Thanks to the users who exercised this release through several layout iterations, caught the `fetchSessions` short-circuit on a fresh VM, and pushed on the "agent doesn't know what project it's in" question until the AGENTS.md mechanism clicked. Several of these fixes are small on their own but add up to a much tighter per-project workflow.
@@ -627,6 +627,13 @@ public struct HermesConfig: Sendable {
public var prefillMessagesFile: String public var prefillMessagesFile: String
public var skillsExternalDirs: [String] public var skillsExternalDirs: [String]
/// Per-platform toolset allowlists as written by `hermes setup tools`.
/// Keyed by platform (`cli`, `slack`, ) to enabled toolset identifiers
/// (`browser`, `messaging`, `nous-tools`, ). Hermes v0.10.0's Tool
/// Gateway; enabling `nous-tools` here is how subscribers opt-in per
/// platform. Scarf reads for display; edits go through Hermes CLI.
public var platformToolsets: [String: [String]]
// Grouped blocks // Grouped blocks
public var display: DisplaySettings public var display: DisplaySettings
public var terminal: TerminalSettings public var terminal: TerminalSettings
@@ -686,6 +693,7 @@ public struct HermesConfig: Sendable {
cronWrapResponse: Bool, cronWrapResponse: Bool,
prefillMessagesFile: String, prefillMessagesFile: String,
skillsExternalDirs: [String], skillsExternalDirs: [String],
platformToolsets: [String: [String]],
display: DisplaySettings, display: DisplaySettings,
terminal: TerminalSettings, terminal: TerminalSettings,
browser: BrowserSettings, browser: BrowserSettings,
@@ -742,6 +750,7 @@ public struct HermesConfig: Sendable {
self.cronWrapResponse = cronWrapResponse self.cronWrapResponse = cronWrapResponse
self.prefillMessagesFile = prefillMessagesFile self.prefillMessagesFile = prefillMessagesFile
self.skillsExternalDirs = skillsExternalDirs self.skillsExternalDirs = skillsExternalDirs
self.platformToolsets = platformToolsets
self.display = display self.display = display
self.terminal = terminal self.terminal = terminal
self.browser = browser self.browser = browser
@@ -799,6 +808,7 @@ public struct HermesConfig: Sendable {
cronWrapResponse: true, cronWrapResponse: true,
prefillMessagesFile: "", prefillMessagesFile: "",
skillsExternalDirs: [], skillsExternalDirs: [],
platformToolsets: [:],
display: .empty, display: .empty,
terminal: .empty, terminal: .empty,
browser: .empty, browser: .empty,
@@ -65,6 +65,10 @@ public struct HermesPathSet: Sendable, Hashable {
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" } public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
public nonisolated var scarfDir: String { home + "/scarf" } public nonisolated var scarfDir: String { home + "/scarf" }
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" } public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
/// Maps Hermes session IDs to the Scarf project path a chat was
/// started for. Scarf-owned; Hermes never touches this file.
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" } public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution // MARK: - Binary resolution
@@ -17,15 +17,51 @@ public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
public let name: String public let name: String
public let path: String public let path: String
/// Folder path for sidebar grouping. `nil` means top-level.
/// v2.3 registry schema v2; v2.2 files decode cleanly as `nil`.
public var folder: String?
/// Soft-archive flag. Archived projects are hidden from the sidebar
/// by default; non-destructive. v2.3 schema v2; defaults to `false`.
public var archived: Bool
public init( public init(
name: String, name: String,
path: String path: String,
folder: String? = nil,
archived: Bool = false
) { ) {
self.name = name self.name = name
self.path = path self.path = path
self.folder = folder
self.archived = archived
} }
public var dashboardPath: String { path + "/.scarf/dashboard.json" } public var dashboardPath: String { path + "/.scarf/dashboard.json" }
// MARK: - Codable (custom for backward compat)
private enum CodingKeys: String, CodingKey {
case name, path, folder, archived
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.name = try c.decode(String.self, forKey: .name)
self.path = try c.decode(String.self, forKey: .path)
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(name, forKey: .name)
try c.encode(path, forKey: .path)
try c.encodeIfPresent(folder, forKey: .folder)
if archived {
try c.encode(archived, forKey: .archived)
}
}
} }
// MARK: - Dashboard // MARK: - Dashboard
@@ -205,6 +205,16 @@ public extension HermesConfig {
replyPrefix: str("whatsapp.reply_prefix") replyPrefix: str("whatsapp.reply_prefix")
) )
// `platform_toolsets.<platform>` is a dict of lists in config.yaml
// parseNestedYAML flattens nested lists into dotted-path keys. Pull
// every key under the prefix and strip it.
var platformToolsets: [String: [String]] = [:]
for (key, items) in lists where key.hasPrefix("platform_toolsets.") {
let platform = String(key.dropFirst("platform_toolsets.".count))
guard !platform.isEmpty else { continue }
platformToolsets[platform] = items
}
// Home Assistant lives under `platforms.homeassistant.extra.*`. // Home Assistant lives under `platforms.homeassistant.extra.*`.
let homeAssistant = HomeAssistantSettings( let homeAssistant = HomeAssistantSettings(
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [], watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
@@ -252,6 +262,7 @@ public extension HermesConfig {
cronWrapResponse: bool("cron.wrap_response", default: true), cronWrapResponse: bool("cron.wrap_response", default: true),
prefillMessagesFile: str("prefill_messages_file"), prefillMessagesFile: str("prefill_messages_file"),
skillsExternalDirs: lists["skills.external_dirs"] ?? [], skillsExternalDirs: lists["skills.external_dirs"] ?? [],
platformToolsets: platformToolsets,
display: display, display: display,
terminal: terminal, terminal: terminal,
browser: browser, browser: browser,
@@ -70,19 +70,32 @@ public struct HermesProviderInfo: Sendable, Identifiable, Hashable {
public let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"] public let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
public let docURL: String? public let docURL: String?
public let modelCount: Int public let modelCount: Int
/// True when this provider is surfaced only by the Hermes overlay list
/// i.e. no entry in `models_dev_cache.json`. The picker renders a
/// different right-column affordance (subscription CTA or free-form
/// model entry).
public let isOverlay: Bool
/// True for providers whose tool access is subscription-gated rather
/// than BYO API key. Nous Portal is the only such provider as of
/// hermes-agent v0.10.0.
public let subscriptionGated: Bool
public init( public init(
providerID: String, providerID: String,
providerName: String, providerName: String,
envVars: [String], envVars: [String],
docURL: String?, docURL: String?,
modelCount: Int modelCount: Int,
isOverlay: Bool = false,
subscriptionGated: Bool = false
) { ) {
self.providerID = providerID self.providerID = providerID
self.providerName = providerName self.providerName = providerName
self.envVars = envVars self.envVars = envVars
self.docURL = docURL self.docURL = docURL
self.modelCount = modelCount self.modelCount = modelCount
self.isOverlay = isOverlay
self.subscriptionGated = subscriptionGated
} }
} }
@@ -111,20 +124,49 @@ public struct ModelCatalogService: Sendable {
self.transport = LocalTransport() self.transport = LocalTransport()
} }
/// All providers, sorted by display name. /// All providers, sorted with subscription-gated providers first (Nous
/// Portal), then alphabetical by display name. Merges the models.dev
/// cache with `Self.overlayOnlyProviders` so Hermes-injected providers
/// (Nous Portal, OpenAI Codex, ) appear in the picker even when
/// they're absent from `models_dev_cache.json`.
public func loadProviders() -> [HermesProviderInfo] { public func loadProviders() -> [HermesProviderInfo] {
guard let catalog = loadCatalog() else { return [] } let catalog = loadCatalog() ?? [:]
return catalog var byID: [String: HermesProviderInfo] = [:]
.map { (id, p) in for (id, p) in catalog {
HermesProviderInfo( byID[id] = HermesProviderInfo(
providerID: id, providerID: id,
providerName: p.name ?? id, providerName: p.name ?? id,
envVars: p.env ?? [], envVars: p.env ?? [],
docURL: p.doc, docURL: p.doc,
modelCount: p.models?.count ?? 0 modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
) )
} }
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending } for (id, overlay) in Self.overlayOnlyProviders where byID[id] == nil {
byID[id] = HermesProviderInfo(
providerID: id,
providerName: overlay.displayName,
envVars: [],
docURL: overlay.docURL,
modelCount: 0,
isOverlay: true,
subscriptionGated: overlay.subscriptionGated
)
}
return byID.values.sorted { lhs, rhs in
if lhs.subscriptionGated != rhs.subscriptionGated {
return lhs.subscriptionGated
}
return lhs.providerName.localizedCaseInsensitiveCompare(rhs.providerName) == .orderedAscending
}
}
/// Overlay metadata for a provider that isn't in the models.dev catalog
/// Scarf needs to surface these so the picker matches `hermes model` on
/// the CLI.
public func overlayMetadata(for providerID: String) -> HermesProviderOverlay? {
Self.overlayOnlyProviders[providerID]
} }
/// Models for one provider, sorted by release date (newest first), then name. /// Models for one provider, sorted by release date (newest first), then name.
@@ -167,7 +209,9 @@ public struct ModelCatalogService: Sendable {
providerName: p.name ?? providerID, providerName: p.name ?? providerID,
envVars: p.env ?? [], envVars: p.env ?? [],
docURL: p.doc, docURL: p.doc,
modelCount: p.models?.count ?? 0 modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
) )
} }
} }
@@ -181,13 +225,45 @@ public struct ModelCatalogService: Sendable {
providerName: p.name ?? prefix, providerName: p.name ?? prefix,
envVars: p.env ?? [], envVars: p.env ?? [],
docURL: p.doc, docURL: p.doc,
modelCount: p.models?.count ?? 0 modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
) )
} }
} }
return nil return nil
} }
/// Look up a provider by ID, falling back to overlays when the cache has
/// no entry. Use this when resolving a stored `model.provider` to display
/// metadata `nous` and other overlay-only IDs never appear in the
/// cache, so a plain catalog lookup returns nil for them.
public func providerByID(_ providerID: String) -> HermesProviderInfo? {
if let catalog = loadCatalog(), let p = catalog[providerID] {
return HermesProviderInfo(
providerID: providerID,
providerName: p.name ?? providerID,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
if let overlay = Self.overlayOnlyProviders[providerID] {
return HermesProviderInfo(
providerID: providerID,
providerName: overlay.displayName,
envVars: [],
docURL: overlay.docURL,
modelCount: 0,
isOverlay: true,
subscriptionGated: overlay.subscriptionGated
)
}
return nil
}
/// Look up a specific model by provider + ID. Returns nil if not in the /// Look up a specific model by provider + ID. Returns nil if not in the
/// catalog (e.g., free-typed custom model). /// catalog (e.g., free-typed custom model).
public func model(providerID: String, modelID: String) -> HermesModelInfo? { public func model(providerID: String, modelID: String) -> HermesModelInfo? {
@@ -253,4 +329,93 @@ public struct ModelCatalogService: Sendable {
let context: Int? let context: Int?
let output: Int? let output: Int?
} }
// MARK: - Hermes overlay providers
/// The six providers Hermes surfaces via `hermes model` that have no
/// entry in `models_dev_cache.json` (models.dev doesn't mirror them).
/// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in
/// `hermes-agent/hermes_cli/providers.py`. The other ~19 overlay entries
/// already ship in the cache and only add augmentation (base-URL
/// override, extra env vars) that Scarf doesn't currently display.
///
/// Keep this in sync with the Python side on Hermes version bumps.
static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
"nous": HermesProviderOverlay(
displayName: "Nous Portal",
baseURL: "https://inference-api.nousresearch.com/v1",
authType: .oauthDeviceCode,
subscriptionGated: true,
docURL: "https://hermes-agent.nousresearch.com/docs/user-guide/setup/nous-portal"
),
"openai-codex": HermesProviderOverlay(
displayName: "OpenAI Codex",
baseURL: "https://chatgpt.com/backend-api/codex",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"qwen-oauth": HermesProviderOverlay(
displayName: "Qwen (OAuth)",
baseURL: "https://portal.qwen.ai/v1",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"google-gemini-cli": HermesProviderOverlay(
displayName: "Google Gemini CLI",
baseURL: "cloudcode-pa://google",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"copilot-acp": HermesProviderOverlay(
displayName: "GitHub Copilot ACP",
baseURL: "acp://copilot",
authType: .externalProcess,
subscriptionGated: false,
docURL: nil
),
"arcee": HermesProviderOverlay(
displayName: "Arcee",
baseURL: "https://api.arcee.ai/api/v1",
authType: .apiKey,
subscriptionGated: false,
docURL: nil
),
]
}
/// Scarf-side mirror of `HermesOverlay` from hermes-agent's
/// `hermes_cli/providers.py`. Describes a provider that isn't in the
/// models.dev catalog.
public struct HermesProviderOverlay: Sendable {
public let displayName: String
public let baseURL: String?
public let authType: AuthType
/// True for providers whose tool access is subscription-gated rather than
/// BYO-API-key. Nous Portal is the only `true` entry today.
public let subscriptionGated: Bool
public let docURL: String?
public init(
displayName: String,
baseURL: String?,
authType: AuthType,
subscriptionGated: Bool,
docURL: String?
) {
self.displayName = displayName
self.baseURL = baseURL
self.authType = authType
self.subscriptionGated = subscriptionGated
self.docURL = docURL
}
public enum AuthType: String, Sendable {
case apiKey
case oauthDeviceCode
case oauthExternal
case externalProcess
}
} }
@@ -108,6 +108,11 @@ public struct LocalTransport: ServerTransport {
// MARK: - Processes // MARK: - Processes
public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
#if os(iOS)
// iOS can't spawn processes. Callers on iOS use `CitadelServerTransport`
// (from the ScarfIOS package) instead; reaching here is a wiring bug.
throw TransportError.other(message: "LocalTransport.runProcess is unavailable on iOS")
#else
let proc = Process() let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable) proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args proc.arguments = args
@@ -148,6 +153,7 @@ public struct LocalTransport: ServerTransport {
try? stderrPipe.fileHandleForReading.close() try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close() try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err) return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
#endif
} }
#if !os(iOS) #if !os(iOS)
@@ -637,6 +637,11 @@ public struct SSHTransport: ServerTransport {
/// SSH-specific code paths live on this type and we want all Process /// SSH-specific code paths live on this type and we want all Process
/// lifecycle in one place per transport. /// lifecycle in one place per transport.
nonisolated private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { nonisolated private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
#if os(iOS)
// iOS uses `CitadelServerTransport` instead of spawning ssh/scp
// binaries. Reaching here from iOS is a wiring bug.
throw TransportError.other(message: "SSHTransport.runLocal is unavailable on iOS")
#else
ensureControlDir() ensureControlDir()
let proc = Process() let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable) proc.executableURL = URL(fileURLWithPath: executable)
@@ -682,5 +687,6 @@ public struct SSHTransport: ServerTransport {
try? stderrPipe.fileHandleForReading.close() try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close() try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err) return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
#endif
} }
} }
@@ -30,15 +30,15 @@ public final class ConnectionStatusViewModel {
case error(message: String, stderr: String) case error(message: String, stderr: String)
} }
private(set) var status: Status = .idle public private(set) var status: Status = .idle
/// Timestamp of the last successful probe. Used by the UI to show how /// Timestamp of the last successful probe. Used by the UI to show how
/// fresh the status indicator is ("just now", "2m ago"). /// fresh the status indicator is ("just now", "2m ago").
private(set) var lastSuccess: Date? public private(set) var lastSuccess: Date?
/// Number of consecutive probe failures. Surfaced as a yellow "Reconnecting" /// Number of consecutive probe failures. Surfaced as a yellow "Reconnecting"
/// state for the first failure (silent retry), then promoted to red after /// state for the first failure (silent retry), then promoted to red after
/// `consecutiveFailureThreshold` failures so flaky connections don't /// `consecutiveFailureThreshold` failures so flaky connections don't
/// flap the indicator on every dropped packet. /// flap the indicator on every dropped packet.
private(set) var consecutiveFailures = 0 public private(set) var consecutiveFailures = 0
private let consecutiveFailureThreshold = 2 private let consecutiveFailureThreshold = 2
public let context: ServerContext public let context: ServerContext
@@ -30,7 +30,7 @@ public final class LogsViewModel {
switch self { switch self {
case .agent: return "Agent" case .agent: return "Agent"
case .errors: return "Errors" case .errors: return "Errors"
case .gateway: return "Gateway" case .gateway: return "Messaging Gateway"
} }
} }
#endif #endif
@@ -58,7 +58,7 @@ public final class LogsViewModel {
public var displayName: LocalizedStringResource { public var displayName: LocalizedStringResource {
switch self { switch self {
case .all: return "All" case .all: return "All"
case .gateway: return "Gateway" case .gateway: return "Messaging Gateway"
case .agent: return "Agent" case .agent: return "Agent"
case .tools: return "Tools" case .tools: return "Tools"
case .cli: return "CLI" case .cli: return "CLI"
@@ -73,6 +73,88 @@ public final class ProjectsViewModel {
} }
} }
// MARK: - v2.3 registry verbs (folder / archive / rename)
/// Move a project into a folder. `nil` folder returns the project
/// to the top level. No-op when the target already matches.
public func moveProject(_ project: ProjectEntry, toFolder folder: String?) {
mutateEntry(project) { $0.folder = folder }
}
/// Rename a project. `name` is the registry's unique key + the
/// Identifiable id; rejects renames that would collide with an
/// existing project's name. Returns true on success.
@discardableResult
public func renameProject(_ project: ProjectEntry, to newName: String) -> Bool {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
guard trimmed != project.name else { return true }
var registry = service.loadRegistry()
guard !registry.projects.contains(where: { $0.name == trimmed }) else { return false }
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return false }
let old = registry.projects[index]
registry.projects[index] = ProjectEntry(
name: trimmed,
path: old.path,
folder: old.folder,
archived: old.archived
)
do {
try service.saveRegistry(registry)
} catch {
logger.error("renameProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
return false
}
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = registry.projects[index]
}
return true
}
/// Soft-archive a project. Stays on disk + in the registry; the
/// sidebar just hides it unless `showArchived` is on.
public func archiveProject(_ project: ProjectEntry) {
mutateEntry(project) { $0.archived = true }
if selectedProject?.name == project.name {
selectedProject = nil
dashboard = nil
}
}
/// Restore an archived project to the default view.
public func unarchiveProject(_ project: ProjectEntry) {
mutateEntry(project) { $0.archived = false }
}
/// Distinct folder labels across the current project set, sorted
/// alphabetically. Drives the sidebar's DisclosureGroups + the
/// Move-to-Folder sheet's existing-folder list.
public var folders: [String] {
let set = Set(projects.compactMap(\.folder).filter { !$0.isEmpty })
return set.sorted()
}
// MARK: - Helpers
private func mutateEntry(_ project: ProjectEntry, _ mutation: (inout ProjectEntry) -> Void) {
var registry = service.loadRegistry()
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return }
var entry = registry.projects[index]
mutation(&entry)
registry.projects[index] = entry
do {
try service.saveRegistry(registry)
} catch {
logger.error("mutateEntry couldn't persist registry for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
return
}
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = entry
}
}
public func refreshDashboard() { public func refreshDashboard() {
guard let project = selectedProject else { return } guard let project = selectedProject else { return }
loadDashboard(for: project) loadDashboard(for: project)
@@ -50,15 +50,15 @@ public final class RichChatViewModel {
public var scrollTrigger = UUID() public var scrollTrigger = UUID()
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none) // Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
private(set) var acpInputTokens = 0 public private(set) var acpInputTokens = 0
private(set) var acpOutputTokens = 0 public private(set) var acpOutputTokens = 0
private(set) var acpThoughtTokens = 0 public private(set) var acpThoughtTokens = 0
private(set) var acpCachedReadTokens = 0 public private(set) var acpCachedReadTokens = 0
/// Slash commands advertised by the ACP server via `available_commands_update`. /// Slash commands advertised by the ACP server via `available_commands_update`.
private(set) var acpCommands: [HermesSlashCommand] = [] public private(set) var acpCommands: [HermesSlashCommand] = []
/// User-defined commands parsed from `config.yaml` `quick_commands`. /// User-defined commands parsed from `config.yaml` `quick_commands`.
private(set) var quickCommands: [HermesSlashCommand] = [] public private(set) var quickCommands: [HermesSlashCommand] = []
/// Merged list, ACP-first, de-duplicated by name. /// Merged list, ACP-first, de-duplicated by name.
public var availableCommands: [HermesSlashCommand] { public var availableCommands: [HermesSlashCommand] {
@@ -81,7 +81,7 @@ public final class RichChatViewModel {
public private(set) var sessionId: String? public private(set) var sessionId: String?
/// The original CLI session ID when resuming a CLI session via ACP. /// The original CLI session ID when resuming a CLI session via ACP.
/// Used to combine old CLI messages with new ACP messages. /// Used to combine old CLI messages with new ACP messages.
private(set) var originSessionId: String? public private(set) var originSessionId: String?
private var nextLocalId = -1 private var nextLocalId = -1
private var streamingAssistantText = "" private var streamingAssistantText = ""
private var streamingThinkingText = "" private var streamingThinkingText = ""
@@ -253,13 +253,13 @@ public final class RichChatViewModel {
public func loadQuickCommands() { public func loadQuickCommands() {
let ctx = context let ctx = context
Task.detached { [weak self] in Task.detached { [weak self] in
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx) let loaded = Self.loadQuickCommands(context: ctx)
let mapped = loaded.map { qc -> HermesSlashCommand in let mapped = loaded.map { (name, command) -> HermesSlashCommand in
let truncated = qc.command.count > 60 let truncated = command.count > 60
? String(qc.command.prefix(60)) + "" ? String(command.prefix(60)) + ""
: qc.command : command
return HermesSlashCommand( return HermesSlashCommand(
name: qc.name, name: name,
description: "Run: \(truncated)", description: "Run: \(truncated)",
argumentHint: nil, argumentHint: nil,
source: .quickCommand source: .quickCommand
@@ -271,6 +271,33 @@ public final class RichChatViewModel {
} }
} }
/// Parse `quick_commands` from `<context>/config.yaml`. Returns
/// `[(name, command)]` for every well-formed `type: exec` entry.
/// Mac-side `QuickCommandsViewModel` uses a richer model + adds
/// an `isDangerous` check; here we only need the slash-menu
/// projection, so we keep the parser minimal and ScarfCore-local.
nonisolated static func loadQuickCommands(context: ServerContext) -> [(name: String, command: String)] {
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
let parsed = HermesYAML.parseNestedYAML(yaml)
var byName: [String: (type: String, command: String)] = [:]
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3 else { continue }
let name = String(parts[1])
let field = String(parts[2])
var existing = byName[name] ?? (type: "exec", command: "")
let stripped = HermesYAML.stripYAMLQuotes(value)
if field == "type" { existing.type = stripped }
if field == "command" { existing.command = stripped }
byName[name] = existing
}
return byName.compactMap { (name, entry) in
guard entry.type == "exec", !entry.command.isEmpty else { return nil }
return (name: name, command: entry.command)
}
.sorted { $0.name < $1.name }
}
private func appendMessageChunk(text: String) { private func appendMessageChunk(text: String) {
streamingAssistantText += text streamingAssistantText += text
upsertStreamingMessage() upsertStreamingMessage()
@@ -302,7 +302,7 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
// Parallel to LocalTransport: no-op if the file doesn't exist. // Parallel to LocalTransport: no-op if the file doesn't exist.
let exists = try await asyncFileExists(path) let exists = try await asyncFileExists(path)
if !exists { return } if !exists { return }
try await sftp.remove(atPath: path) try await sftp.remove(at: path)
} }
private func asyncRunProcess( private func asyncRunProcess(
+53
View File
@@ -56,3 +56,56 @@ Rich usage analytics pulled from the sessions and messages SQLite tables:
### 10. Config Editor ### 10. Config Editor
- Structured form editor for config.yaml with validation - Structured form editor for config.yaml with validation
---
## Projects System Evolution (post-v2.2.1)
A parallel backlog specific to the Projects feature. Ordered by dependency: organization first, then per-project attribution via sidecar, then observability built on that attribution, then polish, then platform bets.
### Shipping in v2.3 (planned — plan file at `~/.claude/plans/`)
- **Folder hierarchy in the sidebar.** `ProjectEntry` gains optional `folder: String?`. `DisclosureGroup`-based sidebar.
- **Rename + archive + search.** Registry verbs + a fuzzy ⌘F search + soft-archive (`archived: Bool?`) with Show/Hide toggle.
- **⌘1–⌘9 project jumps.**
- **Per-project Sessions tab** alongside Dashboard / Site. Filters the global sessions list by a new `~/.hermes/scarf/session_project_map.json` sidecar that Scarf populates when it starts a chat with a project context.
- **New Chat button** on the Sessions tab — spawns `hermes acp` with `cwd = project.path` and attributes the resulting session in the sidecar.
### v2.4+ — per-project observability
Depends on v2.3's sidecar being stable. All features below are "filter the existing data by the sidecar's project mapping."
- **Per-project activity feed.** Extend `ActivityViewModel` with a `projectPath` filter that maps through the sidecar. Dashboard widget type `recent-activity`.
- **Per-project token / cost rollup.** `InsightsViewModel.computeAggregates()` already sums over sessions; add a project filter. Widget binding `project.tokens` exposes it to agent-driven dashboards.
- **Per-project cron-job filter.** Cron sidebar gains a project dropdown. Template-installed jobs already carry `[tmpl:<id>]` prefixes; match against installed template manifests to attribute.
- **Desktop notifications for cron completion.** When a project-attributed cron job finishes (success or failure), fire a `UNUserNotification`. Per-project mute.
### v2.5+ — platform bets
Bigger investments with longer arcs.
- **Hermes upstream: `sessions.cwd` column.** Propose adding a nullable `cwd` (or `workspace_id`) column to Hermes's sessions table, populated on session create. Scarf would prefer the canonical column when available and fall back to the sidecar for pre-upgrade sessions. Requires coordinated Hermes release; filed under platform bets because it cuts the sidecar's blind spot (CLI-started sessions never enter the sidecar today).
- **Per-project memory slice.** Hermes reads `MEMORY.md` from a known path. Explore whether Scarf can spawn `hermes acp` with an overridden memory path (per-project `<project>/.scarf/MEMORY.md`) so projects get isolated context. Needs a Hermes-side env var or flag.
- **Per-project skills namespace.** Today user-authored skills are flat under `~/.hermes/skills/`. A `~/.hermes/skills/project/<slug>/` namespace parallel to the existing `templates/` namespace would let users install skills *into* a project without a template. Uninstall = drop the folder.
- **Cross-project meta-dashboards.** A portfolio view that aggregates widgets from multiple projects — total token spend, combined activity feed, project-health matrix. Useful at 20+ projects.
- **Project backup / restore.** One-click zip of `<project>/` + sidecar entries + related Keychain secrets, restorable on another machine. Richer than the existing Export flow (which carries the template shape only).
### Continuous — UX polish
Small, shippable at any time. Each is a half-day-to-one-day item.
- **Drag-and-drop to reorder** projects within a folder and between folders. Would be the first use of `.onDrag`/`.onDrop` in the codebase; establishes the pattern.
- **Tags as a secondary axis.** Keep folders as primary, add multi-valued string tags + filter chips at the sidebar top. Decide only if folders feel insufficient after v2.3 lands.
- **Favorites / pin** — bubble a project to the top of its folder.
- **Recent projects collection** — auto-populated "Recents" row at the top of the sidebar.
- **Color labels or SF Symbol icons** per project (Finder-tag-style).
- **Project dashboard starter templates** — "blank", "monitor", "feed", "timeline" shapes when creating a bare project (distinct from `.scarftemplate` sharing flow).
- **Opportunistic session backfill.** When Scarf loads any session that isn't in the sidecar, peek at first tool call's `working_directory` or `cwd` hint; if it matches a registered project path, write a sidecar entry. Heuristic, not perfect — useful as an "it just works" improvement after v2.3 ships.
### Research / verification gaps
Noted during v2.3 planning; chase when relevant:
- `DisclosureGroup` inside `List(.sidebar)` on macOS — occasional animation glitches with many-rows-expanding. Early prototype will confirm before full commit.
- Concurrent sidecar writers from multiple Scarf windows on the same `~/.hermes` — atomic replace handles per-write; reload behavior may lag. Acceptable; revisit if users report stale attribution.
- Do Hermes sessions ever persist `cwd` anywhere in `state.db` today that we've missed? If so, we can skip the sidecar and use it directly. Worth a one-hour investigation before starting v2.4 observability work.
+18 -18
View File
@@ -9,7 +9,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; }; 53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
53SCARFCORE0010 /* ScarfCore in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0001 /* ScarfCore */; }; 53SCARFCORE0010 /* ScarfCore in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0001 /* ScarfCore */; };
53SCARFCORE0011 /* ScarfCore in Frameworks (iOS) */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0002 /* ScarfCore */; }; 53SCARFCORE0011 /* ScarfCore in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0002 /* ScarfCore */; };
53SCARFIOS0010 /* ScarfIOS in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFIOS0001 /* ScarfIOS */; }; 53SCARFIOS0010 /* ScarfIOS in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFIOS0001 /* ScarfIOS */; };
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; }; 53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -115,7 +115,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
53SCARFCORE0011 /* ScarfCore in Frameworks (iOS) */, 53SCARFCORE0011 /* ScarfCore in Frameworks */,
53SCARFIOS0010 /* ScarfIOS in Frameworks */, 53SCARFIOS0010 /* ScarfIOS in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -522,7 +522,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -539,7 +539,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app"; PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -560,7 +560,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -577,7 +577,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app"; PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -819,7 +819,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -833,7 +833,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.6; MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app; PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -855,7 +855,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
@@ -869,7 +869,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.6; MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app; PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -887,12 +887,12 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -909,12 +909,12 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -930,11 +930,11 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -950,11 +950,11 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.2.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

@@ -1,61 +1,61 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "AW Mac OS Applications-iOS-Default-16x16@1x.png", "filename" : "AW Mac OS Applications-macOS-Default-16x16@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "16x16" "size" : "16x16"
}, },
{ {
"filename" : "AW Mac OS Applications-iOS-Default-16x16@2x.png", "filename" : "AW Mac OS Applications-macOS-Default-16x16@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "16x16" "size" : "16x16"
}, },
{ {
"filename" : "AW Mac OS Applications-iOS-Default-32x32@1x.png", "filename" : "AW Mac OS Applications-macOS-Default-32x32@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "32x32" "size" : "32x32"
}, },
{ {
"filename" : "AW Mac OS Applications-iOS-Default-32x32@2x.png", "filename" : "AW Mac OS Applications-macOS-Default-32x32@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "32x32" "size" : "32x32"
}, },
{ {
"filename" : "AW Mac OS Applications-iOS-Default-128x128@1x.png", "filename" : "AW Mac OS Applications-macOS-Default-128x128@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "128x128" "size" : "128x128"
}, },
{ {
"filename" : "AW Mac OS Applications-iOS-Default-128x128@2x.png", "filename" : "AW Mac OS Applications-macOS-Default-128x128@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "128x128" "size" : "128x128"
}, },
{ {
"filename" : "AW Mac OS Applications-iOS-Default-256x256@1x.png", "filename" : "AW Mac OS Applications-macOS-Default-256x256@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "256x256" "size" : "256x256"
}, },
{ {
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x 1.png", "filename" : "AW Mac OS Applications-macOS-Default-256x256@2x 1.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "256x256" "size" : "256x256"
}, },
{ {
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x.png", "filename" : "AW Mac OS Applications-macOS-Default-512x512@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "512x512" "size" : "512x512"
}, },
{ {
"filename" : "AW Mac OS Applications-iOS-Default-1024x1024@1x.png", "filename" : "AW Mac OS Applications-macOS-Default-1024x1024@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "512x512" "size" : "512x512"
@@ -0,0 +1,43 @@
import Foundation
/// Scarf-owned sidecar mapping Hermes session IDs to the Scarf
/// project path a chat was started for. Written on session create
/// when Scarf spawns `hermes acp` with a project-scoped cwd; read
/// by the per-project Sessions tab.
///
/// Hermes's own `state.db` has no `cwd` column on the sessions
/// table the cwd is passed at runtime via ACP but not persisted
/// on its side. This sidecar is how we recover the attribution
/// without requiring an upstream schema change.
///
/// Stored at `~/.hermes/scarf/session_project_map.json`. Forward-
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
/// can prefer that and fall back to this file for pre-upgrade
/// sessions. Missing file empty map (nothing attributed yet).
struct SessionProjectMap: Codable, Sendable {
/// session-id absolute-project-path. Both strings are opaque
/// from this file's perspective; the service validates project
/// paths against the live registry when building the reverse
/// lookup used by the Sessions tab, so stale entries for
/// removed projects are ignored at read time without needing a
/// write-side cleanup.
var mappings: [String: String]
/// ISO-8601 timestamp of the most recent write. Informational
/// only not used for any decision logic. Useful when debugging
/// a stale sidecar ("when was this last updated?").
var updatedAt: String?
init(mappings: [String: String] = [:], updatedAt: String? = nil) {
self.mappings = mappings
self.updatedAt = updatedAt
}
/// Current time in ISO-8601 format, suitable for the
/// `updatedAt` field. Matches the format used elsewhere in
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
/// greps across .json files sees consistent timestamps.
static func nowISO8601() -> String {
ISO8601DateFormatter().string(from: Date())
}
}
@@ -212,6 +212,16 @@ struct HermesFileService: Sendable {
replyPrefix: str("whatsapp.reply_prefix") replyPrefix: str("whatsapp.reply_prefix")
) )
// `platform_toolsets.<platform>` is a dict of lists in config.yaml
// parseNestedYAML flattens nested lists into dotted-path keys. Pull
// every key under the prefix and strip it.
var platformToolsets: [String: [String]] = [:]
for (key, items) in lists where key.hasPrefix("platform_toolsets.") {
let platform = String(key.dropFirst("platform_toolsets.".count))
guard !platform.isEmpty else { continue }
platformToolsets[platform] = items
}
// Home Assistant lives under `platforms.homeassistant.extra.*`. // Home Assistant lives under `platforms.homeassistant.extra.*`.
let homeAssistant = HomeAssistantSettings( let homeAssistant = HomeAssistantSettings(
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [], watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
@@ -259,6 +269,7 @@ struct HermesFileService: Sendable {
cronWrapResponse: bool("cron.wrap_response", default: true), cronWrapResponse: bool("cron.wrap_response", default: true),
prefillMessagesFile: str("prefill_messages_file"), prefillMessagesFile: str("prefill_messages_file"),
skillsExternalDirs: lists["skills.external_dirs"] ?? [], skillsExternalDirs: lists["skills.external_dirs"] ?? [],
platformToolsets: platformToolsets,
display: display, display: display,
terminal: terminal, terminal: terminal,
browser: browser, browser: browser,
@@ -36,6 +36,16 @@ final class HermesFileWatcher {
paths.errorsLog, paths.errorsLog,
paths.gatewayLog, paths.gatewayLog,
paths.projectsRegistry, paths.projectsRegistry,
// v2.3: sidecar attributing Hermes session IDs to Scarf project
// paths. Written by SessionAttributionService when a chat
// starts with a project context; read by
// ProjectSessionsViewModel to filter the session list. Without
// watching this file, the per-project Sessions tab would only
// pick up new sessions when the user re-entered the tab
// (triggering .task(id:) re-fire) switching directly back
// to the project's Sessions tab after a chat left the tab
// stale.
paths.sessionProjectMap,
paths.mcpTokensDir paths.mcpTokensDir
] ]
} }
@@ -0,0 +1,265 @@
import Foundation
import AppKit
import os
import ScarfCore
/// Drives `hermes auth add nous --no-browser` for Nous Portal sign-in.
///
/// Nous uses OAuth 2.0 device-code flow, not PKCE. Hermes prints the
/// verification URL + user code to stdout, then long-polls the token
/// endpoint every ~1s until the user approves in their browser (or the
/// device code expires, currently 15 minutes).
///
/// The controller:
///
/// 1. Spawns hermes via `context.makeTransport().makeProcess(...)`.
/// 2. Streams stdout, regex-extracts the `verification_uri_complete` and
/// `user_code` from the lines hermes prints (auth.py:3282-3286).
/// 3. Auto-opens the verification URL in the default browser and
/// transitions to `.waitingForApproval` so the sheet can show the code.
/// 4. On subprocess exit, confirms success by re-reading `auth.json` via
/// `NousSubscriptionService` hermes exit 0 alone isn't enough, we want
/// to see `providers.nous.access_token` actually landed.
/// 5. Detects the `subscription_required` failure (auth.py:3347-3356) and
/// surfaces the billing URL so the sheet can offer a Subscribe link.
///
/// The parser functions are `nonisolated static` so tests can feed fixture
/// buffers without standing up a real subprocess.
@Observable
@MainActor
final class NousAuthFlow {
enum State: Equatable {
case idle
case starting
case waitingForApproval(userCode: String, verificationURL: URL)
case success
case failure(reason: String, billingURL: URL?)
}
private(set) var state: State = .idle
/// Accumulated subprocess output. Surfaced in the failure UI so the user
/// can copy the tail for bug reports.
private(set) var output: String = ""
let context: ServerContext
private let subscriptionService: NousSubscriptionService
private let logger = Logger(subsystem: "com.scarf", category: "NousAuthFlow")
private var process: Process?
private var stdoutPipe: Pipe?
init(context: ServerContext = .local) {
self.context = context
self.subscriptionService = NousSubscriptionService(context: context)
}
// MARK: - Lifecycle
/// Start the sign-in flow. Any in-flight subprocess is terminated first.
/// Safe to call repeatedly (e.g. user hits "Try again").
func start() {
cancel()
output = ""
state = .starting
let proc = context.makeTransport().makeProcess(
executable: context.paths.hermesBinary,
args: ["auth", "add", "nous", "--no-browser"]
)
if !context.isRemote {
// Only enrich env locally remote ssh gets the remote login env
// naturally, and exporting our local keys into it would be wrong.
var env = HermesFileService.enrichedEnvironment()
// Python block-buffers stdout when it's a pipe (not a TTY). The
// device-code flow prints the verification URL + user code, then
// enters a ~15-minute polling loop that never hits `input()`
// so nothing flushes and our readability handler never sees the
// output. Users see the sheet spinning forever while hermes is
// actually waiting for approval.
//
// PKCE doesn't have this problem because `input("Authorization
// code: ")` flushes stdout before blocking, which is why
// OAuthFlowController works without this setting.
//
// PYTHONUNBUFFERED forces line-buffered stdout for the whole
// subprocess tiny perf cost, huge UX win for device-code.
env["PYTHONUNBUFFERED"] = "1"
proc.environment = env
}
let outPipe = Pipe()
// Merge stderr into stdout hermes prints the device-code block to
// stdout but may emit diagnostics on stderr; we want them interleaved
// in display order so the failure-tail UI reads naturally.
proc.standardOutput = outPipe
proc.standardError = outPipe
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
return
}
let chunk = String(data: data, encoding: .utf8) ?? ""
Task { @MainActor [weak self] in
self?.handleOutputChunk(chunk)
}
}
proc.terminationHandler = { [weak self] p in
let code = p.terminationStatus
Task { @MainActor [weak self] in
outPipe.fileHandleForReading.readabilityHandler = nil
self?.handleTermination(exitCode: code)
}
}
do {
try proc.run()
process = proc
stdoutPipe = outPipe
} catch {
logger.error("failed to spawn hermes: \(error.localizedDescription, privacy: .public)")
state = .failure(
reason: "Failed to start hermes: \(error.localizedDescription)",
billingURL: nil
)
}
}
/// Terminate the in-flight subprocess. Idempotent. Does NOT clear state
/// the sheet dismisses on cancel via its own binding, and re-opening
/// calls `start()` which does a fresh reset.
func cancel() {
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
process?.terminate()
process = nil
stdoutPipe = nil
}
// MARK: - Output handling
private func handleOutputChunk(_ chunk: String) {
output += chunk
// Only transition into waiting while we're still in .starting once
// we've already emitted the URL + code, subsequent "Waiting for
// approval..." noise shouldn't re-fire NSWorkspace.open.
guard case .starting = state else { return }
if let result = Self.parseDeviceCode(from: output) {
state = .waitingForApproval(
userCode: result.userCode,
verificationURL: result.verificationURL
)
NSWorkspace.shared.open(result.verificationURL)
}
}
private func handleTermination(exitCode: Int32) {
// Subscription-required is a specific failure path that hermes
// signals both via an exit code and a unique billing-URL message.
// It overrides other checks because we want the Subscribe affordance
// in the UI regardless of exit code.
if let billing = Self.parseSubscriptionRequired(from: output) {
state = .failure(
reason: "Your Nous Portal account does not have an active subscription.",
billingURL: billing
)
return
}
if exitCode == 0 {
// Hermes claims success. Confirm by reading auth.json the
// authoritative signal is that providers.nous has an access token
// AND active_provider flipped to nous. Anything short of that is
// a silent failure on the hermes side.
let sub = subscriptionService.loadState()
if sub.subscribed {
state = .success
} else if sub.present {
state = .failure(
reason: "Signed in, but Nous isn't the active provider yet. Run `hermes model` and pick Nous Portal.",
billingURL: nil
)
} else {
state = .failure(
reason: "Sign-in finished without writing credentials. Try again, or run `hermes auth add nous` in a terminal to see full diagnostics.",
billingURL: nil
)
}
} else {
let tail = Self.lastLines(of: output, count: 8)
state = .failure(
reason: tail.isEmpty
? "hermes exited with code \(exitCode)"
: tail,
billingURL: nil
)
}
}
// MARK: - Parsers (pure, testable)
struct DeviceCodeResult: Equatable {
let verificationURL: URL
let userCode: String
}
/// Extract the device-code verification URL and user code from hermes's
/// output. Anchored on the exact shape hermes prints (auth.py:3282-3286):
///
/// To continue:
/// 1. Open: https://portal.nousresearch.com/device/XXXX-XXXX
/// 2. If prompted, enter code: XXXX-XXXX
///
/// Returns nil when either line is missing the sheet stays on the
/// `.starting` spinner until both are captured.
nonisolated static func parseDeviceCode(from text: String) -> DeviceCodeResult? {
let urlPattern = #"^\s*1\.\s*Open:\s*(https?://\S+)\s*$"#
let codePattern = #"^\s*2\.\s*If prompted, enter code:\s*(\S+)\s*$"#
guard
let urlString = firstCapture(in: text, pattern: urlPattern),
let userCode = firstCapture(in: text, pattern: codePattern),
let url = URL(string: urlString)
else {
return nil
}
return DeviceCodeResult(verificationURL: url, userCode: userCode)
}
/// Detect the subscription-required failure and extract the billing URL
/// hermes prints (auth.py:3347-3356). Scarf shows a "Subscribe" button
/// linking to this URL so the user can resolve the blocker without
/// hunting through logs.
nonisolated static func parseSubscriptionRequired(from text: String) -> URL? {
guard text.contains("Your Nous Portal account does not have an active subscription") else {
return nil
}
guard
let raw = firstCapture(in: text, pattern: #"Subscribe here:\s*(https?://\S+)"#),
let url = URL(string: raw)
else {
return nil
}
return url
}
private nonisolated static func firstCapture(in text: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
return nil
}
let range = NSRange(text.startIndex..., in: text)
guard
let match = regex.firstMatch(in: text, range: range),
match.numberOfRanges >= 2,
let r = Range(match.range(at: 1), in: text)
else {
return nil
}
return String(text[r])
}
private nonisolated static func lastLines(of text: String, count: Int) -> String {
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
return lines.suffix(count).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -0,0 +1,86 @@
import Foundation
import os
import ScarfCore
/// Snapshot of the user's Nous Portal subscription state, derived from the
/// `providers.nous` entry in `~/.hermes/auth.json`. Read-only Scarf never
/// writes the subscription record; `hermes model` + `hermes auth` own that
/// path.
struct NousSubscriptionState: Sendable, Hashable {
/// True when `providers.nous` exists and has a usable access token.
/// Mirrors the `nous_auth_present` field on
/// `NousSubscriptionFeatures` in `hermes_cli/nous_subscription.py`.
let present: Bool
/// True when the user's **active provider** is `nous`, i.e. they've not
/// just authed but selected it as the primary model provider. The Tool
/// Gateway only routes tools when this is true auth alone isn't enough.
let providerIsNous: Bool
/// Last update time for the auth record, if known. Useful in the Health
/// view to tell the user when their subscription state was last refreshed.
let updatedAt: Date?
static let absent = NousSubscriptionState(present: false, providerIsNous: false, updatedAt: nil)
/// Overall subscription active for Tool Gateway routing. Both halves have
/// to line up: auth record present *and* `nous` is the active provider.
/// Mirrors `NousSubscriptionFeatures.subscribed` on the Python side.
var subscribed: Bool { present && providerIsNous }
}
/// Reads `auth.json` to detect Nous Portal subscription state. Delegates file
/// I/O to the active `ServerTransport`, so remote installations work the same
/// as local ones.
///
/// The auth-record shape is defined by hermes-agent and is load-bearing. This
/// service parses a small, stable subset and tolerates anything new Hermes
/// adds we only rely on `providers.nous` being a dict with `access_token`
/// and `active_provider` being either `"nous"` or not.
struct NousSubscriptionService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "NousSubscriptionService")
let authJSONPath: String
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.authJSONPath = context.paths.authJSON
self.transport = context.makeTransport()
}
/// Escape hatch for tests point at a fixture `auth.json` without
/// constructing a full `ServerContext`. Uses `LocalTransport` so the
/// fixture must live on the local filesystem.
init(path: String) {
self.authJSONPath = path
self.transport = LocalTransport()
}
/// Load the current subscription state. Returns ``NousSubscriptionState/absent``
/// on any read or parse failure callers treat "absent" and "can't
/// read" the same in UI (show a "not subscribed" CTA).
nonisolated func loadState() -> NousSubscriptionState {
guard let data = try? transport.readFile(authJSONPath) else {
return .absent
}
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
logger.warning("auth.json is not a JSON object; assuming no Nous subscription")
return .absent
}
let providers = root["providers"] as? [String: Any] ?? [:]
let nous = providers["nous"] as? [String: Any]
let token = nous?["access_token"] as? String
let present = (token?.isEmpty == false)
let activeProvider = root["active_provider"] as? String
let providerIsNous = (activeProvider == "nous")
let updatedAt: Date? = {
guard let raw = root["updated_at"] as? String else { return nil }
return ISO8601DateFormatter().date(from: raw)
}()
return NousSubscriptionState(
present: present,
providerIsNous: providerIsNous,
updatedAt: updatedAt
)
}
}
@@ -0,0 +1,294 @@
import Foundation
import os
import ScarfCore
/// Writes a Scarf-managed marker block into `<project>/AGENTS.md` so
/// that Hermes which auto-reads `AGENTS.md` from the session's cwd
/// at startup has consistent project identity and metadata in every
/// project-scoped chat.
///
/// **Why this exists.** Hermes has no native "project" concept and ACP
/// passes only `(cwd, mcpServers)` at session create extra params
/// are silently dropped on Hermes's side. The documented hook for
/// giving the agent context when cwd is set programmatically is the
/// auto-load of `AGENTS.md` (or `.hermes.md` / `CLAUDE.md` /
/// `.cursorrules`, in that priority) from the cwd. Scarf owns a
/// managed region of the project's AGENTS.md; template-author content
/// lives outside that region and is preserved.
///
/// **Marker contract.** The region sits between:
///
/// ```
/// <!-- scarf-project:begin -->
/// Scarf-managed content
/// <!-- scarf-project:end -->
/// ```
///
/// Same pattern as the v2.2 memory-block appendix bounded, self-
/// declaring, safe to re-generate. Everything outside the markers is
/// left byte-identical across refreshes.
///
/// **Secret-safe.** The block surfaces field NAMES from `config.json`
/// (via the cached manifest's schema) but never VALUES. A rendered
/// block contains no secrets even for a project whose config.json
/// has Keychain-ref URIs.
///
/// **Refresh timing.** `ChatViewModel.startACPSession(resume:projectPath:)`
/// calls `refresh(for:)` immediately before Hermes opens the session.
/// Hermes reads AGENTS.md during session boot, so the marker block
/// must have landed on disk first. Non-blocking on failure a
/// failed refresh logs and the chat proceeds without the block.
struct ProjectAgentContextService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService")
/// Marker strings. Load-bearing: the format must stay stable
/// across releases so existing project AGENTS.md files continue
/// to be recognized and rewritten cleanly.
static let beginMarker = "<!-- scarf-project:begin -->"
static let endMarker = "<!-- scarf-project:end -->"
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Public
/// Refresh (or create) the Scarf-managed block in the project's
/// AGENTS.md. Reads current project state template manifest,
/// config schema, registered cron jobs and produces a block
/// reflecting today's truth. Idempotent: two consecutive calls
/// with no intervening state change produce byte-identical
/// output.
nonisolated func refresh(for project: ProjectEntry) throws {
let block = renderBlock(for: project)
let path = agentsMdPath(for: project)
let transport = context.makeTransport()
// Ensure the project directory exists this service is the
// first thing that touches the project dir when the user
// scaffolds a bare project via `+` + starts a chat. Normally
// the dir exists (registered project = dir exists); belt-
// and-suspenders for edge cases.
if !transport.fileExists(project.path) {
try transport.createDirectory(project.path)
}
if !transport.fileExists(path) {
// Fresh AGENTS.md with just our block + a trailing
// newline so editors render it cleanly.
let data = (block + "\n").data(using: .utf8) ?? Data()
try transport.writeFile(path, data: data)
Self.logger.info("created AGENTS.md with Scarf block for \(project.name, privacy: .public)")
return
}
// Read existing, splice in the new block.
let existingData = try transport.readFile(path)
let existing = String(data: existingData, encoding: .utf8) ?? ""
let rewritten = Self.applyBlock(block: block, to: existing)
guard let outData = rewritten.data(using: .utf8) else {
throw ProjectAgentContextError.encodingFailed
}
// Skip the write when nothing changed avoids unnecessary
// file-watcher churn. Matches what disk snapshot shows.
guard outData != existingData else { return }
try transport.writeFile(path, data: outData)
Self.logger.info("refreshed Scarf block in AGENTS.md for \(project.name, privacy: .public)")
}
// MARK: - Marker splice (testable in isolation)
/// Core text transform: given an existing file and a freshly-
/// rendered block, return the file with the block spliced in.
///
/// Three cases handled:
/// 1. Existing file has both markers replace the inclusive
/// region, preserve everything outside untouched.
/// 2. Existing file has no markers prepend the block followed
/// by a two-newline separator so it reads as its own section.
/// 3. Existing file has a begin marker but no end we DON'T try
/// to be clever; treat as "no markers present" and prepend.
/// User intervention or a later refresh can restore shape.
/// The stray begin-marker is left in the file; we don't
/// truncate to EOF (as the memory-block installer does)
/// because an orphaned begin on this file is more likely
/// hand-typed than a corrupt Scarf write.
nonisolated static func applyBlock(block: String, to existing: String) -> String {
guard let beginRange = existing.range(of: beginMarker),
let endRange = existing.range(
of: endMarker,
range: beginRange.upperBound..<existing.endIndex
)
else {
// No well-formed Scarf block present prepend.
let trimmedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedExisting.isEmpty {
return block + "\n"
}
return block + "\n\n" + existing
}
// Full span: from the begin marker through the end marker
// (inclusive). Consumes any trailing whitespace/newlines
// immediately following the end marker so a re-render of a
// shorter block doesn't leave a dangling blank line.
var upperBound = endRange.upperBound
while upperBound < existing.endIndex,
existing[upperBound].isNewline {
upperBound = existing.index(after: upperBound)
}
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
let after = String(existing[upperBound..<existing.endIndex])
// Preserve the leading whitespace / content structure of
// `before` but ensure exactly one blank line separates it
// from the new block when there IS prior content.
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? ""
: before.trimmingRightNewlines() + "\n\n"
// Suffix: a blank line BEFORE the remaining content, ensuring
// the template/user content is visually separated from the
// Scarf block.
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "\n"
: "\n\n" + after.trimmingLeftNewlines()
return prefix + block + suffix
}
// MARK: - Block rendering
/// Build the Markdown block for a given project. Pure function of
/// project state exposed for tests that want to assert on
/// rendered content without touching disk.
nonisolated func renderBlock(for project: ProjectEntry) -> String {
let templateInfo = readTemplateInfo(for: project)
let configFieldsLine = renderConfigFieldsLine(for: project)
let cronLines = renderCronLines(for: project, templateId: templateInfo?.id)
let lockFilePresent = context.makeTransport().fileExists(
project.path + "/.scarf/template.lock.json"
)
var lines: [String] = []
lines.append(Self.beginMarker)
lines.append("## Scarf project context")
lines.append("")
lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._")
lines.append("")
lines.append("You are operating inside a Scarf project named **\"\(project.name)\"**. Scarf is a macOS GUI for Hermes; the user is working with this project through it. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.")
lines.append("")
lines.append("- **Project directory:** `\(project.path)`")
lines.append("- **Dashboard:** `\(project.path)/.scarf/dashboard.json`")
if let tpl = templateInfo {
lines.append("- **Template:** `\(tpl.id)` v\(tpl.version)")
}
lines.append("- **Configuration fields:** \(configFieldsLine)")
if cronLines.isEmpty {
lines.append("- **Registered cron jobs:** (none attributed to this project)")
} else {
lines.append("- **Registered cron jobs:**")
for line in cronLines {
lines.append(" - \(line)")
}
}
if lockFilePresent {
lines.append("- **Uninstall manifest:** `\(project.path)/.scarf/template.lock.json` (tracks files written by template install)")
}
lines.append("")
lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.")
lines.append(Self.endMarker)
return lines.joined(separator: "\n")
}
// MARK: - Helpers
nonisolated private func agentsMdPath(for project: ProjectEntry) -> String {
project.path + "/AGENTS.md"
}
/// Read `<project>/.scarf/manifest.json` for template id + version.
/// Nil when not present (bare project) or when the file is
/// unparseable the block still renders cleanly without the
/// template line.
nonisolated private func readTemplateInfo(for project: ProjectEntry) -> (id: String, version: String)? {
let manifestPath = project.path + "/.scarf/manifest.json"
let transport = context.makeTransport()
guard transport.fileExists(manifestPath) else { return nil }
guard let data = try? transport.readFile(manifestPath) else { return nil }
guard let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data) else { return nil }
return (id: manifest.id, version: manifest.version)
}
/// Build the "Configuration fields" bullet's tail. Returns a
/// comma-joined list of backticked field names with inline type
/// hints (`(secret)`), or the literal string "(none)" when the
/// project has no config schema. **Never** includes values.
nonisolated private func renderConfigFieldsLine(for project: ProjectEntry) -> String {
let manifestPath = project.path + "/.scarf/manifest.json"
let transport = context.makeTransport()
guard transport.fileExists(manifestPath),
let data = try? transport.readFile(manifestPath),
let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data),
let schema = manifest.config,
!schema.fields.isEmpty
else {
return "(none)"
}
let fieldList = schema.fields.map { field -> String in
let secretTag = field.type == .secret ? " (secret — name only, value stored in Keychain)" : ""
return "`\(field.key)`\(secretTag)"
}
return fieldList.joined(separator: ", ")
}
/// Return a list of human-readable cron-job descriptions for jobs
/// attributed to this project via the `[tmpl:<id>] ` name prefix.
/// Empty array when no jobs match (either the project has no
/// template or no jobs carry the tag).
nonisolated private func renderCronLines(for project: ProjectEntry, templateId: String?) -> [String] {
guard let templateId else { return [] }
let prefix = "[tmpl:\(templateId)]"
let jobs = HermesFileService(context: context).loadCronJobs()
return jobs
.filter { $0.name.hasPrefix(prefix) }
.map { job in
let scheduleDesc = job.schedule.display
?? job.schedule.expression
?? job.schedule.kind
let pausedDesc = job.enabled ? "enabled" : "paused"
return "`\(job.name)` — schedule `\(scheduleDesc)`, currently \(pausedDesc)"
}
}
}
enum ProjectAgentContextError: Error {
case encodingFailed
}
// MARK: - String helpers (file-scoped)
private extension String {
/// Drop trailing newlines + CRs but preserve other trailing
/// whitespace (tabs, non-breaking spaces) that might be
/// meaningful in some edge case.
func trimmingRightNewlines() -> String {
var result = self
while let last = result.last, last.isNewline {
result.removeLast()
}
return result
}
/// Symmetric counterpart: strip leading newlines / CRs.
func trimmingLeftNewlines() -> String {
var result = self
while let first = result.first, first.isNewline {
result.removeFirst()
}
return result
}
}
@@ -0,0 +1,116 @@
import Foundation
import os
import ScarfCore
/// Owns the sidecar that attributes Hermes session IDs to Scarf
/// project paths. The `cwd` passed to `hermes acp` at session
/// creation is ephemeral from Hermes's perspective (not written to
/// `state.db`), so Scarf keeps this Scarf-owned record parallel to
/// Hermes's session store.
///
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
/// `HermesPathSet.sessionProjectMap`).
///
/// Thread safety: all public methods are `nonisolated` and each
/// performs a single read-modify-write cycle that's atomic on
/// disk. Concurrent writers (two Scarf windows on the same
/// `~/.hermes`) are safe at the file level last write wins
/// but the in-memory read in one window may lag until that window
/// reloads. Acceptable for v2.3's scale; revisit if multi-window
/// cross-talk becomes a problem.
struct SessionAttributionService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Read
/// Load the current sidecar contents. Missing file or unparseable
/// JSON returns an empty map the sidecar is a convenience
/// index, not a source of truth for anything load-bearing.
nonisolated func load() -> SessionProjectMap {
let path = context.paths.sessionProjectMap
let transport = context.makeTransport()
guard transport.fileExists(path) else {
return SessionProjectMap()
}
do {
let data = try transport.readFile(path)
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
} catch {
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
return SessionProjectMap()
}
}
/// Look up the project path a given session was attributed to.
/// Returns nil for unattributed sessions (CLI-started, or
/// started before v2.3) those surface in the global Sessions
/// sidebar unchanged and don't appear in any project's Sessions
/// tab.
nonisolated func projectPath(for sessionID: String) -> String? {
load().mappings[sessionID]
}
/// Reverse lookup: every session ID attributed to the given
/// project path. Used by the per-project Sessions tab to filter
/// the global session list. Comparison is exact-string; the
/// registry stores absolute paths and we write absolute paths,
/// so no normalisation is needed in practice.
nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
let map = load()
return Set(map.mappings.filter { $0.value == projectPath }.keys)
}
// MARK: - Write
/// Record that `sessionID` was created under the given project
/// path. Idempotent repeated calls for the same pair are no-
/// ops. Replacing an existing mapping (session moved to a
/// different project) is legal but expected to be rare; the
/// caller decides when that's correct.
nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
var map = load()
if map.mappings[sessionID] == projectPath {
return
}
map.mappings[sessionID] = projectPath
map.updatedAt = SessionProjectMap.nowISO8601()
persist(map)
}
/// Remove a mapping. Called in v2.3's Sessions-tab code path is
/// minimal we don't currently prune on session delete because
/// Hermes owns session lifecycle and we don't observe deletes.
/// Exposed for future roadmap items (e.g. explicit "detach
/// from project" action) and tests.
nonisolated func forget(sessionID: String) {
var map = load()
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
map.updatedAt = SessionProjectMap.nowISO8601()
persist(map)
}
// MARK: - Private
private func persist(_ map: SessionProjectMap) {
let path = context.paths.sessionProjectMap
let transport = context.makeTransport()
let dir = context.paths.scarfDir
do {
if !transport.fileExists(dir) {
try transport.createDirectory(dir)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(map)
try transport.writeFile(path, data: data)
} catch {
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
}
@@ -42,6 +42,20 @@ final class ChatViewModel {
let richChatViewModel: RichChatViewModel let richChatViewModel: RichChatViewModel
private var coordinator: Coordinator? private var coordinator: Coordinator?
/// Absolute project path for the current session, when the chat is
/// project-scoped (either started via a project's "New Chat" button
/// or resumed from a session that was previously attributed via the
/// v2.3 sidecar). Nil for plain global chats. Drives the project
/// indicator in SessionInfoBar + the `Chat · <Name>` nav title.
private(set) var currentProjectPath: String?
/// Human-readable name of the active project, resolved from the
/// projects registry at session-start time. Stored alongside the
/// path so the view renders without hitting disk on every update.
/// Nil when `currentProjectPath` is nil OR the path isn't in the
/// registry (project was removed after the session was attributed).
private(set) var currentProjectName: String?
// ACP state // ACP state
private var acpClient: ACPClient? private var acpClient: ACPClient?
private var acpEventTask: Task<Void, Never>? private var acpEventTask: Task<Void, Never>?
@@ -119,15 +133,20 @@ final class ChatViewModel {
// MARK: - Session Lifecycle // MARK: - Session Lifecycle
func startNewSession() { func startNewSession(projectPath: String? = nil) {
voiceEnabled = false voiceEnabled = false
ttsEnabled = false ttsEnabled = false
isRecording = false isRecording = false
richChatViewModel.reset() richChatViewModel.reset()
if displayMode == .richChat { if displayMode == .richChat {
startACPSession(resume: nil) startACPSession(resume: nil, projectPath: projectPath)
} else { } else {
// Terminal mode doesn't surface project attribution today
// `hermes chat` uses the shell's cwd, so starting a terminal
// chat from a project button would require changing the
// shell's cwd too. Out of scope for v2.3 Rich Chat is
// the primary surface for project-scoped sessions.
launchTerminal(arguments: ["chat"]) launchTerminal(arguments: ["chat"])
} }
} }
@@ -290,13 +309,33 @@ final class ChatViewModel {
// MARK: - ACP Session Management // MARK: - ACP Session Management
private func startACPSession(resume sessionId: String?) { private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
stopACP() stopACP()
clearACPErrorState() clearACPErrorState()
acpStatus = "Starting..." acpStatus = "Starting..."
let client = ACPClient.forMacApp(context: context) let client = ACPClient.forMacApp(context: context)
self.acpClient = client self.acpClient = client
let attribution = SessionAttributionService(context: context)
// If the caller passed a project path, refresh the Scarf-
// managed block in the project's AGENTS.md BEFORE starting
// ACP Hermes auto-reads AGENTS.md at session boot, so the
// block has to land on disk first. Non-blocking on failure:
// we log and proceed without the block. Safe on bare
// projects (creates AGENTS.md with just the block); safe on
// template-installed projects (splices the block into
// existing AGENTS.md without touching template content).
if let projectPath {
let registry = ProjectDashboardService(context: context).loadRegistry()
if let project = registry.projects.first(where: { $0.path == projectPath }) {
do {
try ProjectAgentContextService(context: context).refresh(for: project)
} catch {
logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)")
}
}
}
Task { @MainActor in Task { @MainActor in
do { do {
@@ -306,7 +345,19 @@ final class ChatViewModel {
startACPEventLoop(client: client) startACPEventLoop(client: client)
startHealthMonitor(client: client) startHealthMonitor(client: client)
let cwd = await context.resolvedUserHome() // Project-scoped chats pass the project's absolute path
// as cwd so Hermes tool calls and subsequent ACP ops
// resolve relative paths against the project's files.
// Falls back to the user's home (existing v2.2 behavior)
// when the caller didn't request a project scope.
// `??` can't wrap an async autoclosure, so we
// materialize the fallback with an if-let.
let cwd: String
if let projectPath {
cwd = projectPath
} else {
cwd = await context.resolvedUserHome()
}
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true // Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
// and doesn't wipe messages with a DB refresh // and doesn't wipe messages with a DB refresh
@@ -335,6 +386,48 @@ final class ChatViewModel {
richChatViewModel.setSessionId(resolvedSessionId) richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))" acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Attribute this session to the project it was started
// under, so the per-project Sessions tab can surface it
// without a user action. No-op when projectPath is nil.
// Idempotent: re-attribution of the same pair is free.
if let projectPath {
attribution.attribute(
sessionID: resolvedSessionId,
toProjectPath: projectPath
)
}
// Resolve which project (if any) this session belongs
// to, so SessionInfoBar + nav title can surface it.
// Two inputs use whichever is non-nil:
// * `projectPath` the caller asked for a project
// scope (fresh project chat). Just-attributed;
// definitely in the sidecar.
// * `attribution.projectPath(for: resolvedSessionId)`
// the resumed session was previously attributed.
// Covers "click an old project-attributed session
// from the global Sessions sidebar / Resume menu"
// where projectPath isn't known at the call site.
let attributedPath = projectPath
?? attribution.projectPath(for: resolvedSessionId)
if let path = attributedPath {
// Look up a human-readable name from the projects
// registry. Missing project (path in the sidecar,
// project since removed) show the path as a
// fallback label so the chip still renders and the
// user sees *something* rather than silently losing
// the indicator.
let registry = ProjectDashboardService(context: context).loadRegistry()
let name = registry.projects.first(where: { $0.path == path })?.name
self.currentProjectPath = path
self.currentProjectName = name ?? path
} else {
// Explicit clear on non-project sessions so the
// indicator doesn't leak from a previous chat.
self.currentProjectPath = nil
self.currentProjectName = nil
}
// Refresh session list so the new ACP session appears in the Resume menu // Refresh session list so the new ACP session appears in the Resume menu
await loadRecentSessions() await loadRecentSessions()
+60 -2
View File
@@ -4,25 +4,83 @@ import ScarfCore
struct ChatView: View { struct ChatView: View {
@Environment(ChatViewModel.self) private var viewModel @Environment(ChatViewModel.self) private var viewModel
@Environment(HermesFileWatcher.self) private var fileWatcher @Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(AppCoordinator.self) private var coordinator
@State private var showErrorDetails = false @State private var showErrorDetails = false
var body: some View { var body: some View {
@Bindable var vm = viewModel @Bindable var vm = viewModel
@Bindable var coord = coordinator
VStack(spacing: 0) { VStack(spacing: 0) {
toolbar toolbar
Divider() Divider()
errorBanner errorBanner
chatArea chatArea
} }
.navigationTitle("Chat") // Clamp the outer VStack to the detail column's offered
// space. Without this, the chat area's intrinsic height (a
// RichChatView whose message list grows with content) can
// bubble up through NavigationSplitView's detail slot and
// push the whole window past the screen. Same pattern as
// the Sessions tab fix in the v2.3 branch.
.frame(maxWidth: .infinity, maxHeight: .infinity)
// v2.3: reflect the active Scarf project in the nav title
// so the user can see at a glance that the chat is scoped
// (complements the folder chip in SessionInfoBar). Falls
// back to the plain "Chat" label for global chats.
.navigationTitle(
viewModel.currentProjectName.map { "Chat · \($0)" } ?? "Chat"
)
.task { .task {
await viewModel.loadRecentSessions() await viewModel.loadRecentSessions()
viewModel.refreshCredentialPreflight() viewModel.refreshCredentialPreflight()
// Cold-launch handoff: if the user clicked "New Chat" on
// a project before ChatView had a chance to render, the
// coordinator was already populated. Consume the request
// here. The onChange below handles the live case.
if let pending = coordinator.pendingProjectChat {
coordinator.pendingProjectChat = nil
viewModel.startNewSession(projectPath: pending)
}
// Same story for resume-session handoff: the user clicked
// a session in the Projects Sessions tab (routes to `.chat`
// rather than `.sessions` so the chat actually reopens).
// SessionsView consumes `selectedSessionId` for its own
// routing; Chat now consumes it too. Mutually exclusive at
// any given render because only one section is active per
// `coordinator.selectedSection`. `else if` makes precedence
// explicit pendingProjectChat (new) outranks
// selectedSessionId (resume) when both are somehow set.
else if let pendingId = coordinator.selectedSessionId {
coordinator.selectedSessionId = nil
viewModel.resumeSession(pendingId)
}
} }
.onChange(of: fileWatcher.lastChangeDate) { .onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() } Task { await viewModel.loadRecentSessions() }
viewModel.refreshCredentialPreflight() viewModel.refreshCredentialPreflight()
} }
// Live handoff from the per-project Sessions tab: the tab
// sets `pendingProjectChat` + flips `selectedSection` to
// `.chat`; this view consumes the path and starts a fresh
// session with cwd=projectPath. Attribution happens inside
// ChatViewModel on successful session creation.
.onChange(of: coord.pendingProjectChat) { _, new in
if let projectPath = new {
coordinator.pendingProjectChat = nil
viewModel.startNewSession(projectPath: projectPath)
}
}
// Live handoff for resume: user clicked an existing session in
// the Projects Sessions tab while already in the Chat section
// (or switched back to Chat after). Project-chip rendering
// happens automatically inside ChatViewModel.resumeSession ->
// startACPSession via the attribution.projectPath(for:) lookup.
.onChange(of: coord.selectedSessionId) { _, new in
if let sessionId = new {
coordinator.selectedSessionId = nil
viewModel.resumeSession(sessionId)
}
}
} }
/// Banner rendered between the toolbar and the chat area when either /// Banner rendered between the toolbar and the chat area when either
@@ -361,7 +419,7 @@ struct ChatView: View {
// MARK: - Permission Approval View // MARK: - Permission Approval View
extension RichChatViewModel.PendingPermission: Identifiable { extension RichChatViewModel.PendingPermission: Identifiable {
var id: Int { requestId } public var id: Int { requestId }
} }
struct PermissionApprovalView: View { struct PermissionApprovalView: View {
@@ -18,7 +18,13 @@ struct RichChatView: View {
isWorking: richChat.isAgentWorking, isWorking: richChat.isAgentWorking,
acpInputTokens: richChat.acpInputTokens, acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens, acpOutputTokens: richChat.acpOutputTokens,
acpThoughtTokens: richChat.acpThoughtTokens acpThoughtTokens: richChat.acpThoughtTokens,
// v2.3: surface the active Scarf project (if any) as
// a folder chip at the start of the bar. Driven by
// ChatViewModel.currentProjectName which is set in
// startACPSession on both new project chats and
// resumed project-attributed sessions.
projectName: chatViewModel.currentProjectName
) )
Divider() Divider()
@@ -43,6 +49,19 @@ struct RichChatView: View {
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
) )
} }
// `idealHeight: 500` caps what this subtree REPORTS as its ideal
// height. Load-bearing: RichChatMessageList uses a plain VStack
// (not LazyVStack see RichChatMessageList.swift:13-24 for the
// rationale) inside a ScrollView, so its natural ideal grows
// with message count. Under the WindowGroup's
// `.windowResizability(.contentMinSize)` policy, that uncapped
// ideal would open the window at a height that exceeds the
// screen on long conversations, pushing the input bar below
// the visible desktop. `maxHeight: .infinity` still lets the
// view fill any larger offered space, and `minHeight: 0`
// allows it to shrink freely the ideal cap only affects the
// initial-size hint reported up to the window.
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
// DB polling fallback for terminal mode only never overwrite ACP messages // DB polling fallback for terminal mode only never overwrite ACP messages
.onChange(of: fileWatcher.lastChangeDate) { .onChange(of: fileWatcher.lastChangeDate) {
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil { if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
@@ -8,10 +8,28 @@ struct SessionInfoBar: View {
var acpInputTokens: Int = 0 var acpInputTokens: Int = 0
var acpOutputTokens: Int = 0 var acpOutputTokens: Int = 0
var acpThoughtTokens: Int = 0 var acpThoughtTokens: Int = 0
/// Name of the Scarf project this session is attributed to, when
/// applicable. Nil for plain global chats. Drives the folder-chip
/// indicator rendered before the session title. Resolved by
/// `ChatViewModel.currentProjectName` the view just passes it
/// through.
var projectName: String? = nil
var body: some View { var body: some View {
HStack(spacing: 16) { HStack(spacing: 16) {
if let session { if let session {
// Project indicator first visually anchors the session
// as "scoped to project X" before the working dot and
// title. Hidden for non-project chats so the bar looks
// identical to v2.2.1 behavior.
if let projectName {
Label(projectName, systemImage: "folder.fill")
.font(.caption)
.foregroundStyle(.tint)
.lineLimit(1)
.help("Chat is scoped to Scarf project \"\(projectName)\"")
}
HStack(spacing: 4) { HStack(spacing: 4) {
Circle() Circle()
.fill(isWorking ? .green : .secondary) .fill(isWorking ? .green : .secondary)
@@ -0,0 +1,53 @@
import Foundation
import ScarfCore
/// Describes whether Credential Pools' generic OAuth flow
/// (``OAuthFlowController``) can handle a given provider.
///
/// Hermes supports four OAuth styles, and only **PKCE** is driven by the
/// generic controller:
///
/// | Style | Works via `OAuthFlowController`? | Example providers |
/// |---|---|---|
/// | PKCE | Yes | anthropic, github-copilot |
/// | Device-code | No stalls silently | nous |
/// | External OAuth | No needs a terminal | openai-codex, qwen-oauth, google-gemini-cli |
/// | External process | No uses an agent bridge | copilot-acp |
///
/// Routing a non-PKCE provider through the generic controller silently
/// fails: the PKCE URL regex in ``OAuthFlowController/extractAuthURL`` only
/// matches `client_id=&redirect_uri=` -shaped strings, and nothing else
/// hermes prints for the other styles matches that. This gate closes the
/// dead end by steering the user to the right flow for each style.
///
/// `.ok` is the default for unknown providers so existing PKCE-based
/// flows (anthropic, etc.) keep working this gate is strictly additive.
enum CredentialPoolsOAuthGate: Equatable {
/// The standard PKCE flow works for this provider show the normal
/// "Start OAuth" button and let ``OAuthFlowController`` handle it.
case ok
/// User hasn't typed a provider ID yet. Disable the button.
case providerEmpty
/// Route Nous Portal through ``NousSignInSheet`` instead of the
/// generic flow, since Nous uses device-code.
case useNousSignIn
/// Hermes knows how to sign in to this provider but Scarf doesn't yet
/// have a dedicated UI for it. Point the user to `hermes auth add
/// <provider>` in a terminal.
case useCLI(provider: String)
/// Compute the gate for a typed provider ID. Consults the Hermes
/// overlay table via ``ModelCatalogService/overlayMetadata(for:)`` to
/// decide which OAuth style applies.
static func resolve(providerID rawID: String, catalog: ModelCatalogService) -> CredentialPoolsOAuthGate {
let id = rawID.trimmingCharacters(in: .whitespaces).lowercased()
guard !id.isEmpty else { return .providerEmpty }
if id == "nous" { return .useNousSignIn }
switch catalog.overlayMetadata(for: id)?.authType {
case .oauthDeviceCode, .oauthExternal, .externalProcess:
return .useCLI(provider: id)
default:
return .ok
}
}
}
@@ -15,6 +15,33 @@ struct HermesCredential: Identifiable, Sendable, Equatable {
let tokenTail: String // Last 4 chars of the token NEVER store full token in UI state let tokenTail: String // Last 4 chars of the token NEVER store full token in UI state
let lastStatus: String // "ok" | "cooldown" | "exhausted" | "" let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
let requestCount: Int let requestCount: Int
/// OAuth access-token expiry. Populated from `expires_at_ms` (epoch ms,
/// preferred) or `expires_at` (ISO8601). Nil for API-key entries and
/// for OAuth providers that haven't yet recorded an expiry.
let expiresAt: Date?
/// When the current Nous agent key was minted surfaced so users can
/// tell whether a recent rotation has gone through. Nil for non-Nous
/// providers and for older Nous entries without the field.
let agentKeyObtainedAt: Date?
/// Display-time badge for expiry. Recomputed against `Date()` on each
/// render so the label stays current without needing a timer.
enum ExpiryBadge: Equatable {
case expired
case expiringSoon(days: Int)
}
/// Returns a badge when expiry is within 7 days or already past. Nil
/// means "not worth flagging" either expiry is unknown or still far
/// enough out that a warning would be noise.
func expiryBadge(now: Date = Date()) -> ExpiryBadge? {
guard let expiresAt else { return nil }
if expiresAt <= now { return .expired }
let seconds = expiresAt.timeIntervalSince(now)
let days = Int(seconds / 86_400)
if days <= 7 { return .expiringSoon(days: max(1, days)) }
return nil
}
} }
/// Summary of one provider's pool with its rotation strategy. /// Summary of one provider's pool with its rotation strategy.
@@ -102,7 +129,9 @@ final class CredentialPoolsViewModel {
source: entry.source ?? "", source: entry.source ?? "",
tokenTail: Self.tail(of: entry.access_token ?? ""), tokenTail: Self.tail(of: entry.access_token ?? ""),
lastStatus: entry.last_status ?? "", lastStatus: entry.last_status ?? "",
requestCount: entry.request_count ?? 0 requestCount: entry.request_count ?? 0,
expiresAt: Self.resolveExpiry(msField: entry.expires_at_ms, isoField: entry.expires_at),
agentKeyObtainedAt: Self.parseISO8601(entry.agent_key_obtained_at)
) )
} }
return HermesCredentialPool( return HermesCredentialPool(
@@ -113,6 +142,30 @@ final class CredentialPoolsViewModel {
} }
} }
/// Prefer `expires_at_ms` (integer epoch ms unambiguous) over
/// `expires_at` (ISO8601 string). Hermes writes whichever format the
/// upstream provider returned; new entries almost always carry the ms
/// form, older Nous entries may only have the ISO form.
nonisolated private static func resolveExpiry(msField: Double?, isoField: String?) -> Date? {
if let ms = msField, ms > 0 {
return Date(timeIntervalSince1970: ms / 1000.0)
}
return parseISO8601(isoField)
}
nonisolated private static func parseISO8601(_ str: String?) -> Date? {
guard let s = str, !s.isEmpty else { return nil }
// Fractional seconds are present on Nous tokens; plain seconds on
// most OAuth providers. Try the fractional parser first, fall back
// to the strict one.
let withFractional = ISO8601DateFormatter()
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = withFractional.date(from: s) { return d }
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
return plain.date(from: s)
}
/// Return last 4 chars prefixed with "", or "" if the token is too short. /// Return last 4 chars prefixed with "", or "" if the token is too short.
/// Callers MUST NOT pass the full token anywhere user-visible beyond this. /// Callers MUST NOT pass the full token anywhere user-visible beyond this.
nonisolated private static func tail(of token: String) -> String { nonisolated private static func tail(of token: String) -> String {
@@ -251,9 +304,20 @@ private struct AuthEntry: Decodable, Sendable {
nonisolated let access_token: String? nonisolated let access_token: String?
nonisolated let last_status: String? nonisolated let last_status: String?
nonisolated let request_count: Int? nonisolated let request_count: Int?
/// Epoch milliseconds. Double (not Int64) because some Nous entries
/// round-trip through JS and end up as `1780339200000.0`. Decoding as
/// Int would throw on the fractional zero.
nonisolated let expires_at_ms: Double?
/// ISO8601 fallback when `expires_at_ms` isn't present.
nonisolated let expires_at: String?
/// Nous-specific when the current agent key was issued. Surfaced as
/// "Agent key rotated Nh ago" so the user can tell if a recent manual
/// rotation has taken effect.
nonisolated let agent_key_obtained_at: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, label, auth_type, source, access_token, last_status, request_count case id, label, auth_type, source, access_token, last_status, request_count
case expires_at_ms, expires_at, agent_key_obtained_at
} }
nonisolated init(from decoder: any Decoder) throws { nonisolated init(from decoder: any Decoder) throws {
@@ -265,5 +329,8 @@ private struct AuthEntry: Decodable, Sendable {
self.access_token = try c.decodeIfPresent(String.self, forKey: .access_token) self.access_token = try c.decodeIfPresent(String.self, forKey: .access_token)
self.last_status = try c.decodeIfPresent(String.self, forKey: .last_status) self.last_status = try c.decodeIfPresent(String.self, forKey: .last_status)
self.request_count = try c.decodeIfPresent(Int.self, forKey: .request_count) self.request_count = try c.decodeIfPresent(Int.self, forKey: .request_count)
self.expires_at_ms = try c.decodeIfPresent(Double.self, forKey: .expires_at_ms)
self.expires_at = try c.decodeIfPresent(String.self, forKey: .expires_at)
self.agent_key_obtained_at = try c.decodeIfPresent(String.self, forKey: .agent_key_obtained_at)
} }
} }
@@ -136,6 +136,7 @@ struct CredentialPoolsView: View {
.font(.caption2) .font(.caption2)
.foregroundStyle(statusColor(cred.lastStatus)) .foregroundStyle(statusColor(cred.lastStatus))
} }
expiryBadge(cred)
} }
HStack(spacing: 8) { HStack(spacing: 8) {
Text(cred.tokenTail.isEmpty ? "" : cred.tokenTail) Text(cred.tokenTail.isEmpty ? "" : cred.tokenTail)
@@ -151,6 +152,11 @@ struct CredentialPoolsView: View {
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
if let rotated = cred.agentKeyObtainedAt {
Text("agent key · \(Self.relativeAge(rotated))")
.font(.caption2)
.foregroundStyle(.tertiary)
}
} }
} }
Spacer() Spacer()
@@ -180,6 +186,45 @@ struct CredentialPoolsView: View {
default: return .secondary default: return .secondary
} }
} }
/// Red "expired" / orange "expires in Nd" pill shown inline with the
/// credential's auth-type chip. Hidden when the credential has no
/// expiry or is more than 7 days out no point pulling attention to a
/// token the user doesn't need to think about yet.
@ViewBuilder
private func expiryBadge(_ cred: HermesCredential) -> some View {
if let badge = cred.expiryBadge() {
switch badge {
case .expired:
Text("expired")
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.red)
.clipShape(Capsule())
case .expiringSoon(let days):
Text("expires in \(days)d")
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.orange)
.clipShape(Capsule())
}
}
}
/// "2h ago" / "3d ago" / "just now". Kept terse for the one-line
/// credential row. `RelativeDateTimeFormatter` isn't used because its
/// output ("2 hours ago") is too long for the slot.
private static func relativeAge(_ date: Date, now: Date = Date()) -> String {
let seconds = Int(now.timeIntervalSince(date))
if seconds < 60 { return "just now" }
if seconds < 3600 { return "\(seconds / 60)m ago" }
if seconds < 86_400 { return "\(seconds / 3600)h ago" }
return "\(seconds / 86_400)d ago"
}
} }
/// Two-step sheet for adding a credential: /// Two-step sheet for adding a credential:
@@ -211,9 +256,18 @@ private struct AddCredentialSheet: View {
@State private var providers: [HermesProviderInfo] = [] @State private var providers: [HermesProviderInfo] = []
@State private var oauthStarted: Bool = false @State private var oauthStarted: Bool = false
@State private var authCode: String = "" @State private var authCode: String = ""
/// Drives presentation of the dedicated Nous sign-in sheet from inside
/// this add-credential sheet. Nous uses device-code, not PKCE the
/// regular `OAuthFlowController` silently stalls, so we route Nous
/// through ``NousSignInSheet`` instead.
@State private var showNousSignIn: Bool = false
private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) } private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) }
private func oauthGate(for rawID: String) -> CredentialPoolsOAuthGate {
CredentialPoolsOAuthGate.resolve(providerID: rawID, catalog: catalog)
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Add Credential") Text("Add Credential")
@@ -241,6 +295,17 @@ private struct AddCredentialSheet: View {
onDismiss() onDismiss()
} }
} }
// Nous sign-in is a parallel flow that bypasses OAuthFlowController.
// When it completes, the parent list refreshes from auth.json just
// like it does after a regular OAuth add so we dismiss the
// AddCredentialSheet after a short delay.
.sheet(isPresented: $showNousSignIn) {
NousSignInSheet {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
onDismiss()
}
}
}
} }
// MARK: - Step 1: provider + type + label + optional API key // MARK: - Step 1: provider + type + label + optional API key
@@ -291,11 +356,57 @@ private struct AddCredentialSheet: View {
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
} }
} else { } else {
oauthPreamble oauthGuidance
} }
} }
} }
/// Renders either the standard PKCE preamble, the Nous-specific
/// "sign in with the dedicated sheet" affordance, or a CLI fallback
/// whichever matches the provider the user has typed.
@ViewBuilder
private var oauthGuidance: some View {
switch oauthGate(for: providerID) {
case .ok, .providerEmpty:
oauthPreamble
case .useNousSignIn:
nousSignInPreamble
case .useCLI(let provider):
cliFallbackPreamble(for: provider)
}
}
private var nousSignInPreamble: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.foregroundStyle(.tint)
Text("Nous Portal uses a dedicated sign-in flow.")
.font(.caption)
}
Text("We'll open the Nous Portal approval page in your browser and show the device code here. No code-paste step.")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func cliFallbackPreamble(for provider: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "terminal")
.foregroundStyle(.secondary)
Text("`\(provider)` uses a different sign-in flow.")
.font(.caption)
}
Text("Run `hermes auth add \(provider)` in a terminal to finish sign-in. In-app support for this provider is coming in a follow-up.")
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
/// Brief explanation shown before the user clicks "Start OAuth". Sets /// Brief explanation shown before the user clicks "Start OAuth". Sets
/// expectations about the embedded-terminal flow so the browser window /// expectations about the embedded-terminal flow so the browser window
/// and code-paste step aren't surprises. /// and code-paste step aren't surprises.
@@ -477,14 +588,38 @@ private struct AddCredentialSheet: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty) .disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
} else { } else {
oauthActionButton
}
}
}
}
/// Gate-aware OAuth primary action. For PKCE providers it's the
/// unchanged "Start OAuth" button; for Nous it's "Sign in to Nous
/// Portal" (opens ``NousSignInSheet``); for other device-code /
/// external providers it's a disabled button with a CLI hint inline.
@ViewBuilder
private var oauthActionButton: some View {
switch oauthGate(for: providerID) {
case .providerEmpty:
Button("Start OAuth") {}
.buttonStyle(.borderedProminent)
.disabled(true)
case .ok:
Button("Start OAuth") { Button("Start OAuth") {
viewModel.startOAuth(provider: providerID, label: label) viewModel.startOAuth(provider: providerID, label: label)
oauthStarted = true oauthStarted = true
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty) case .useNousSignIn:
} Button("Sign in to Nous Portal") {
} showNousSignIn = true
}
.buttonStyle(.borderedProminent)
case .useCLI:
Button("Start OAuth") {}
.buttonStyle(.borderedProminent)
.disabled(true)
} }
} }
} }
@@ -96,7 +96,7 @@ struct DashboardView: View {
color: .purple color: .purple
) )
StatusCard( StatusCard(
title: "Gateway", title: "Messaging Gateway",
value: viewModel.gatewayState?.statusText ?? "unknown", value: viewModel.gatewayState?.statusText ?? "unknown",
icon: "network", icon: "network",
color: viewModel.gatewayState?.isRunning == true ? .green : .secondary color: viewModel.gatewayState?.isRunning == true ? .green : .secondary
@@ -20,7 +20,7 @@ struct GatewayView: View {
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
} }
.navigationTitle("Gateway") .navigationTitle("Messaging Gateway")
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() } .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
} }
@@ -1,5 +1,23 @@
import Foundation import Foundation
import ScarfCore import ScarfCore
#if canImport(AppKit)
import AppKit
#endif
import os
/// Observed state of the local `hermes dashboard` web UI (introduced in
/// Hermes v0.10.x). `port` defaults to 9119 the CLI's default and the only
/// value Scarf launches with today.
struct WebDashboardStatus: Sendable, Equatable {
var running: Bool
var port: Int
/// True while a start/stop transition is in flight so the UI can disable
/// buttons and show a spinner.
var busy: Bool
static let defaultPort = 9119
static let unknown = WebDashboardStatus(running: false, port: defaultPort, busy: false)
}
struct HealthCheck: Identifiable { struct HealthCheck: Identifiable {
let id = UUID() let id = UUID()
@@ -25,10 +43,12 @@ struct HealthSection: Identifiable {
final class HealthViewModel { final class HealthViewModel {
let context: ServerContext let context: ServerContext
private let fileService: HermesFileService private let fileService: HermesFileService
private let subscriptionService: NousSubscriptionService
init(context: ServerContext = .local) { init(context: ServerContext = .local) {
self.context = context self.context = context
self.fileService = HermesFileService(context: context) self.fileService = HermesFileService(context: context)
self.subscriptionService = NousSubscriptionService(context: context)
} }
@@ -49,10 +69,24 @@ final class HealthViewModel {
var diagnosticsOutput: String = "" var diagnosticsOutput: String = ""
var isSharingDebug = false var isSharingDebug = false
/// Liveness + control state for `hermes dashboard` (local web UI). The
/// section in `HealthView` is hidden for remote contexts the dashboard
/// binds 127.0.0.1 by default and remote probing / tunneling is out of
/// scope for v1.
var dashboardStatus: WebDashboardStatus = .unknown
/// Our own spawned subprocess, if the user hit "Launch Dashboard" from
/// Scarf. Nil when the dashboard was started externally (we still detect
/// it via the probe but can't terminate it cleanly via `Process.terminate`).
private var dashboardProcess: Process?
/// Background polling loop; started in `startDashboardMonitoring()` and
/// cancelled on view disappear.
private var dashboardProbeTask: Task<Void, Never>?
func load() { func load() {
isLoading = true isLoading = true
let ctx = context let ctx = context
let svc = fileService let svc = fileService
let subSvc = subscriptionService
// Health runs four sync transport-mediated commands plus a process // Health runs four sync transport-mediated commands plus a process
// probe that's 4-5 ssh round-trips on remote, easily 1-2s. Detach // probe that's 4-5 ssh round-trips on remote, easily 1-2s. Detach
// the whole load. // the whole load.
@@ -61,6 +95,8 @@ final class HealthViewModel {
let versionOutput = ctx.runHermes(["version"]).output let versionOutput = ctx.runHermes(["version"]).output
let statusOutput = ctx.runHermes(["status"]).output let statusOutput = ctx.runHermes(["status"]).output
let doctorOutput = ctx.runHermes(["doctor"]).output let doctorOutput = ctx.runHermes(["doctor"]).output
let subscription = subSvc.loadState()
let config = svc.loadConfig()
let lines = versionOutput.components(separatedBy: "\n") let lines = versionOutput.components(separatedBy: "\n")
let version = lines.first ?? "" let version = lines.first ?? ""
@@ -69,6 +105,7 @@ final class HealthViewModel {
let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? "" let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? ""
let statusSections = Self.parseOutputStatic(statusOutput) let statusSections = Self.parseOutputStatic(statusOutput)
+ [Self.toolGatewaySection(subscription: subscription, config: config)]
let doctorSections = Self.parseOutputStatic(doctorOutput) let doctorSections = Self.parseOutputStatic(doctorOutput)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
@@ -86,6 +123,80 @@ final class HealthViewModel {
} }
} }
/// Synthesize a Tool Gateway health section from the subscription state +
/// `platform_toolsets` table. Runs alongside the other status sections so
/// the user sees at a glance whether their Nous Portal subscription is
/// wired up.
///
/// This is distinct from the "Messaging Gateway" (inbound Slack/Discord/
/// requests) the two are unrelated systems that unfortunately share the
/// "gateway" name in Hermes's CLI output.
///
/// `nonisolated` so `load()` can call it from `Task.detached` alongside
/// `parseOutputStatic` without hopping back to MainActor.
nonisolated private static func toolGatewaySection(subscription: NousSubscriptionState, config: HermesConfig) -> HealthSection {
var checks: [HealthCheck] = []
let subscriptionCheck: HealthCheck = {
if subscription.subscribed {
return HealthCheck(
label: "Nous Portal subscription active",
status: .ok,
detail: "Tool requests route through the Nous Portal gateway."
)
}
if subscription.present {
return HealthCheck(
label: "Signed in, but Nous isn't the active provider",
status: .warning,
detail: "Open Settings → General and pick Nous Portal to route tools through the gateway."
)
}
return HealthCheck(
label: "Not subscribed",
status: .warning,
detail: "Run `hermes auth` and pick Nous Portal to enable subscription-gated tools."
)
}()
checks.append(subscriptionCheck)
if !config.platformToolsets.isEmpty {
let platforms = config.platformToolsets.keys.sorted()
for platform in platforms {
let toolsets = config.platformToolsets[platform] ?? []
checks.append(HealthCheck(
label: "\(platform): \(toolsets.count) toolset\(toolsets.count == 1 ? "" : "s")",
status: .ok,
detail: toolsets.joined(separator: ", ")
))
}
}
let auxOnNous = [
("vision", config.auxiliary.vision.provider),
("web_extract", config.auxiliary.webExtract.provider),
("compression", config.auxiliary.compression.provider),
("session_search", config.auxiliary.sessionSearch.provider),
("skills_hub", config.auxiliary.skillsHub.provider),
("approval", config.auxiliary.approval.provider),
("mcp", config.auxiliary.mcp.provider),
("flush_memories", config.auxiliary.flushMemories.provider),
].filter { $0.1 == "nous" }.map(\.0)
if !auxOnNous.isEmpty {
checks.append(HealthCheck(
label: "Auxiliary tasks routed through Nous",
status: subscription.subscribed ? .ok : .warning,
detail: auxOnNous.joined(separator: ", ")
))
}
return HealthSection(
title: "Tool Gateway",
icon: "arrow.triangle.branch",
checks: checks
)
}
func refreshProcessStatus() { func refreshProcessStatus() {
let svc = fileService let svc = fileService
Task.detached { [weak self] in Task.detached { [weak self] in
@@ -368,4 +479,191 @@ final class HealthViewModel {
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
context.runHermes(arguments) context.runHermes(arguments)
} }
// MARK: - Web Dashboard (`hermes dashboard`)
/// Called from `HealthView.onAppear`. Starts a background loop that
/// probes `http://127.0.0.1:<port>/api/status` every 3s and keeps
/// `dashboardStatus.running` in sync with reality whether we launched
/// the dashboard or the user did via terminal. No-op on remote contexts.
func startDashboardMonitoring() {
guard !context.isRemote else { return }
dashboardProbeTask?.cancel()
let port = dashboardStatus.port
dashboardProbeTask = Task { [weak self] in
while !Task.isCancelled {
let running = await Self.probeDashboard(port: port)
await MainActor.run { [weak self] in
guard let self else { return }
// Preserve `busy` so the button stays disabled during an
// in-flight start/stop; only toggle the `running` bit.
self.dashboardStatus = WebDashboardStatus(
running: running,
port: self.dashboardStatus.port,
busy: self.dashboardStatus.busy
)
// Reap our spawned process if it exited externally.
if !running, let p = self.dashboardProcess, !p.isRunning {
self.dashboardProcess = nil
}
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
}
func stopDashboardMonitoring() {
dashboardProbeTask?.cancel()
dashboardProbeTask = nil
}
/// Launch `hermes dashboard --no-open --port 9119` detached. We pass
/// `--no-open` so Hermes doesn't try to open its own browser tab Scarf
/// opens the URL after the probe confirms the server is listening, which
/// avoids the "Safari tab loads faster than uvicorn binds the port" race.
func launchDashboard() {
guard !context.isRemote else { return }
guard !dashboardStatus.running, !dashboardStatus.busy else { return }
guard let binary = fileService.hermesBinaryPath() else {
actionMessage = "hermes binary not found"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.actionMessage = nil
}
return
}
dashboardStatus = WebDashboardStatus(
running: dashboardStatus.running,
port: dashboardStatus.port,
busy: true
)
actionMessage = "Starting dashboard…"
let port = dashboardStatus.port
let proc = Process()
proc.executableURL = URL(fileURLWithPath: binary)
proc.arguments = ["dashboard", "--no-open", "--port", String(port)]
proc.environment = HermesFileService.enrichedEnvironment()
// Discard stdout/stderr we rely on the HTTP probe for liveness and
// don't want a growing pipe buffer to block the subprocess.
proc.standardOutput = FileHandle.nullDevice
proc.standardError = FileHandle.nullDevice
do {
try proc.run()
dashboardProcess = proc
Task { [weak self] in
// Give uvicorn up to ~6 seconds to bind the port, probing
// every 300ms. First 200 response opens the browser.
for _ in 0..<20 {
if await Self.probeDashboard(port: port) {
if let url = URL(string: "http://127.0.0.1:\(port)") {
await MainActor.run {
NSWorkspace.shared.open(url)
}
}
break
}
try? await Task.sleep(nanoseconds: 300_000_000)
}
await MainActor.run { [weak self] in
guard let self else { return }
self.dashboardStatus = WebDashboardStatus(
running: self.dashboardStatus.running,
port: self.dashboardStatus.port,
busy: false
)
self.actionMessage = nil
}
}
} catch {
Self.dashboardLogger.error("Failed to spawn hermes dashboard: \(error.localizedDescription, privacy: .public)")
dashboardProcess = nil
dashboardStatus = WebDashboardStatus(
running: dashboardStatus.running,
port: dashboardStatus.port,
busy: false
)
actionMessage = "Failed to start: \(error.localizedDescription)"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.actionMessage = nil
}
}
}
/// Stop the dashboard. If Scarf spawned it, send SIGTERM directly. If an
/// external instance is running, fall back to `pkill -f "hermes dashboard"`
/// so the Stop button works regardless of who launched it.
func stopDashboard() {
guard !context.isRemote else { return }
dashboardStatus = WebDashboardStatus(
running: dashboardStatus.running,
port: dashboardStatus.port,
busy: true
)
actionMessage = "Stopping dashboard…"
if let proc = dashboardProcess, proc.isRunning {
proc.terminate()
dashboardProcess = nil
} else {
// External instance best-effort pkill.
let kill = Process()
kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
kill.arguments = ["-f", "hermes dashboard"]
_ = try? kill.run()
kill.waitUntilExit()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
guard let self else { return }
Task {
let running = await Self.probeDashboard(port: self.dashboardStatus.port)
await MainActor.run {
self.dashboardStatus = WebDashboardStatus(
running: running,
port: self.dashboardStatus.port,
busy: false
)
self.actionMessage = nil
}
}
}
}
/// Open the dashboard in the default browser. Safe to call only when the
/// probe reports `running: true` UI gates the button on that.
func openDashboardInBrowser() {
guard let url = URL(string: "http://127.0.0.1:\(dashboardStatus.port)") else { return }
NSWorkspace.shared.open(url)
}
/// HEAD-shaped GET against `/api/status`. Returns true on any 2xx response.
/// `/api/status` is whitelisted in `_PUBLIC_API_PATHS` in Hermes's
/// `web_server.py` no token required, so a bare GET works.
///
/// `nonisolated` + `async` so the polling loop can call it without
/// bouncing through MainActor on every tick.
nonisolated private static func probeDashboard(port: Int) async -> Bool {
guard let url = URL(string: "http://127.0.0.1:\(port)/api/status") else { return false }
var request = URLRequest(url: url)
request.timeoutInterval = 0.5
request.httpMethod = "GET"
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 0.5
config.timeoutIntervalForResource = 1.0
let session = URLSession(configuration: config)
defer { session.invalidateAndCancel() }
do {
let (_, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse {
return (200..<300).contains(http.statusCode)
}
return false
} catch {
return false
}
}
nonisolated private static let dashboardLogger = Logger(subsystem: "com.scarf", category: "WebDashboard")
} }
@@ -54,7 +54,11 @@ struct HealthView: View {
label: "Running health checks…", label: "Running health checks…",
isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty
) )
.onAppear { viewModel.load() } .onAppear {
viewModel.load()
viewModel.startDashboardMonitoring()
}
.onDisappear { viewModel.stopDashboardMonitoring() }
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) { .confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
Button("Upload", role: .destructive) { Button("Upload", role: .destructive) {
viewModel.runDebugShare() viewModel.runDebugShare()
@@ -162,8 +166,55 @@ struct HealthView: View {
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 8) .padding(.vertical, 8)
if !viewModel.context.isRemote {
Divider()
webDashboardRow
} }
} }
}
/// Status + controls for `hermes dashboard` (the web UI introduced in
/// v0.10.x). Hidden for remote contexts the dashboard binds 127.0.0.1
/// and remote tunneling is deferred.
private var webDashboardRow: some View {
HStack(spacing: 16) {
HStack(spacing: 6) {
Image(systemName: "safari")
.foregroundStyle(viewModel.dashboardStatus.running ? .green : .secondary)
.font(.caption)
if viewModel.dashboardStatus.running {
Text("Web Dashboard on :\(viewModel.dashboardStatus.port)")
.font(.caption.bold())
} else {
Text("Web Dashboard")
.font(.caption.bold())
Text("not running")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
HStack(spacing: 8) {
if viewModel.dashboardStatus.running {
Button("Open in Browser") { viewModel.openDashboardInBrowser() }
Button("Stop") { viewModel.stopDashboard() }
.disabled(viewModel.dashboardStatus.busy)
} else {
Button("Launch Dashboard") { viewModel.launchDashboard() }
.disabled(viewModel.dashboardStatus.busy)
}
if viewModel.dashboardStatus.busy {
ProgressView().controlSize(.small)
}
}
.controlSize(.small)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
// MARK: - Grid // MARK: - Grid
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct MCPServerEditorView: View { struct MCPServerEditorView: View {
@State var viewModel: MCPServerEditorViewModel @State var viewModel: MCPServerEditorViewModel
@@ -0,0 +1,97 @@
import Foundation
import os
import ScarfCore
/// Drives the per-project Sessions tab introduced in v2.3. Pulls the
/// global session list from `HermesDataService`, filters by the
/// attribution sidecar, and exposes a minimal surface for the view:
/// the filtered sessions array, loading state, and a refresh entry
/// point that the view can call on appearance + on file-watcher
/// change.
@Observable
@MainActor
final class ProjectSessionsViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
private let dataService: HermesDataService
private let attribution: SessionAttributionService
private let project: ProjectEntry
init(context: ServerContext, project: ProjectEntry) {
self.dataService = HermesDataService(context: context)
self.attribution = SessionAttributionService(context: context)
self.project = project
}
/// Sessions attributed to the owning project, in the order
/// `HermesDataService.fetchSessions` returns them (newest first).
var sessions: [HermesSession] = []
/// True from `load()` start to its completion. The view renders
/// a ProgressView during the first fetch; afterwards, re-fetches
/// triggered by file-watcher changes happen silently.
var isLoading: Bool = false
/// Short diagnostic string for an empty list nil when sessions
/// are loaded and populated, otherwise explains the empty state
/// (no sessions ever created in this project, vs. no sessions
/// matched the project's attribution map).
var emptyStateHint: String?
/// Refresh the session list. Safe to call repeatedly; the data
/// service reconnects to state.db on demand and the attribution
/// service reads the sidecar afresh each call.
func load() async {
isLoading = true
defer { isLoading = false }
let attributed = attribution.sessionIDs(forProject: project.path)
if attributed.isEmpty {
sessions = []
emptyStateHint = "No chats have been started in this project yet. Click New Chat to begin."
return
}
// Open (or re-open for remote) the DB handle before querying.
// `HermesDataService` is an actor with a lazily-initialised
// SQLite pointer; every query method short-circuits to `[]`
// when `db == nil`. This VM constructs its own service
// instance (separate from ChatViewModel / InsightsVM /
// ActivityVM), so we have to open it ourselves. Same
// pattern used by those other VMs (`refresh()` rather than
// `open()` because refresh also re-pulls the remote-server
// snapshot on each call local is a cheap no-op).
_ = await dataService.refresh()
// Fetch a generous page; we filter client-side by attribution
// map membership. The 200 ceiling matches other feature VMs
// (ActivityViewModel, InsightsViewModel). HermesDataService
// is an actor so this crosses the isolation boundary the
// SQLite read happens off the MainActor. If a single project
// accumulates more than 200 attributed sessions, we'll need
// a paged query; roadmap item, not a v2.3 problem.
let all = await dataService.fetchSessions(limit: 200)
let filtered = all.filter { attributed.contains($0.id) }
sessions = filtered
if filtered.isEmpty {
// Attribution map has entries but none appear in the
// recent session fetch likely stale sidecar entries
// for sessions Hermes has since deleted. The view shows
// an informational empty state; pruning stale entries
// is a roadmap follow-up, not a blocker.
emptyStateHint = "This project has \(attributed.count) attributed session\(attributed.count == 1 ? "" : "s"), but none are in the recent history. They may have been deleted from Hermes."
} else {
emptyStateHint = nil
}
}
/// Release the underlying DB handle. Safe to call repeatedly; the
/// service re-opens on the next `load()`. Mirrors the pattern in
/// ActivityViewModel.swift:80 view calls this on `.onDisappear`
/// so file descriptors and the SQLite cache don't dangle once
/// the tab isn't visible.
func close() async {
await dataService.close()
}
}
@@ -0,0 +1,114 @@
import SwiftUI
import ScarfCore
/// Sheet for assigning a project to a folder in the sidebar. Folders
/// are implicit they exist because at least one project references
/// them via its `folder` field. The "create" action here just seeds
/// a new label the user types; it becomes real once any project is
/// assigned to it.
struct MoveToFolderSheet: View {
@Environment(\.dismiss) private var dismiss
let project: ProjectEntry
/// Existing folder labels in the registry, sorted. Computed by
/// the caller via `ProjectsViewModel.folders`.
let existingFolders: [String]
/// Called with the chosen folder. `nil` means "move back to top
/// level". Caller wires this through
/// `ProjectsViewModel.moveProject(_:toFolder:)`.
let onMove: (String?) -> Void
@State private var mode: Mode
@State private var newFolderName: String = ""
private enum Mode: Hashable {
case topLevel
case existing(String)
case new
}
init(
project: ProjectEntry,
existingFolders: [String],
onMove: @escaping (String?) -> Void
) {
self.project = project
self.existingFolders = existingFolders
self.onMove = onMove
// Start selection on the project's current folder if any,
// otherwise "Top Level". Feels right Move sheet should
// reflect where the project currently lives.
if let current = project.folder, existingFolders.contains(current) {
_mode = State(initialValue: .existing(current))
} else {
_mode = State(initialValue: .topLevel)
}
}
private var canMove: Bool {
switch mode {
case .topLevel, .existing:
return true
case .new:
return !newFolderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Move \"\(project.name)\" to folder").font(.headline)
Text("Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Picker("Destination", selection: $mode) {
Text("Top Level").tag(Mode.topLevel)
if !existingFolders.isEmpty {
Section {
ForEach(existingFolders, id: \.self) { folder in
Text(folder).tag(Mode.existing(folder))
}
}
}
Text("New folder…").tag(Mode.new)
}
.labelsHidden()
.pickerStyle(.inline)
if case .new = mode {
TextField("New folder name", text: $newFolderName)
.textFieldStyle(.roundedBorder)
.onSubmit {
if canMove { commit() }
}
}
HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Move") { commit() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(!canMove)
}
}
.padding()
.frame(minWidth: 420, minHeight: 320)
}
private func commit() {
switch mode {
case .topLevel:
onMove(nil)
case .existing(let folder):
onMove(folder)
case .new:
let trimmed = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onMove(trimmed)
}
dismiss()
}
}
@@ -0,0 +1,199 @@
import SwiftUI
import ScarfCore
/// Per-project Sessions tab (v2.3). Lives beside the Dashboard and
/// Site tabs in the project view; populated from the session
/// attribution sidecar maintained by ChatViewModel. A "New Chat"
/// button spawns a fresh ACP session at cwd = project.path and
/// routes the user into the Chat feature via AppCoordinator.
struct ProjectSessionsView: View {
let project: ProjectEntry
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(\.serverContext) private var serverContext
@State private var viewModel: ProjectSessionsViewModel?
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
}
// `idealHeight: 400` caps what this subtree reports as its
// ideal height. Without it, the inner List's row-materialised
// intrinsic height bubbles up through NavigationSplitView's
// detail slot and, under `.windowResizability(.contentMinSize)`,
// opens the window at a height that exceeds the screen on
// busy projects the Sessions tab header + "New Chat" button
// end up below the visible desktop edge. `maxHeight: .infinity`
// still lets the List fill any taller offered space, and
// `minHeight: 0` allows it to shrink. Mirrors the same pattern
// applied in RichChatView.
.frame(minHeight: 0, idealHeight: 400, maxHeight: .infinity)
.task(id: project.id) {
// Rebuild the VM when the project changes so stale state
// from a previously-selected project doesn't bleed
// through.
viewModel = ProjectSessionsViewModel(
context: serverContext,
project: project
)
await viewModel?.load()
}
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel?.load() }
}
.onDisappear {
// Release the SQLite handle so it doesn't dangle once
// the user leaves this tab. `load()` will re-open next
// time. Mirrors ActivityView's disappear cleanup.
Task { await viewModel?.close() }
}
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Sessions in this project")
.font(.headline)
Text("Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button {
// Route into the Chat feature with a cwd override.
// ChatView observes this via its onChange and starts
// a fresh session with projectPath = our project.
coordinator.pendingProjectChat = project.path
coordinator.selectedSection = .chat
} label: {
Label("New Chat", systemImage: "message.badge.filled.fill")
}
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Content
@ViewBuilder
private var content: some View {
if let vm = viewModel {
if vm.isLoading && vm.sessions.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if vm.sessions.isEmpty {
emptyState(hint: vm.emptyStateHint)
} else {
sessionList(vm.sessions)
}
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func emptyState(hint: String?) -> some View {
VStack(spacing: 10) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 36))
.foregroundStyle(.tertiary)
Text(hint ?? "No sessions yet.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func sessionList(_ sessions: [HermesSession]) -> some View {
List(sessions) { session in
ProjectSessionRow(session: session)
.contentShape(Rectangle())
.onTapGesture {
// Route into the Chat feature with this session
// as a resume target. Existing ChatView logic
// handles ACP reconnect.
coordinator.selectedSessionId = session.id
coordinator.selectedSection = .chat
}
}
.listStyle(.plain)
}
}
/// Single row in the per-project Sessions list. Intentionally small
/// and self-contained so it can evolve independently of the global
/// Sessions sidebar's row UI if the two visualisations diverge
/// (e.g. the project tab wants to hide the `source` badge that's
/// useful in the global list), they don't pull each other along.
private struct ProjectSessionRow: View {
let session: HermesSession
var body: some View {
HStack(spacing: 10) {
Image(systemName: iconForSource(session.source))
.foregroundStyle(.secondary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle)
.font(.callout)
.lineLimit(1)
HStack(spacing: 6) {
Text(session.id.prefix(12))
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
if let started = formattedStart {
Text("·")
.foregroundStyle(.tertiary)
Text(started)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
Spacer(minLength: 12)
VStack(alignment: .trailing, spacing: 2) {
Text("\(session.messageCount)")
.font(.caption.monospaced())
Text("msgs")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private var displayTitle: String {
if let t = session.title, !t.isEmpty { return t }
return "Untitled session"
}
private var formattedStart: String? {
// `startedAt` is `Date?` the DB column can be null for
// sessions in unusual states. Locale-aware short form keeps
// us consistent with Insights + Activity.
guard let date = session.startedAt else { return nil }
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
private func iconForSource(_ source: String) -> String {
switch source.lowercased() {
case "cli", "acp": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
default: return "message"
}
}
}
@@ -0,0 +1,275 @@
import SwiftUI
import ScarfCore
/// Sidebar view for the Projects feature. Renders the registry as:
/// - A search field at the top (F focus).
/// - Top-level (folder-less) projects.
/// - Collapsible DisclosureGroups, one per folder.
/// - An "Archived" DisclosureGroup at the bottom, hidden unless the
/// Show Archived toggle is on.
///
/// Selection is bound to `viewModel.selectedProject` so the
/// dashboard area stays in sync with clicks anywhere in the hierarchy.
/// Context-menu actions delegate back to the parent view via closures
/// so the sheets / confirmation dialogs stay co-located with the rest
/// of ProjectsView's state.
struct ProjectsSidebar: View {
@Bindable var viewModel: ProjectsViewModel
// Predicates hoisted from the parent avoid reaching down into
// service objects from this view.
let canConfigureProject: (ProjectEntry) -> Bool
let isTemplateInstalled: (ProjectEntry) -> Bool
// Context-menu + bottom-bar callbacks. Parent owns sheet state
// (install, uninstall, rename, move-to-folder, remove-from-list
// confirmation dialog) this view just routes user intent.
let onConfigure: (ProjectEntry) -> Void
let onUninstallTemplate: (ProjectEntry) -> Void
let onRemoveFromList: (ProjectEntry) -> Void
let onRename: (ProjectEntry) -> Void
let onMoveToFolder: (ProjectEntry) -> Void
let onAddProject: () -> Void
/// Per-view UI state filter text, show-archived toggle, and
/// which folders are expanded. Folder expansion defaults to all
/// open so a new user sees everything; they can collapse what
/// they don't want.
@State private var filterText: String = ""
@State private var showArchived: Bool = false
@State private var expandedFolders: Set<String> = []
@FocusState private var searchFocused: Bool
var body: some View {
VStack(spacing: 0) {
searchField
Divider()
list
Divider()
bottomBar
}
.onAppear {
// Start with every folder expanded on first render. If
// users collapse, that choice persists for the lifetime
// of the view instance (window open).
expandedFolders = Set(viewModel.folders)
}
.onChange(of: viewModel.folders) { _, newFolders in
// When a new folder appears (user just moved a project
// into one), start it expanded so the move is visibly
// reflected.
expandedFolders.formUnion(newFolders)
}
}
// MARK: - Search
private var searchField: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
.font(.caption)
TextField("Filter projects", text: $filterText)
.textFieldStyle(.plain)
.focused($searchFocused)
.font(.caption)
if !filterText.isEmpty {
Button {
filterText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
.font(.caption)
}
.buttonStyle(.borderless)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
// MARK: - List
private var list: some View {
List(selection: Binding(
get: { viewModel.selectedProject },
set: { if let p = $0 { viewModel.selectProject(p) } }
)) {
// Top-level projects first matches the Finder-like
// mental model where top-level items sit above folders.
ForEach(topLevelVisible) { project in
projectRow(project)
}
// Per-folder collapsible sections.
ForEach(visibleFolders, id: \.self) { folder in
let children = folderProjects(folder)
if !children.isEmpty {
DisclosureGroup(
isExpanded: Binding(
get: { expandedFolders.contains(folder) },
set: { expanded in
if expanded {
expandedFolders.insert(folder)
} else {
expandedFolders.remove(folder)
}
}
)
) {
ForEach(children) { project in
projectRow(project)
}
} label: {
Label(folder, systemImage: "folder")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// Archived section only surfaces under the toggle.
if showArchived, !archivedVisible.isEmpty {
DisclosureGroup {
ForEach(archivedVisible) { project in
projectRow(project)
.opacity(0.7)
}
} label: {
Label("Archived (\(archivedVisible.count))", systemImage: "archivebox")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.listStyle(.sidebar)
}
@ViewBuilder
private func projectRow(_ project: ProjectEntry) -> some View {
HStack {
Image(
systemName: viewModel.dashboard != nil
&& viewModel.selectedProject == project
? "square.grid.2x2.fill"
: "square.grid.2x2"
)
.foregroundStyle(.secondary)
Text(project.name)
.lineLimit(1)
.truncationMode(.tail)
}
.tag(project)
.contextMenu {
projectContextMenu(project)
}
}
@ViewBuilder
private func projectContextMenu(_ project: ProjectEntry) -> some View {
if canConfigureProject(project) {
Button("Configuration…", systemImage: "slider.horizontal.3") {
onConfigure(project)
}
Divider()
}
Button("Rename…", systemImage: "pencil") { onRename(project) }
Button("Move to Folder…", systemImage: "folder") { onMoveToFolder(project) }
if project.archived {
Button("Unarchive", systemImage: "tray.and.arrow.up") {
viewModel.unarchiveProject(project)
}
} else {
Button("Archive", systemImage: "archivebox") {
viewModel.archiveProject(project)
}
}
Divider()
if isTemplateInstalled(project) {
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
onUninstallTemplate(project)
}
Divider()
}
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
onRemoveFromList(project)
}
}
// MARK: - Bottom bar
private var bottomBar: some View {
HStack {
Button(action: onAddProject) {
Image(systemName: "plus")
}
.buttonStyle(.borderless)
.help("Add a project")
Toggle(isOn: $showArchived) {
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
.font(.caption)
}
.toggleStyle(.button)
.buttonStyle(.borderless)
.help(showArchived ? "Hide archived projects" : "Show archived projects")
Spacer()
if let selected = viewModel.selectedProject {
Button(action: { onRemoveFromList(selected) }) {
Image(systemName: "minus")
}
.buttonStyle(.borderless)
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
}
}
.padding(8)
}
// MARK: - Derived data
/// Fuzzy-match on name + path + folder label. Case-insensitive,
/// substring not a true fuzzy search, but matches the project
/// count scale (tens, not thousands). Upgradable to a Levenshtein
/// scorer later without changing the call sites.
private func matches(_ project: ProjectEntry) -> Bool {
let needle = filterText
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !needle.isEmpty else { return true }
if project.name.lowercased().contains(needle) { return true }
if project.path.lowercased().contains(needle) { return true }
if let folder = project.folder, folder.lowercased().contains(needle) { return true }
return false
}
/// Visible top-level projects (no folder, not archived, passes
/// the current filter). Sort is stable by name the registry
/// already preserves insertion order, but showing a sorted list
/// of homogeneous top-level entries feels cleaner.
private var topLevelVisible: [ProjectEntry] {
viewModel.projects
.filter { ($0.folder ?? "").isEmpty && !$0.archived && matches($0) }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
/// Folders that currently have at least one matching, non-
/// archived project. Folders with only archived projects move
/// into the Archived section's items; empty folders disappear.
private var visibleFolders: [String] {
viewModel.folders.filter { !folderProjects($0).isEmpty }
}
private func folderProjects(_ folder: String) -> [ProjectEntry] {
viewModel.projects
.filter { $0.folder == folder && !$0.archived && matches($0) }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
private var archivedVisible: [ProjectEntry] {
viewModel.projects
.filter { $0.archived && matches($0) }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
}
@@ -5,11 +5,21 @@ import UniformTypeIdentifiers
private enum DashboardTab: String, CaseIterable { private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard" case dashboard = "Dashboard"
case site = "Site" case site = "Site"
case sessions = "Sessions"
var displayName: LocalizedStringResource { var displayName: LocalizedStringResource {
switch self { switch self {
case .dashboard: return "Dashboard" case .dashboard: return "Dashboard"
case .site: return "Site" case .site: return "Site"
case .sessions: return "Sessions"
}
}
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .site: return "globe"
case .sessions: return "bubble.left.and.bubble.right"
} }
} }
} }
@@ -35,6 +45,16 @@ struct ProjectsView: View {
/// drop from the registry. /// drop from the registry.
@State private var pendingRemoveFromList: ProjectEntry? @State private var pendingRemoveFromList: ProjectEntry?
/// Project queued for the rename sheet (v2.3). Sheet state lives
/// on the parent view so the sidebar stays a pure presentation
/// layer; rename logic routes through `ProjectsViewModel.renameProject`.
@State private var renameTarget: ProjectEntry?
/// Project queued for the move-to-folder sheet (v2.3). Same
/// pattern as renameTarget: parent owns sheet state, sidebar
/// delegates up.
@State private var moveTarget: ProjectEntry?
private let uninstaller: ProjectTemplateUninstaller private let uninstaller: ProjectTemplateUninstaller
init(context: ServerContext) { init(context: ServerContext) {
@@ -264,79 +284,47 @@ struct ProjectsView: View {
// MARK: - Project List // MARK: - Project List
private var projectList: some View { private var projectList: some View {
VStack(spacing: 0) { // Sidebar is an extracted view; this view stays the owner of
List(viewModel.projects, selection: Binding( // sheet state (add / rename / move / uninstall / remove-from-
get: { viewModel.selectedProject }, // list confirmation) and routes intents down as closures.
set: { project in ProjectsSidebar(
if let project { viewModel: viewModel,
viewModel.selectProject(project) canConfigureProject: { isConfigurable($0) },
} isTemplateInstalled: { uninstaller.isTemplateInstalled(project: $0) },
} onConfigure: { configEditorProject = $0 },
)) { project in onUninstallTemplate: { project in
HStack {
Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project
? "square.grid.2x2.fill" : "square.grid.2x2")
.foregroundStyle(.secondary)
Text(project.name)
}
.tag(project)
.contextMenu {
if isConfigurable(project) {
Button("Configuration…", systemImage: "slider.horizontal.3") {
configEditorProject = project
}
}
if uninstaller.isTemplateInstalled(project: project) {
// "Uninstall Template" only appears for projects
// installed from a `.scarftemplate`. Trailing
// ellipsis signals a confirmation sheet follows
// (macOS HIG convention); the sheet itself lists
// every file/cron/skill that will be removed.
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
uninstallerViewModel.begin(project: project) uninstallerViewModel.begin(project: project)
showingUninstallSheet = true showingUninstallSheet = true
} },
Divider() onRemoveFromList: { pendingRemoveFromList = $0 },
} onRename: { renameTarget = $0 },
// "Remove from List" used to be "Remove from Scarf", onMoveToFolder: { moveTarget = $0 },
// which users read as a full delete. Clarified label + onAddProject: { showingAddSheet = true }
// ellipsis + confirmation dialog all spell out that )
// this is registry-only; nothing on disk is touched.
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
pendingRemoveFromList = project
}
}
}
.listStyle(.sidebar)
Divider()
HStack {
Button(action: { showingAddSheet = true }) {
Image(systemName: "plus")
}
.buttonStyle(.borderless)
Spacer()
if let selected = viewModel.selectedProject {
// Route through the same confirmation dialog as the
// context-menu "Remove from List" entry. The minus
// icon is a drive-by click target right next to "+"
// confirming before mutating the registry stops the
// "I clicked by accident and my project's gone" case.
Button(action: { pendingRemoveFromList = selected }) {
Image(systemName: "minus")
}
.buttonStyle(.borderless)
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
}
}
.padding(8)
}
.sheet(isPresented: $showingAddSheet) { .sheet(isPresented: $showingAddSheet) {
AddProjectSheet { name, path in AddProjectSheet { name, path in
viewModel.addProject(name: name, path: path) viewModel.addProject(name: name, path: path)
fileWatcher.updateProjectWatches(viewModel.dashboardPaths) fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
} }
} }
.sheet(item: $renameTarget) { target in
RenameProjectSheet(
project: target,
existingNames: viewModel.projects
.filter { $0.name != target.name }
.map(\.name)
) { newName in
viewModel.renameProject(target, to: newName)
}
}
.sheet(item: $moveTarget) { target in
MoveToFolderSheet(
project: target,
existingFolders: viewModel.folders
) { newFolder in
viewModel.moveProject(target, toFolder: newFolder)
}
}
} }
// MARK: - Dashboard Area // MARK: - Dashboard Area
@@ -356,11 +344,13 @@ struct ProjectsView: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.top) .padding(.top)
.padding(.bottom, 8) .padding(.bottom, 8)
if siteWidget != nil { // Sessions tab is always present in v2.3, so the tab
// bar always renders when a dashboard is loaded.
// Site tab filters out when there's no webview widget
// (existing v2.2 behavior preserved).
tabBar tabBar
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 8) .padding(.bottom, 8)
}
switch selectedTab { switch selectedTab {
case .dashboard: case .dashboard:
widgetsTab(dashboard) widgetsTab(dashboard)
@@ -370,8 +360,24 @@ struct ProjectsView: View {
} else { } else {
widgetsTab(dashboard) widgetsTab(dashboard)
} }
case .sessions:
if let project = viewModel.selectedProject {
ProjectSessionsView(project: project)
} else {
ContentUnavailableView("No project selected", systemImage: "bubble.left.and.bubble.right")
} }
} }
}
// Clamp the container VStack to the detail column's
// offered space. Without it, any tab whose content is
// taller than the window (long Sessions list, tall
// README block in a dashboard's text widget, etc.) can
// bubble its intrinsic height up through
// NavigationSplitView's detail slot and push the whole
// window past the screen. widgetsTab's own ScrollView
// and siteTab's explicit maxHeight both cooperate; the
// sessions tab needs this as well.
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.dashboardError { } else if let error = viewModel.dashboardError {
ContentUnavailableView { ContentUnavailableView {
Label("No Dashboard", systemImage: "square.grid.2x2") Label("No Dashboard", systemImage: "square.grid.2x2")
@@ -395,14 +401,23 @@ struct ProjectsView: View {
} }
} }
/// Tabs that should appear for the current project. `.site` is
/// gated on the dashboard actually containing a webview widget,
/// per v2.2 behavior the Site tab is meaningless without one.
private var visibleTabs: [DashboardTab] {
DashboardTab.allCases.filter { tab in
tab != .site || siteWidget != nil
}
}
private var tabBar: some View { private var tabBar: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(DashboardTab.allCases, id: \.self) { tab in ForEach(visibleTabs, id: \.self) { tab in
Button { Button {
selectedTab = tab selectedTab = tab
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe") Image(systemName: tab.systemImage)
.font(.caption) .font(.caption)
Text(tab.displayName) Text(tab.displayName)
.font(.subheadline) .font(.subheadline)
@@ -0,0 +1,88 @@
import SwiftUI
import ScarfCore
/// Sheet for renaming a project in the registry. Preserves the
/// project's `path`, `folder`, and `archived` fields the rename
/// only changes the user-visible name (and therefore the Identifiable
/// id). Duplicate-name / empty-name rejection lives in the VM.
struct RenameProjectSheet: View {
@Environment(\.dismiss) private var dismiss
let project: ProjectEntry
/// Current set of project names in the registry, used to flag
/// duplicates before the user tries to Save. Excludes the
/// project being renamed so same-name is a no-op (accepted).
let existingNames: [String]
/// Called with the trimmed new name. Caller is responsible for
/// calling `ProjectsViewModel.renameProject(_:to:)`; this sheet
/// just gathers input + validates inline.
let onSave: (String) -> Void
@State private var newName: String
init(
project: ProjectEntry,
existingNames: [String],
onSave: @escaping (String) -> Void
) {
self.project = project
self.existingNames = existingNames
self.onSave = onSave
_newName = State(initialValue: project.name)
}
/// Validation for the live input. Empty / whitespace-only / a
/// collision with another project's name all disable Save.
private var validation: (isValid: Bool, message: String?) {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return (false, nil) // no error message just disabled
}
if trimmed != project.name && existingNames.contains(trimmed) {
return (false, String(localized: "A project named \"\(trimmed)\" already exists."))
}
return (true, nil)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Rename project").font(.headline)
Text("The project directory on disk isn't changed — only the label Scarf shows in the sidebar.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
TextField("Project name", text: $newName)
.textFieldStyle(.roundedBorder)
.onSubmit {
if validation.isValid {
save()
}
}
if let message = validation.message {
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
}
HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Save") { save() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(!validation.isValid)
}
}
.padding()
.frame(minWidth: 420)
}
private func save() {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(trimmed)
dismiss()
}
}
@@ -4,6 +4,12 @@ import ScarfCore
/// Two-column model browser sheet. Left column lists providers, right column /// Two-column model browser sheet. Left column lists providers, right column
/// lists models for the selected provider. Supports filtering and a "Custom" /// lists models for the selected provider. Supports filtering and a "Custom"
/// option for free-form model IDs not in the catalog. /// option for free-form model IDs not in the catalog.
///
/// Overlay-only providers (Nous Portal, OpenAI Codex, Qwen OAuth, ) have no
/// models.dev catalog entry, so their right column renders an overlay detail
/// view: subscription state for Nous, plus a free-form model-ID field for
/// users who know what they want. This is how the picker keeps parity with
/// `hermes model` on the CLI, which can reach these providers natively.
struct ModelPickerSheet: View { struct ModelPickerSheet: View {
let initialProvider: String let initialProvider: String
let initialModel: String let initialModel: String
@@ -22,8 +28,21 @@ struct ModelPickerSheet: View {
@State private var customModelID: String = "" @State private var customModelID: String = ""
@State private var customProviderID: String = "" @State private var customProviderID: String = ""
// Overlay-provider model entry distinct from `customMode` because the
// provider is pinned; only the model ID is user-editable.
@State private var overlayModelID: String = ""
// Subscription state for the Nous Portal row / detail view. Loaded on
// appear; stays in-memory for the life of the sheet.
@State private var subscription: NousSubscriptionState = .absent
/// Drives presentation of the Nous sign-in sheet. Bound to the
/// "Sign in to Nous Portal" button in the subscription summary.
@State private var showNousSignIn: Bool = false
@Environment(\.serverContext) private var serverContext @Environment(\.serverContext) private var serverContext
private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) } private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) }
private var subscriptionService: NousSubscriptionService { NousSubscriptionService(context: serverContext) }
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -45,8 +64,18 @@ struct ModelPickerSheet: View {
providers = catalog.loadProviders() providers = catalog.loadProviders()
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
selectedModelID = initialModel selectedModelID = initialModel
overlayModelID = initialModel
subscription = subscriptionService.loadState()
loadModelsForSelection() loadModelsForSelection()
} }
.sheet(isPresented: $showNousSignIn) {
NousSignInSheet {
// Refresh subscription immediately so the right-column
// status row flips to "active" without waiting for the
// picker to be re-opened.
subscription = subscriptionService.loadState()
}
}
} }
private var header: some View { private var header: some View {
@@ -81,20 +110,39 @@ struct ModelPickerSheet: View {
} }
)) { )) {
ForEach(filteredProviders) { provider in ForEach(filteredProviders) { provider in
HStack { providerRow(provider)
Text(provider.providerName)
Spacer()
Text("\(provider.modelCount)")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
.tag(provider.providerID) .tag(provider.providerID)
} }
} }
.listStyle(.inset) .listStyle(.inset)
} }
@ViewBuilder
private func providerRow(_ provider: HermesProviderInfo) -> some View {
HStack(spacing: 6) {
Text(provider.providerName)
if provider.subscriptionGated {
capsuleTag("Subscription", tint: .accentColor)
}
Spacer()
if !provider.isOverlay {
Text("\(provider.modelCount)")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var modelColumn: some View { private var modelColumn: some View {
if let selected = providers.first(where: { $0.providerID == selectedProviderID }), selected.isOverlay {
overlayProviderDetail(selected)
} else {
cachedModelList
}
}
private var cachedModelList: some View {
List(selection: $selectedModelID) { List(selection: $selectedModelID) {
ForEach(filteredModels) { model in ForEach(filteredModels) { model in
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -139,6 +187,114 @@ struct ModelPickerSheet: View {
} }
} }
/// Right-column detail for overlay-only providers (Nous Portal, OpenAI
/// Codex, Qwen OAuth, ). models.dev has no catalog for them, so the user
/// either trusts Hermes's default (subscription providers) or types a
/// model ID they know is valid for the provider's API.
@ViewBuilder
private func overlayProviderDetail(_ provider: HermesProviderInfo) -> some View {
let overlay = catalog.overlayMetadata(for: provider.providerID)
ScrollView {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(provider.providerName).font(.title3.bold())
if provider.subscriptionGated {
capsuleTag("Subscription", tint: .accentColor)
}
}
if provider.subscriptionGated {
subscriptionSummary(provider: provider, overlay: overlay)
} else {
Text(overlayInstruction(for: overlay?.authType))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Divider()
VStack(alignment: .leading, spacing: 4) {
Text("Model ID").font(.caption).foregroundStyle(.secondary)
TextField(modelIDPlaceholder(for: provider), text: $overlayModelID)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
if provider.subscriptionGated {
Text("Leave blank to use Hermes's default Nous model.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if let docURL = overlay?.docURL, let url = URL(string: docURL) {
Link(destination: url) {
Label("Setup documentation", systemImage: "book")
.font(.caption)
}
}
Spacer(minLength: 0)
}
.padding()
}
}
@ViewBuilder
private func subscriptionSummary(provider: HermesProviderInfo, overlay: HermesProviderOverlay?) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) {
Image(systemName: subscription.subscribed ? "checkmark.circle.fill" : "exclamationmark.circle")
.foregroundStyle(subscription.subscribed ? Color.green : Color.secondary)
if subscription.subscribed {
Text("Subscription active — active provider is Nous.")
} else if subscription.present {
Text("Signed in to Nous, but another provider is active.")
.foregroundStyle(.secondary)
} else {
Text("Not signed in yet.")
.foregroundStyle(.secondary)
}
}
.font(.callout)
if !subscription.subscribed {
Button {
showNousSignIn = true
} label: {
Label("Sign in to Nous Portal", systemImage: "person.badge.key.fill")
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
}
}
}
private func overlayInstruction(for authType: HermesProviderOverlay.AuthType?) -> String {
switch authType {
case .oauthExternal:
return "Sign in through the provider's OAuth flow — run `hermes auth` from a terminal, then pick the provider to complete sign-in. Back here, set the model ID you want to use."
case .externalProcess:
return "Uses an external process (e.g. a local agent bridge). Run `hermes auth` from a terminal to complete the link, then set the model ID you want to use."
case .oauthDeviceCode:
return "Sign in via device-code flow — run `hermes auth` from a terminal and follow the printed URL."
default:
return "This provider isn't in the models.dev catalog. Enter the model ID you want to use — Hermes will pass it through to the provider verbatim."
}
}
private func modelIDPlaceholder(for provider: HermesProviderInfo) -> String {
switch provider.providerID {
case "nous": return "e.g. hermes-3"
case "openai-codex": return "e.g. gpt-5-codex"
case "qwen-oauth": return "e.g. qwen3-coder-plus"
default: return "e.g. model-name"
}
}
private var customEntry: some View { private var customEntry: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".") Text("Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".")
@@ -202,14 +358,35 @@ struct ModelPickerSheet: View {
} }
} }
private var isSelectedProviderOverlay: Bool {
providers.first(where: { $0.providerID == selectedProviderID })?.isOverlay ?? false
}
private var isSelectedProviderSubscriptionGated: Bool {
providers.first(where: { $0.providerID == selectedProviderID })?.subscriptionGated ?? false
}
private var canSubmit: Bool { private var canSubmit: Bool {
if customMode { if customMode {
return !customModelID.trimmingCharacters(in: .whitespaces).isEmpty return !customModelID.trimmingCharacters(in: .whitespaces).isEmpty
} }
if isSelectedProviderOverlay {
// Subscription-gated providers can submit with an empty model ID
// (Hermes picks its default). Other overlays require a model ID.
if isSelectedProviderSubscriptionGated { return true }
return !overlayModelID.trimmingCharacters(in: .whitespaces).isEmpty
}
return !selectedModelID.isEmpty return !selectedModelID.isEmpty
} }
private var selectedPreview: String? { private var selectedPreview: String? {
if isSelectedProviderOverlay {
let trimmed = overlayModelID.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
return selectedProviderID.isEmpty ? nil : "\(selectedProviderID) / (default)"
}
return "\(selectedProviderID) / \(trimmed)"
}
guard !selectedModelID.isEmpty, !selectedProviderID.isEmpty else { return nil } guard !selectedModelID.isEmpty, !selectedProviderID.isEmpty else { return nil }
return "\(selectedProviderID) / \(selectedModelID)" return "\(selectedProviderID) / \(selectedModelID)"
} }
@@ -250,18 +427,21 @@ struct ModelPickerSheet: View {
let model = customModelID.trimmingCharacters(in: .whitespaces) let model = customModelID.trimmingCharacters(in: .whitespaces)
let provider = resolvedCustomProvider() let provider = resolvedCustomProvider()
onSelect(model, provider) onSelect(model, provider)
} else if isSelectedProviderOverlay {
let model = overlayModelID.trimmingCharacters(in: .whitespaces)
onSelect(model, selectedProviderID)
} else { } else {
onSelect(selectedModelID, selectedProviderID) onSelect(selectedModelID, selectedProviderID)
} }
} }
private func capsuleTag(_ text: String) -> some View { private func capsuleTag(_ text: String, tint: Color = .secondary) -> some View {
Text(text) Text(text)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(tint == .secondary ? AnyShapeStyle(.secondary) : AnyShapeStyle(tint))
.padding(.horizontal, 5) .padding(.horizontal, 5)
.padding(.vertical, 1) .padding(.vertical, 1)
.background(.quaternary) .background(tint == .secondary ? AnyShapeStyle(.quaternary) : AnyShapeStyle(tint.opacity(0.15)))
.clipShape(Capsule()) .clipShape(Capsule())
} }
} }
@@ -0,0 +1,237 @@
import SwiftUI
import AppKit
/// In-app sign-in sheet for Nous Portal hosts a ``NousAuthFlow`` and
/// renders one of four sub-views keyed on `flow.state`. Reached from the
/// model picker's Nous Portal row, the Auxiliary tab's per-task toggle,
/// and Credential Pools when the selected provider is `nous`.
///
/// UX contract with the caller:
///
/// - Sheet is presented via `.sheet(isPresented:)` from the caller.
/// - Parent owns the `isPresented` binding and a `@State var` for the
/// dismiss trigger.
/// - `onSignedIn` fires on success so the caller can refresh subscription
/// state (e.g. re-query ``NousSubscriptionService``) before the sheet
/// auto-dismisses ~1.2s later.
struct NousSignInSheet: View {
@Environment(\.serverContext) private var serverContext
@Environment(\.dismiss) private var dismiss
/// Fires on `.success`. Callers use this to refresh their cached
/// ``NousSubscriptionState`` so the new "Subscription active" chip
/// shows immediately without waiting for a full view reload.
var onSignedIn: () -> Void = {}
@State private var flow: NousAuthFlow?
@State private var successDismissTask: Task<Void, Never>?
var body: some View {
VStack(spacing: 16) {
header
Divider()
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(20)
.frame(minWidth: 440, idealWidth: 440, minHeight: 340)
.onAppear {
if flow == nil {
let f = NousAuthFlow(context: serverContext)
flow = f
f.start()
}
}
.onDisappear {
successDismissTask?.cancel()
flow?.cancel()
}
.onChange(of: flowState) { _, newValue in
if case .success = newValue {
onSignedIn()
successDismissTask?.cancel()
successDismissTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_200_000_000)
if !Task.isCancelled { dismiss() }
}
}
}
}
private var flowState: NousAuthFlow.State {
flow?.state ?? .idle
}
// MARK: - Header
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "person.badge.key.fill")
.foregroundStyle(.tint)
Text("Sign in to Nous Portal")
.font(.headline)
Spacer()
if case .waitingForApproval = flowState {
Button("Cancel") { dismiss() }
.controlSize(.small)
} else if case .starting = flowState {
Button("Cancel") { dismiss() }
.controlSize(.small)
} else {
Button("Close") { dismiss() }
.controlSize(.small)
}
}
}
// MARK: - State-keyed content
@ViewBuilder
private var content: some View {
switch flowState {
case .idle, .starting:
startingView
case .waitingForApproval(let code, let url):
waitingView(userCode: code, verificationURL: url)
case .success:
successView
case .failure(let reason, let billingURL):
failureView(reason: reason, billingURL: billingURL)
}
}
// MARK: - .starting
private var startingView: some View {
VStack(spacing: 12) {
ProgressView()
.controlSize(.large)
Text("Contacting Nous Portal…")
.font(.callout)
.foregroundStyle(.secondary)
Text("This may take a few seconds.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - .waitingForApproval
@ViewBuilder
private func waitingView(userCode: String, verificationURL: URL) -> some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text("Approve in your browser")
.font(.headline)
Text("We opened the Nous Portal approval page. Confirm this code matches what it shows, then approve.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
userCodeBadge(userCode)
HStack(spacing: 12) {
Button {
NSWorkspace.shared.open(verificationURL)
} label: {
Label("Open approval page again", systemImage: "safari")
}
.controlSize(.small)
Spacer()
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Waiting for approval…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
}
private func userCodeBadge(_ code: String) -> some View {
HStack(spacing: 10) {
Text(code)
.font(.system(size: 28, weight: .semibold, design: .monospaced))
.textSelection(.enabled)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
Button {
copyToPasteboard(code)
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
.controlSize(.small)
}
}
// MARK: - .success
private var successView: some View {
VStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.system(size: 48))
Text("Signed in to Nous Portal")
.font(.headline)
Text("Your tools will now route through your subscription.")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - .failure
@ViewBuilder
private func failureView(reason: String, billingURL: URL?) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(billingURL == nil ? "Sign-in didn't complete" : "Subscription required")
.font(.headline)
}
Text(reason)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
if let billingURL {
Button {
NSWorkspace.shared.open(billingURL)
} label: {
Label("Subscribe", systemImage: "creditcard")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
HStack(spacing: 10) {
Button("Try again") { flow?.start() }
.buttonStyle(.bordered)
Button("Copy error") {
let payload = (flow?.output.isEmpty == false) ? flow!.output : reason
copyToPasteboard(payload)
}
.buttonStyle(.bordered)
Spacer()
Button("Close") { dismiss() }
}
}
}
// MARK: - Helpers
private func copyToPasteboard(_ value: String) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(value, forType: .string)
}
}
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Advanced tab network, compression, checkpoints, logging, delegation, file read cap, /// Advanced tab network, compression, checkpoints, logging, delegation, file read cap,
/// cron wrap, config diagnostics, backup/restore, paths, raw config. /// cron wrap, config diagnostics, backup/restore, paths, raw config.
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Agent tab turns, reasoning effort, tool use enforcement, approvals, gateway timing, service tier. /// Agent tab turns, reasoning effort, tool use enforcement, approvals, gateway timing, service tier.
struct AgentTab: View { struct AgentTab: View {
@@ -16,7 +17,7 @@ struct AgentTab: View {
StepperRow(label: "Approval Timeout (s)", value: viewModel.config.approvalTimeout, range: 5...600, step: 5) { viewModel.setApprovalTimeout($0) } StepperRow(label: "Approval Timeout (s)", value: viewModel.config.approvalTimeout, range: 5...600, step: 5) { viewModel.setApprovalTimeout($0) }
} }
SettingsSection(title: "Gateway", icon: "antenna.radiowaves.left.and.right") { SettingsSection(title: "Messaging Gateway", icon: "antenna.radiowaves.left.and.right") {
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
viewModel.setServiceTier(on ? "fast" : "normal") viewModel.setServiceTier(on ? "fast" : "normal")
} }
@@ -3,9 +3,19 @@ import ScarfCore
/// Auxiliary tab the 8 sub-model tasks hermes delegates to cheaper models. /// Auxiliary tab the 8 sub-model tasks hermes delegates to cheaper models.
/// Each follows the same provider/model/base_url/api_key/timeout pattern. /// Each follows the same provider/model/base_url/api_key/timeout pattern.
///
/// Adds a per-task **Route through Nous Portal** toggle for Hermes v0.10.0+
/// subscribers. The toggle flips `auxiliary.<task>.provider` between `nous`
/// (subscription-routed) and `auto` (inherit main provider) Hermes derives
/// the gateway routing from that single field; there is no separate
/// `use_gateway` key to write.
struct AuxiliaryTab: View { struct AuxiliaryTab: View {
@Bindable var viewModel: SettingsViewModel @Bindable var viewModel: SettingsViewModel
@Environment(\.serverContext) private var serverContext
@State private var subscription: NousSubscriptionState = .absent
@State private var showNousSignIn: Bool = false
// Keyed by the config path name matches `auxiliary.<task>.*` in config.yaml. // Keyed by the config path name matches `auxiliary.<task>.*` in config.yaml.
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [ private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
("vision", "Vision", "eye"), ("vision", "Vision", "eye"),
@@ -29,11 +39,21 @@ struct AuxiliaryTab: View {
auxRows(for: task.key) auxRows(for: task.key)
} }
} }
Color.clear.frame(height: 0)
.onAppear {
subscription = NousSubscriptionService(context: serverContext).loadState()
}
.sheet(isPresented: $showNousSignIn) {
NousSignInSheet {
subscription = NousSubscriptionService(context: serverContext).loadState()
}
}
} }
@ViewBuilder @ViewBuilder
private func auxRows(for key: String) -> some View { private func auxRows(for key: String) -> some View {
let model = auxModel(for: key) let model = auxModel(for: key)
nousGatewayToggle(for: key, currentProvider: model.provider)
EditableTextField(label: "Provider", value: model.provider) { viewModel.setAuxiliary(key, field: "provider", value: $0) } EditableTextField(label: "Provider", value: model.provider) { viewModel.setAuxiliary(key, field: "provider", value: $0) }
EditableTextField(label: "Model", value: model.model) { viewModel.setAuxiliary(key, field: "model", value: $0) } EditableTextField(label: "Model", value: model.model) { viewModel.setAuxiliary(key, field: "model", value: $0) }
EditableTextField(label: "Base URL", value: model.baseURL) { viewModel.setAuxiliary(key, field: "base_url", value: $0) } EditableTextField(label: "Base URL", value: model.baseURL) { viewModel.setAuxiliary(key, field: "base_url", value: $0) }
@@ -41,6 +61,30 @@ struct AuxiliaryTab: View {
StepperRow(label: "Timeout (s)", value: model.timeout, range: 5...3600, step: 5) { viewModel.setAuxiliaryTimeout(key, value: $0) } StepperRow(label: "Timeout (s)", value: model.timeout, range: 5...3600, step: 5) { viewModel.setAuxiliaryTimeout(key, value: $0) }
} }
@ViewBuilder
private func nousGatewayToggle(for key: String, currentProvider: String) -> some View {
let isOn = (currentProvider == "nous")
ToggleRow(label: "Nous Portal", isOn: isOn) { wantsOn in
// "nous" enables subscription routing; "auto" reverts to the
// inherit-main-provider default. We never touch model/base/key
// fields here Hermes reuses them if the user switches back.
viewModel.setAuxiliary(key, field: "provider", value: wantsOn ? "nous" : "auto")
}
if !subscription.present && !isOn {
HStack(spacing: 8) {
Text("Requires an active Nous Portal subscription.")
.font(.caption2)
.foregroundStyle(.tertiary)
Button("Sign in first") { showNousSignIn = true }
.controlSize(.mini)
.buttonStyle(.borderedProminent)
Spacer()
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
}
private func auxModel(for key: String) -> AuxiliaryModel { private func auxModel(for key: String) -> AuxiliaryModel {
switch key { switch key {
case "vision": return viewModel.config.auxiliary.vision case "vision": return viewModel.config.auxiliary.vision
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Browser tab browser backend + automation timeouts + camofox. /// Browser tab browser backend + automation timeouts + camofox.
struct BrowserTab: View { struct BrowserTab: View {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Display tab streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc. /// Display tab streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
struct DisplayTab: View { struct DisplayTab: View {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// General tab model picker (provider auto-follows), personality, locale. /// General tab model picker (provider auto-follows), personality, locale.
/// Credential management lives in the Credential Pools sidebar item; a hint /// Credential management lives in the Credential Pools sidebar item; a hint
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Memory tab built-in memory settings + external provider picker. /// Memory tab built-in memory settings + external provider picker.
struct MemoryTab: View { struct MemoryTab: View {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Security tab redaction, command allowlist (read-only), Tirith sandbox, website blocklist, human delay. /// Security tab redaction, command allowlist (read-only), Tirith sandbox, website blocklist, human delay.
struct SecurityTab: View { struct SecurityTab: View {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Terminal tab backend plus docker/container options. /// Terminal tab backend plus docker/container options.
/// Heavy docker/container settings are hidden unless a container backend is selected. /// Heavy docker/container settings are hidden unless a container backend is selected.
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Voice tab push-to-talk + TTS + STT provider settings. /// Voice tab push-to-talk + TTS + STT provider settings.
struct VoiceTab: View { struct VoiceTab: View {
@@ -24,6 +24,20 @@ struct TemplateConfigSheet: View {
header header
Divider() Divider()
ScrollView { ScrollView {
// `.frame(maxWidth: .infinity, alignment: .leading)` is
// load-bearing: without it, SwiftUI resolves width
// bottom-up and an unbreakable token in a child (e.g. a
// raw URL inside a field description rendered via
// AttributedString markdown) sets the whole VStack's
// ideal width to that token's length. ScrollView's
// content then exceeds the sheet's viewport, the outer
// `.frame(minWidth: 560)` grows to content width, and
// the window clips the result with labels cut off on
// the left + URL spilling off the right. With the
// explicit maxWidth, the ScrollView's offered width
// propagates down and the description Text's
// `.fixedSize(horizontal: false, vertical: true)`
// wraps at whitespace boundaries as intended.
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
if viewModel.schema.fields.isEmpty { if viewModel.schema.fields.isEmpty {
ContentUnavailableView( ContentUnavailableView(
@@ -41,6 +55,7 @@ struct TemplateConfigSheet: View {
modelRecommendation(rec) modelRecommendation(rec)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20) .padding(20)
} }
Divider() Divider()
@@ -117,7 +132,11 @@ struct TemplateConfigSheet: View {
// Inline markdown so descriptions can include // Inline markdown so descriptions can include
// `[Create one](https://)`-style links to token // `[Create one](https://)`-style links to token
// generation pages, **bold** emphasis on important // generation pages, **bold** emphasis on important
// prerequisites, etc. // prerequisites, etc. Raw URLs (not wrapped in
// markdown link syntax) will still render but can't
// word-break mid-token keep the parent maxWidth
// constraint below so a rogue raw URL wraps cleanly
// instead of expanding the entire sheet.
TemplateMarkdown.inlineText(description) TemplateMarkdown.inlineText(description)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -130,6 +149,12 @@ struct TemplateConfigSheet: View {
.foregroundStyle(.red) .foregroundStyle(.red)
} }
} }
// maxWidth: .infinity forces this row to span the column's
// full width so its internal description Text wraps instead
// of expanding the outer VStack when a description contains
// a long unbreakable token (raw URL, path, etc.). See the
// comment on the parent ScrollView's inner VStack.
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
@@ -288,17 +313,17 @@ private struct EnumControl: View {
let options: [TemplateConfigField.EnumOption] let options: [TemplateConfigField.EnumOption]
@Binding var value: String @Binding var value: String
var body: some View { var body: some View {
// Segmented for 4 options, dropdown otherwise fits Scarf's // Always use the default Menu picker (dropdown). An earlier
// existing settings UI. // version switched to `.pickerStyle(.segmented)` when
if options.count <= 4 { // `options.count 4` for a more compact look, but on macOS
Picker("", selection: $value) { // segmented pickers size to the intrinsic width of all their
ForEach(options) { opt in // labels concatenated they refuse offered width constraints
Text(opt.label).tag(opt.value) // and refuse to wrap. A schema with three long labels like
} // "Claude Opus 4 (Recommended - Most Capable)" produced a
} // ~650pt picker that overflowed the 560pt sheet viewport,
.pickerStyle(.segmented) // clipping the entire form. Menu pickers respect the fieldRow's
.labelsHidden() // offered width and show long labels in the popup list, so the
} else { // sheet can't overflow regardless of label length.
Picker("", selection: $value) { Picker("", selection: $value) {
ForEach(options) { opt in ForEach(options) { opt in
Text(opt.label).tag(opt.value) Text(opt.label).tag(opt.value)
@@ -307,7 +332,6 @@ private struct EnumControl: View {
.labelsHidden() .labelsHidden()
} }
} }
}
/// Variable-length list of string values. Each row is a text field /// Variable-length list of string values. Each row is a text field
/// with an inline remove button; a + button adds a trailing row. /// with an inline remove button; a + button adds a trailing row.
@@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import ScarfCore
/// Author-facing sheet for exporting an existing project as a /// Author-facing sheet for exporting an existing project as a
/// `.scarftemplate`. Mirrors the profile-export flow: fill in a few fields, /// `.scarftemplate`. Mirrors the profile-export flow: fill in a few fields,
@@ -127,6 +127,16 @@ struct TemplateInstallSheet: View {
.padding(.bottom, 8) .padding(.bottom, 8)
Divider() Divider()
ScrollView { ScrollView {
// `.frame(maxWidth: .infinity, alignment: .leading)`
// without it, a subsection containing an unbreakable
// token (raw URL in a cron prompt or README block, a
// long file path in the project-files list, a schema
// description with a bare URL, etc.) sets the VStack's
// ideal width to that token's length; the sheet grows
// past its `.frame(minWidth: 620)` and gets clipped by
// the window. Same fix as `TemplateConfigSheet`'s
// inner VStack propagate the ScrollView's width down
// so inner Text wraps instead of expanding outward.
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
projectFilesSection(plan: plan) projectFilesSection(plan: plan)
if plan.skillsNamespaceDir != nil { if plan.skillsNamespaceDir != nil {
@@ -143,6 +153,7 @@ struct TemplateInstallSheet: View {
} }
readmeSection readmeSection
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical) .padding(.vertical)
} }
Divider() Divider()
+211
View File
@@ -889,6 +889,10 @@
}, },
"••••••••••" : { "••••••••••" : {
},
"`%@` uses a different sign-in flow." : {
"comment" : "A description of the sign-in flow for a given provider.",
"isCommentAutoGenerated" : true
}, },
"+ %lld more…" : { "+ %lld more…" : {
"comment" : "A button that shows the number of files that were left behind by the template uninstaller.", "comment" : "A button that shows the number of files that were left behind by the template uninstaller.",
@@ -1028,6 +1032,10 @@
"comment" : "A message that appears when a memory block is no longer present in MEMORY.md.", "comment" : "A message that appears when a memory block is no longer present in MEMORY.md.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"A project named \"%@\" already exists." : {
"comment" : "A warning message that appears in a Rename Project sheet if the user-provided name is a duplicate of an existing project. The argument is the name of the duplicate project.",
"isCommentAutoGenerated" : true
},
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts." : { "A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts." : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1391,6 +1399,10 @@
} }
} }
}, },
"Add a project" : {
"comment" : "A button that adds a new project.",
"isCommentAutoGenerated" : true
},
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets." : { "Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets." : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2480,6 +2492,9 @@
} }
} }
} }
},
"Approve in your browser" : {
}, },
"Archive" : { "Archive" : {
"localizations" : { "localizations" : {
@@ -2521,6 +2536,10 @@
} }
} }
}, },
"Archived (%lld)" : {
"comment" : "A label that opens a group of archived projects.",
"isCommentAutoGenerated" : true
},
"Args (one per line)" : { "Args (one per line)" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3745,6 +3764,14 @@
} }
} }
}, },
"Chat · %@" : {
"comment" : "A label that shows the name of the active Scarf project, followed by \"Chat\".",
"isCommentAutoGenerated" : true
},
"Chat is scoped to Scarf project \"%@\"" : {
"comment" : "Tooltip for the folder-chip indicator.",
"isCommentAutoGenerated" : true
},
"Chat Messages" : { "Chat Messages" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3785,6 +3812,10 @@
} }
} }
}, },
"Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar." : {
"comment" : "A description of the purpose of the Sessions tab.",
"isCommentAutoGenerated" : true
},
"Check" : { "Check" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -5246,6 +5277,9 @@
} }
} }
} }
},
"Contacting Nous Portal…" : {
}, },
"Container Limits" : { "Container Limits" : {
"localizations" : { "localizations" : {
@@ -5530,6 +5564,9 @@
} }
} }
} }
},
"Copy error" : {
}, },
"Copy error details" : { "Copy error details" : {
"localizations" : { "localizations" : {
@@ -6874,6 +6911,10 @@
}, },
"Description" : { "Description" : {
},
"Destination" : {
"comment" : "A label for the folder picker in the move-to-folder sheet.",
"isCommentAutoGenerated" : true
}, },
"Details" : { "Details" : {
"localizations" : { "localizations" : {
@@ -8802,6 +8843,10 @@
} }
} }
}, },
"Filter projects" : {
"comment" : "A label for a search field in the sidebar.",
"isCommentAutoGenerated" : true
},
"Filter servers..." : { "Filter servers..." : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -9006,6 +9051,10 @@
"comment" : "A placeholder for a comma-separated list of tags.", "comment" : "A placeholder for a comma-separated list of tags.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes." : {
"comment" : "A description of how folders affect project grouping.",
"isCommentAutoGenerated" : true
},
"Full copy of active profile (all state)" : { "Full copy of active profile (all state)" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -9047,6 +9096,7 @@
} }
}, },
"Gateway" : { "Gateway" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -9127,6 +9177,7 @@
} }
}, },
"Gateway Running" : { "Gateway Running" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -9167,6 +9218,7 @@
} }
}, },
"Gateway Stopped" : { "Gateway Stopped" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -9698,6 +9750,10 @@
} }
} }
}, },
"Hide archived projects" : {
"comment" : "A toggle that hides archived projects.",
"isCommentAutoGenerated" : true
},
"Hide details" : { "Hide details" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -10958,6 +11014,10 @@
} }
} }
}, },
"Leave blank to use Hermes's default Nous model." : {
"comment" : "A description of the default Nous model.",
"isCommentAutoGenerated" : true
},
"Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates." : { "Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates." : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -11902,6 +11962,18 @@
} }
} }
}, },
"Messaging Gateway" : {
"comment" : "The title of the messaging gateway view.",
"isCommentAutoGenerated" : true
},
"Messaging Gateway Running" : {
"comment" : "A label that indicates that the messaging gateway is running.",
"isCommentAutoGenerated" : true
},
"Messaging Gateway Stopped" : {
"comment" : "A label that describes the messaging gateway as stopped.",
"isCommentAutoGenerated" : true
},
"Metadata" : { "Metadata" : {
"comment" : "A heading for the metadata section of the template export sheet.", "comment" : "A heading for the metadata section of the template export sheet.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -12186,6 +12258,22 @@
} }
} }
}, },
"Move" : {
"comment" : "A button that moves a project to a folder.",
"isCommentAutoGenerated" : true
},
"Move \"%@\" to folder" : {
"comment" : "A heading for a dialog that lets the user move a project to a folder.",
"isCommentAutoGenerated" : true
},
"Move to Folder…" : {
"comment" : "A context menu action that moves a project to a folder.",
"isCommentAutoGenerated" : true
},
"msgs" : {
"comment" : "A label for the number of messages in a session.",
"isCommentAutoGenerated" : true
},
"my_server" : { "my_server" : {
}, },
@@ -12309,6 +12397,17 @@
} }
} }
}, },
"New Chat" : {
"comment" : "A button that starts a new chat session.",
"isCommentAutoGenerated" : true
},
"New folder name" : {
},
"New folder…" : {
"comment" : "A label for a new folder name.",
"isCommentAutoGenerated" : true
},
"New name for '%@'" : { "New name for '%@'" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -13327,6 +13426,10 @@
} }
} }
}, },
"No project selected" : {
"comment" : "A label that indicates that no project is selected.",
"isCommentAutoGenerated" : true
},
"No Projects" : { "No Projects" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -13734,6 +13837,10 @@
} }
} }
}, },
"Not signed in yet." : {
"comment" : "A description of a model picker sheet's subscription",
"isCommentAutoGenerated" : true
},
"Notable Sessions" : { "Notable Sessions" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -13774,6 +13881,10 @@
} }
} }
}, },
"Nous Portal uses a dedicated sign-in flow." : {
"comment" : "A description of the process of using the Nous Portal.",
"isCommentAutoGenerated" : true
},
"npx" : { "npx" : {
}, },
@@ -13867,6 +13978,10 @@
"comment" : "A placeholder for a template's description.", "comment" : "A placeholder for a template's description.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Open approval page again" : {
"comment" : "A button that opens a web page.",
"isCommentAutoGenerated" : true
},
"Open BotFather" : { "Open BotFather" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -14559,6 +14674,10 @@
"comment" : "A label for the template's owner and name.", "comment" : "A label for the template's owner and name.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed." : {
"comment" : "A description of the benefits of using a Nous",
"isCommentAutoGenerated" : true
},
"Pair Device" : { "Pair Device" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -15458,6 +15577,10 @@
}, },
"Project folder kept" : { "Project folder kept" : {
},
"Project name" : {
"comment" : "A label for a text field that lets the user enter a project name.",
"isCommentAutoGenerated" : true
}, },
"Project Name" : { "Project Name" : {
"localizations" : { "localizations" : {
@@ -16870,6 +16993,10 @@
} }
} }
}, },
"Rename project" : {
"comment" : "A title for a sheet that renames a project.",
"isCommentAutoGenerated" : true
},
"Rename Session" : { "Rename Session" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -16949,6 +17076,9 @@
} }
} }
} }
},
"Rename…" : {
}, },
"required" : { "required" : {
@@ -17036,6 +17166,10 @@
} }
} }
}, },
"Requires an active Nous Portal subscription." : {
"comment" : "A message that appears when the Nous Portal subscription is not active.",
"isCommentAutoGenerated" : true
},
"Requires: %@" : { "Requires: %@" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -17716,6 +17850,10 @@
} }
} }
}, },
"Run `hermes auth add %@` in a terminal to finish sign-in. In-app support for this provider is coming in a follow-up." : {
"comment" : "A description of the CLI fallback for a given provider.",
"isCommentAutoGenerated" : true
},
"Run `hermes memory setup` in Terminal for full provider configuration." : { "Run `hermes memory setup` in Terminal for full provider configuration." : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -19379,6 +19517,10 @@
} }
} }
}, },
"Sessions in this project" : {
"comment" : "A heading for the list of sessions in a project.",
"isCommentAutoGenerated" : true
},
"Set as default — open this server when Scarf launches." : { "Set as default — open this server when Scarf launches." : {
"comment" : "A tooltip for the star button in the Manage Servers view.", "comment" : "A tooltip for the star button in the Manage Servers view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -19463,6 +19605,10 @@
} }
} }
}, },
"Setup documentation" : {
"comment" : "A link to a documentation page for setting up a",
"isCommentAutoGenerated" : true
},
"Share Debug Report…" : { "Share Debug Report…" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -19623,6 +19769,10 @@
} }
} }
}, },
"Show archived projects" : {
"comment" : "A toggle that shows/hides archived projects.",
"isCommentAutoGenerated" : true
},
"Show details" : { "Show details" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -19790,6 +19940,17 @@
"comment" : "A hint for the user on how to show/hide the secret.", "comment" : "A hint for the user on how to show/hide the secret.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Sign in first" : {
"comment" : "A button that opens a sheet for signing in to a Nous Portal subscription.",
"isCommentAutoGenerated" : true
},
"Sign in to Nous Portal" : {
},
"Sign-in didn't complete" : {
"comment" : "A title for a failed sign-in attempt.",
"isCommentAutoGenerated" : true
},
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : { "Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -19915,6 +20076,13 @@
}, },
"signal-cli Terminal" : { "signal-cli Terminal" : {
},
"Signed in to Nous Portal" : {
},
"Signed in to Nous, but another provider is active." : {
"comment" : "A description of a user's subscription to Nous, but",
"isCommentAutoGenerated" : true
}, },
"SILENT" : { "SILENT" : {
@@ -20980,6 +21148,13 @@
} }
} }
} }
},
"Subscription active — active provider is Nous." : {
"comment" : "A description of a user's active subscription to",
"isCommentAutoGenerated" : true
},
"Subscription required" : {
}, },
"Succeeded" : { "Succeeded" : {
"localizations" : { "localizations" : {
@@ -21435,6 +21610,10 @@
} }
} }
}, },
"The project directory on disk isn't changed — only the label Scarf shows in the sidebar." : {
"comment" : "A description of the project name field.",
"isCommentAutoGenerated" : true
},
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection." : { "The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection." : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -21643,6 +21822,10 @@
"comment" : "A description of the local machine.", "comment" : "A description of the local machine.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"This may take a few seconds." : {
"comment" : "A description of the time it takes to connect to Nous Portal.",
"isCommentAutoGenerated" : true
},
"This project wasn't installed from a schemaful template." : { "This project wasn't installed from a schemaful template." : {
}, },
@@ -22422,6 +22605,10 @@
} }
} }
}, },
"Top Level" : {
"comment" : "A folder in the sidebar.",
"isCommentAutoGenerated" : true
},
"Top Tools" : { "Top Tools" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -22462,6 +22649,10 @@
} }
} }
}, },
"Try again" : {
"comment" : "A button that triggers a re-attempt to sign in to Nous Portal.",
"isCommentAutoGenerated" : true
},
"TTS Off" : { "TTS Off" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -22582,6 +22773,10 @@
} }
} }
}, },
"Unarchive" : {
"comment" : "A button that unarchives a project.",
"isCommentAutoGenerated" : true
},
"Uninstall" : { "Uninstall" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -23409,6 +23604,10 @@
} }
} }
}, },
"Waiting for approval…" : {
"comment" : "A label displayed in the `.waitingForApproval` state of the",
"isCommentAutoGenerated" : true
},
"Waiting for authorization URL…" : { "Waiting for authorization URL…" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -23529,6 +23728,14 @@
} }
} }
}, },
"We opened the Nous Portal approval page. Confirm this code matches what it shows, then approve." : {
"comment" : "A description of the user's task to approve a",
"isCommentAutoGenerated" : true
},
"We'll open the Nous Portal approval page in your browser and show the device code here. No code-paste step." : {
"comment" : "A description of the process of adding a credential via the Nous Portal.",
"isCommentAutoGenerated" : true
},
"Web Extract" : { "Web Extract" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -23951,6 +24158,10 @@
"Your name" : { "Your name" : {
"comment" : "A label for the user's name.", "comment" : "A label for the user's name.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Your tools will now route through your subscription." : {
"comment" : "A description of the success state of the",
"isCommentAutoGenerated" : true
} }
}, },
"version" : "1.1" "version" : "1.1"
+12 -1
View File
@@ -50,7 +50,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .profiles: return "Profiles" case .profiles: return "Profiles"
case .tools: return "Tools" case .tools: return "Tools"
case .mcpServers: return "MCP Servers" case .mcpServers: return "MCP Servers"
case .gateway: return "Gateway" case .gateway: return "Messaging Gateway"
case .cron: return "Cron" case .cron: return "Cron"
case .health: return "Health" case .health: return "Health"
case .logs: return "Logs" case .logs: return "Logs"
@@ -91,4 +91,15 @@ final class AppCoordinator {
var selectedSection: SidebarSection = .dashboard var selectedSection: SidebarSection = .dashboard
var selectedSessionId: String? var selectedSessionId: String?
var selectedProjectName: String? var selectedProjectName: String?
/// When non-nil, ChatView should start a fresh ACP session with
/// this absolute project path as cwd and then clear the value.
/// Wired from the per-project Sessions tab's "New Chat" button
/// (v2.3): the tab sets this, switches `selectedSection` to
/// `.chat`, and ChatView reacts on its next render.
///
/// Separate from `selectedSessionId` (which resumes an existing
/// session) a new session needs a cwd override Scarf doesn't
/// yet have an id for.
var pendingProjectChat: String?
} }
+1
View File
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct SidebarView: View { struct SidebarView: View {
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
+14 -1
View File
@@ -95,6 +95,19 @@ struct ScarfApp: App {
registry.defaultServerID registry.defaultServerID
} }
.defaultSize(width: 1100, height: 700) .defaultSize(width: 1100, height: 700)
// Without an explicit resizability, `WindowGroup` defaults to
// `.automatic` which on macOS evaluates to `.contentSize`
// meaning the window is BOUND to its content's ideal size
// rather than bounded-below by it. Any section whose content's
// intrinsic height changes (Chat's message list, the v2.3
// per-project Sessions tab, Insights charts) would resize the
// window on every section switch, snap back against user
// resize, and sometimes push the whole window past the
// screen. `.contentMinSize` turns the content's ideal height
// into a minimum floor: user resize works freely, the window
// stays put across section switches, and it still can't shrink
// smaller than a section's minimum render.
.windowResizability(.contentMinSize)
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
Button("Check for Updates…") { updater.checkForUpdates() } Button("Check for Updates…") { updater.checkForUpdates() }
@@ -374,7 +387,7 @@ struct MenuBarMenu: View {
systemImage: status.hermesRunning ? "circle.fill" : "circle" systemImage: status.hermesRunning ? "circle.fill" : "circle"
) )
Label( Label(
status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", status.gatewayRunning ? "Messaging Gateway Running" : "Messaging Gateway Stopped",
systemImage: status.gatewayRunning ? "circle.fill" : "circle" systemImage: status.gatewayRunning ? "circle.fill" : "circle"
) )
Button("Start Hermes") { status.startHermes() } Button("Start Hermes") { status.startHermes() }
@@ -0,0 +1,84 @@
import Testing
import Foundation
@testable import scarf
/// Tests that ``CredentialPoolsOAuthGate`` steers each known provider to
/// the right OAuth flow. The regression this prevents: a user hitting the
/// "Start OAuth" button for nous / openai-codex / qwen-oauth /
/// google-gemini-cli / copilot-acp and watching the UI stall silently.
@Suite struct CredentialPoolsGatingTests {
/// Synthesize a ModelCatalogService over a minimal fixture cache so
/// tests don't depend on the live `~/.hermes/models_dev_cache.json`.
private func makeCatalog() throws -> ModelCatalogService {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-cpgate-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("models_dev_cache.json").path
// Include anthropic so the .ok path has a recognizable provider.
let json = """
{
"anthropic": {
"name": "Anthropic",
"models": { "claude-sonnet-4-5": { "name": "Claude Sonnet 4.5" } }
}
}
"""
try json.write(toFile: path, atomically: true, encoding: .utf8)
return ModelCatalogService(path: path)
}
@Test func nousRoutesToDedicatedSignInFlow() throws {
let catalog = try makeCatalog()
#expect(CredentialPoolsOAuthGate.resolve(providerID: "nous", catalog: catalog) == .useNousSignIn)
// Whitespace + case insensitivity should also work users who type
// "Nous " shouldn't fall through to the generic flow.
#expect(CredentialPoolsOAuthGate.resolve(providerID: " Nous ", catalog: catalog) == .useNousSignIn)
}
@Test func deviceCodeAndExternalProvidersRouteToCLI() throws {
let catalog = try makeCatalog()
// `openai-codex` is .oauthExternal in the overlay table.
if case .useCLI(let provider) = CredentialPoolsOAuthGate.resolve(providerID: "openai-codex", catalog: catalog) {
#expect(provider == "openai-codex")
} else {
Issue.record("openai-codex should route to .useCLI")
}
// `qwen-oauth` is .oauthExternal.
if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "qwen-oauth", catalog: catalog) {
// ok
} else {
Issue.record("qwen-oauth should route to .useCLI")
}
// `google-gemini-cli` is .oauthExternal.
if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "google-gemini-cli", catalog: catalog) {
// ok
} else {
Issue.record("google-gemini-cli should route to .useCLI")
}
// `copilot-acp` is .externalProcess.
if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "copilot-acp", catalog: catalog) {
// ok
} else {
Issue.record("copilot-acp should route to .useCLI")
}
}
@Test func pkceProvidersPassThroughAsOK() throws {
let catalog = try makeCatalog()
// Anthropic is a standard PKCE provider in Hermes must not be gated.
#expect(CredentialPoolsOAuthGate.resolve(providerID: "anthropic", catalog: catalog) == .ok)
}
@Test func unknownProvidersDefaultToOK() throws {
let catalog = try makeCatalog()
// Providers we don't know about shouldn't be blocked users with
// custom setups need the escape hatch.
#expect(CredentialPoolsOAuthGate.resolve(providerID: "custom-provider-xyz", catalog: catalog) == .ok)
}
@Test func emptyProviderReturnsProviderEmpty() throws {
let catalog = try makeCatalog()
#expect(CredentialPoolsOAuthGate.resolve(providerID: "", catalog: catalog) == .providerEmpty)
#expect(CredentialPoolsOAuthGate.resolve(providerID: " ", catalog: catalog) == .providerEmpty)
}
}
+109
View File
@@ -0,0 +1,109 @@
import Testing
import Foundation
@testable import scarf
/// Unit tests for the pure parsers in ``NousAuthFlow``. The subprocess side
/// of the flow is covered by manual end-to-end testing against a live
/// hermes install parser behavior is what we can pin here.
@Suite struct NousAuthFlowParserTests {
// MARK: - Device-code block
@Test func parsesVerificationURLAndUserCode() throws {
let text = """
Requesting device code from Nous Portal...
To continue:
1. Open: https://portal.nousresearch.com/device/ABCD-EFGH
2. If prompted, enter code: ABCD-EFGH
Waiting for approval (polling every 1s)...
"""
let result = try #require(NousAuthFlow.parseDeviceCode(from: text))
#expect(result.verificationURL.absoluteString == "https://portal.nousresearch.com/device/ABCD-EFGH")
#expect(result.userCode == "ABCD-EFGH")
}
@Test func ignoresNoiseBetweenExpectedLines() throws {
// Hermes may log unrelated diagnostics between or after the two
// expected lines. The parser anchors on line-start regex so noise
// above, below, or even intermixed shouldn't block it.
let text = """
[DEBUG] some internal log line
To continue:
1. Open: https://portal.nousresearch.com/device/WXYZ-1234
[DEBUG] another log line
2. If prompted, enter code: WXYZ-1234
extra trailing noise
"""
let result = try #require(NousAuthFlow.parseDeviceCode(from: text))
#expect(result.userCode == "WXYZ-1234")
}
@Test func returnsNilWhenUserCodeLineMissing() {
let text = """
To continue:
1. Open: https://portal.nousresearch.com/device/AAAA-AAAA
Waiting for approval...
"""
#expect(NousAuthFlow.parseDeviceCode(from: text) == nil)
}
@Test func returnsNilWhenURLLineMissing() {
let text = """
To continue:
2. If prompted, enter code: BBBB-BBBB
"""
#expect(NousAuthFlow.parseDeviceCode(from: text) == nil)
}
@Test func returnsNilOnEmptyInput() {
#expect(NousAuthFlow.parseDeviceCode(from: "") == nil)
}
// MARK: - Subscription-required failure
@Test func parsesSubscriptionRequiredBillingURL() throws {
let text = """
Login successful!
Your Nous Portal account does not have an active subscription.
Subscribe here: https://portal.nousresearch.com/billing
After subscribing, run `hermes model` again to finish setup.
"""
let url = try #require(NousAuthFlow.parseSubscriptionRequired(from: text))
#expect(url.absoluteString == "https://portal.nousresearch.com/billing")
}
@Test func subscriptionRequiredReturnsNilWithoutMarker() {
let text = """
hermes: something else went wrong
Subscribe here: https://example.com/billing
"""
// The "Subscribe here:" URL alone isn't enough we require the
// specific subscription-required sentinel so we don't misclassify
// unrelated errors as subscription failures.
#expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil)
}
@Test func subscriptionRequiredReturnsNilWhenBillingURLMissing() {
let text = """
Your Nous Portal account does not have an active subscription.
(no subscribe here line)
"""
#expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil)
}
// MARK: - State equality
@Test func stateEnumEquatableDistinguishesCases() {
let u = URL(string: "https://example.com")!
let a: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u)
let b: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u)
let c: NousAuthFlow.State = .waitingForApproval(userCode: "Y", verificationURL: u)
#expect(a == b)
#expect(a != c)
#expect(NousAuthFlow.State.idle != NousAuthFlow.State.starting)
#expect(NousAuthFlow.State.success != NousAuthFlow.State.failure(reason: "", billingURL: nil))
}
}
@@ -0,0 +1,261 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Exercises the Scarf-managed AGENTS.md marker block logic added in
/// v2.3. Tests operate on isolated temp directories no dependency
/// on ~/.hermes contents, no cross-suite lock needed.
@Suite struct ProjectAgentContextServiceTests {
// MARK: - applyBlock pure-text transform
@Test func applyBlockPrependsWhenNoMarkersPresent() {
let existing = "# My Template\n\nSome instructions.\n"
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
#expect(result.hasPrefix("<!-- scarf-project:begin -->"))
#expect(result.contains("<!-- scarf-project:end -->"))
#expect(result.contains("# My Template"))
#expect(result.contains("Some instructions."))
// Exactly one blank line between block and original content.
#expect(result.contains("<!-- scarf-project:end -->\n\n# My Template"))
}
@Test func applyBlockWritesFreshFileWhenEmpty() {
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: block, to: "")
// Empty input just the block + trailing newline; no weird
// leading whitespace.
#expect(result == block + "\n")
}
@Test func applyBlockReplacesExistingMarkerRegion() {
let existing = """
<!-- scarf-project:begin -->
old content line 1
old content line 2
<!-- scarf-project:end -->
# Template docs preserved
Template behavior.
"""
let newBlock = "<!-- scarf-project:begin -->\nfresh content\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: newBlock, to: existing)
#expect(result.contains("fresh content"))
// Old content is gone.
#expect(!result.contains("old content line 1"))
#expect(!result.contains("old content line 2"))
// Template content outside markers is preserved.
#expect(result.contains("# Template docs preserved"))
#expect(result.contains("Template behavior."))
}
@Test func applyBlockIsIdempotent() {
let existing = "# Project\n\nContent.\n"
let block = "<!-- scarf-project:begin -->\nv1\n<!-- scarf-project:end -->"
let once = ProjectAgentContextService.applyBlock(block: block, to: existing)
let twice = ProjectAgentContextService.applyBlock(block: block, to: once)
#expect(once == twice)
}
@Test func applyBlockOrphanedBeginMarkerFallsBackToPrepend() {
// Stray begin with no end: treat as "no well-formed block,"
// prepend. Leaves the orphan in place it was probably
// hand-typed, not a corrupt Scarf write. Conservative.
let existing = "<!-- scarf-project:begin -->\nstray text with no end marker\n"
let block = "<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
#expect(result.hasPrefix("<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"))
#expect(result.contains("stray text with no end marker"))
}
// MARK: - renderBlock content
@Test func renderBlockIncludesProjectIdentity() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let project = ProjectEntry(name: "My Project", path: dir)
let svc = ProjectAgentContextService(context: .local)
let block = svc.renderBlock(for: project)
#expect(block.contains(ProjectAgentContextService.beginMarker))
#expect(block.contains(ProjectAgentContextService.endMarker))
#expect(block.contains("\"My Project\""))
#expect(block.contains(dir))
#expect(block.contains("dashboard.json"))
}
@Test func renderBlockOmitsTemplateSectionForBareProject() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let project = ProjectEntry(name: "Bare", path: dir)
let svc = ProjectAgentContextService(context: .local)
let block = svc.renderBlock(for: project)
#expect(!block.contains("**Template:**"))
#expect(block.contains("**Configuration fields:** (none)"))
}
@Test func renderBlockIncludesTemplateWhenManifestPresent() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let scarfDir = dir + "/.scarf"
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
// Minimal valid v1 manifest no config schema.
let manifest = """
{
"schemaVersion": 1,
"id": "author/example",
"name": "Example",
"version": "1.2.3",
"description": "",
"contents": { "dashboard": true, "agentsMd": true }
}
"""
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
let project = ProjectEntry(name: "Example", path: dir)
let svc = ProjectAgentContextService(context: .local)
let block = svc.renderBlock(for: project)
#expect(block.contains("**Template:** `author/example` v1.2.3"))
}
@Test func renderBlockListsConfigFieldNamesNotValues() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let scarfDir = dir + "/.scarf"
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
// Schema-bearing manifest with one string field and one secret.
let manifest = """
{
"schemaVersion": 2,
"id": "x/y",
"name": "Y",
"version": "1.0.0",
"description": "",
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
"config": {
"schema": [
{ "key": "site_url", "type": "string", "label": "Site URL", "required": true },
{ "key": "api_token", "type": "secret", "label": "API Token", "required": true }
]
}
}
"""
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
// A config.json with a "secret" VALUE the block must NOT
// echo this value. If it does, secrets leak into an agent-
// readable file, which is exactly the thing to avoid.
let configJSON = """
{
"schemaVersion": 2,
"templateId": "x/y",
"values": {
"site_url": { "type": "string", "value": "https://example.com" },
"api_token": { "type": "keychainRef", "uri": "keychain://com.scarf.template.x-y/api_token:abc123" }
},
"updatedAt": "2026-04-24T00:00:00Z"
}
"""
try configJSON.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/config.json"))
let project = ProjectEntry(name: "Y", path: dir)
let svc = ProjectAgentContextService(context: .local)
let block = svc.renderBlock(for: project)
// Field names present with type hints.
#expect(block.contains("`site_url`"))
#expect(block.contains("`api_token`"))
#expect(block.contains("(secret — name only, value stored in Keychain)"))
// CRITICAL: no VALUES appear not the site URL, not the
// keychain ref. The block is safe to drop into an agent
// context.
#expect(!block.contains("https://example.com"))
#expect(!block.contains("keychain://"))
#expect(!block.contains("abc123"))
}
// MARK: - refresh end-to-end (temp dir on local filesystem)
@Test func refreshCreatesAGENTSMdWhenMissing() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let project = ProjectEntry(name: "Fresh", path: dir)
try ProjectAgentContextService(context: .local).refresh(for: project)
let agentsMd = dir + "/AGENTS.md"
#expect(FileManager.default.fileExists(atPath: agentsMd))
let contents = try String(contentsOf: URL(fileURLWithPath: agentsMd))
#expect(contents.contains(ProjectAgentContextService.beginMarker))
#expect(contents.contains(ProjectAgentContextService.endMarker))
#expect(contents.contains("\"Fresh\""))
}
@Test func refreshPreservesUserContentBelow() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let agentsMd = dir + "/AGENTS.md"
let userContent = "# Template\n\nDo the thing.\n"
try userContent.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
let project = ProjectEntry(name: "Preserved", path: dir)
try ProjectAgentContextService(context: .local).refresh(for: project)
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
#expect(after.contains(ProjectAgentContextService.beginMarker))
#expect(after.contains("# Template"))
#expect(after.contains("Do the thing."))
// Block goes FIRST; user content follows.
let beginIdx = after.range(of: ProjectAgentContextService.beginMarker)!.lowerBound
let userIdx = after.range(of: "# Template")!.lowerBound
#expect(beginIdx < userIdx)
}
@Test func refreshIsFullyIdempotent() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let project = ProjectEntry(name: "Twice", path: dir)
let svc = ProjectAgentContextService(context: .local)
try svc.refresh(for: project)
let first = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
try svc.refresh(for: project)
let second = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
#expect(first == second)
}
@Test func refreshRewritesStaleBlock() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let agentsMd = dir + "/AGENTS.md"
// Pre-seed a stale Scarf block with a different project name
// and a user section below.
let seed = """
<!-- scarf-project:begin -->
Old stale content project was called "Something Else".
<!-- scarf-project:end -->
# Template
"""
try seed.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
let project = ProjectEntry(name: "Current Name", path: dir)
try ProjectAgentContextService(context: .local).refresh(for: project)
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
#expect(after.contains("\"Current Name\""))
#expect(!after.contains("Something Else"))
#expect(after.contains("# Template"))
}
// MARK: - Helpers
nonisolated static func makeTempDir() throws -> String {
let dir = NSTemporaryDirectory() + "scarf-project-context-test-" + UUID().uuidString
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}
}
@@ -0,0 +1,130 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// v2.3 grew `ProjectEntry` with `folder` and `archived` fields.
/// Both are optional/defaulted at the decoder so v2.2-era
/// `~/.hermes/scarf/projects.json` files still parse cleanly, and
/// v2.3-written files are forward-compatible with v2.2 readers
/// (which ignore unknown keys). These tests lock in both ends of
/// that contract.
///
/// No disk or Hermes dependency we work entirely with in-memory
/// `Data`, so the `TestRegistryLock` from `ProjectTemplateTests` isn't
/// needed. Safe to run in parallel with every other test suite.
@Suite struct ProjectRegistryMigrationTests {
@Test func decodesV22RegistryWithoutNewFields() throws {
// v2.2-era file: just name + path. No folder, no archived.
let json = """
{
"projects": [
{ "name": "Legacy", "path": "/Users/x/legacy" },
{ "name": "Another", "path": "/Users/x/another" }
]
}
""".data(using: .utf8)!
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
#expect(registry.projects.count == 2)
#expect(registry.projects[0].name == "Legacy")
#expect(registry.projects[0].path == "/Users/x/legacy")
// Defaults hydrate for absent v2.3 fields.
#expect(registry.projects[0].folder == nil)
#expect(registry.projects[0].archived == false)
}
@Test func decodesV23RegistryWithFolderAndArchived() throws {
let json = """
{
"projects": [
{ "name": "Client A", "path": "/Users/x/a", "folder": "Clients" },
{ "name": "Client B", "path": "/Users/x/b", "folder": "Clients", "archived": true },
{ "name": "Personal", "path": "/Users/x/p" }
]
}
""".data(using: .utf8)!
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
#expect(registry.projects.count == 3)
#expect(registry.projects[0].folder == "Clients")
#expect(registry.projects[0].archived == false)
#expect(registry.projects[1].folder == "Clients")
#expect(registry.projects[1].archived == true)
#expect(registry.projects[2].folder == nil)
#expect(registry.projects[2].archived == false)
}
@Test func encodeOmitsDefaultedFields() throws {
// A top-level, non-archived project should encode with ONLY
// name + path keys. This keeps v2.3-written registries
// loadable by v2.2 Scarf (which ignores unknown keys), and
// keeps the file clean for the common case.
let entry = ProjectEntry(name: "Plain", path: "/Users/x/plain")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(entry)
let s = try #require(String(data: data, encoding: .utf8))
#expect(s == #"{"name":"Plain","path":"\/Users\/x\/plain"}"#)
}
@Test func encodeIncludesFolderWhenPresent() throws {
let entry = ProjectEntry(name: "Acme", path: "/a", folder: "Clients")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(entry)
let s = try #require(String(data: data, encoding: .utf8))
#expect(s.contains(#""folder":"Clients""#))
// archived still omitted when false cleanliness matters.
#expect(!s.contains(#""archived""#))
}
@Test func encodeIncludesArchivedOnlyWhenTrue() throws {
let archived = ProjectEntry(name: "Old", path: "/o", archived: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(archived)
let s = try #require(String(data: data, encoding: .utf8))
#expect(s.contains(#""archived":true"#))
let active = ProjectEntry(name: "New", path: "/n", archived: false)
let data2 = try encoder.encode(active)
let s2 = try #require(String(data: data2, encoding: .utf8))
#expect(!s2.contains(#""archived""#))
}
@Test func roundTripPreservesAllFields() throws {
let original = ProjectRegistry(projects: [
ProjectEntry(name: "Top", path: "/t"),
ProjectEntry(name: "InFolder", path: "/f", folder: "Work"),
ProjectEntry(name: "ArchivedTop", path: "/a", archived: true),
ProjectEntry(name: "ArchivedInFolder", path: "/af", folder: "Work", archived: true)
])
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(ProjectRegistry.self, from: encoded)
#expect(decoded.projects.count == 4)
#expect(decoded.projects[0].folder == nil && decoded.projects[0].archived == false)
#expect(decoded.projects[1].folder == "Work" && decoded.projects[1].archived == false)
#expect(decoded.projects[2].folder == nil && decoded.projects[2].archived == true)
#expect(decoded.projects[3].folder == "Work" && decoded.projects[3].archived == true)
}
@Test func identityStaysKeyedOnName() throws {
// ProjectEntry.id should remain `name`, so selecting by id
// across a folder-move or archive-flip still works without
// a reselection step.
let a = ProjectEntry(name: "Foo", path: "/p")
let b = ProjectEntry(name: "Foo", path: "/p", folder: "Clients")
let c = ProjectEntry(name: "Foo", path: "/p", archived: true)
#expect(a.id == "Foo")
#expect(b.id == "Foo")
#expect(c.id == "Foo")
#expect(a.id == b.id)
#expect(a.id == c.id)
}
}
@@ -1064,6 +1064,68 @@ final class TestRegistryLock: @unchecked Sendable {
#expect(cronPrompt.contains("{{PROJECT_DIR}}")) #expect(cronPrompt.contains("{{PROJECT_DIR}}"))
} }
/// Exercises the second shipped template `awizemann/template-author`
/// which is a skill-only bundle (no config, no cron, no memory). The
/// shape is deliberately different from site-status-checker so a
/// regression in the installer's "no config, no cron" path can't hide
/// behind the richer example template. Also asserts the skill lands
/// under the expected namespaced path so Hermes's recursive skill
/// discovery finds it.
@Test func templateAuthorParsesAndPlans() throws {
let bundle = try Self.locateExample(author: "awizemann", name: "template-author")
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
// Manifest shape: schemaVersion 2 (contains `skills` claim, which
// wasn't part of v1), no config, no cron, one skill.
#expect(inspection.manifest.id == "awizemann/template-author")
#expect(inspection.manifest.name == "Scarf Template Author")
#expect(inspection.manifest.version == "1.0.0")
#expect(inspection.manifest.schemaVersion == 2)
#expect(inspection.manifest.contents.dashboard)
#expect(inspection.manifest.contents.agentsMd)
#expect(inspection.manifest.contents.cron == nil)
#expect(inspection.manifest.contents.config == nil)
#expect(inspection.manifest.contents.memory == nil)
#expect(inspection.manifest.contents.skills == ["scarf-template-author"])
#expect(inspection.manifest.config == nil)
#expect(inspection.cronJobs.isEmpty)
// Plan: empty config, empty cron, but one skill queued for install
// under the template's namespaced dir. The namespace path has to
// match what the uninstaller wipes `skills/templates/<slug>`
// or uninstall leaves orphan skill files.
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-template-author"))
#expect(plan.cronJobs.isEmpty)
#expect(plan.configSchema == nil)
#expect(plan.configValues.isEmpty)
#expect(plan.memoryAppendix == nil)
// The skill should land at
// `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`
// namespace dir + skill folder + SKILL.md. Anything else
// breaks Hermes's recursive discovery or the uninstaller's
// `rm -rf` on the namespace dir.
let namespaceDir = try #require(plan.skillsNamespaceDir)
#expect(namespaceDir.hasSuffix("/skills/templates/awizemann-template-author"))
#expect(plan.skillsFiles.count == 1)
let skillDest = try #require(plan.skillsFiles.first?.destinationPath)
#expect(skillDest.hasSuffix("/scarf-template-author/SKILL.md"))
#expect(skillDest.hasPrefix(namespaceDir))
// No-config templates deliberately skip the manifest cache
// the dashboard's Configuration button only shows up when
// `.scarf/manifest.json` exists, so a skill-only template
// like this one correctly doesn't surface that button.
// (See ProjectTemplateService.buildPlan lines 198227.)
#expect(plan.manifestCachePath == nil)
}
/// Resolve the example bundle path robustly. Unit-test working dirs /// Resolve the example bundle path robustly. Unit-test working dirs
/// differ between `xcodebuild test` (project root) and an Xcode IDE /// differ between `xcodebuild test` (project root) and an Xcode IDE
/// run (build-output dir), so we walk up from this source file until /// run (build-output dir), so we walk up from this source file until
@@ -0,0 +1,174 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Exercises the v2.3 registry verbs added to ProjectsViewModel:
/// moveProject, renameProject, archiveProject, unarchiveProject,
/// + the derived `folders` list. All verbs write through to
/// `~/.hermes/scarf/projects.json` via ProjectDashboardService, so
/// each test uses TestRegistryLock to snapshot + restore the real
/// file. Cross-suite serialization ensures we don't race with other
/// registry-touching tests.
@MainActor @Suite(.serialized) struct ProjectsViewModelTests {
@Test func moveProjectSetsFolder() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Alpha", path: "/a"),
ProjectEntry(name: "Beta", path: "/b")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.projects.count == 2)
vm.moveProject(vm.projects[0], toFolder: "Clients")
#expect(vm.projects.count == 2)
#expect(vm.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
#expect(vm.projects.first(where: { $0.name == "Beta" })?.folder == nil)
// Round-trip: reload from disk and confirm the move persisted.
let fresh = ProjectDashboardService(context: .local).loadRegistry()
#expect(fresh.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
}
@Test func moveProjectToNilReturnsToTopLevel() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Nested", path: "/n", folder: "Clients")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
vm.moveProject(vm.projects[0], toFolder: nil)
#expect(vm.projects[0].folder == nil)
let fresh = ProjectDashboardService(context: .local).loadRegistry()
#expect(fresh.projects[0].folder == nil)
}
@Test func renameProjectUpdatesNameAndPreservesOtherFields() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "OldName", path: "/p", folder: "Work", archived: false)
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
vm.selectProject(vm.projects[0])
let ok = vm.renameProject(vm.projects[0], to: "NewName")
#expect(ok == true)
#expect(vm.projects.count == 1)
#expect(vm.projects[0].name == "NewName")
#expect(vm.projects[0].folder == "Work")
#expect(vm.projects[0].archived == false)
// Selection follows the rename the user stays on the same
// project they were on.
#expect(vm.selectedProject?.name == "NewName")
}
@Test func renameProjectRejectsDuplicateName() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "A", path: "/a"),
ProjectEntry(name: "B", path: "/b")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
// Renaming A to B should be refused B already exists.
let ok = vm.renameProject(vm.projects[0], to: "B")
#expect(ok == false)
// Registry unchanged.
#expect(vm.projects.map(\.name) == ["A", "B"])
}
@Test func renameProjectRejectsEmptyName() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Foo", path: "/f")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.renameProject(vm.projects[0], to: "") == false)
#expect(vm.renameProject(vm.projects[0], to: " ") == false)
#expect(vm.projects[0].name == "Foo")
}
@Test func renameProjectToSameNameIsNoOpSuccess() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Foo", path: "/f")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.renameProject(vm.projects[0], to: "Foo") == true)
// Whitespace around matching name also no-ops.
#expect(vm.renameProject(vm.projects[0], to: " Foo ") == true)
#expect(vm.projects[0].name == "Foo")
}
@Test func archiveAndUnarchiveProject() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Target", path: "/t")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
vm.selectProject(vm.projects[0])
#expect(vm.projects[0].archived == false)
#expect(vm.selectedProject != nil)
vm.archiveProject(vm.projects[0])
#expect(vm.projects[0].archived == true)
// Archiving clears the selection so the dashboard doesn't
// linger on a project the sidebar will hide.
#expect(vm.selectedProject == nil)
vm.unarchiveProject(vm.projects[0])
#expect(vm.projects[0].archived == false)
// Unarchive doesn't re-select the user chose to hide it,
// surfacing it doesn't mean they want focus back.
#expect(vm.selectedProject == nil)
}
@Test func foldersListIsSortedAndDeduped() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "A", path: "/a", folder: "Work"),
ProjectEntry(name: "B", path: "/b", folder: "Personal"),
ProjectEntry(name: "C", path: "/c", folder: "Work"),
ProjectEntry(name: "D", path: "/d"), // top-level
ProjectEntry(name: "E", path: "/e", folder: "") // empty string treated as nil
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.folders == ["Personal", "Work"])
}
// MARK: - Helpers
@MainActor
private func seedRegistry(_ registry: ProjectRegistry) throws {
try ProjectDashboardService(context: .local).saveRegistry(registry)
}
}
@@ -0,0 +1,154 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Exercises the v2.3 sidecar at `~/.hermes/scarf/session_project_map.json`
/// via the real `ServerContext.local`. Each test snapshots + restores
/// the file through `TestRegistryLock` (reused the sidecar lives
/// in the same scarf/ dir as projects.json, so serialising on one
/// lock prevents both cross-suite races).
///
/// We scope the shared lock to this file's registry helper so tests
/// here don't step on the real registry either.
@Suite(.serialized) struct SessionAttributionServiceTests {
@Test func loadOnMissingFileReturnsEmptyMap() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
let map = svc.load()
#expect(map.mappings.isEmpty)
#expect(svc.projectPath(for: "anything") == nil)
#expect(svc.sessionIDs(forProject: "/anything").isEmpty)
}
@Test func attributeWritesMappingAndPersists() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "sess-1", toProjectPath: "/proj/a")
// Read back via a fresh service instance confirms the
// write actually landed on disk, not just the in-memory map.
let fresh = SessionAttributionService(context: .local)
#expect(fresh.projectPath(for: "sess-1") == "/proj/a")
// updatedAt populated on write.
let map = fresh.load()
let ts = try #require(map.updatedAt)
#expect(!ts.isEmpty)
}
@Test func attributeIsIdempotent() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "s", toProjectPath: "/p")
let firstStamp = svc.load().updatedAt
// Call again with the same pair should short-circuit, NOT
// bump updatedAt. We check that the timestamp didn't change
// even if the file would have been rewritten.
svc.attribute(sessionID: "s", toProjectPath: "/p")
let secondStamp = svc.load().updatedAt
#expect(firstStamp == secondStamp)
}
@Test func reattributeChangesMapping() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "s", toProjectPath: "/a")
svc.attribute(sessionID: "s", toProjectPath: "/b")
#expect(svc.projectPath(for: "s") == "/b")
#expect(svc.sessionIDs(forProject: "/a").isEmpty)
#expect(svc.sessionIDs(forProject: "/b") == ["s"])
}
@Test func reverseLookupReturnsAllAttributedSessions() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "s1", toProjectPath: "/proj")
svc.attribute(sessionID: "s2", toProjectPath: "/proj")
svc.attribute(sessionID: "s3", toProjectPath: "/other")
#expect(svc.sessionIDs(forProject: "/proj") == ["s1", "s2"])
#expect(svc.sessionIDs(forProject: "/other") == ["s3"])
#expect(svc.sessionIDs(forProject: "/nobody").isEmpty)
}
@Test func forgetRemovesMapping() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "s", toProjectPath: "/p")
#expect(svc.projectPath(for: "s") == "/p")
svc.forget(sessionID: "s")
#expect(svc.projectPath(for: "s") == nil)
// Forget on a missing session is a no-op, not an error.
svc.forget(sessionID: "s")
#expect(svc.projectPath(for: "s") == nil)
}
@Test func corruptedFileReturnsEmptyMap() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
// Write garbage to the sidecar path and confirm the service
// treats it as "no attributions" rather than crashing. Users
// hand-editing the JSON shouldn't soft-brick the Sessions tab.
let path = ServerContext.local.paths.sessionProjectMap
try FileManager.default.createDirectory(
atPath: (path as NSString).deletingLastPathComponent,
withIntermediateDirectories: true
)
try "not json at all".data(using: .utf8)!.write(to: URL(fileURLWithPath: path))
let svc = SessionAttributionService(context: .local)
let map = svc.load()
#expect(map.mappings.isEmpty)
}
// MARK: - Helpers
/// Snapshot + restore the sidecar file (and delete if missing).
/// Uses the shared TestRegistryLock so this suite serialises
/// with any other registry-writing suite both touch scarfDir.
static func snapshot() -> (lockToken: Any, data: Data?) {
// Re-use the ProjectTemplateTests lock implementation
// same NSLock gates all scarfDir writes across suites.
let projectSnapshot = TestRegistryLock.acquireAndSnapshot()
let path = ServerContext.local.paths.sessionProjectMap
let sidecarData = try? Data(contentsOf: URL(fileURLWithPath: path))
return (lockToken: projectSnapshot as Any, data: sidecarData)
}
static func restore(_ snapshot: (lockToken: Any, data: Data?)) {
let path = ServerContext.local.paths.sessionProjectMap
if let data = snapshot.data {
try? data.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
// Release the shared lock via the existing helper.
TestRegistryLock.restore(snapshot.lockToken as? Data)
}
static func deleteSidecar() {
let path = ServerContext.local.paths.sessionProjectMap
try? FileManager.default.removeItem(atPath: path)
}
}
+200
View File
@@ -0,0 +1,200 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Invariants around Hermes v0.10.0 Tool Gateway integration:
/// overlay-provider merge, Nous Portal subscription detection, and
/// `platform_toolsets` YAML parsing.
@Suite struct ToolGatewayTests {
// MARK: - Fixtures
/// Minimal models.dev cache with exactly two providers so the overlay
/// merge is easy to reason about none of them are overlays.
private func writeCacheFixture() throws -> String {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("models_dev_cache.json").path
let json = """
{
"anthropic": {
"name": "Anthropic",
"models": {
"claude-sonnet-4-5-20250929": { "name": "Claude Sonnet 4.5" }
}
},
"openai": {
"name": "OpenAI",
"models": {
"gpt-4o": { "name": "GPT-4o" }
}
}
}
"""
try json.write(toFile: path, atomically: true, encoding: .utf8)
return path
}
private func writeAuthFixture(_ body: String) throws -> String {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-auth-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("auth.json").path
try body.write(toFile: path, atomically: true, encoding: .utf8)
return path
}
// MARK: - ModelCatalogService overlay merge
@Test func overlayOnlyProvidersAppearInPicker() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let providers = service.loadProviders()
let ids = providers.map(\.providerID)
#expect(ids.contains("nous"), "Nous Portal must appear after overlay merge")
#expect(ids.contains("openai-codex"), "OpenAI Codex overlay must appear")
#expect(ids.contains("qwen-oauth"), "Qwen OAuth overlay must appear")
// Cached providers still present.
#expect(ids.contains("anthropic"))
#expect(ids.contains("openai"))
}
@Test func nousPortalSortsFirst() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let providers = service.loadProviders()
#expect(providers.first?.providerID == "nous",
"Subscription-gated providers must sort before the alphabetical block")
}
@Test func overlayProvidersCarryMetadata() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let providers = service.loadProviders()
let nous = providers.first { $0.providerID == "nous" }
#expect(nous?.isOverlay == true)
#expect(nous?.subscriptionGated == true)
#expect(nous?.providerName == "Nous Portal")
#expect(nous?.modelCount == 0, "Overlay-only providers have no models in the cache")
let codex = providers.first { $0.providerID == "openai-codex" }
#expect(codex?.isOverlay == true)
#expect(codex?.subscriptionGated == false,
"Only Nous is subscription-gated today")
}
@Test func cachedProvidersAreNotMarkedOverlay() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let providers = service.loadProviders()
let anthropic = providers.first { $0.providerID == "anthropic" }
#expect(anthropic?.isOverlay == false)
#expect(anthropic?.subscriptionGated == false)
}
@Test func providerByIDReturnsOverlayWhenCacheMisses() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let nous = service.providerByID("nous")
#expect(nous?.providerName == "Nous Portal")
#expect(nous?.isOverlay == true)
let missing = service.providerByID("definitely-not-a-provider")
#expect(missing == nil)
}
// MARK: - NousSubscriptionService
@Test func subscriptionAbsentWhenAuthFileMissing() throws {
let path = "/tmp/this-file-should-not-exist-\(UUID().uuidString).json"
let service = NousSubscriptionService(path: path)
let state = service.loadState()
#expect(state == .absent)
}
@Test func subscriptionAbsentWhenProvidersEmpty() throws {
let path = try writeAuthFixture("""
{ "version": 1, "providers": {}, "active_provider": null }
""")
let state = NousSubscriptionService(path: path).loadState()
#expect(state.present == false)
#expect(state.subscribed == false)
}
@Test func subscriptionPresentButInactiveWhenOtherProviderActive() throws {
let path = try writeAuthFixture("""
{
"version": 1,
"providers": { "nous": { "access_token": "tok-12345" } },
"active_provider": "anthropic"
}
""")
let state = NousSubscriptionService(path: path).loadState()
#expect(state.present == true)
#expect(state.providerIsNous == false)
#expect(state.subscribed == false,
"Auth alone isn't enough — the Tool Gateway only routes when Nous is the active provider")
}
@Test func subscriptionActiveWhenAuthAndActiveProviderLineUp() throws {
let path = try writeAuthFixture("""
{
"version": 1,
"providers": { "nous": { "access_token": "tok-12345" } },
"active_provider": "nous"
}
""")
let state = NousSubscriptionService(path: path).loadState()
#expect(state.present == true)
#expect(state.providerIsNous == true)
#expect(state.subscribed == true)
}
@Test func subscriptionAbsentWhenTokenEmpty() throws {
let path = try writeAuthFixture("""
{
"version": 1,
"providers": { "nous": { "access_token": "" } },
"active_provider": "nous"
}
""")
let state = NousSubscriptionService(path: path).loadState()
#expect(state.present == false,
"Empty token is as good as no token — don't claim subscription")
}
@Test func subscriptionAbsentOnMalformedJSON() throws {
let path = try writeAuthFixture("{ this is not valid json")
let state = NousSubscriptionService(path: path).loadState()
#expect(state == .absent)
}
// MARK: - platform_toolsets YAML parse
@Test func platformToolsetsParsed() throws {
let yaml = """
model:
default: claude-sonnet-4.5
provider: anthropic
platform_toolsets:
cli:
- browser
- messaging
slack:
- messaging
"""
let parsed = HermesFileService.parseNestedYAML(yaml)
#expect(parsed.lists["platform_toolsets.cli"] == ["browser", "messaging"])
#expect(parsed.lists["platform_toolsets.slack"] == ["messaging"])
}
@Test func platformToolsetsEmptyWhenMissing() throws {
// HermesConfig.empty should have no platform toolsets.
let config = HermesConfig.empty
#expect(config.platformToolsets.isEmpty)
}
}
@@ -0,0 +1,55 @@
# Template Author — Agent Instructions
This project is a help surface for the `scarf-template-author` Hermes skill. The same instructions apply whether you're Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
## What this project is
Two things:
1. A minimal dashboard (`.scarf/dashboard.json`) the user lands on after install. It's a Quick Start text widget + an empty list widget. The list is an optional scratchpad where you can log projects you've scaffolded for the user, giving them a running audit trail. That's nice-to-have, not mandatory.
2. A skill at `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`. The skill is the real value — it teaches you how to interview the user and scaffold a new Scarf-compatible project.
## What this project is NOT
- Not a running service. No cron jobs, no background tasks, no secrets.
- Not a dashboard you need to keep updated. The dashboard is documentation; the only mutation worth doing is appending to the Scaffolded Projects list after you scaffold something.
## When the user asks to create a Scarf project
The primary trigger. Phrases that should activate the full scaffolding flow:
- "Create a new Scarf project that …"
- "Scaffold a dashboard for …"
- "Set up a project to watch / track / report on …"
- "Help me author a Scarf template."
- "Build me a project that runs daily and …"
When you hear those:
1. Load the skill at `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md` and follow its interview flow. Do not improvise — the skill encodes the specific invariants Scarf enforces (widget types, field-type constraints, the `{{PROJECT_DIR}}` token, the paused-on-install cron rule, the secret-fields-have-no-defaults rule).
2. Scaffold into a directory the user picks. Use absolute paths.
3. After writing files, tell the user to register the project: click **+** in Scarf's Projects sidebar and pick the directory. Do not try to edit `~/.hermes/scarf/projects.json` yourself — Scarf reloads the registry on its own and the UI path is safer.
4. Optionally append to the Scaffolded Projects list in this project's `dashboard.json` so the user has a local record of what you've built for them. Preserve every other field in the dashboard as-is.
## When the user asks reference questions
If the user asks something like "what widget types does Scarf support?" or "how do I add a secret field?", you don't need to scaffold anything — answer inline. The skill's reference sections cover:
- The seven widget types (`stat`, `progress`, `text`, `table`, `chart`, `list`, `webview`) and their required fields.
- The seven config field types (`string`, `text`, `number`, `bool`, `enum`, `list`, `secret`) and their constraint keys.
- The `AGENTS.md` contract that every scaffolded project should honour.
Point them at the skill file if they want to read it directly. It's ~400 lines of structured markdown.
## What not to do
- Don't scaffold without asking the user where the project should live. The interview always asks for a parent directory.
- Don't register secrets in `<project>/.scarf/config.json`. Secret field values go through the macOS Keychain at install time; `config.json` stores `keychain://…` URIs, never plaintext. A scaffolded project that hasn't been installed yet has no secrets on disk at all.
- Don't claim dashboard widget titles the cron job doesn't actually update. The scaffolded `AGENTS.md` is a contract — if it says "the cron updates Sites Up / Sites Down", the cron prompt must match.
- Don't skip `{{PROJECT_DIR}}` token substitution in cron prompts. Hermes doesn't set a CWD for cron runs, so relative paths resolve against the agent's own dir — the installer swaps `{{PROJECT_DIR}}` for the absolute project path at install time.
## Reference
- `SKILL.md` at `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md` — the full scaffolding playbook.
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — user-facing docs.
- [`awizemann/site-status-checker`](https://awizemann.github.io/scarf/templates/awizemann-site-status-checker/) — a complete working example covering dashboard stats, a configurable list, a cron job, a Site-tab webview, and a full AGENTS.md contract. Read it when you're unsure how a piece should look.
@@ -0,0 +1,46 @@
# Scarf Template Author
A Hermes skill that teaches your agent how to scaffold a new Scarf project — and, because Scarf's `.scarftemplate` format is symmetric with a live project on disk, how to shape it so you can publish it to the catalog later if you want.
## What you get
Installing this template drops a skill at `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md` and a minimal "how to use" project in a folder of your choice. Every agent that reads the standard `~/.hermes/skills/` directory — Claude Code, Cursor, Codex, Aider, and the rest of the [agents.md](https://agents.md/) family — picks the skill up automatically.
## How to use it
After install, open your agent in any directory and say something like:
- *"Create a new Scarf project that watches the number of open PRs in my GitHub repo."*
- *"Scaffold a Scarf dashboard that tracks daily focus time from my Toggl logs."*
- *"Set up a project that runs a cron job to summarise my inbox each morning."*
- *"Help me author a Scarf template I can share."*
The agent will ask four or five questions (purpose, data source, cadence, what to display, any secrets) and then write:
- `<your-dir>/.scarf/dashboard.json`
- `<your-dir>/.scarf/manifest.json` — only if you're going to use a configuration form or want to export later
- `<your-dir>/AGENTS.md`
- `<your-dir>/README.md`
- Optionally a cron job registered via `hermes cron create` (always created paused — you enable it from Scarf's Cron sidebar when ready).
When it's done, click **+** in Scarf's Projects sidebar and pick the directory. Your dashboard appears. Iterate on it by asking your agent to tweak widgets or add fields.
## Turning a local project into a shareable template
Once you're happy with the result, Scarf → Projects → Templates → *Export "&lt;name&gt;" as Template…* produces a `.scarftemplate` anyone can install. The exporter carries the configuration *schema* but never your filled-in values — so your secrets and personal settings stay local.
## About this template's own dashboard
The installed project itself is tiny — a single Quick Start text widget and an empty list widget meant to serve as a scratchpad for tracking which scaffolded projects you've created. Its only purpose is to give you a place to land after install and a reminder of the trigger phrases above. The real value is the skill.
## Reference
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — full spec + troubleshooting.
- [`awizemann/site-status-checker`](https://awizemann.github.io/scarf/templates/awizemann-site-status-checker/) — a complete, non-trivial example the skill studies and references.
- Dashboard / configuration schemas are Swift-authoritative at `scarf/scarf/Core/Models/ProjectDashboard.swift` and `scarf/scarf/Core/Models/TemplateConfig.swift` in the Scarf repo.
## What this template intentionally is not
- Not an archetype picker. v1 is blank-slate conversational; pre-baked starters (`monitor`, `dev-dashboard`, `personal-log`, etc.) may land in v1.1 once we see what shapes people ask for most often.
- Not a graphical wizard. The conversational agent path is strictly richer than a fixed form, and dogfoods Scarf's agent-first philosophy.
- Not a remote-scaffolding tool. It writes files into a directory on the machine where the agent runs; pair with Scarf's remote-server mode if you want to scaffold onto another box.
@@ -0,0 +1,33 @@
{
"version": 1,
"title": "Template Author",
"description": "A Hermes skill that helps your agent scaffold new Scarf projects — ask in chat, answer a short interview, and land a working dashboard with the right shape to export as a .scarftemplate later. The Scaffolded Projects list below grows as you use the skill.",
"theme": { "accent": "blue" },
"sections": [
{
"title": "Quick Start",
"columns": 1,
"widgets": [
{
"type": "text",
"title": "Ask your agent",
"format": "markdown",
"content": "**This project gives you a skill, not a service.** There are no cron jobs running, no dashboards to maintain. The real value lives at `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`.\n\n**Trigger phrases** your agent listens for:\n\n- *\"Create a new Scarf project that watches …\"*\n- *\"Scaffold a dashboard to track …\"*\n- *\"Set up a project that runs a daily check on …\"*\n- *\"Help me author a Scarf template.\"*\n\nThe agent will interview you (purpose → data source → cadence → widgets → config → secrets), write `<your-dir>/.scarf/dashboard.json`, `<your-dir>/.scarf/manifest.json`, `<your-dir>/AGENTS.md`, and `<your-dir>/README.md`, then tell you to click **+** in Scarf's Projects sidebar to register the directory.\n\nWhen you're happy with the result, **Projects → Templates → Export** turns it into a `.scarftemplate` you can share.\n\nSee the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) for the full spec."
}
]
},
{
"title": "Scaffolded Projects",
"columns": 1,
"widgets": [
{
"type": "list",
"title": "Projects this skill has built for you",
"items": [
{ "text": "Nothing yet — ask your agent to scaffold a project and it'll optionally log entries here.", "status": "pending" }
]
}
]
}
]
}
@@ -0,0 +1,410 @@
---
name: scarf-template-author
description: Scaffold a new Scarf project — dashboard, optional configuration schema, optional cron job, and AGENTS.md — from a short conversational interview with the user. Output is immediately usable locally and cleanly exportable as a .scarftemplate bundle.
version: 1.0.0
author: Alan Wizemann
license: MIT
platforms: [macos]
metadata:
hermes:
tags: [Scarf, templates, scaffolding, dashboard, authoring]
homepage: https://github.com/awizemann/scarf/wiki/Project-Templates
prerequisites:
commands: [hermes]
---
# Scarf Template Author
Scaffold a new Scarf-compatible project from a conversational interview. The output is both (a) a working project on disk the user can register with Scarf and use immediately, and (b) correctly shaped to be exported as a `.scarftemplate` bundle via Scarf's Export flow later.
## When to invoke this skill
Activate when the user says things like:
- *"Create a new Scarf project that watches / tracks / reports on …"*
- *"Scaffold a dashboard for …"*
- *"Set up a project that runs a daily check on …"*
- *"Help me author a Scarf template."*
- *"Build me a Scarf project to monitor …"*
Do **not** activate for pure reference questions like *"what widget types does Scarf support?"* or *"how does Scarf handle secrets?"* — answer those inline from the reference sections below.
Also do not activate when the user explicitly wants to edit an existing project's dashboard — that's a plain file edit, not a scaffold.
## How a Scarf project is shaped on disk
A Scarf project is just a directory registered in `~/.hermes/scarf/projects.json`. For Scarf to render a useful dashboard and for the project to be exportable as a `.scarftemplate`, it needs these files at minimum:
```
<project>/
├── .scarf/
│ ├── dashboard.json # REQUIRED for dashboard rendering
│ └── manifest.json # OPTIONAL — required only if the project declares a config schema or you want to export cleanly
├── AGENTS.md # Cross-agent instructions (agents.md standard) — ship this for every project
└── README.md # User-facing explanation
```
If the project will have a scheduled job, ALSO register a cron entry via `hermes cron create`. For an exportable bundle, also author `cron/jobs.json` in the staging directory — that's where Scarf's exporter will pick jobs up from.
Secrets never land in `dashboard.json` or `config.json`. At install time, Scarf routes secret-type config values to the macOS Keychain; `config.json` stores `keychain://service/account` URIs. When scaffolding from scratch (no install), the user either manages secrets via the post-install Configuration editor after export, or stashes them in their `~/.hermes/config.yaml` if they're Hermes-level secrets rather than project-level.
## The interview
Ask these questions in order. Don't batch. Each answer shapes the next question.
### 1. Purpose and data source
- *"In one sentence — what does this project do?"*
- *"Where does its data come from? Files, a URL, a shell command's output, an API call, a database, a spreadsheet?"*
Goal: figure out whether the project is **passive** (user maintains some files, dashboard reflects them), **pull-based** (we fetch from an HTTP endpoint or CLI tool on a schedule), or **push-based** (something external writes to a file we watch).
### 2. Refresh cadence
- *"How often should it refresh? Every hour? Daily? Weekly? Only when I ask?"*
If "only when I ask" → no cron job; user invokes the agent manually. If any scheduled cadence → cron job.
Map to cron expressions:
- Every hour: `0 * * * *`
- Daily at 9 AM: `0 9 * * *`
- Weekly Monday 9 AM: `0 9 * * 1`
- Every 15 minutes: `*/15 * * * *`
### 3. What the dashboard shows
Explain the seven widget types (see Widget Catalog below) in plain English, then ask which ones feel right. Offer concrete suggestions based on the purpose:
- Counting things (open PRs, failing tests, up/down sites) → `stat` widgets.
- A list of items with status → `list` with `text` + `status` per item.
- Time-series data → `chart` with `line` or `bar` type.
- Rows × columns of heterogeneous data → `table`.
- A live URL (useful for monitoring a site) → `webview`. **Including a webview widget exposes a Site tab** next to the Dashboard tab — worth noting to the user.
- A progress bar for something with a clear 0-to-N scale → `progress`.
- Static help / markdown → `text` with `format: "markdown"`.
### 4. Configuration needs
- *"Does this project need anything configurable by the user — URLs to watch, API tokens, thresholds, a list of accounts?"*
If yes → design a config schema. Fields map to seven types (see Config Schema Design below). Remember: **secret fields never have defaults**; that's a hard validator rule.
If no → skip `.scarf/manifest.json`; the project works but won't have a Configuration form.
### 5. Target agents
- *"Which agents will operate this project? Just Claude Code? Also Cursor / Codex / Aider / other?"*
For v1 just write `AGENTS.md` — every modern agent reads it, and if you need a specific shim (CLAUDE.md, GEMINI.md, .cursorrules), add it as a symlink to AGENTS.md so content stays in sync.
## Widget Catalog (JSON shapes)
All widgets require `type` and `title`. Type-specific fields:
### `stat` — single metric
```json
{ "type": "stat", "title": "Sites Up", "value": 0,
"icon": "checkmark.circle.fill", "color": "green", "subtitle": "responded 2xx/3xx" }
```
`value` accepts number OR string (`WidgetValue` enum). `icon` is an SF Symbol name. `color` is one of: `green`, `red`, `blue`, `orange`, `yellow`, `purple`, `gray`.
### `progress` — 0.0 to 1.0 progress bar
```json
{ "type": "progress", "title": "Test Coverage", "value": 0.72, "label": "72% of statements" }
```
### `text` — markdown or plain text block
```json
{ "type": "text", "title": "Quick Start", "format": "markdown",
"content": "**1.** Click + in the Projects sidebar.\n\n**2.** ..." }
```
`format` is `"markdown"` or `"plain"`.
### `table` — columns × rows of strings
```json
{ "type": "table", "title": "Failing Tests",
"columns": ["Test", "Duration", "Last Passed"],
"rows": [["testFoo", "4.2s", "Apr 20"], ["testBar", "0.9s", "Apr 18"]] }
```
Every row MUST have the same length as `columns`.
### `chart` — line / bar / area / pie with series
```json
{ "type": "chart", "title": "Requests / day", "chartType": "line",
"xLabel": "Date", "yLabel": "Count",
"series": [{
"name": "staging",
"color": "blue",
"data": [{"x": "Apr 20", "y": 142}, {"x": "Apr 21", "y": 189}]
}]
}
```
`chartType` is `"line"`, `"bar"`, `"area"`, or `"pie"`.
### `list` — items with optional status badge
```json
{ "type": "list", "title": "Watched Sites",
"items": [
{ "text": "https://example.com", "status": "up" },
{ "text": "https://example.org", "status": "down" }
]
}
```
`status` values: `"up"`, `"down"`, `"pending"`, `"ok"`, `"warn"`, `"error"` — render as coloured badges.
### `webview` — embedded live URL
```json
{ "type": "webview", "title": "First Watched Site",
"url": "https://awizemann.github.io/scarf/", "height": 420 }
```
**Important:** including any `webview` widget in a dashboard exposes a **Site** tab next to the Dashboard tab in the project view. Useful for templates that watch something renderable. The agent can update `url` on cron runs to keep the Site tab in sync with config (e.g., set it to `values.sites[0]`).
## Config Schema Design
If the project needs user-configurable values, design a schema. Put it in `<project>/.scarf/manifest.json` with this shape:
```json
{
"schemaVersion": 2,
"id": "author/project",
"name": "My Project",
"version": "1.0.0",
"description": "Short one-liner.",
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
"config": {
"schema": [
{ "key": "sites", "type": "list", "itemType": "string", "label": "Sites",
"required": true, "minItems": 1, "maxItems": 25,
"default": ["https://example.com"] },
{ "key": "api_token", "type": "secret", "label": "API Token", "required": true }
],
"modelRecommendation": {
"preferred": "claude-haiku-4",
"rationale": "Short-running, tool-light workload — haiku is plenty."
}
}
}
```
Note: `contents.config` is the **count of schema fields**, not a boolean. In the example above it's `2` because there are two fields.
### Field types and constraints
| Type | Rendered as | Constraint keys |
|---|---|---|
| `string` | Text field | `pattern` (regex), `minLength`, `maxLength` |
| `text` | Multi-line editor | `minLength`, `maxLength` |
| `number` | Number field | `min`, `max` |
| `bool` | Toggle | — |
| `enum` | Segmented (≤4) / Dropdown (>4) | `options: [{value, label}]` (REQUIRED) |
| `list` | Repeatable rows | `itemType: "string"` (required), `minItems`, `maxItems` |
| `secret` | Password field, routes to Keychain | — |
Every field takes `key` (required), `label` (required), `description` (optional — markdown), `required` (bool), `default` (optional; type matches the field type).
### Writing good descriptions
Descriptions render inline with markdown support (bold, italic, code, links). Keep them short — a single line or two is ideal.
**Always use markdown link syntax for URLs**, never bare `https://…` — the Configuration sheet's inline text renderer doesn't word-break mid-URL, so a raw URL in a description will force that whole description's width to the URL's character length. Older Scarf versions clipped the sheet in that case; current versions wrap correctly, but the visible text is still cleaner with named links.
```json
// ✓ Good — short label, URL in the href
"description": "Token with `repo` scope. Get one [from the GitHub tokens page](https://github.com/settings/tokens)."
// ✗ Bad — raw URL bloats the visible text
"description": "Token with `repo` scope. Get one at https://github.com/settings/tokens"
```
Same rule for long file paths, API endpoints, or any other unbreakable token — wrap them in inline code (backticks) if they have to appear verbatim, and prefer markdown links otherwise.
### Hard rules
- **Secret fields MUST NOT have a `default`.** The validator rejects the manifest if they do — a default makes no sense because the Keychain entry doesn't exist yet at install time.
- **Enum fields MUST have non-empty `options`.**
- **List fields MUST have `itemType: "string"`** in v1 (only itemType supported).
- **Field keys MUST be unique** within a schema.
- **`schemaVersion` MUST be 2** when a `config` block is present; it stays 1 if there's no config.
- **`contents.config`** must equal the actual count of schema fields — a claim mismatch is rejected.
## Cron Job Design
If the project has a scheduled task, register a cron job via `hermes cron create` AND — if you expect the user to export this as a `.scarftemplate` — author a `cron/jobs.json` in the staging layout so the exporter picks it up.
### Staging shape (for exportable templates)
```
<project>/
├── .scarf/
├── AGENTS.md
├── README.md
└── cron/
└── jobs.json
```
Where `cron/jobs.json` is:
```json
[
{
"name": "Check site status",
"schedule": "0 9 * * *",
"prompt": "Read {{PROJECT_DIR}}/.scarf/config.json — get values.sites and values.timeout_seconds — then HTTP GET each URL with that timeout, write the results to {{PROJECT_DIR}}/status-log.md, and update {{PROJECT_DIR}}/.scarf/dashboard.json's stat widgets by title (Sites Up, Sites Down, Last Checked). Reply with a one-line summary."
}
]
```
### Gotchas
- **Hermes does not set a CWD when firing cron jobs.** Relative paths in the prompt resolve against wherever the Hermes process happens to be running, not the project. Always use `{{PROJECT_DIR}}` in the prompt — the installer substitutes the absolute path at install time. This is THE most common template-author mistake.
- **Cron jobs created by the installer start paused.** Their name is auto-prefixed with `[tmpl:<template-id>]`. The user enables them from Scarf's Cron sidebar when ready.
- **Registering a cron job for a user's local (non-exported) project:** run `hermes cron create --name "<descriptive name>" "<schedule>" "<prompt>"` directly, substituting the absolute `<project>` path for `{{PROJECT_DIR}}` yourself. Then `hermes cron pause <id>` so it doesn't run until the user opts in.
### Schedule quick reference
| Cadence | Expression |
|---|---|
| Every 15 minutes | `*/15 * * * *` |
| Hourly at :00 | `0 * * * *` |
| Daily at 9 AM | `0 9 * * *` |
| Weekly Monday 9 AM | `0 9 * * 1` |
| First of the month, 9 AM | `0 9 1 * *` |
## Writing the files
After the interview, write files in this order.
### Step 1 — confirm parent directory
Ask: *"Where should I create the project? Give me an absolute path — I'll make a `<project-name>` directory inside it."*
Make sure the parent exists and is writable. Make sure `<parent>/<project-name>` does NOT already exist. If it does, ask whether to pick a different name or bail.
### Step 2 — create the skeleton
```bash
mkdir -p <parent>/<project-name>/.scarf
```
### Step 3 — write `dashboard.json`
Use the Widget Catalog above. Always include:
- `version: 1`
- `title` (the project's display name)
- `description` (a one-liner shown under the title)
- `sections` (array; each has `title`, optional `columns` (14, default 3), `widgets`)
Keep section titles short. Group related widgets. First section is usually "Current Status" or similar with the key stats.
### Step 4 — write `manifest.json` (only if the project has a config schema)
Put the full manifest shape from Config Schema Design above. Use `schemaVersion: 2`, match `contents.config` to the actual field count, and ensure every secret field has no `default`.
If there's no config schema, skip this file — the project still works, it just won't have a Configuration button. You can add it later.
### Step 5 — write `AGENTS.md`
Every scaffolded project needs an `AGENTS.md` that covers:
- **Purpose** — what the project does.
- **Layout** — which files exist and what they're for.
- **Configuration** — if there's a config schema, document every field: what it's for, what valid values look like, what happens when it's missing.
- **Dashboard** — list every widget the cron job (if any) updates, by title. If the cron updates a webview widget's URL, document that explicitly.
- **Cron behaviour** — what the cron job does, what it reads, what it writes, what its exit criteria are.
- **Chat prompts** — common user questions and how to answer them (e.g., *"What's the status of my sites?"* → "read the top section of `status-log.md` and summarise").
- **What NOT to do** — e.g., *don't modify `.scarf/config.json` yourself; tell the user to open the Configuration button.*
Use `{{PROJECT_DIR}}` placeholders in AGENTS.md only if the template will be installed through the installer (which substitutes the token). For a hand-scaffolded local-only project, substitute the absolute path yourself — `{{PROJECT_DIR}}` only resolves at install time.
### Step 6 — write `README.md`
User-facing. Keep it short:
- One-paragraph purpose.
- How to install / first run (for an unexported project: "click + in Scarf's Projects sidebar").
- How to trigger the cron job manually (Cron sidebar → Run Now).
- A pointer at `AGENTS.md` for agents.
### Step 7 — register the cron job (if any)
For a local non-exported project:
```bash
hermes cron create --name "<descriptive name>" "<schedule>" "<prompt with absolute project dir substituted>"
# Then pause it so it doesn't fire until the user's ready:
hermes cron pause <newly-created-job-id>
```
Read the id back from `hermes cron list --json` or parse the create output.
For an exportable template (one you're staging in `templates/<author>/<name>/staging/`): just author `cron/jobs.json` — the installer registers + pauses at install time, and prefixes the name with `[tmpl:<id>]`.
### Step 8 — register the project with Scarf
Tell the user: *"I've written the files. Click the **+** button in Scarf's Projects sidebar and pick `<absolute-project-dir>`. The dashboard will appear."*
Do NOT edit `~/.hermes/scarf/projects.json` directly — Scarf owns that file and reloads it on its own. The UI path is safer.
### Step 9 (optional) — log to the Template Author project's list
If the user has the `awizemann/template-author` project installed (the one that shipped this skill), append an entry to its `dashboard.json`'s `Scaffolded Projects` list widget:
```json
{ "text": "<absolute-project-dir> — <one-line purpose>", "status": "ok" }
```
This gives the user a running audit trail of everything you've scaffolded for them. Preserve every other field in the dashboard as-is.
## Testing your scaffold
### Minimum smoke test
1. Tell the user to click **+** in Scarf's Projects sidebar and pick the directory.
2. Dashboard appears — sanity check every widget renders correctly.
3. If there's a cron job: click the job in Scarf's Cron sidebar → **Run Now**. The agent executes the prompt; dashboard updates when it finishes.
### Configuration-form test (only if schema was declared)
To verify the Configuration form renders, you need to *install* the project as a template — scaffolded projects don't go through the installer, so the form never runs. Export the project first:
1. Projects → Templates → **Export "&lt;name&gt;" as Template…** → save the `.scarftemplate` somewhere.
2. Projects → Templates → **Install from File…** → pick the bundle → the Configure step should render the form you designed.
3. Cancel the install (the preview sheet has a Cancel button) — you just wanted to verify the form shape.
### Catalog validation (only if publishing)
If the user plans to submit this to the public catalog at `awizemann.github.io/scarf/templates/`:
```bash
# From the repo root
./scripts/catalog.sh check
```
Validates every template in `templates/<author>/<name>/` against the Python validator — the same one the PR CI uses. Catches schema issues, claim mismatches, size violations, common secret patterns.
## Common pitfalls
Things to check before declaring the scaffold done:
- [ ] Every cron prompt uses `{{PROJECT_DIR}}` (for exported) OR an absolute path (for local-only). Relative paths will fail.
- [ ] `contents.config` in the manifest equals the actual field count. Claim mismatch = rejected.
- [ ] No `default` on any `secret` field.
- [ ] Every enum field has non-empty `options`.
- [ ] Every list field has `itemType: "string"`.
- [ ] Every table widget has rows of length equal to `columns`.
- [ ] Every webview widget has an https URL that renders something meaningful even pre-first-run (Scarf homepage is a decent placeholder).
- [ ] `dashboard.json` has `version: 1` at the top.
- [ ] `AGENTS.md` documents every config field, every updated widget, and the cron behaviour — the user relies on it as the source of truth when things drift.
- [ ] **No raw URLs in field descriptions.** Use `[link text](https://…)` markdown syntax instead — raw URLs read as long unbreakable tokens in the Configuration sheet. Same rule for long paths and other unbreakable strings; wrap in `` ` `` if they must appear verbatim.
- [ ] **Leave the `<!-- scarf-project:begin -->` / `<!-- scarf-project:end -->` region alone in the project's `AGENTS.md`.** As of Scarf v2.3, the app auto-injects a project-identity block at chat-start time (project name, directory, template id, configuration field names, cron jobs). Anything you write inside that region will be overwritten on the next chat start. Put template-specific agent instructions BELOW the block so they're preserved across refreshes.
## Reference — source of truth files
- **Dashboard widget schema**`scarf/scarf/Core/Models/ProjectDashboard.swift` in the Scarf repo. If you need exact field types or defaults, read it.
- **Config schema + validation**`scarf/scarf/Core/Models/TemplateConfig.swift` and `scarf/scarf/Core/Services/ProjectConfigService.swift`.
- **Exporter behaviour**`scarf/scarf/Core/Services/ProjectTemplateExporter.swift`. Verifies what files the exporter will pick up from a live project and what it'll carry into a bundle.
- **Installer contract**`scarf/scarf/Core/Services/ProjectTemplateInstaller.swift`. Verifies what `{{PROJECT_DIR}}` substitution covers and where installed files land.
- **Catalog validator**`tools/build-catalog.py` in the Scarf repo. Run with `./scripts/catalog.sh check` for the same rules CI uses.
- **Worked example**`templates/awizemann/site-status-checker/staging/` in the Scarf repo. Complete end-to-end: dashboard with stats + list + webview, a config schema with a list + a number, a cron job, an AGENTS.md that documents every moving part. Read it first whenever you're unsure how a piece should look.
- **User-facing docs** — [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates).
@@ -0,0 +1,19 @@
{
"schemaVersion": 2,
"id": "awizemann/template-author",
"name": "Scarf Template Author",
"version": "1.0.0",
"description": "Install this to give your agent a skill that scaffolds new Scarf projects — dashboards, optional configuration schemas, cron jobs, and AGENTS.md — from a short conversational interview. Scaffolded projects are usable locally and cleanly exportable as .scarftemplate bundles.",
"minScarfVersion": "2.2.0",
"author": {
"name": "Alan Wizemann",
"url": "https://github.com/awizemann"
},
"category": "developer-tools",
"tags": ["meta", "authoring", "skill", "scaffolding"],
"contents": {
"dashboard": true,
"agentsMd": true,
"skills": ["scarf-template-author"]
}
}
+31
View File
@@ -63,6 +63,37 @@
"configurable" "configurable"
], ],
"version": "1.1.0" "version": "1.1.0"
},
{
"author": {
"name": "Alan Wizemann",
"url": "https://github.com/awizemann"
},
"bundleSha256": "56ab97eeb45ab7b9e6715ce9c88ec2c953bf795698cd19628d300d5b8cffd475",
"bundleSize": 14610,
"category": "developer-tools",
"config": null,
"contents": {
"agentsMd": true,
"dashboard": true,
"skills": [
"scarf-template-author"
]
},
"description": "Install this to give your agent a skill that scaffolds new Scarf projects \u2014 dashboards, optional configuration schemas, cron jobs, and AGENTS.md \u2014 from a short conversational interview. Scaffolded projects are usable locally and cleanly exportable as .scarftemplate bundles.",
"detailSlug": "awizemann-template-author",
"id": "awizemann/template-author",
"installUrl": "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/template-author/template-author.scarftemplate",
"minHermesVersion": null,
"minScarfVersion": "2.2.0",
"name": "Scarf Template Author",
"tags": [
"meta",
"authoring",
"skill",
"scaffolding"
],
"version": "1.0.0"
} }
] ]
} }