Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8fcd699f2 | |||
| d82b28258d | |||
| 323a1e55f4 | |||
| 959a68b707 |
@@ -105,79 +105,6 @@ Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTe
|
||||
|
||||
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
|
||||
|
||||
### Template configuration (v2.3, schemaVersion 2)
|
||||
|
||||
Templates can declare a typed configuration schema in `template.json`'s new `config` block. The installer renders a **Configure** step between the parent-directory pick and the preview sheet; values land at `<project>/.scarf/config.json` (non-secret) and in the login Keychain (secret). A post-install **Configuration** button on the dashboard header (shown when `<project>/.scarf/manifest.json` exists) opens the same form pre-filled for editing.
|
||||
|
||||
Manifest shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"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}
|
||||
],
|
||||
"modelRecommendation": {
|
||||
"preferred": "claude-sonnet-4.5",
|
||||
"rationale": "Tool-heavy workload — reasoning helps."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported field types: `string`, `text`, `number`, `bool`, `enum` (with `options: [{value, label}]`), `list` (itemType `"string"` only in v1), `secret`. Type-specific constraints (`pattern`, `min`/`max`, `minLength`/`maxLength`, `minItems`/`maxItems`) are optional. `secret` fields **must not** declare a `default` — the validator refuses.
|
||||
|
||||
Key services: [TemplateConfig.swift](scarf/scarf/Core/Models/TemplateConfig.swift) (schema + value models + Keychain ref helpers), [ProjectConfigKeychain.swift](scarf/scarf/Core/Services/ProjectConfigKeychain.swift) (thin `SecItemAdd`/`Copy`/`Delete` wrapper; the only Keychain user in Scarf today), [ProjectConfigService.swift](scarf/scarf/Core/Services/ProjectConfigService.swift) (load/save config.json, resolve secrets, cache manifest, validate schema + values). UI in [Features/Templates/ViewModels/TemplateConfigViewModel.swift](scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift) + [Features/Templates/Views/TemplateConfigSheet.swift](scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift).
|
||||
|
||||
**Secret storage.** Keychain service name is `com.scarf.template.<slug>`, account is `<fieldKey>:<project-path-hash-short>`. The path-hash suffix means two installs of the same template in different dirs don't collide on Keychain entries. Values in `config.json` are `"keychain://service/account"` URIs — never plaintext. The bytes hit the Keychain only on form commit, so cancelling never leaves orphan entries.
|
||||
|
||||
**Uninstall.** `TemplateLock` v2 gains `config_keychain_items` and `config_fields` arrays. The uninstaller iterates each URI through `SecItemDelete` before removing the lock file. Absent items (user hand-cleaned) are no-ops.
|
||||
|
||||
**Exporter.** Carries the *schema* from `<project>/.scarf/manifest.json` through into exported bundles, never values. Exporting never leaks anyone's secrets. `schemaVersion` bumps to 2 only when a schema is forwarded; schema-less exports stay at 1.
|
||||
|
||||
**Catalog site.** [tools/build-catalog.py](tools/build-catalog.py) mirrors the Swift schema validator. Each v2 template's `template.json` is copied into `.gh-pages-worktree/templates/<slug>/manifest.json` and the site's `widgets.js` calls `ScarfWidgets.renderConfigSchema` to display the schema on the detail page (display-only — the form lives in-app).
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
|
||||
@@ -19,24 +19,13 @@
|
||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
||||
</p>
|
||||
|
||||
## What's New in 2.3
|
||||
## What's New in 2.2
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, and cron jobs 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.
|
||||
- **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), and a live diff of any memory appendix. Nothing writes until you click Install.
|
||||
- **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 everything written for easy uninstall. Templates **never** touch `config.yaml`, `auth.json`, sessions, or credentials.
|
||||
|
||||
See the full [v2.3.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.3.0) and the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates).
|
||||
|
||||
### 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.
|
||||
See the full [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0).
|
||||
|
||||
### Previously, in 2.1
|
||||
|
||||
|
||||
@@ -1,50 +1,15 @@
|
||||
## What's New in 2.2.0
|
||||
|
||||
Scarf projects can now travel. This release introduces **Project Templates** — a shareable `.scarftemplate` bundle format that packages a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a single file anyone can install with one click. Bundles are agent-portable by design: every template ships with a cross-agent [`AGENTS.md`](https://agents.md/) so the instructions work natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and every other agent that reads the Linux Foundation standard.
|
||||
|
||||
This is also the first release to ship a public **template catalog website** — a static site generated from `templates/<author>/<name>/` in this repo, previewed at [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/), with a CI-enforced validator for community submissions.
|
||||
Scarf projects can now travel. This release introduces **Project Templates** — a shareable `.scarftemplate` bundle format that packages a project's dashboard, agent instructions, skills, and cron jobs into a single file anyone can install with one click from a local file or an `scarf://install?url=…` deep link.
|
||||
|
||||
### Project Templates
|
||||
|
||||
- **Bundle format: `.scarftemplate`.** A zip carrying a `template.json` manifest, the project's dashboard, a required `AGENTS.md` (the [Linux Foundation cross-agent instructions standard](https://agents.md/) — reads natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and more), a README shown in the installer, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), optional namespaced skills, optional cron job definitions, and an optional memory appendix.
|
||||
- **Install preview sheet.** Before anything touches disk, Scarf shows you the exact project directory that will be created, every file inside it, every skill that will be namespaced under `~/.hermes/skills/templates/<slug>/`, every cron job that will be registered (always paused — you enable each one manually), and a live diff of the memory appendix against your existing `MEMORY.md`. Markdown fields — the README, field descriptions, cron prompts — render inline. The manifest's content claim is cross-checked against the actual zip entries so a bundle can't hide files from the preview.
|
||||
- **Bundle format: `.scarftemplate`.** A zip archive carrying a `template.json` manifest, the project's dashboard, a required `AGENTS.md` (the [Linux Foundation cross-agent instructions standard](https://agents.md/) — reads natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and more), a README shown in the installer, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), optional namespaced skills, optional cron job definitions, and an optional memory appendix. Every bundle is agent-portable out of the box.
|
||||
- **Install preview sheet.** Before anything touches disk, Scarf shows you the exact project directory that will be created, every file inside it, every skill that will be namespaced under `~/.hermes/skills/templates/<slug>/`, every cron job that will be registered (always paused — you enable each one manually), and a live diff of the memory appendix against your existing `MEMORY.md`. The manifest's content claim is cross-checked against the actual zip entries so a bundle can't hide files from the preview.
|
||||
- **`scarf://install?url=…` deep links.** Register Scarf as the handler for the `scarf` URL scheme so a future catalog site can link one-click installs straight into the app. Only `https://` payloads are accepted; `file://`, `javascript:`, and `http://` are refused on principle. A 50 MB size cap keeps a malicious link from exhausting disk. The URL never auto-installs — the preview sheet is always user-confirmed.
|
||||
- **Install-time token substitution.** Template authors use `{{PROJECT_DIR}}`, `{{TEMPLATE_ID}}`, and `{{TEMPLATE_SLUG}}` placeholders in cron prompts; the installer resolves them to absolute paths at install time so the registered cron job works regardless of where Hermes sets CWD.
|
||||
- **Export any project as a template.** Select a project, open the new Templates menu in the Projects toolbar, fill in a handful of fields (id, name, version, description, optional author + category + tags), tick the skills and cron jobs you want to include, optionally drop in a memory snippet, and save. The exporter carries the authored configuration schema forward but **never** the user's values — exports are safe on projects with live config.
|
||||
- **No-overwrite, reversible by design.** Installed templates drop a `<project>/.scarf/template.lock.json` recording exactly what they wrote — every project file, skill path, cron job name, memory block id, and Keychain reference. Installing the same template id twice is refused at the preview step so you don't accidentally double-append to `MEMORY.md`.
|
||||
- **Safe globals.** Skills install to `~/.hermes/skills/templates/<slug>/<skill-name>/` so they never collide with your own skills. Cron jobs are prefixed with `[tmpl:<id>]` and start paused. The installer **never** touches `~/.hermes/config.yaml`, `auth.json`, sessions, or any credential-bearing path.
|
||||
|
||||
### Template Configuration (schemaVersion 2)
|
||||
|
||||
Templates can now declare a typed configuration schema that drives a form step during install — no more "edit a `sites.txt` file to get started."
|
||||
|
||||
- **Typed field vocabulary.** Seven field types: `string`, `text` (multiline), `number` (with `min`/`max`), `bool`, `enum` (with `{value, label}` options), `list` (of strings, with `minItems`/`maxItems`), and `secret` (routed to the macOS Keychain). Constraints per type — `pattern` for regex, `minLength`/`maxLength` for text, etc. — are enforced at install and at edit time.
|
||||
- **Configure step in the install flow.** If the template declares a schema, a **Configure** screen is inserted between "pick parent directory" and the preview sheet. Non-secret values land in `<project>/.scarf/config.json`; secrets land in the macOS Keychain with a service name of `com.scarf.template.<slug>` and an account keyed to the project-directory hash (so two installs of the same template in different dirs don't collide on Keychain entries).
|
||||
- **Post-install Configuration editor.** A slider icon in the dashboard header opens the same form pre-filled with the current values. Change a site, rotate a token, toggle a feature — the cron job picks up the new values on its next run. Secrets are never echoed back ("Saved in Keychain — leave empty to keep the stored value").
|
||||
- **Model recommendations.** Templates can suggest a preferred model (`claude-sonnet-4.5`, `claude-haiku-4`, `gpt-4.1`, etc.) with a rationale. Scarf surfaces the recommendation in the configure sheet without auto-switching your active model — always your call.
|
||||
- **Secrets are tracked in the lock file.** Uninstalling a template runs `SecItemDelete` on every Keychain ref recorded at install, so a full clean-up leaves nothing behind. Absent entries (user already cleaned them) are no-ops.
|
||||
|
||||
### Template Catalog
|
||||
|
||||
A Sparkle-style pipeline for community-contributed templates, living on the same `gh-pages` branch as the auto-update feed.
|
||||
|
||||
- **Static site.** [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) — generated from every `templates/<author>/<name>/` directory. Each template gets a detail page showing the README, a live preview of the post-install dashboard, and the configuration schema rendered with human-readable constraint summaries. One-click install via the `scarf://install?url=…` button.
|
||||
- **Stdlib-only Python validator.** `tools/build-catalog.py` is a no-external-dependencies Python script that mirrors the Swift-side schema and validation invariants (supported widget types, supported field types, `contents` claim verification, secret-with-default rejection, bundle-size cap, high-confidence secret patterns). Run it locally with `./scripts/catalog.sh check` before submitting a PR.
|
||||
- **CI gate on PRs.** [`.github/workflows/validate-template-pr.yml`](https://github.com/awizemann/scarf/blob/main/.github/workflows/validate-template-pr.yml) runs the validator + its 24-test suite on every PR touching `templates/`, the validator itself, or its tests. Failing PRs get an inline comment with the last 3 KB of the validator output; passing PRs get a tailored checklist naming the specific template directory being changed.
|
||||
- **Install-URL hosting.** Bundles are raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template GitHub Releases ceremony.
|
||||
- **Dogfood: the site uses Scarf's dashboard format.** `site/widgets.js` is ~300 lines of vanilla JS that renders a `ProjectDashboard` JSON using the same widget vocabulary the app uses, so each detail page's "live preview" is the actual dashboard the user will get.
|
||||
|
||||
### Example template: `awizemann/site-status-checker`
|
||||
|
||||
Ships as the first catalog entry and exercises every v2.2 surface. [See it in the catalog →](https://awizemann.github.io/scarf/templates/awizemann-site-status-checker/)
|
||||
|
||||
- Configure step asks for a list of URLs and a per-URL timeout.
|
||||
- A paused cron job runs daily at 09:00 (editable from the Cron sidebar), does HTTP GETs with 3-redirect follow, writes a timestamped results table to `status-log.md`, updates the dashboard's Sites Up / Sites Down / Last Checked stat widgets plus the Watched Sites list, and rewrites the Site tab's webview URL to the first configured site.
|
||||
- Works in any agent — the `AGENTS.md` is the single source of truth; no per-agent shim needed.
|
||||
|
||||
### Site tab
|
||||
|
||||
A dashboard with at least one `webview` widget now exposes a **Site** tab next to Dashboard. Useful for templates that watch something renderable (a site, a preview endpoint, a Grafana panel). The `site-status-checker` example rewrites the webview URL to the first configured site on every cron run, so the tab stays in sync with live config.
|
||||
- **Export any project as a template.** Select a project, open the new Templates menu in the Projects toolbar, fill in a handful of fields (id, name, version, description, optional author + category + tags), tick the skills and cron jobs you want to include, optionally drop in a memory snippet, and save. The exporter builds the bundle and you can hand it to anyone.
|
||||
- **No-overwrite, reversible by design.** Installed templates drop a `<project>/.scarf/template.lock.json` recording exactly what they wrote — every project file, skill path, cron job name, and memory block id. Installing the same template id twice is refused at the preview step so you don't accidentally double-append to `MEMORY.md`. Uninstalling by hand is a matter of deleting the project directory, the skills namespace folder, and any `[tmpl:<id>] …` cron jobs — no hidden state.
|
||||
- **Safe globals.** Skills install to `~/.hermes/skills/templates/<slug>/<skill-name>/` so they never collide with your own skills. Cron jobs are prefixed with `[tmpl:<id>]` and start paused so nothing unexpected kicks off on install. The installer **never** touches `~/.hermes/config.yaml`, `auth.json`, sessions, or any credential-bearing path.
|
||||
|
||||
### Using templates
|
||||
|
||||
@@ -52,43 +17,29 @@ A dashboard with at least one `webview` widget now exposes a **Site** tab next t
|
||||
- **Install from URL:** Projects → Templates → *Install from URL…*, paste an https URL.
|
||||
- **Install from the web:** click any `scarf://install?url=…` link in a browser.
|
||||
- **Export:** select a project → Projects → Templates → *Export "<name>" as Template…*, fill the form, save.
|
||||
- **Edit config post-install:** slider icon in the dashboard header.
|
||||
- **Uninstall:** right-click the project in the sidebar → *Uninstall Template (remove installed files)…*, or click the uninstall icon in the dashboard header. The preview sheet lists every file, cron job, Keychain secret, and memory block that will be removed, plus every user-created file that will be preserved.
|
||||
|
||||
### UX clarifications
|
||||
|
||||
- **Remove from List vs. Uninstall Template.** Sidebar context-menu labels clarified so you can see at a glance whether a click is destructive. *Remove from List (keep files)…* is registry-only — nothing on disk is touched, cron jobs stay, Keychain secrets stay. A confirmation dialog spells this out before the click lands. *Uninstall Template (remove installed files)…* is the full, lock-driven cleanup.
|
||||
- **Post-uninstall "folder kept" banner.** When the uninstaller preserves the project directory because the cron wrote a `status-log.md` (or the user dropped files in there), the success view now explicitly lists the preserved paths with a pointer to delete the folder from Finder if desired.
|
||||
- **Run Now no longer blocks on agent runs.** The Cron sidebar's Run Now button used to show a "Run failed" toast whenever an agent job ran longer than 60 s — even when the job was finishing correctly in the background. Run Now now shows "Agent started — dashboard will update when it finishes" immediately and the dashboard watcher picks up the completed state when it lands (timeout bumped to 300 s for the catch-stuck-process case).
|
||||
|
||||
### Uninstall
|
||||
|
||||
- **One-click uninstall** driven by `template.lock.json`. The preview sheet lists every file, cron job, Keychain ref, and memory block that will be removed, and every user-created file that will be preserved.
|
||||
- **User content is never removed.** Files you (or the agent) added to the project dir after install — like a `sites.txt` or `status-log.md` — are detected and listed as "keep" in the preview. The project directory itself is removed only if nothing user-owned is left inside.
|
||||
- **Clean global state.** The isolated `~/.hermes/skills/templates/<slug>/` namespace is removed wholesale. Tagged cron jobs are removed via `hermes cron remove`. Every recorded Keychain ref is cleared via `SecItemDelete`. The memory block between the `<!-- scarf-template:<id>:begin/end -->` markers is stripped, leaving the rest of MEMORY.md intact. The project registry entry is removed last.
|
||||
- **No undo.** Uninstall is destructive — to reinstall, run the install flow again.
|
||||
|
||||
### Under the hood
|
||||
|
||||
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock file v2) and `Core/Models/TemplateConfig.swift` (schema + typed values + Keychain ref model).
|
||||
- `Core/Services/ProjectTemplateService.swift` unzips, parses, and validates; `ProjectTemplateInstaller.swift` executes the plan with preflight + fail-fast semantics; `ProjectTemplateUninstaller.swift` reverses an install driven by the lock file; `ProjectTemplateExporter.swift` builds bundles from a live project + selections.
|
||||
- `Core/Services/ProjectConfigService.swift` owns load/save/validation of `<project>/.scarf/config.json` + secret resolution; `Core/Services/ProjectConfigKeychain.swift` is the thin `SecItemAdd`/`Copy`/`Delete` wrapper (the only Keychain consumer in Scarf today).
|
||||
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock, errors).
|
||||
- `Core/Services/ProjectTemplateService.swift` unzips, parses, and validates; `ProjectTemplateInstaller.swift` executes the plan atomically-enough (pre-flights conflicts, then writes); `ProjectTemplateExporter.swift` builds bundles from a live project + selections.
|
||||
- `Core/Services/TemplateURLRouter.swift` is the process-wide landing pad for `scarf://` URLs so a cold-launch browser click still reaches the install sheet.
|
||||
- New Swift Testing suites covering 57 tests across the service / installer / uninstaller / exporter / config / Keychain / URL-router paths.
|
||||
- New Python validator (`tools/build-catalog.py`) + test suite (`tools/test_build_catalog.py`, 24 tests) mirrors the Swift invariants for the CI gate and the site generator. Schema is Swift-primary — additions go to Swift first, Python mirrors.
|
||||
- `scripts/catalog.sh` wraps the validator with `check / build / preview / serve / publish` subcommands that parallel the `scripts/release.sh` shape.
|
||||
- Installer dispatches cron creation via `hermes cron create` (there's no direct Scarf write path for `cron/jobs.json`), then diffs before/after to pause the newly-registered jobs.
|
||||
- New Swift Testing suites: `ProjectTemplateServiceTests`, `TemplateURLRouterTests`, `ProjectTemplateExportTests`.
|
||||
|
||||
### Uninstall
|
||||
|
||||
- **One-click uninstall** driven by `template.lock.json`. Right-click any template-installed project in the sidebar → **Uninstall Template…**, or click the uninstall button in the dashboard header. A preview sheet lists every file, cron job, and memory block that will be removed, and every user-created file that will be preserved.
|
||||
- **User content is never removed.** Files you (or the agent) added to the project dir after install — like a `sites.txt` or `status-log.md` — are detected and listed as "keep" in the preview. The project directory itself is removed only if nothing user-owned is left inside.
|
||||
- **Clean global state.** The isolated `~/.hermes/skills/templates/<slug>/` namespace is removed wholesale. Tagged cron jobs are removed via `hermes cron remove`. The memory block between the `<!-- scarf-template:<id>:begin/end -->` markers is stripped, leaving the rest of MEMORY.md intact. The project registry entry is removed last.
|
||||
- **No undo.** v1 uninstall is destructive — to reinstall, run the install flow again.
|
||||
|
||||
### Not in this release (planned for v2.3)
|
||||
|
||||
- In-app catalog browser backed by a GitHub Pages `templates.json`.
|
||||
- EdDSA-signed bundles reusing the Sparkle key.
|
||||
- Template updates (compare installed lock against a newer bundle's version, offer a diff).
|
||||
- Installing into remote `ServerContext`s (v1 is local-only).
|
||||
|
||||
### Migrating from 2.1.x
|
||||
|
||||
Sparkle will offer the update automatically. No config migration needed. Existing projects are untouched — templates are additive. If you had a v2.2.0-dev install of the earlier `project-templates` branch, uninstall and reinstall any previously-installed templates to pick up the schema-version-2 lock file.
|
||||
|
||||
### 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/) — the public catalog with live dashboard previews.
|
||||
- [`templates/CONTRIBUTING.md`](https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md) — how to submit a template via PR.
|
||||
- [Architecture notes in root `CLAUDE.md`](https://github.com/awizemann/scarf/blob/main/CLAUDE.md#project-templates) — service-layer map, Keychain scheme, schema-drift discipline.
|
||||
|
||||
### Thanks
|
||||
|
||||
Thanks to everyone who tested drafts of the install flow, caught the "Run Now blocks on agent" bug, and pushed on the Remove-vs-Uninstall UX until it was clear. A 2.3 follow-up will extend the catalog validator to enforce per-field-type constraints at PR-time (currently enforced on install but not at submission).
|
||||
Sparkle will offer the update automatically. No config migration needed. Existing projects are untouched — templates are additive.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,104 +0,0 @@
|
||||
## What's New in 2.3.0
|
||||
|
||||
The projects sidebar stops being a flat list and becomes a workspace. Folders, rename + archive + search + keyboard jumps, a per-project Sessions tab with a one-click New Chat button, and — the big architectural piece — every project-scoped chat now automatically carries Scarf-managed context into the agent itself, so the agent knows what project it's operating in without any user prompting.
|
||||
|
||||
### 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.
|
||||
- **22 new Swift tests** across `ProjectRegistryMigrationTests`, `ProjectsViewModelTests`, `SessionAttributionServiceTests`, `ProjectAgentContextServiceTests`. Total: 93 tests.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
- **Root `CLAUDE.md`** — new subsection "Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)" under Project Templates, covering the sidecar, the marker contract, invariants, and the template-author 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.
|
||||
@@ -56,56 +56,3 @@ Rich usage analytics pulled from the sessions and messages SQLite tables:
|
||||
|
||||
### 10. Config Editor
|
||||
- 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.
|
||||
|
||||
@@ -436,7 +436,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -449,7 +449,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -471,7 +471,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -484,7 +484,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -502,12 +502,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -524,12 +524,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -545,11 +545,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -565,11 +565,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 962 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 742 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 742 KiB |
@@ -1,61 +1,61 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-16x16@1x.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-16x16@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-16x16@2x.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-32x32@1x.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-32x32@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-32x32@2x.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-128x128@1x.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-128x128@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-128x128@2x.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-256x256@1x.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-256x256@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-256x256@2x 1.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-512x512@1x.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-1024x1024@1x.png",
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-1024x1024@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
||||
@@ -55,18 +55,6 @@ struct HermesPathSet: Sendable, Hashable {
|
||||
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||
nonisolated var scarfDir: String { home + "/scarf" }
|
||||
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||
|
||||
/// Maps Hermes session IDs to the Scarf project path a chat was
|
||||
/// started for. Written by `SessionAttributionService` when
|
||||
/// Scarf spawns `hermes acp` with a project-scoped cwd; read by
|
||||
/// the per-project Sessions tab (v2.3) to filter the session list
|
||||
/// to just those attributed to a given project.
|
||||
///
|
||||
/// Scarf-owned — Hermes never touches this file. Forward-only:
|
||||
/// we only attribute sessions Scarf creates in a project context;
|
||||
/// older / CLI-started sessions stay unattributed and surface in
|
||||
/// the global Sessions sidebar unchanged.
|
||||
nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
|
||||
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
@@ -11,63 +11,7 @@ struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
|
||||
let name: String
|
||||
let path: String
|
||||
|
||||
/// Folder path for sidebar grouping. `nil` means top-level (no
|
||||
/// folder). Introduced in v2.3 — v2.2 registry files have no
|
||||
/// `folder` key, which decodes cleanly as `nil` via
|
||||
/// `decodeIfPresent` below.
|
||||
///
|
||||
/// We leave shape flexible: today this is treated as an opaque
|
||||
/// single-level label (e.g. "Clients"), and the sidebar renders
|
||||
/// one DisclosureGroup per distinct value. If nesting becomes a
|
||||
/// requirement later, we can interpret the string as a slash-
|
||||
/// separated path without a migration (old single-label values
|
||||
/// still mean a top-level folder with that name).
|
||||
var folder: String?
|
||||
|
||||
/// Soft-archive flag. Archived projects are hidden from the
|
||||
/// sidebar by default; a Show Archived toggle surfaces them.
|
||||
/// Non-destructive — nothing is deleted on disk. Introduced in
|
||||
/// v2.3; v2.2 registry files default to `false` via the custom
|
||||
/// decoder below.
|
||||
var archived: Bool
|
||||
|
||||
var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
||||
|
||||
init(name: String, path: String, folder: String? = nil, archived: Bool = false) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
self.archived = archived
|
||||
}
|
||||
|
||||
// MARK: - Codable (custom for backward compat)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, path, folder, archived
|
||||
}
|
||||
|
||||
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)
|
||||
// Both new fields: tolerate absence for v2.2-era registries.
|
||||
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
|
||||
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
|
||||
}
|
||||
|
||||
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)
|
||||
// Only emit optional fields when they carry meaning — keeps
|
||||
// registry files clean for the common (top-level, unarchived)
|
||||
// case and means v2.2 Scarf can still load a v2.3-written
|
||||
// registry of projects that never used the new features.
|
||||
try c.encodeIfPresent(folder, forKey: .folder)
|
||||
if archived {
|
||||
try c.encode(archived, forKey: .archived)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
@@ -23,12 +23,6 @@ struct ProjectTemplateManifest: Codable, Sendable, Equatable {
|
||||
let icon: String?
|
||||
let screenshots: [String]?
|
||||
let contents: TemplateContents
|
||||
/// Optional configuration schema (added in manifest schemaVersion 2).
|
||||
/// When present, the installer presents a form during install and
|
||||
/// writes values to `<project>/.scarf/config.json` + the Keychain.
|
||||
/// Schema-v1 manifests omit this field entirely — Codable's
|
||||
/// optional-field decoding keeps them working unchanged.
|
||||
let config: TemplateConfigSchema?
|
||||
|
||||
/// Filesystem-safe slug derived from `id` (`"owner/name"` → `"owner-name"`).
|
||||
/// Used for the install directory name, skills namespace, and cron-job tag.
|
||||
@@ -57,11 +51,6 @@ struct TemplateContents: Codable, Sendable, Equatable {
|
||||
let skills: [String]?
|
||||
let cron: Int?
|
||||
let memory: TemplateMemoryClaim?
|
||||
/// Number of configuration fields the template ships (schemaVersion 2+).
|
||||
/// Cross-checked against `manifest.config?.fields.count` by the
|
||||
/// validator so a bundle can't hide a schema from the preview.
|
||||
/// `nil` or `0` means schema-less (v1-compatible behaviour).
|
||||
let config: Int?
|
||||
}
|
||||
|
||||
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
|
||||
@@ -141,39 +130,10 @@ struct TemplateInstallPlan: Sendable {
|
||||
/// `ProjectEntry.name` that will be appended to the projects registry.
|
||||
let projectRegistryName: String
|
||||
|
||||
/// Configuration schema declared by the template (manifest schemaVersion 2).
|
||||
/// `nil` means the template is schema-less — the installer skips the
|
||||
/// config sheet and writes no `.scarf/config.json` or manifest cache.
|
||||
let configSchema: TemplateConfigSchema?
|
||||
|
||||
/// Values the user entered in the configure sheet. Populated by the
|
||||
/// VM just before `install()` runs; empty when `configSchema` is nil.
|
||||
/// Secrets appear here as `.keychainRef(...)` — the bytes themselves
|
||||
/// were routed straight from the form field into the Keychain and
|
||||
/// never held in memory past that point.
|
||||
var configValues: [String: TemplateConfigValue]
|
||||
|
||||
/// Path at which the installer will stash a copy of `template.json`
|
||||
/// so the post-install Configuration editor can render the form
|
||||
/// offline. `nil` when `configSchema` is nil.
|
||||
let manifestCachePath: String?
|
||||
|
||||
/// Convenience: total number of writes (files + cron jobs + optional
|
||||
/// memory append + registry append + optional config.json + one
|
||||
/// entry per secret written to the Keychain). Displayed in the
|
||||
/// preview sheet.
|
||||
/// memory append + registry append). Displayed in the preview sheet.
|
||||
nonisolated var totalWriteCount: Int {
|
||||
let configFileCount = (configSchema?.isEmpty ?? true) ? 0 : 1
|
||||
let secretCount = configValues.values.filter {
|
||||
if case .keychainRef = $0 { return true } else { return false }
|
||||
}.count
|
||||
return projectFiles.count
|
||||
+ skillsFiles.count
|
||||
+ cronJobs.count
|
||||
+ (memoryAppendix == nil ? 0 : 1)
|
||||
+ 1 // registry entry
|
||||
+ configFileCount
|
||||
+ secretCount
|
||||
projectFiles.count + skillsFiles.count + cronJobs.count + (memoryAppendix == nil ? 0 : 1) + 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,17 +161,6 @@ struct TemplateLock: Codable, Sendable {
|
||||
let skillsFiles: [String]
|
||||
let cronJobNames: [String]
|
||||
let memoryBlockId: String?
|
||||
/// Every `keychain://service/account` URI the installer stored in
|
||||
/// the Keychain for this project's secret fields. Empty/nil for
|
||||
/// schema-less (v1-style) installs. The uninstaller iterates this
|
||||
/// list and calls `SecItemDelete` for each entry; absent on older
|
||||
/// lock files so Codable's optional decoding keeps pre-2.3 installs
|
||||
/// uninstallable.
|
||||
let configKeychainItems: [String]?
|
||||
/// Field keys the installer wrote to `<project>/.scarf/config.json`.
|
||||
/// Informational — the actual removal of config.json rides on
|
||||
/// `projectFiles`. Optional for back-compat.
|
||||
let configFields: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case templateId = "template_id"
|
||||
@@ -223,8 +172,6 @@ struct TemplateLock: Codable, Sendable {
|
||||
case skillsFiles = "skills_files"
|
||||
case cronJobNames = "cron_job_names"
|
||||
case memoryBlockId = "memory_block_id"
|
||||
case configKeychainItems = "config_keychain_items"
|
||||
case configFields = "config_fields"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Schema (ships inside template.json as manifest.config)
|
||||
|
||||
/// Author-declared configuration schema for a template. Published as the
|
||||
/// `config` block of `template.json` (manifest schemaVersion 2). Users fill
|
||||
/// in values at install time via `TemplateConfigSheet`; values land in
|
||||
/// `<project>/.scarf/config.json` with secrets resolved through the
|
||||
/// macOS Keychain.
|
||||
struct TemplateConfigSchema: Codable, Sendable, Equatable {
|
||||
let fields: [TemplateConfigField]
|
||||
let modelRecommendation: TemplateModelRecommendation?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fields = "schema"
|
||||
case modelRecommendation
|
||||
}
|
||||
|
||||
nonisolated var isEmpty: Bool { fields.isEmpty }
|
||||
|
||||
/// Fast lookup by key. Validators guarantee keys are unique within a
|
||||
/// schema at manifest-parse time, so this is safe.
|
||||
nonisolated func field(for key: String) -> TemplateConfigField? {
|
||||
fields.first { $0.key == key }
|
||||
}
|
||||
}
|
||||
|
||||
/// One configurable field the user fills in. Discriminated by `type`.
|
||||
/// We keep one flat struct rather than an enum-associated-value encoding
|
||||
/// so JSON reads cleanly as a record and authors can hand-edit manifests
|
||||
/// without fighting Swift's `"case"` discriminator syntax.
|
||||
struct TemplateConfigField: Codable, Sendable, Equatable, Identifiable {
|
||||
nonisolated var id: String { key }
|
||||
|
||||
let key: String
|
||||
let type: FieldType
|
||||
let label: String
|
||||
let description: String?
|
||||
let required: Bool
|
||||
let placeholder: String?
|
||||
|
||||
// Type-specific constraints — all optional. The validator enforces
|
||||
// only the ones that apply to `type`; extras are ignored.
|
||||
let defaultValue: TemplateConfigValue?
|
||||
let options: [EnumOption]? // type == .enum
|
||||
let minLength: Int? // type == .string / .text
|
||||
let maxLength: Int?
|
||||
let pattern: String? // type == .string (regex)
|
||||
let minNumber: Double? // type == .number
|
||||
let maxNumber: Double?
|
||||
let step: Double?
|
||||
let itemType: String? // type == .list — only "string" supported in v1
|
||||
let minItems: Int?
|
||||
let maxItems: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case key, type, label, description, required, placeholder
|
||||
case defaultValue = "default"
|
||||
case options
|
||||
case minLength, maxLength, pattern
|
||||
case minNumber = "min"
|
||||
case maxNumber = "max"
|
||||
case step
|
||||
case itemType, minItems, maxItems
|
||||
}
|
||||
|
||||
enum FieldType: String, Codable, Sendable, Equatable {
|
||||
case string
|
||||
case text
|
||||
case number
|
||||
case bool
|
||||
case `enum`
|
||||
case list
|
||||
case secret
|
||||
}
|
||||
|
||||
/// One option of an `enum` field. `value` is what ends up in
|
||||
/// `config.json`; `label` is the human-readable text shown in the UI.
|
||||
struct EnumOption: Codable, Sendable, Equatable, Identifiable {
|
||||
nonisolated var id: String { value }
|
||||
let value: String
|
||||
let label: String
|
||||
}
|
||||
}
|
||||
|
||||
/// Author's model-of-choice hint, shown in the install preview + on the
|
||||
/// catalog detail page. Purely advisory — Scarf never auto-switches the
|
||||
/// active model. Individual cron jobs can override via
|
||||
/// `HermesCronJob.model` if the author wants enforcement.
|
||||
struct TemplateModelRecommendation: Codable, Sendable, Equatable {
|
||||
let preferred: String
|
||||
let rationale: String?
|
||||
let alternatives: [String]?
|
||||
}
|
||||
|
||||
// MARK: - Values (what lands in config.json and the Keychain)
|
||||
|
||||
/// One configured value. Secrets don't carry their raw bytes — only a
|
||||
/// Keychain reference of the form `"keychain://<service>/<account>"` so
|
||||
/// serialising config.json to disk never leaks the secret into git or
|
||||
/// into backups.
|
||||
enum TemplateConfigValue: Codable, Sendable, Equatable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case list([String])
|
||||
case keychainRef(String)
|
||||
|
||||
/// Convenience: the string representation suitable for display or
|
||||
/// for writing into a placeholder that the agent reads. Keychain
|
||||
/// refs return the ref string, not the resolved secret — callers
|
||||
/// resolve through `ProjectConfigKeychain` explicitly when they
|
||||
/// actually need the plaintext.
|
||||
nonisolated var displayString: String {
|
||||
switch self {
|
||||
case .string(let s): return s
|
||||
case .number(let n):
|
||||
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(Int(n))
|
||||
: String(n)
|
||||
case .bool(let b): return b ? "true" : "false"
|
||||
case .list(let items): return items.joined(separator: ", ")
|
||||
case .keychainRef(let ref): return ref
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let s = try? container.decode(String.self) {
|
||||
// Preserve the keychain:// scheme so secrets round-trip as
|
||||
// references, not as plaintext.
|
||||
if s.hasPrefix("keychain://") {
|
||||
self = .keychainRef(s)
|
||||
} else {
|
||||
self = .string(s)
|
||||
}
|
||||
} else if let b = try? container.decode(Bool.self) {
|
||||
self = .bool(b)
|
||||
} else if let n = try? container.decode(Double.self) {
|
||||
self = .number(n)
|
||||
} else if let arr = try? container.decode([String].self) {
|
||||
self = .list(arr)
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(
|
||||
TemplateConfigValue.self,
|
||||
.init(codingPath: decoder.codingPath,
|
||||
debugDescription: "Expected String, Bool, Number, or [String]")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let s): try container.encode(s)
|
||||
case .number(let n): try container.encode(n)
|
||||
case .bool(let b): try container.encode(b)
|
||||
case .list(let items): try container.encode(items)
|
||||
case .keychainRef(let ref): try container.encode(ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-disk shape (what's in <project>/.scarf/config.json)
|
||||
|
||||
/// The JSON file the installer writes + the editor reads. Non-secret
|
||||
/// values appear inline; secrets are `"keychain://<service>/<account>"`
|
||||
/// references that `ProjectConfigService` resolves through the Keychain
|
||||
/// on demand.
|
||||
struct ProjectConfigFile: Codable, Sendable {
|
||||
let schemaVersion: Int
|
||||
let templateId: String
|
||||
var values: [String: TemplateConfigValue]
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case templateId
|
||||
case values
|
||||
case updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keychain reference helpers
|
||||
|
||||
/// One secret stored via `ProjectConfigKeychain`. We derive both halves
|
||||
/// (service + account) from the template slug + project-path hash so two
|
||||
/// installs of the same template in different dirs don't collide in the
|
||||
/// login Keychain.
|
||||
struct TemplateKeychainRef: Sendable, Equatable {
|
||||
/// Macro service name, e.g. `com.scarf.template.awizemann-site-status-checker`.
|
||||
let service: String
|
||||
/// Account name: `<fieldKey>:<projectPathHashShort>`. The hash suffix
|
||||
/// guarantees uniqueness across multiple installs of the same template.
|
||||
let account: String
|
||||
|
||||
/// `"keychain://<service>/<account>"` — what lands in `config.json`.
|
||||
nonisolated var uri: String { "keychain://\(service)/\(account)" }
|
||||
|
||||
/// Parse a `keychain://…` URI back into a ref. Returns `nil` when the
|
||||
/// input isn't well-formed so callers can distinguish a missing ref
|
||||
/// from a malformed one.
|
||||
nonisolated static func parse(_ uri: String) -> TemplateKeychainRef? {
|
||||
guard uri.hasPrefix("keychain://") else { return nil }
|
||||
let rest = String(uri.dropFirst("keychain://".count))
|
||||
guard let slash = rest.firstIndex(of: "/") else { return nil }
|
||||
let service = String(rest[..<slash])
|
||||
let account = String(rest[rest.index(after: slash)...])
|
||||
guard !service.isEmpty, !account.isEmpty else { return nil }
|
||||
return TemplateKeychainRef(service: service, account: account)
|
||||
}
|
||||
|
||||
/// Build a ref from a template slug + field key + project path.
|
||||
/// The hash suffix is a SHA-256-truncated-to-8-hex-chars fingerprint
|
||||
/// of the absolute project path. Stable across launches, different
|
||||
/// between `/Users/a/proj1` and `/Users/a/proj2`.
|
||||
nonisolated static func make(
|
||||
templateSlug: String,
|
||||
fieldKey: String,
|
||||
projectPath: String
|
||||
) -> TemplateKeychainRef {
|
||||
TemplateKeychainRef(
|
||||
service: "com.scarf.template.\(templateSlug)",
|
||||
account: "\(fieldKey):\(Self.shortHash(of: projectPath))"
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated static func shortHash(of string: String) -> String {
|
||||
// 8 hex chars is 32 bits of uniqueness — plenty for
|
||||
// distinguishing a handful of project dirs per template install.
|
||||
let data = Data(string.utf8)
|
||||
var hash: UInt32 = 0x811c9dc5
|
||||
for byte in data {
|
||||
hash ^= UInt32(byte)
|
||||
hash &*= 0x01000193
|
||||
}
|
||||
return String(format: "%08x", hash)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// One schema- or value-validation problem. Carries `fieldKey` so the
|
||||
/// UI can surface the error inline with the field rather than at the
|
||||
/// top of the form.
|
||||
struct TemplateConfigValidationError: Error, Sendable, Equatable {
|
||||
let fieldKey: String?
|
||||
let message: String
|
||||
}
|
||||
|
||||
enum TemplateConfigSchemaError: LocalizedError, Sendable {
|
||||
case duplicateKey(String)
|
||||
case unsupportedType(String)
|
||||
case emptyEnumOptions(String)
|
||||
case duplicateEnumValue(key: String, value: String)
|
||||
case unsupportedListItemType(key: String, itemType: String)
|
||||
case secretFieldHasDefault(String)
|
||||
case emptyModelPreferred
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .duplicateKey(let k):
|
||||
return "Config schema has duplicate key: \(k)"
|
||||
case .unsupportedType(let t):
|
||||
return "Config schema uses unsupported field type: \(t)"
|
||||
case .emptyEnumOptions(let k):
|
||||
return "Enum field '\(k)' must declare at least one option"
|
||||
case .duplicateEnumValue(let k, let v):
|
||||
return "Enum field '\(k)' has duplicate option value: \(v)"
|
||||
case .unsupportedListItemType(let k, let t):
|
||||
return "List field '\(k)' uses unsupported itemType '\(t)'. Only 'string' is supported in v1."
|
||||
case .secretFieldHasDefault(let k):
|
||||
return "Secret field '\(k)' cannot declare a default value — secrets belong only in the Keychain."
|
||||
case .emptyModelPreferred:
|
||||
return "modelRecommendation.preferred must be a non-empty model id."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,16 +35,6 @@ final class HermesFileWatcher {
|
||||
paths.errorsLog,
|
||||
paths.gatewayLog,
|
||||
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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import os
|
||||
|
||||
/// Thin wrapper around the macOS Keychain for template-config secrets.
|
||||
/// Scarf doesn't have other Keychain users yet so this file is the one
|
||||
/// place that touches the `Security` framework; keep it small and
|
||||
/// auditable so a reader can tell at a glance what we store, under what
|
||||
/// identifiers, and when items are removed.
|
||||
///
|
||||
/// **What we store.** Generic passwords (kSecClassGenericPassword) in
|
||||
/// the login Keychain. Each item is identified by a (service, account)
|
||||
/// pair derived from the template slug + field key + project-path hash
|
||||
/// — see `TemplateKeychainRef.make`. The stored Data is the user's
|
||||
/// raw secret bytes; we never transform or encode them.
|
||||
///
|
||||
/// **When items are written.** By `ProjectTemplateInstaller` after the
|
||||
/// install preview is confirmed and the user has filled in the
|
||||
/// configure sheet. By `TemplateConfigSheet` when the user edits a
|
||||
/// secret field post-install.
|
||||
///
|
||||
/// **When items are removed.** By `ProjectTemplateUninstaller`,
|
||||
/// iterating the lock file's `configKeychainItems` list. The login
|
||||
/// Keychain is never swept for stray entries — if the lock is out of
|
||||
/// sync we log + skip rather than guess which items are ours.
|
||||
///
|
||||
/// **What shows to the user.** macOS prompts "Scarf wants to access
|
||||
/// the Keychain" the first time we read a secret in a given session.
|
||||
/// User approves; subsequent reads in that session are silent. We
|
||||
/// never bypass this — the prompt is the user's trust boundary.
|
||||
struct ProjectConfigKeychain: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigKeychain")
|
||||
|
||||
/// Which Keychain to target. The default is the login Keychain
|
||||
/// (`nil` uses the user's default chain). Tests pass an explicit
|
||||
/// namespace suffix via `testServiceSuffix` — see `TemplateConfigTests` —
|
||||
/// so integration tests can roundtrip without polluting real
|
||||
/// user state.
|
||||
let testServiceSuffix: String?
|
||||
|
||||
nonisolated init(testServiceSuffix: String? = nil) {
|
||||
self.testServiceSuffix = testServiceSuffix
|
||||
}
|
||||
|
||||
/// Write or overwrite the secret for (service, account). Tests
|
||||
/// route their items through a distinct service prefix via
|
||||
/// `testServiceSuffix` so they can't leak into the user's real
|
||||
/// Keychain.
|
||||
nonisolated func set(service: String, account: String, secret: Data) throws {
|
||||
let svc = resolved(service: service)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: svc,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
// Try update first — cheaper than delete-then-add and doesn't
|
||||
// trip macOS's "item already exists" if another thread raced us.
|
||||
let update: [String: Any] = [
|
||||
kSecValueData as String: secret,
|
||||
]
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||
if updateStatus == errSecSuccess { return }
|
||||
if updateStatus != errSecItemNotFound {
|
||||
throw Self.error(status: updateStatus, op: "update")
|
||||
}
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = secret
|
||||
// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — stays in
|
||||
// this device's Keychain, not synced via iCloud, usable after
|
||||
// first unlock (so background cron triggers can read).
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(insert as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
throw Self.error(status: addStatus, op: "add")
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve the secret for (service, account). Returns `nil` when
|
||||
/// the item simply doesn't exist (user never set it, or an
|
||||
/// uninstall already removed it). Throws on every other Keychain
|
||||
/// error so callers don't silently treat "access denied" or
|
||||
/// "corrupt keychain" as "no value."
|
||||
nonisolated func get(service: String, account: String) throws -> Data? {
|
||||
let svc = resolved(service: service)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: svc,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
if status != errSecSuccess {
|
||||
throw Self.error(status: status, op: "get")
|
||||
}
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
/// Delete the secret for (service, account). Absent item is a
|
||||
/// no-op; any other failure throws. Called by
|
||||
/// `ProjectTemplateUninstaller` for every item in
|
||||
/// `TemplateLock.configKeychainItems`.
|
||||
nonisolated func delete(service: String, account: String) throws {
|
||||
let svc = resolved(service: service)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: svc,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status == errSecItemNotFound || status == errSecSuccess { return }
|
||||
throw Self.error(status: status, op: "delete")
|
||||
}
|
||||
|
||||
/// Convenience: apply the test suffix when in test mode.
|
||||
nonisolated private func resolved(service: String) -> String {
|
||||
guard let suffix = testServiceSuffix, !suffix.isEmpty else { return service }
|
||||
return "\(service).\(suffix)"
|
||||
}
|
||||
|
||||
/// Build a useful NSError from a Keychain OSStatus. Logs at warning
|
||||
/// — callers decide whether the failure is fatal.
|
||||
nonisolated private static func error(status: OSStatus, op: String) -> NSError {
|
||||
let description = (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error"
|
||||
logger.warning("Keychain \(op, privacy: .public) failed: \(status) \(description, privacy: .public)")
|
||||
return NSError(
|
||||
domain: "com.scarf.keychain",
|
||||
code: Int(status),
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Keychain \(op) failed (\(status)): \(description)"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ref-shaped convenience layer
|
||||
|
||||
extension ProjectConfigKeychain {
|
||||
/// Set a secret using a pre-built `TemplateKeychainRef`. Mirrors the
|
||||
/// service/account plumbing every caller would otherwise repeat.
|
||||
nonisolated func set(ref: TemplateKeychainRef, secret: Data) throws {
|
||||
try set(service: ref.service, account: ref.account, secret: secret)
|
||||
}
|
||||
|
||||
nonisolated func get(ref: TemplateKeychainRef) throws -> Data? {
|
||||
try get(service: ref.service, account: ref.account)
|
||||
}
|
||||
|
||||
nonisolated func delete(ref: TemplateKeychainRef) throws {
|
||||
try delete(service: ref.service, account: ref.account)
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Per-project configuration I/O: reads `<project>/.scarf/config.json`
|
||||
/// into typed values, writes them back, resolves Keychain-backed secrets
|
||||
/// on demand, and validates user-entered values against the schema.
|
||||
///
|
||||
/// Separation of concerns:
|
||||
///
|
||||
/// - **Schema authority.** `TemplateConfigSchema` lives in the bundle's
|
||||
/// `template.json` and a copy is stashed at `<project>/.scarf/manifest.json`
|
||||
/// at install time so the post-install editor works offline. This
|
||||
/// service treats the schema as read-only input; `validateSchema`
|
||||
/// checks structural invariants and is called by
|
||||
/// `ProjectTemplateService` during install-plan building.
|
||||
/// - **Value storage.** Non-secret values live inline in `config.json`;
|
||||
/// secret values are Keychain references of the form
|
||||
/// `"keychain://<service>/<account>"`. The service owns both halves
|
||||
/// of that storage — callers never open `config.json` or touch the
|
||||
/// Keychain directly.
|
||||
/// - **Remote readiness.** All file I/O goes through
|
||||
/// `ServerContext.makeTransport()` so when `ProjectTemplateInstaller`
|
||||
/// eventually supports remote contexts, the config store comes along
|
||||
/// for the ride. Keychain access stays local (it's a macOS-side thing
|
||||
/// by definition — agents on remote Hermes installs would fetch
|
||||
/// values via Scarf's channel, same as today).
|
||||
struct ProjectConfigService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigService")
|
||||
|
||||
let context: ServerContext
|
||||
let keychain: ProjectConfigKeychain
|
||||
|
||||
nonisolated init(
|
||||
context: ServerContext = .local,
|
||||
keychain: ProjectConfigKeychain = ProjectConfigKeychain()
|
||||
) {
|
||||
self.context = context
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
nonisolated static func configPath(for project: ProjectEntry) -> String {
|
||||
project.path + "/.scarf/config.json"
|
||||
}
|
||||
|
||||
nonisolated static func manifestCachePath(for project: ProjectEntry) -> String {
|
||||
project.path + "/.scarf/manifest.json"
|
||||
}
|
||||
|
||||
// MARK: - Load / save on-disk config
|
||||
|
||||
/// Read + decode `<project>/.scarf/config.json`. Returns `nil`
|
||||
/// cleanly when the file is absent (e.g. a project installed from
|
||||
/// a schema-less template, or a hand-added project). Throws on
|
||||
/// malformed JSON so the caller can surface a concrete error
|
||||
/// rather than silently treating a corrupt file as missing.
|
||||
nonisolated func load(project: ProjectEntry) throws -> ProjectConfigFile? {
|
||||
let transport = context.makeTransport()
|
||||
let path = Self.configPath(for: project)
|
||||
guard transport.fileExists(path) else { return nil }
|
||||
let data = try transport.readFile(path)
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectConfigFile.self, from: data)
|
||||
} catch {
|
||||
Self.logger.error("couldn't decode config.json at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `<project>/.scarf/config.json`. Secrets should already be
|
||||
/// represented as `TemplateConfigValue.keychainRef` references here
|
||||
/// — this service never inspects their plaintext.
|
||||
nonisolated func save(
|
||||
project: ProjectEntry,
|
||||
templateId: String,
|
||||
values: [String: TemplateConfigValue]
|
||||
) throws {
|
||||
let transport = context.makeTransport()
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: templateId,
|
||||
values: values,
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
let parent = (Self.configPath(for: project) as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
try transport.writeFile(Self.configPath(for: project), data: data)
|
||||
}
|
||||
|
||||
// MARK: - Manifest cache (schema used by post-install editor)
|
||||
|
||||
/// Copy a template's `template.json` into `<project>/.scarf/manifest.json`
|
||||
/// so the post-install "Configuration" button can render the form
|
||||
/// offline. Called once by the installer after unpack + validate.
|
||||
nonisolated func cacheManifest(project: ProjectEntry, manifestData: Data) throws {
|
||||
let transport = context.makeTransport()
|
||||
let path = Self.manifestCachePath(for: project)
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
try transport.writeFile(path, data: manifestData)
|
||||
}
|
||||
|
||||
/// Load the cached manifest into a `ProjectTemplateManifest` so the
|
||||
/// editor can look up field types + labels. Returns `nil` when the
|
||||
/// project wasn't installed from a schemaful template.
|
||||
nonisolated func loadCachedManifest(project: ProjectEntry) throws -> ProjectTemplateManifest? {
|
||||
let transport = context.makeTransport()
|
||||
let path = Self.manifestCachePath(for: project)
|
||||
guard transport.fileExists(path) else { return nil }
|
||||
let data = try transport.readFile(path)
|
||||
return try JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Secrets
|
||||
|
||||
/// Resolve a `keychainRef` value into the actual secret bytes.
|
||||
/// Returns `nil` if the Keychain entry has been removed (e.g.
|
||||
/// external user cleanup, a previous uninstall that didn't finish).
|
||||
nonisolated func resolveSecret(ref value: TemplateConfigValue) throws -> Data? {
|
||||
guard case .keychainRef(let uri) = value,
|
||||
let ref = TemplateKeychainRef.parse(uri) else {
|
||||
return nil
|
||||
}
|
||||
return try keychain.get(ref: ref)
|
||||
}
|
||||
|
||||
/// Store a freshly-entered secret. Returns the `keychainRef` value
|
||||
/// suitable for writing into `config.json`.
|
||||
nonisolated func storeSecret(
|
||||
templateSlug: String,
|
||||
fieldKey: String,
|
||||
project: ProjectEntry,
|
||||
secret: Data
|
||||
) throws -> TemplateConfigValue {
|
||||
let ref = TemplateKeychainRef.make(
|
||||
templateSlug: templateSlug,
|
||||
fieldKey: fieldKey,
|
||||
projectPath: project.path
|
||||
)
|
||||
try keychain.set(ref: ref, secret: secret)
|
||||
return .keychainRef(ref.uri)
|
||||
}
|
||||
|
||||
/// Delete every Keychain item tracked in `refs`. Absent items are
|
||||
/// fine (uninstall may run after the user manually cleaned an
|
||||
/// entry). Any other failure is logged and re-thrown so the
|
||||
/// uninstaller can surface it.
|
||||
nonisolated func deleteSecrets(refs: [TemplateKeychainRef]) throws {
|
||||
for ref in refs {
|
||||
try keychain.delete(ref: ref)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schema validation (author-facing; called at bundle inspect time)
|
||||
|
||||
/// Verify structural invariants on a schema: unique keys, known
|
||||
/// types, enum options, secret-without-default rule, model
|
||||
/// recommendation non-empty when present. Called by
|
||||
/// `ProjectTemplateService.inspect` before buildPlan runs.
|
||||
nonisolated static func validateSchema(_ schema: TemplateConfigSchema) throws {
|
||||
var seen = Set<String>()
|
||||
for field in schema.fields {
|
||||
if !seen.insert(field.key).inserted {
|
||||
throw TemplateConfigSchemaError.duplicateKey(field.key)
|
||||
}
|
||||
switch field.type {
|
||||
case .enum:
|
||||
let opts = field.options ?? []
|
||||
guard !opts.isEmpty else {
|
||||
throw TemplateConfigSchemaError.emptyEnumOptions(field.key)
|
||||
}
|
||||
var seenValues = Set<String>()
|
||||
for opt in opts {
|
||||
if !seenValues.insert(opt.value).inserted {
|
||||
throw TemplateConfigSchemaError.duplicateEnumValue(key: field.key, value: opt.value)
|
||||
}
|
||||
}
|
||||
case .list:
|
||||
let item = field.itemType ?? "string"
|
||||
if item != "string" {
|
||||
throw TemplateConfigSchemaError.unsupportedListItemType(key: field.key, itemType: item)
|
||||
}
|
||||
case .secret:
|
||||
if field.defaultValue != nil {
|
||||
throw TemplateConfigSchemaError.secretFieldHasDefault(field.key)
|
||||
}
|
||||
case .string, .text, .number, .bool:
|
||||
break
|
||||
}
|
||||
}
|
||||
if let rec = schema.modelRecommendation {
|
||||
if rec.preferred.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
throw TemplateConfigSchemaError.emptyModelPreferred
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Value validation (runs on user input in the configure sheet)
|
||||
|
||||
/// Validate user-entered values against the schema. Returns one
|
||||
/// `TemplateConfigValidationError` per problem. Empty array means
|
||||
/// the form is submittable.
|
||||
nonisolated static func validateValues(
|
||||
_ values: [String: TemplateConfigValue],
|
||||
against schema: TemplateConfigSchema
|
||||
) -> [TemplateConfigValidationError] {
|
||||
var errors: [TemplateConfigValidationError] = []
|
||||
for field in schema.fields {
|
||||
let value = values[field.key]
|
||||
if field.required && !Self.hasMeaningfulValue(value, type: field.type) {
|
||||
errors.append(.init(fieldKey: field.key, message: "\(field.label) is required."))
|
||||
continue
|
||||
}
|
||||
guard let value else { continue }
|
||||
switch field.type {
|
||||
case .string, .text:
|
||||
if case .string(let s) = value {
|
||||
if let min = field.minLength, s.count < min {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be at least \(min) characters."))
|
||||
}
|
||||
if let max = field.maxLength, s.count > max {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be at most \(max) characters."))
|
||||
}
|
||||
if let pattern = field.pattern,
|
||||
s.range(of: pattern, options: .regularExpression) == nil {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) doesn't match the expected format."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be a string."))
|
||||
}
|
||||
|
||||
case .number:
|
||||
if case .number(let n) = value {
|
||||
if let min = field.minNumber, n < min {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be ≥ \(min)."))
|
||||
}
|
||||
if let max = field.maxNumber, n > max {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be ≤ \(max)."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be a number."))
|
||||
}
|
||||
|
||||
case .bool:
|
||||
if case .bool = value { /* ok */ } else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be true or false."))
|
||||
}
|
||||
|
||||
case .enum:
|
||||
if case .string(let s) = value {
|
||||
let options = (field.options ?? []).map(\.value)
|
||||
if !options.contains(s) {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be one of \(options.joined(separator: ", "))."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be one of the predefined options."))
|
||||
}
|
||||
|
||||
case .list:
|
||||
if case .list(let items) = value {
|
||||
if let min = field.minItems, items.count < min {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) needs at least \(min) item(s)."))
|
||||
}
|
||||
if let max = field.maxItems, items.count > max {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) accepts at most \(max) item(s)."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be a list."))
|
||||
}
|
||||
|
||||
case .secret:
|
||||
if case .keychainRef = value { /* opaque — trust it */ } else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be supplied (Keychain entry missing)."))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
nonisolated private static func hasMeaningfulValue(
|
||||
_ value: TemplateConfigValue?,
|
||||
type: TemplateConfigField.FieldType
|
||||
) -> Bool {
|
||||
guard let value else { return false }
|
||||
switch (type, value) {
|
||||
case (.string, .string(let s)), (.text, .string(let s)), (.enum, .string(let s)):
|
||||
return !s.isEmpty
|
||||
case (.number, .number):
|
||||
return true
|
||||
case (.bool, .bool):
|
||||
return true
|
||||
case (.list, .list(let arr)):
|
||||
return !arr.isEmpty
|
||||
case (.secret, .keychainRef):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct ProjectDashboardService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
||||
|
||||
let context: ServerContext
|
||||
let transport: any ServerTransport
|
||||
@@ -21,28 +19,23 @@ struct ProjectDashboardService: Sendable {
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||
} catch {
|
||||
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
|
||||
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the project registry to `~/.hermes/scarf/projects.json`.
|
||||
///
|
||||
/// **Throws** on every non-success path — the previous version of
|
||||
/// this method silently swallowed `createDirectory` and `writeFile`
|
||||
/// failures with `try?`, which meant the installer could return a
|
||||
/// valid-looking `ProjectEntry` while the registry on disk never
|
||||
/// received the new row (project would complete install, show a
|
||||
/// success screen, then be invisible in the sidebar). Callers that
|
||||
/// want fire-and-forget behaviour can still use `try?`, but the
|
||||
/// choice is now theirs.
|
||||
func saveRegistry(_ registry: ProjectRegistry) throws {
|
||||
func saveRegistry(_ registry: ProjectRegistry) {
|
||||
let dir = context.paths.scarfDir
|
||||
if !transport.fileExists(dir) {
|
||||
try transport.createDirectory(dir)
|
||||
do {
|
||||
try transport.createDirectory(dir)
|
||||
} catch {
|
||||
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
}
|
||||
let data = try JSONEncoder().encode(registry)
|
||||
// Pretty-print for readability (agents may read this file).
|
||||
guard let data = try? JSONEncoder().encode(registry) else { return }
|
||||
// Pretty-print for readability (agents may read this file)
|
||||
let writeData: Data
|
||||
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||
@@ -50,7 +43,7 @@ struct ProjectDashboardService: Sendable {
|
||||
} else {
|
||||
writeData = data
|
||||
}
|
||||
try transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||
try? transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
@@ -182,25 +182,9 @@ struct ProjectTemplateExporter: Sendable {
|
||||
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
|
||||
}
|
||||
|
||||
// If the source project was itself installed from a schemaful
|
||||
// template, its `.scarf/manifest.json` carries the schema we
|
||||
// want to forward to the exported bundle. We carry only the
|
||||
// SCHEMA — never user values. Exporting must be safe on a
|
||||
// project with live config: the schema is author-supplied
|
||||
// metadata; the values in `config.json` are the current user's
|
||||
// secrets or personal settings.
|
||||
let forwardedSchema: TemplateConfigSchema? = try Self.readCachedSchema(
|
||||
from: plan.projectDir
|
||||
)
|
||||
|
||||
// Bump schemaVersion to 2 when a schema is carried through;
|
||||
// remain on 1 otherwise so schema-less exports stay
|
||||
// byte-compatible with existing v2.2 catalog validators.
|
||||
let schemaVersion = forwardedSchema == nil ? 1 : 2
|
||||
|
||||
// Manifest — claims exactly what we just wrote
|
||||
let manifest = ProjectTemplateManifest(
|
||||
schemaVersion: schemaVersion,
|
||||
schemaVersion: 1,
|
||||
id: inputs.templateId,
|
||||
name: inputs.templateName,
|
||||
version: inputs.templateVersion,
|
||||
@@ -220,10 +204,8 @@ struct ProjectTemplateExporter: Sendable {
|
||||
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
|
||||
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
|
||||
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
|
||||
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil,
|
||||
config: forwardedSchema?.fields.count
|
||||
),
|
||||
config: forwardedSchema
|
||||
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil
|
||||
)
|
||||
)
|
||||
let manifestEncoder = JSONEncoder()
|
||||
manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
@@ -257,23 +239,6 @@ struct ProjectTemplateExporter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the cached manifest from `<project>/.scarf/manifest.json` (if
|
||||
/// present) and pull out just the config schema. Values in
|
||||
/// `.scarf/config.json` are intentionally ignored — an exported
|
||||
/// bundle carries the schema's shape, never the current user's
|
||||
/// configured values.
|
||||
nonisolated private static func readCachedSchema(from projectDir: String) throws -> TemplateConfigSchema? {
|
||||
let manifestPath = projectDir + "/.scarf/manifest.json"
|
||||
guard FileManager.default.fileExists(atPath: manifestPath) else { return nil }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
|
||||
// Use a bespoke decode rather than ProjectTemplateManifest so
|
||||
// this helper stays resilient if the manifest shape evolves
|
||||
// incompatibly in a future release.
|
||||
struct OnlyConfig: Decodable { let config: TemplateConfigSchema? }
|
||||
let onlyConfig = try JSONDecoder().decode(OnlyConfig.self, from: data)
|
||||
return onlyConfig.config
|
||||
}
|
||||
|
||||
/// Convert a live cron job (with runtime state) into the spec the
|
||||
/// installer will feed back to `hermes cron create`. Only preserves
|
||||
/// fields the CLI accepts.
|
||||
|
||||
@@ -87,46 +87,14 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
let transport = context.makeTransport()
|
||||
try transport.createDirectory(plan.projectDir)
|
||||
for copy in plan.projectFiles {
|
||||
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
|
||||
// Empty `sourceRelativePath` is the "synthesized content"
|
||||
// sentinel used by `buildPlan` for `.scarf/config.json`.
|
||||
// The installer materialises config.json from
|
||||
// `plan.configValues` here rather than copying a bundle
|
||||
// file that doesn't exist.
|
||||
if copy.sourceRelativePath.isEmpty {
|
||||
if copy.destinationPath.hasSuffix("/.scarf/config.json") {
|
||||
let data = try encodeConfigFile(plan: plan)
|
||||
try transport.writeFile(copy.destinationPath, data: data)
|
||||
continue
|
||||
}
|
||||
throw ProjectTemplateError.requiredFileMissing(
|
||||
"synthesized file with unknown destination: \(copy.destinationPath)"
|
||||
)
|
||||
}
|
||||
|
||||
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
try transport.writeFile(copy.destinationPath, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise `plan.configValues` into the `<project>/.scarf/config.json`
|
||||
/// shape. Secrets appear as `keychainRef` URIs — the raw bytes were
|
||||
/// routed into the Keychain by the VM before `install()` was called.
|
||||
nonisolated private func encodeConfigFile(plan: TemplateInstallPlan) throws -> Data {
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: plan.manifest.id,
|
||||
values: plan.configValues,
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return try encoder.encode(file)
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
|
||||
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
|
||||
@@ -179,17 +147,7 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
}
|
||||
args.append(job.schedule)
|
||||
if let prompt = job.prompt, !prompt.isEmpty {
|
||||
// Substitute template-author tokens with install-time
|
||||
// values. Hermes doesn't set a CWD for cron runs — when
|
||||
// the agent fires the prompt, any relative path
|
||||
// (`.scarf/config.json`, `status-log.md`, etc.) resolves
|
||||
// against the agent's own dir, not the project. Templates
|
||||
// use `{{PROJECT_DIR}}` as a placeholder for the absolute
|
||||
// path; we swap in the real project dir here so the
|
||||
// registered cron job carries a fully-qualified prompt
|
||||
// that works regardless of CWD.
|
||||
let resolvedPrompt = Self.substituteCronTokens(prompt, plan: plan)
|
||||
args.append(resolvedPrompt)
|
||||
args.append(prompt)
|
||||
}
|
||||
|
||||
let (output, exit) = context.runHermes(args)
|
||||
@@ -221,66 +179,16 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
var registry = service.loadRegistry()
|
||||
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
|
||||
registry.projects.append(entry)
|
||||
// Must throw on failure — silent failure here used to make the
|
||||
// installer return a valid entry while the registry on disk
|
||||
// never got updated, producing the "install completed but the
|
||||
// project doesn't show up in the sidebar" bug. If the registry
|
||||
// write fails, the whole install is surfaced as failed so the
|
||||
// user can see + address the underlying problem.
|
||||
try service.saveRegistry(registry)
|
||||
service.saveRegistry(registry)
|
||||
return entry
|
||||
}
|
||||
|
||||
// MARK: - Token substitution (install-time placeholder resolution)
|
||||
|
||||
/// Supported placeholders for template-author prompts. Keep the set
|
||||
/// intentionally small — every token here becomes a load-bearing
|
||||
/// part of the template format that we can't rename without
|
||||
/// breaking existing bundles.
|
||||
///
|
||||
/// - `{{PROJECT_DIR}}`: absolute path of the newly-created project
|
||||
/// directory. Required for cron prompts because Hermes doesn't
|
||||
/// establish a CWD when firing cron jobs; relative paths would
|
||||
/// resolve against whatever dir Hermes happens to be in.
|
||||
///
|
||||
/// - `{{TEMPLATE_ID}}`: the `owner/name` id from the manifest.
|
||||
/// Less load-bearing; occasionally useful for tagging or
|
||||
/// delivery targets that reference the template.
|
||||
///
|
||||
/// - `{{TEMPLATE_SLUG}}`: the sanitised slug the installer used
|
||||
/// for the skills namespace and project dir name.
|
||||
nonisolated static func substituteCronTokens(
|
||||
_ prompt: String,
|
||||
plan: TemplateInstallPlan
|
||||
) -> String {
|
||||
var out = prompt
|
||||
out = out.replacingOccurrences(of: "{{PROJECT_DIR}}", with: plan.projectDir)
|
||||
out = out.replacingOccurrences(of: "{{TEMPLATE_ID}}", with: plan.manifest.id)
|
||||
out = out.replacingOccurrences(of: "{{TEMPLATE_SLUG}}", with: plan.manifest.slug)
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Lock file
|
||||
|
||||
nonisolated private func writeLockFile(
|
||||
plan: TemplateInstallPlan,
|
||||
cronJobNames: [String]
|
||||
) throws {
|
||||
// Every value that ended up as a keychainRef in config.json gets
|
||||
// tracked in the lock so the uninstaller can SecItemDelete each
|
||||
// entry. Field keys are recorded separately for informational
|
||||
// display in the uninstall preview sheet.
|
||||
let keychainItems: [String]? = {
|
||||
let refs = plan.configValues.compactMap { (_, value) -> String? in
|
||||
if case .keychainRef(let uri) = value { return uri } else { return nil }
|
||||
}
|
||||
return refs.isEmpty ? nil : refs.sorted()
|
||||
}()
|
||||
let configFields: [String]? = {
|
||||
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
|
||||
return schema.fields.map(\.key)
|
||||
}()
|
||||
|
||||
let lock = TemplateLock(
|
||||
templateId: plan.manifest.id,
|
||||
templateVersion: plan.manifest.version,
|
||||
@@ -290,9 +198,7 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
skillsNamespaceDir: plan.skillsNamespaceDir,
|
||||
skillsFiles: plan.skillsFiles.map(\.destinationPath),
|
||||
cronJobNames: cronJobNames,
|
||||
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
|
||||
configKeychainItems: keychainItems,
|
||||
configFields: configFields
|
||||
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
|
||||
@@ -52,27 +52,10 @@ struct ProjectTemplateService: Sendable {
|
||||
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
// schemaVersion 1 is the original v2.2 bundle; 2 adds the
|
||||
// optional `config` block. Both are valid. Newer versions get
|
||||
// refused so the installer never silently misinterprets a
|
||||
// future-shape bundle.
|
||||
guard manifest.schemaVersion == 1 || manifest.schemaVersion == 2 else {
|
||||
guard manifest.schemaVersion == 1 else {
|
||||
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
|
||||
}
|
||||
|
||||
// Validate the optional config schema at inspect time — a
|
||||
// malformed schema (duplicate keys, secret-with-default, etc.)
|
||||
// gets rejected before the user ever sees the preview sheet.
|
||||
if let schema = manifest.config {
|
||||
do {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
} catch {
|
||||
throw ProjectTemplateError.manifestParseFailed(
|
||||
"invalid config schema: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let files = try Self.walk(unpackedDir)
|
||||
let cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
|
||||
try Self.verifyClaims(manifest: manifest, files: files, cronJobCount: cronJobs.count)
|
||||
@@ -196,37 +179,6 @@ struct ProjectTemplateService: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
// Configuration schema + manifest cache. The installer writes
|
||||
// `.scarf/config.json` (non-secret values) + `.scarf/manifest.json`
|
||||
// (schema cache used by the post-install editor) when the
|
||||
// template declares a non-empty schema. Both paths go into
|
||||
// projectFiles so the uninstaller picks them up via the lock.
|
||||
var configSchema: TemplateConfigSchema? = nil
|
||||
var manifestCachePath: String? = nil
|
||||
if let schema = manifest.config, !schema.isEmpty {
|
||||
configSchema = schema
|
||||
let configPath = projectDir + "/.scarf/config.json"
|
||||
projectFiles.append(
|
||||
// Source is synthesized by the installer from configValues;
|
||||
// no file in the unpacked bundle maps to this entry. We use
|
||||
// an empty `sourceRelativePath` as the "no physical source"
|
||||
// sentinel — the installer special-cases it below (see
|
||||
// ProjectTemplateInstaller.createProjectFiles).
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: "",
|
||||
destinationPath: configPath
|
||||
)
|
||||
)
|
||||
let cachePath = projectDir + "/.scarf/manifest.json"
|
||||
manifestCachePath = cachePath
|
||||
projectFiles.append(
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: "template.json",
|
||||
destinationPath: cachePath
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return TemplateInstallPlan(
|
||||
manifest: manifest,
|
||||
unpackedDir: inspection.unpackedDir,
|
||||
@@ -237,10 +189,7 @@ struct ProjectTemplateService: Sendable {
|
||||
cronJobs: cronJobs,
|
||||
memoryAppendix: memoryAppendix,
|
||||
memoryPath: context.paths.memoryMD,
|
||||
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context),
|
||||
configSchema: configSchema,
|
||||
configValues: [:], // filled in by TemplateInstallerViewModel before install()
|
||||
manifestCachePath: manifestCachePath
|
||||
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -469,18 +418,6 @@ struct ProjectTemplateService: Sendable {
|
||||
"manifest.contents.memory.append=\(claimsMemory) disagrees with memory/append.md presence=\(hasMemoryFile)"
|
||||
)
|
||||
}
|
||||
|
||||
// Config claim must match the schema's actual field count so
|
||||
// the preview sheet is honest about the size of the configure
|
||||
// step. `nil` in contents means "no schema" just like `0`;
|
||||
// we normalise both to 0 before comparing.
|
||||
let claimedConfig = manifest.contents.config ?? 0
|
||||
let actualConfig = manifest.config?.fields.count ?? 0
|
||||
if claimedConfig != actualConfig {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"manifest.contents.config=\(claimedConfig) but config.schema has \(actualConfig) field(s)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a project-registry name that doesn't collide. Deterministic
|
||||
|
||||
@@ -183,40 +183,12 @@ struct ProjectTemplateUninstaller: Sendable {
|
||||
try stripMemoryBlock(blockId: blockId, memoryPath: plan.memoryPath, transport: transport)
|
||||
}
|
||||
|
||||
// 4a. Config Keychain items — remove every secret the template's
|
||||
// install step stashed in the login Keychain. Items that were
|
||||
// already deleted (e.g. user cleaned them with Keychain Access)
|
||||
// hit the `errSecItemNotFound` no-op path inside the wrapper, so
|
||||
// a stale lock doesn't abort the rest of the uninstall.
|
||||
let keychain = ProjectConfigKeychain()
|
||||
for uri in plan.lock.configKeychainItems ?? [] {
|
||||
guard let ref = TemplateKeychainRef.parse(uri) else {
|
||||
Self.logger.warning("lock recorded unparseable keychain uri \(uri, privacy: .public); skipping")
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try keychain.delete(ref: ref)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't delete keychain item \(uri, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Projects registry — remove the entry by path (more stable
|
||||
// than name: user may have renamed the project in the UI).
|
||||
let dashboardService = ProjectDashboardService(context: context)
|
||||
var registry = dashboardService.loadRegistry()
|
||||
registry.projects.removeAll { $0.path == plan.project.path }
|
||||
// saveRegistry throws now — log a write failure but don't abort
|
||||
// the uninstall. Every earlier step already completed (files
|
||||
// removed, skills removed, cron jobs removed, memory stripped,
|
||||
// Keychain cleared); failing here leaves a stale registry row
|
||||
// pointing at a deleted project — cosmetic and easy to fix
|
||||
// from the sidebar.
|
||||
do {
|
||||
try dashboardService.saveRegistry(registry)
|
||||
} catch {
|
||||
Self.logger.warning("uninstall couldn't rewrite projects registry: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
dashboardService.saveRegistry(registry)
|
||||
|
||||
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,20 +41,6 @@ final class ChatViewModel {
|
||||
let richChatViewModel: RichChatViewModel
|
||||
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
|
||||
private var acpClient: ACPClient?
|
||||
private var acpEventTask: Task<Void, Never>?
|
||||
@@ -132,20 +118,15 @@ final class ChatViewModel {
|
||||
|
||||
// MARK: - Session Lifecycle
|
||||
|
||||
func startNewSession(projectPath: String? = nil) {
|
||||
func startNewSession() {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: nil, projectPath: projectPath)
|
||||
startACPSession(resume: nil)
|
||||
} 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"])
|
||||
}
|
||||
}
|
||||
@@ -308,33 +289,13 @@ final class ChatViewModel {
|
||||
|
||||
// MARK: - ACP Session Management
|
||||
|
||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||
private func startACPSession(resume sessionId: String?) {
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient(context: context)
|
||||
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
|
||||
do {
|
||||
@@ -344,19 +305,7 @@ final class ChatViewModel {
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
// 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()
|
||||
}
|
||||
let cwd = await context.resolvedUserHome()
|
||||
|
||||
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
|
||||
// and doesn't wipe messages with a DB refresh
|
||||
@@ -385,48 +334,6 @@ final class ChatViewModel {
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
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
|
||||
await loadRecentSessions()
|
||||
|
||||
|
||||
@@ -3,83 +3,25 @@ import SwiftUI
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
@Bindable var coord = coordinator
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
errorBanner
|
||||
chatArea
|
||||
}
|
||||
// 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"
|
||||
)
|
||||
.navigationTitle("Chat")
|
||||
.task {
|
||||
await viewModel.loadRecentSessions()
|
||||
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) {
|
||||
Task { await viewModel.loadRecentSessions() }
|
||||
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
|
||||
|
||||
@@ -17,13 +17,7 @@ struct RichChatView: View {
|
||||
isWorking: richChat.isAgentWorking,
|
||||
acpInputTokens: richChat.acpInputTokens,
|
||||
acpOutputTokens: richChat.acpOutputTokens,
|
||||
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
|
||||
acpThoughtTokens: richChat.acpThoughtTokens
|
||||
)
|
||||
Divider()
|
||||
|
||||
@@ -48,19 +42,6 @@ struct RichChatView: View {
|
||||
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
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
||||
|
||||
@@ -7,28 +7,10 @@ struct SessionInfoBar: View {
|
||||
var acpInputTokens: Int = 0
|
||||
var acpOutputTokens: 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 {
|
||||
HStack(spacing: 16) {
|
||||
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) {
|
||||
Circle()
|
||||
.fill(isWorking ? .green : .secondary)
|
||||
|
||||
@@ -65,61 +65,7 @@ final class CronViewModel {
|
||||
}
|
||||
|
||||
func runNow(_ job: HermesCronJob) {
|
||||
// `hermes cron run <id>` only marks the job as due on the next
|
||||
// scheduler tick — it doesn't actually execute. If the Hermes
|
||||
// gateway's scheduler isn't running (common during dev + right
|
||||
// after install), the user's "Run now" click results in zero
|
||||
// visible effect because the tick never comes. We follow up
|
||||
// with `hermes cron tick` which runs all due jobs once and
|
||||
// exits. Redundant-but-harmless when the gateway is running;
|
||||
// the actual trigger when it isn't.
|
||||
//
|
||||
// Feedback model: show a "Agent started" toast as soon as
|
||||
// `cron run` succeeds, WITHOUT waiting for `cron tick` to
|
||||
// return. Agent jobs routinely run past a minute (network IO +
|
||||
// an LLM call + a file rewrite), and earlier versions with a
|
||||
// 60s tick timeout surfaced a misleading "Run failed" toast
|
||||
// every time while the job kept running in the background.
|
||||
// The app's HermesFileWatcher picks up the dashboard.json
|
||||
// rewrite that the agent lands at the end — that's what the
|
||||
// user actually watches for, not this toast.
|
||||
let svc = fileService
|
||||
let jobID = job.id
|
||||
Task.detached { [weak self] in
|
||||
let runResult = svc.runHermesCLI(args: ["cron", "run", jobID], timeout: 30)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if runResult.exitCode != 0 {
|
||||
self.message = "Run failed to queue: \(runResult.output.prefix(200))"
|
||||
self.logger.warning("cron run failed: \(runResult.output)")
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
self.message = "Agent started — dashboard will update when it finishes"
|
||||
self.load()
|
||||
}
|
||||
// `cron run` is queued; now force the tick. The 300s
|
||||
// timeout catches truly stuck processes without killing
|
||||
// the long-but-valid agent case that blew up the 60s
|
||||
// version. A timeout here is survivable — the Hermes
|
||||
// scheduler re-runs due jobs on its own cadence — so we
|
||||
// log but don't surface it as a failure toast.
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
let tickResult = svc.runHermesCLI(args: ["cron", "tick"], timeout: 300)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if tickResult.exitCode != 0 {
|
||||
self.logger.warning("cron tick exited non-zero (job may still complete via scheduler): \(tickResult.output)")
|
||||
}
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
|
||||
}
|
||||
|
||||
func deleteJob(_ job: HermesCronJob) {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class ProjectsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ProjectsViewModel")
|
||||
let context: ServerContext
|
||||
private let service: ProjectDashboardService
|
||||
|
||||
@@ -41,19 +39,7 @@ final class ProjectsViewModel {
|
||||
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
||||
let entry = ProjectEntry(name: name, path: path)
|
||||
registry.projects.append(entry)
|
||||
// saveRegistry throws now. The VM doesn't currently have a
|
||||
// surface for user-visible errors (there's no alert/toast in
|
||||
// the Projects view), so log at error level to the unified
|
||||
// log and keep the in-memory state consistent with whatever
|
||||
// landed on disk. If the write fails, the added entry won't
|
||||
// persist across launches — the user sees it appear + work
|
||||
// this session, then it's gone at relaunch. Not ideal, but
|
||||
// matches today's UX and flagged for a proper alert later.
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("addProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
service.saveRegistry(registry)
|
||||
projects = registry.projects
|
||||
selectProject(entry)
|
||||
}
|
||||
@@ -61,11 +47,7 @@ final class ProjectsViewModel {
|
||||
func removeProject(_ project: ProjectEntry) {
|
||||
var registry = service.loadRegistry()
|
||||
registry.projects.removeAll { $0.name == project.name }
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("removeProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
service.saveRegistry(registry)
|
||||
projects = registry.projects
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = nil
|
||||
@@ -73,101 +55,6 @@ 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.
|
||||
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; we reject renames that would collide with
|
||||
/// another project's name. Returns true on success.
|
||||
@discardableResult
|
||||
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()
|
||||
// Reject collisions — a second project already owns that name.
|
||||
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
|
||||
// Preserve selection across the rename — the selected project
|
||||
// still exists, it just has a new id.
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = registry.projects[index]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Soft-archive a project. It stays on disk and in the registry;
|
||||
/// the sidebar just hides it unless `showArchived` is on.
|
||||
func archiveProject(_ project: ProjectEntry) {
|
||||
mutateEntry(project) { $0.archived = true }
|
||||
// If the archived project was selected, clear selection so
|
||||
// the dashboard doesn't linger on a hidden project.
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = nil
|
||||
dashboard = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore an archived project to the default view.
|
||||
func unarchiveProject(_ project: ProjectEntry) {
|
||||
mutateEntry(project) { $0.archived = false }
|
||||
}
|
||||
|
||||
/// Distinct folder labels across the current project set, sorted
|
||||
/// alphabetically. Drives the sidebar's DisclosureGroups (commit
|
||||
/// 2) and the Move-to-Folder sheet's existing-folder list. An
|
||||
/// "empty" folder (folder with zero projects) can't exist under
|
||||
/// this model — folders are implicit in the data — which is
|
||||
/// intentional: v2.3 doesn't need first-class empty folders.
|
||||
var folders: [String] {
|
||||
let set = Set(projects.compactMap(\.folder).filter { !$0.isEmpty })
|
||||
return set.sorted()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Fetch the registry, apply `mutation` to the matched entry,
|
||||
/// persist, update in-memory state. Centralises the save +
|
||||
/// re-publish dance shared by `moveProject`, `archiveProject`,
|
||||
/// and `unarchiveProject`. Callers that need different matching
|
||||
/// semantics (rename, remove) handle their own registry mutation.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDashboard() {
|
||||
guard let project = selectedProject else { return }
|
||||
loadDashboard(for: project)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,11 @@ import UniformTypeIdentifiers
|
||||
private enum DashboardTab: String, CaseIterable {
|
||||
case dashboard = "Dashboard"
|
||||
case site = "Site"
|
||||
case sessions = "Sessions"
|
||||
|
||||
var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .dashboard: return "Dashboard"
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,23 +26,6 @@ struct ProjectsView: View {
|
||||
@State private var showingInstallURLPrompt = false
|
||||
@State private var installURLInput = ""
|
||||
@State private var showingUninstallSheet = false
|
||||
@State private var configEditorProject: ProjectEntry?
|
||||
/// Project queued for the "remove from list" confirmation dialog.
|
||||
/// Non-nil while the dialog is up; the `confirmationDialog` binding
|
||||
/// flips based on presence. We store the full entry (not just a
|
||||
/// flag) so the dialog's action closure knows which project to
|
||||
/// drop from the registry.
|
||||
@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
|
||||
|
||||
@@ -63,14 +36,6 @@ struct ProjectsView: View {
|
||||
self.uninstaller = ProjectTemplateUninstaller(context: context)
|
||||
}
|
||||
|
||||
/// True when the given project has a cached manifest (i.e. was
|
||||
/// installed from a schemaful template). Cheap — just a file
|
||||
/// existence check via the transport.
|
||||
private func isConfigurable(_ project: ProjectEntry) -> Bool {
|
||||
let path = ProjectConfigService.manifestCachePath(for: project)
|
||||
return serverContext.makeTransport().fileExists(path)
|
||||
}
|
||||
|
||||
@State private var selectedTab: DashboardTab = .dashboard
|
||||
|
||||
var body: some View {
|
||||
@@ -141,50 +106,6 @@ struct ProjectsView: View {
|
||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||
}
|
||||
}
|
||||
.sheet(item: $configEditorProject) { project in
|
||||
ConfigEditorSheet(
|
||||
context: serverContext,
|
||||
project: project
|
||||
)
|
||||
}
|
||||
// Confirmation dialog for the sidebar's "Remove from List" action.
|
||||
// The action is registry-only (doesn't touch disk), but the name
|
||||
// historically confused users into thinking it was a full delete.
|
||||
// A confirmation with explicit wording clarifies scope before the
|
||||
// click is destructive-looking but actually harmless.
|
||||
.confirmationDialog(
|
||||
removeFromListDialogTitle,
|
||||
isPresented: Binding(
|
||||
get: { pendingRemoveFromList != nil },
|
||||
set: { if !$0 { pendingRemoveFromList = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: pendingRemoveFromList
|
||||
) { project in
|
||||
Button("Remove from List") {
|
||||
viewModel.removeProject(project)
|
||||
if coordinator.selectedProjectName == project.name {
|
||||
coordinator.selectedProjectName = nil
|
||||
}
|
||||
pendingRemoveFromList = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
pendingRemoveFromList = nil
|
||||
}
|
||||
} message: { project in
|
||||
Text(
|
||||
"\(project.name) will be removed from Scarf's project list. " +
|
||||
"Nothing on disk is touched — the folder, cron job, skills, and memory block all stay. " +
|
||||
"To actually remove installed files, use \"Uninstall Template…\" instead."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Title string for the remove-from-list confirmation dialog. Kept
|
||||
/// as a computed property so the dialog and any future reuse share
|
||||
/// the exact same copy.
|
||||
private var removeFromListDialogTitle: LocalizedStringKey {
|
||||
"Remove from Scarf's project list?"
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
@@ -283,47 +204,59 @@ struct ProjectsView: View {
|
||||
// MARK: - Project List
|
||||
|
||||
private var projectList: some View {
|
||||
// Sidebar is an extracted view; this view stays the owner of
|
||||
// sheet state (add / rename / move / uninstall / remove-from-
|
||||
// list confirmation) and routes intents down as closures.
|
||||
ProjectsSidebar(
|
||||
viewModel: viewModel,
|
||||
canConfigureProject: { isConfigurable($0) },
|
||||
isTemplateInstalled: { uninstaller.isTemplateInstalled(project: $0) },
|
||||
onConfigure: { configEditorProject = $0 },
|
||||
onUninstallTemplate: { project in
|
||||
uninstallerViewModel.begin(project: project)
|
||||
showingUninstallSheet = true
|
||||
},
|
||||
onRemoveFromList: { pendingRemoveFromList = $0 },
|
||||
onRename: { renameTarget = $0 },
|
||||
onMoveToFolder: { moveTarget = $0 },
|
||||
onAddProject: { showingAddSheet = true }
|
||||
)
|
||||
VStack(spacing: 0) {
|
||||
List(viewModel.projects, selection: Binding(
|
||||
get: { viewModel.selectedProject },
|
||||
set: { project in
|
||||
if let project {
|
||||
viewModel.selectProject(project)
|
||||
}
|
||||
}
|
||||
)) { 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 uninstaller.isTemplateInstalled(project: project) {
|
||||
Button("Uninstall Template…", systemImage: "trash") {
|
||||
uninstallerViewModel.begin(project: project)
|
||||
showingUninstallSheet = true
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Button("Remove from Scarf", systemImage: "minus.circle") {
|
||||
viewModel.removeProject(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
Divider()
|
||||
HStack {
|
||||
Button(action: { showingAddSheet = true }) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
if let selected = viewModel.selectedProject {
|
||||
Button(action: { viewModel.removeProject(selected) }) {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
AddProjectSheet { name, path in
|
||||
viewModel.addProject(name: name, path: path)
|
||||
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
|
||||
@@ -343,13 +276,11 @@ struct ProjectsView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
.padding(.bottom, 8)
|
||||
// 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
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
if siteWidget != nil {
|
||||
tabBar
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
switch selectedTab {
|
||||
case .dashboard:
|
||||
widgetsTab(dashboard)
|
||||
@@ -359,24 +290,8 @@ struct ProjectsView: View {
|
||||
} else {
|
||||
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 {
|
||||
ContentUnavailableView {
|
||||
Label("No Dashboard", systemImage: "square.grid.2x2")
|
||||
@@ -400,23 +315,14 @@ 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 {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(visibleTabs, id: \.self) { tab in
|
||||
ForEach(DashboardTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: tab.systemImage)
|
||||
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
||||
.font(.caption)
|
||||
Text(tab.displayName)
|
||||
.font(.subheadline)
|
||||
@@ -477,15 +383,6 @@ struct ProjectsView: View {
|
||||
Image(systemName: "folder")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
if isConfigurable(project) {
|
||||
Button {
|
||||
configEditorProject = project
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Edit configuration")
|
||||
}
|
||||
if uninstaller.isTemplateInstalled(project: project) {
|
||||
Button {
|
||||
uninstallerViewModel.begin(project: project)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// Drives the post-install "Configuration" button on the project
|
||||
/// dashboard. Loads `<project>/.scarf/manifest.json` + `config.json`,
|
||||
/// hands a `TemplateConfigViewModel` seeded with current values to the
|
||||
/// sheet, then writes the edited values back on commit.
|
||||
///
|
||||
/// Smaller surface than `TemplateInstallerViewModel` — no unzipping,
|
||||
/// no parent-dir picking, no cron CLI. Just: read → edit → save.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TemplateConfigEditorViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigEditorViewModel")
|
||||
|
||||
enum Stage: Sendable {
|
||||
case idle
|
||||
case loading
|
||||
/// Manifest + config loaded; the sheet is displaying the form.
|
||||
case editing
|
||||
case saving
|
||||
case succeeded
|
||||
case failed(String)
|
||||
/// Project wasn't installed from a schemaful template — no
|
||||
/// manifest cache on disk. The dashboard button is hidden in
|
||||
/// this case so we shouldn't hit this stage normally.
|
||||
case notConfigurable
|
||||
}
|
||||
|
||||
let context: ServerContext
|
||||
let project: ProjectEntry
|
||||
private let configService: ProjectConfigService
|
||||
|
||||
init(context: ServerContext, project: ProjectEntry) {
|
||||
self.context = context
|
||||
self.project = project
|
||||
self.configService = ProjectConfigService(context: context)
|
||||
}
|
||||
|
||||
var stage: Stage = .idle
|
||||
var manifest: ProjectTemplateManifest?
|
||||
var currentValues: [String: TemplateConfigValue] = [:]
|
||||
|
||||
/// Non-nil while `.editing`; used to construct the sheet's VM.
|
||||
var formViewModel: TemplateConfigViewModel?
|
||||
|
||||
/// Load the cached manifest + current config values, then move to
|
||||
/// `.editing` so the sheet can render the form.
|
||||
func begin() {
|
||||
stage = .loading
|
||||
let service = configService
|
||||
let project = project
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
guard let cachedManifest = try service.loadCachedManifest(project: project),
|
||||
let schema = cachedManifest.config,
|
||||
!schema.isEmpty else {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .notConfigurable
|
||||
}
|
||||
return
|
||||
}
|
||||
let configFile = try service.load(project: project)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.manifest = cachedManifest
|
||||
self.currentValues = configFile?.values ?? [:]
|
||||
self.formViewModel = TemplateConfigViewModel(
|
||||
schema: schema,
|
||||
templateId: cachedManifest.id,
|
||||
templateSlug: cachedManifest.slug,
|
||||
initialValues: self.currentValues,
|
||||
mode: .edit(project: project)
|
||||
)
|
||||
self.stage = .editing
|
||||
}
|
||||
} catch {
|
||||
Self.logger.error("couldn't load config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the sheet's commit succeeded. Persists the edited
|
||||
/// values to `<project>/.scarf/config.json`. Secrets are already
|
||||
/// in the Keychain — the VM's commit step wrote them.
|
||||
func save(values: [String: TemplateConfigValue]) {
|
||||
guard let manifest else { return }
|
||||
stage = .saving
|
||||
let service = configService
|
||||
let project = project
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
try service.save(
|
||||
project: project,
|
||||
templateId: manifest.id,
|
||||
values: values
|
||||
)
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .succeeded
|
||||
}
|
||||
} catch {
|
||||
Self.logger.error("couldn't save config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
stage = .idle
|
||||
formViewModel = nil
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// Drives the configure form for template install + post-install editing.
|
||||
///
|
||||
/// **Timing of secret storage.** The VM keeps freshly-entered secret bytes
|
||||
/// in-memory (`pendingSecrets`) until the user clicks the commit button.
|
||||
/// Only then does `commit()` push each secret through
|
||||
/// `ProjectConfigService.storeSecret` and get back a `keychainRef` URI.
|
||||
/// This means cancelling the sheet never leaves an orphan Keychain
|
||||
/// entry behind — the form is transactional from the user's POV.
|
||||
///
|
||||
/// **Validation.** Runs via `ProjectConfigService.validateValues` every
|
||||
/// time the user attempts to commit. Per-field errors are tracked in
|
||||
/// `errors` so the sheet can surface them inline with the offending field.
|
||||
/// No live validation on every keystroke — that creates a messy
|
||||
/// "error appears the moment you start typing" UX.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TemplateConfigViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigViewModel")
|
||||
|
||||
enum Mode: Sendable {
|
||||
/// User is filling in values for the first time as part of the
|
||||
/// install flow. Secrets will be written to the Keychain when
|
||||
/// `commit` succeeds.
|
||||
case install
|
||||
/// User is editing values for an already-installed project.
|
||||
/// Existing keychain refs are preserved for fields the user
|
||||
/// doesn't touch; only secrets the user actually changes get
|
||||
/// re-written to the Keychain.
|
||||
case edit(project: ProjectEntry)
|
||||
}
|
||||
|
||||
let schema: TemplateConfigSchema
|
||||
let templateId: String
|
||||
let templateSlug: String
|
||||
let mode: Mode
|
||||
private let configService: ProjectConfigService
|
||||
|
||||
/// Current form values, keyed by field key. Non-secret values live
|
||||
/// here directly; secret fields either hold a `.keychainRef(...)`
|
||||
/// (existing, untouched in edit mode) or nothing at all (user
|
||||
/// hasn't entered a secret yet, or they just cleared it).
|
||||
var values: [String: TemplateConfigValue] = [:]
|
||||
|
||||
/// Raw secret bytes waiting to be written to the Keychain on
|
||||
/// `commit()`. Indexed by field key. `values[key]` stays as its
|
||||
/// current `.keychainRef(...)` (for edit mode) or missing (for
|
||||
/// install mode) until commit swaps it for the freshly-written
|
||||
/// ref URI.
|
||||
var pendingSecrets: [String: Data] = [:]
|
||||
|
||||
/// One error per field with a problem. Populated by `commit()` on
|
||||
/// validation failure; the sheet surfaces the message inline below
|
||||
/// the offending control.
|
||||
var errors: [String: String] = [:]
|
||||
|
||||
init(
|
||||
schema: TemplateConfigSchema,
|
||||
templateId: String,
|
||||
templateSlug: String,
|
||||
initialValues: [String: TemplateConfigValue] = [:],
|
||||
mode: Mode,
|
||||
configService: ProjectConfigService = ProjectConfigService()
|
||||
) {
|
||||
self.schema = schema
|
||||
self.templateId = templateId
|
||||
self.templateSlug = templateSlug
|
||||
self.mode = mode
|
||||
self.configService = configService
|
||||
self.values = Self.applyDefaults(schema: schema, initial: initialValues)
|
||||
}
|
||||
|
||||
// MARK: - Field setters (the sheet calls these as controls change)
|
||||
|
||||
func setString(_ key: String, _ value: String) {
|
||||
values[key] = .string(value)
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
func setNumber(_ key: String, _ value: Double) {
|
||||
values[key] = .number(value)
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
func setBool(_ key: String, _ value: Bool) {
|
||||
values[key] = .bool(value)
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
func setList(_ key: String, _ items: [String]) {
|
||||
values[key] = .list(items)
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
/// Stage a new secret value. Doesn't hit the Keychain until
|
||||
/// `commit()`. An empty `value` clears both the pending secret and
|
||||
/// the field's stored keychainRef — only valid in edit mode, where
|
||||
/// "empty" means "I want to remove this secret."
|
||||
func setSecret(_ key: String, _ value: String) {
|
||||
if value.isEmpty {
|
||||
pendingSecrets.removeValue(forKey: key)
|
||||
values.removeValue(forKey: key)
|
||||
} else {
|
||||
pendingSecrets[key] = Data(value.utf8)
|
||||
// Keep any existing ref around; the sheet can display
|
||||
// "(changed)" while the ref is still the old one. commit()
|
||||
// overwrites on disk.
|
||||
}
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
// MARK: - Commit
|
||||
|
||||
/// Validate, persist secrets to the Keychain, and hand back the
|
||||
/// final values dictionary. On validation failure, `errors` is
|
||||
/// populated and the method returns `nil` without touching the
|
||||
/// Keychain — the form is transactional.
|
||||
///
|
||||
/// In install mode, `project` is required (secrets need a path
|
||||
/// hash for their Keychain account). In edit mode it falls out of
|
||||
/// the `.edit(project:)` associated value.
|
||||
func commit(project: ProjectEntry? = nil) -> [String: TemplateConfigValue]? {
|
||||
// Build the value set we're about to validate. For secrets
|
||||
// that have a pending update, we treat them as present (we'll
|
||||
// write them in a moment); for secrets already stored as
|
||||
// keychainRef, we treat them as present too. Only a completely
|
||||
// empty secret field is "missing."
|
||||
var candidate = values
|
||||
for key in pendingSecrets.keys {
|
||||
// The field is about to have a fresh keychainRef — for
|
||||
// validation purposes, use a placeholder ref so the type
|
||||
// check passes. The real ref replaces it below.
|
||||
candidate[key] = .keychainRef("pending://\(key)")
|
||||
}
|
||||
let validationErrors = ProjectConfigService.validateValues(candidate, against: schema)
|
||||
guard validationErrors.isEmpty else {
|
||||
var byField: [String: String] = [:]
|
||||
for err in validationErrors {
|
||||
guard let key = err.fieldKey else { continue }
|
||||
byField[key] = err.message
|
||||
}
|
||||
self.errors = byField
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validation passed — write the pending secrets to the Keychain.
|
||||
let targetProject: ProjectEntry
|
||||
switch mode {
|
||||
case .install:
|
||||
guard let project else {
|
||||
Self.logger.error("commit(project:) called in install mode without a project")
|
||||
return nil
|
||||
}
|
||||
targetProject = project
|
||||
case .edit(let proj):
|
||||
targetProject = proj
|
||||
}
|
||||
|
||||
for (key, secret) in pendingSecrets {
|
||||
do {
|
||||
let ref = try configService.storeSecret(
|
||||
templateSlug: templateSlug,
|
||||
fieldKey: key,
|
||||
project: targetProject,
|
||||
secret: secret
|
||||
)
|
||||
values[key] = ref
|
||||
} catch {
|
||||
Self.logger.error("failed to store secret for \(key, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
errors[key] = "Couldn't save secret to the Keychain: \(error.localizedDescription)"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
pendingSecrets.removeAll()
|
||||
errors.removeAll()
|
||||
return values
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Seed the form with any author-supplied defaults for fields that
|
||||
/// don't already have an initial value (from a saved config.json).
|
||||
nonisolated private static func applyDefaults(
|
||||
schema: TemplateConfigSchema,
|
||||
initial: [String: TemplateConfigValue]
|
||||
) -> [String: TemplateConfigValue] {
|
||||
var out = initial
|
||||
for field in schema.fields where out[field.key] == nil {
|
||||
if let def = field.defaultValue {
|
||||
out[field.key] = def
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,6 @@ final class TemplateInstallerViewModel {
|
||||
case fetching(sourceDescription: String)
|
||||
case inspecting
|
||||
case awaitingParentDirectory
|
||||
/// Template declared a non-empty config schema; the sheet
|
||||
/// presents `TemplateConfigSheet` before continuing to the
|
||||
/// preview. Schema-less templates skip this stage entirely.
|
||||
case awaitingConfig
|
||||
case planned
|
||||
case installing
|
||||
case succeeded(installed: ProjectEntry)
|
||||
@@ -143,20 +139,14 @@ final class TemplateInstallerViewModel {
|
||||
guard let inspection else { return }
|
||||
chosenParentDirectory = parentDir
|
||||
let service = templateService
|
||||
let context = context
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||
_ = context
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.plan = plan
|
||||
// If the template declares a non-empty config
|
||||
// schema, insert the configure step before the
|
||||
// preview sheet. Otherwise go straight to .planned.
|
||||
if let schema = plan.configSchema, !schema.isEmpty {
|
||||
self.stage = .awaitingConfig
|
||||
} else {
|
||||
self.stage = .planned
|
||||
}
|
||||
self?.plan = plan
|
||||
self?.stage = .planned
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { [weak self] in
|
||||
@@ -166,26 +156,6 @@ final class TemplateInstallerViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by `TemplateInstallSheet` once the user has filled in
|
||||
/// the configure form and `TemplateConfigViewModel.commit()`
|
||||
/// succeeded. Stashes the values in the plan and advances to the
|
||||
/// preview stage (`.planned`). Secrets in `values` are already
|
||||
/// `.keychainRef(...)` — the VM's commit step wrote them to the
|
||||
/// Keychain.
|
||||
func submitConfig(values: [String: TemplateConfigValue]) {
|
||||
guard var plan else { return }
|
||||
plan.configValues = values
|
||||
self.plan = plan
|
||||
stage = .planned
|
||||
}
|
||||
|
||||
/// Called when the user cancels out of the configure step without
|
||||
/// committing. Returns to `.awaitingParentDirectory` so they can
|
||||
/// try again (or dismiss the whole sheet).
|
||||
func cancelConfig() {
|
||||
stage = .awaitingParentDirectory
|
||||
}
|
||||
|
||||
func confirmInstall() {
|
||||
guard let plan else { return }
|
||||
stage = .installing
|
||||
|
||||
@@ -17,26 +17,6 @@ final class TemplateUninstallerViewModel {
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
/// Snapshot of "what survived the uninstall" — surfaced in the
|
||||
/// success screen so the user understands why the project directory
|
||||
/// is or isn't gone from disk. Computed from the plan right before
|
||||
/// executing it (`plan` itself is nil'd on success, so we can't
|
||||
/// reach back for this info after the fact).
|
||||
struct PreservedOutcome: Sendable {
|
||||
/// True when the uninstaller removed the project dir (nothing
|
||||
/// user-owned was left inside). In this case `preservedPaths`
|
||||
/// is empty and the success view skips the banner entirely.
|
||||
let projectDirRemoved: Bool
|
||||
/// Absolute paths of files the uninstaller refused to touch
|
||||
/// because they weren't installed by the template (typically
|
||||
/// `status-log.md` after the cron ran, or anything the user
|
||||
/// dropped into the project dir manually).
|
||||
let preservedPaths: [String]
|
||||
/// Project dir — echoed back so the success view can show the
|
||||
/// user where the orphan files now live.
|
||||
let projectDir: String
|
||||
}
|
||||
|
||||
let context: ServerContext
|
||||
private let uninstaller: ProjectTemplateUninstaller
|
||||
|
||||
@@ -47,15 +27,11 @@ final class TemplateUninstallerViewModel {
|
||||
|
||||
var stage: Stage = .idle
|
||||
var plan: TemplateUninstallPlan?
|
||||
/// Populated on transition to `.succeeded`. Nil whenever the user
|
||||
/// re-enters the flow (cancel/begin both clear it).
|
||||
var preservedOutcome: PreservedOutcome?
|
||||
|
||||
/// Load the `template.lock.json` for the given project and build a
|
||||
/// removal plan. Moves stage to `.planned` on success.
|
||||
func begin(project: ProjectEntry) {
|
||||
stage = .loading
|
||||
preservedOutcome = nil
|
||||
let uninstaller = uninstaller
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
@@ -77,20 +53,11 @@ final class TemplateUninstallerViewModel {
|
||||
guard let plan else { return }
|
||||
stage = .uninstalling
|
||||
let uninstaller = uninstaller
|
||||
// Capture the preservation shape before executing — the plan
|
||||
// itself gets nil'd on success and we want the banner to show
|
||||
// whatever was true at the moment of removal.
|
||||
let outcome = PreservedOutcome(
|
||||
projectDirRemoved: plan.projectDirBecomesEmpty,
|
||||
preservedPaths: plan.extraProjectEntries,
|
||||
projectDir: plan.project.path
|
||||
)
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
try uninstaller.uninstall(plan: plan)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.preservedOutcome = outcome
|
||||
self.stage = .succeeded(removed: plan.project)
|
||||
self.plan = nil
|
||||
}
|
||||
@@ -104,7 +71,6 @@ final class TemplateUninstallerViewModel {
|
||||
|
||||
func cancel() {
|
||||
plan = nil
|
||||
preservedOutcome = nil
|
||||
stage = .idle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Post-install configuration editor. Thin wrapper around the same
|
||||
/// `TemplateConfigSheet` the install flow uses — owns a
|
||||
/// `TemplateConfigEditorViewModel` that loads the cached manifest +
|
||||
/// current values from `<project>/.scarf/`, feeds them to the form,
|
||||
/// and writes the edited values back to `config.json` on commit.
|
||||
///
|
||||
/// Entry points: right-click on the project list (when the project has
|
||||
/// a cached manifest) and a button on the dashboard header (shown
|
||||
/// only when `isConfigurable` is true).
|
||||
struct ConfigEditorSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel: TemplateConfigEditorViewModel
|
||||
|
||||
init(context: ServerContext, project: ProjectEntry) {
|
||||
_viewModel = State(
|
||||
initialValue: TemplateConfigEditorViewModel(
|
||||
context: context,
|
||||
project: project
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch viewModel.stage {
|
||||
case .idle, .loading:
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Loading configuration…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 320)
|
||||
.padding()
|
||||
case .editing:
|
||||
if let form = viewModel.formViewModel,
|
||||
let manifest = viewModel.manifest {
|
||||
TemplateConfigSheet(
|
||||
viewModel: form,
|
||||
title: "Configure \(manifest.name)",
|
||||
commitLabel: "Save",
|
||||
project: nil, // edit mode; VM carries the project
|
||||
onCommit: { values in
|
||||
viewModel.save(values: values)
|
||||
},
|
||||
onCancel: {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
unexpectedState
|
||||
}
|
||||
case .saving:
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Saving…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 320)
|
||||
.padding()
|
||||
case .succeeded:
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.green)
|
||||
Text("Configuration saved").font(.title2.bold())
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 280)
|
||||
.padding()
|
||||
case .failed(let message):
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Couldn't save").font(.title2.bold())
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 280)
|
||||
.padding()
|
||||
case .notConfigurable:
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No configuration")
|
||||
.font(.title3.bold())
|
||||
Text("This project wasn't installed from a schemaful template.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 280)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.task { viewModel.begin() }
|
||||
}
|
||||
|
||||
private var unexpectedState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "questionmark.circle")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Internal state inconsistency — please close and re-open.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 280)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The configure form rendered for template install + post-install
|
||||
/// editing. One row per schema field; controls dispatch by field type.
|
||||
/// Commit button returns the finalized values via `onCommit` — in
|
||||
/// install mode the caller stashes them in the install plan; in edit
|
||||
/// mode the caller writes them straight to `<project>/.scarf/config.json`.
|
||||
struct TemplateConfigSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State var viewModel: TemplateConfigViewModel
|
||||
let title: LocalizedStringKey
|
||||
let commitLabel: LocalizedStringKey
|
||||
/// In install mode the caller passes the planned `ProjectEntry`
|
||||
/// (project dir path is the unique key for the Keychain secret).
|
||||
/// In edit mode the VM already holds the project; pass `nil` here.
|
||||
let project: ProjectEntry?
|
||||
let onCommit: ([String: TemplateConfigValue]) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
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) {
|
||||
if viewModel.schema.fields.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No fields",
|
||||
systemImage: "slider.horizontal.3",
|
||||
description: Text("This template has no configuration fields.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 120)
|
||||
} else {
|
||||
ForEach(viewModel.schema.fields) { field in
|
||||
fieldRow(field)
|
||||
}
|
||||
}
|
||||
if let rec = viewModel.schema.modelRecommendation {
|
||||
modelRecommendation(rec)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 560, minHeight: 480)
|
||||
}
|
||||
|
||||
// MARK: - Header / footer
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.title2.bold())
|
||||
Text(viewModel.templateId)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
// Caller owns dismissal — this view is used both as a
|
||||
// standalone sheet (ConfigEditorSheet, where the caller
|
||||
// wants dismissal) AND inlined inside the install sheet
|
||||
// (TemplateInstallSheet.configureView, where calling
|
||||
// .dismiss here would tear down the OUTER install sheet
|
||||
// and abort the flow before .planned is reached).
|
||||
onCancel()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button(commitLabel) {
|
||||
if let finalized = viewModel.commit(project: project) {
|
||||
onCommit(finalized)
|
||||
}
|
||||
// Same dismissal-is-caller's-responsibility rule as
|
||||
// Cancel — inside the install sheet, onCommit transitions
|
||||
// stage to .planned and the outer view re-renders to
|
||||
// show the preview. In the edit sheet, onCommit
|
||||
// transitions the editor VM and its state machine
|
||||
// handles dismissal via the success view's Done button.
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
// MARK: - Field rows
|
||||
|
||||
@ViewBuilder
|
||||
private func fieldRow(_ field: TemplateConfigField) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(field.label).font(.headline)
|
||||
if field.required {
|
||||
Text("*")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Spacer()
|
||||
Text(field.type.rawValue)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let description = field.description, !description.isEmpty {
|
||||
// Inline markdown so descriptions can include
|
||||
// `[Create one](https://…)`-style links to token
|
||||
// generation pages, **bold** emphasis on important
|
||||
// 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)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
control(for: field)
|
||||
if let err = viewModel.errors[field.key] {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.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)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.background.secondary)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func control(for field: TemplateConfigField) -> some View {
|
||||
switch field.type {
|
||||
case .string:
|
||||
StringControl(
|
||||
value: stringBinding(for: field),
|
||||
placeholder: field.placeholder
|
||||
)
|
||||
case .text:
|
||||
TextControl(value: stringBinding(for: field))
|
||||
case .number:
|
||||
NumberControl(value: numberBinding(for: field))
|
||||
case .bool:
|
||||
BoolControl(label: field.label, value: boolBinding(for: field))
|
||||
case .enum:
|
||||
EnumControl(
|
||||
options: field.options ?? [],
|
||||
value: stringBinding(for: field)
|
||||
)
|
||||
case .list:
|
||||
ListControl(items: listBinding(for: field))
|
||||
case .secret:
|
||||
SecretControl(
|
||||
fieldKey: field.key,
|
||||
placeholder: field.placeholder,
|
||||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model recommendation panel
|
||||
|
||||
private func modelRecommendation(_ rec: TemplateModelRecommendation) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Recommended model", systemImage: "lightbulb")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(rec.preferred).font(.body.monospaced())
|
||||
if let rationale = rec.rationale, !rationale.isEmpty {
|
||||
Text(rationale)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if let alts = rec.alternatives, !alts.isEmpty {
|
||||
Text("Also works: \(alts.joined(separator: ", "))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Scarf doesn't auto-switch your active model. Change it in Settings if you'd like.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.accentColor.opacity(0.08))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Binding helpers (threading the VM through typed lenses)
|
||||
|
||||
private func stringBinding(for field: TemplateConfigField) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .string(let s) = viewModel.values[field.key] { return s }
|
||||
return ""
|
||||
},
|
||||
set: { viewModel.setString(field.key, $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func numberBinding(for field: TemplateConfigField) -> Binding<Double> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .number(let n) = viewModel.values[field.key] { return n }
|
||||
return 0
|
||||
},
|
||||
set: { viewModel.setNumber(field.key, $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func boolBinding(for field: TemplateConfigField) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .bool(let b) = viewModel.values[field.key] { return b }
|
||||
return false
|
||||
},
|
||||
set: { viewModel.setBool(field.key, $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func listBinding(for field: TemplateConfigField) -> Binding<[String]> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .list(let items) = viewModel.values[field.key] { return items }
|
||||
return []
|
||||
},
|
||||
set: { viewModel.setList(field.key, $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Field controls
|
||||
|
||||
private struct StringControl: View {
|
||||
@Binding var value: String
|
||||
let placeholder: String?
|
||||
var body: some View {
|
||||
TextField(placeholder ?? "", text: $value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TextControl: View {
|
||||
@Binding var value: String
|
||||
var body: some View {
|
||||
TextEditor(text: $value)
|
||||
.font(.body.monospaced())
|
||||
.frame(minHeight: 80, maxHeight: 160)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(.secondary.opacity(0.3))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NumberControl: View {
|
||||
@Binding var value: Double
|
||||
var body: some View {
|
||||
TextField("", value: $value, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BoolControl: View {
|
||||
let label: String
|
||||
@Binding var value: Bool
|
||||
var body: some View {
|
||||
Toggle(isOn: $value) {
|
||||
Text(value ? "Enabled" : "Disabled")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EnumControl: View {
|
||||
let options: [TemplateConfigField.EnumOption]
|
||||
@Binding var value: String
|
||||
var body: some View {
|
||||
// Always use the default Menu picker (dropdown). An earlier
|
||||
// version switched to `.pickerStyle(.segmented)` when
|
||||
// `options.count ≤ 4` for a more compact look, but on macOS
|
||||
// segmented pickers size to the intrinsic width of all their
|
||||
// labels concatenated — they refuse offered width constraints
|
||||
// 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,
|
||||
// clipping the entire form. Menu pickers respect the fieldRow's
|
||||
// offered width and show long labels in the popup list, so the
|
||||
// sheet can't overflow regardless of label length.
|
||||
Picker("", selection: $value) {
|
||||
ForEach(options) { opt in
|
||||
Text(opt.label).tag(opt.value)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
|
||||
/// Variable-length list of string values. Each row is a text field
|
||||
/// with an inline remove button; a + button adds a trailing row.
|
||||
private struct ListControl: View {
|
||||
@Binding var items: [String]
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
HStack(spacing: 6) {
|
||||
TextField("", text: Binding(
|
||||
get: { i < items.count ? items[i] : "" },
|
||||
set: { newValue in
|
||||
guard i < items.count else { return }
|
||||
items[i] = newValue
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button {
|
||||
guard i < items.count else { return }
|
||||
items.remove(at: i)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(items.count <= 1)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
items.append("")
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus.circle")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Secret fields never echo the previously-stored value back. Instead
|
||||
/// we render "(unchanged)" when a Keychain ref already exists and let
|
||||
/// the user type over it if they want to replace. Empty input in edit
|
||||
/// mode signals "remove this secret entirely."
|
||||
private struct SecretControl: View {
|
||||
let fieldKey: String
|
||||
let placeholder: String?
|
||||
@Bindable var viewModel: TemplateConfigViewModel
|
||||
|
||||
@State private var typedValue: String = ""
|
||||
@State private var isRevealed: Bool = false
|
||||
|
||||
private var hasStoredRef: Bool {
|
||||
if case .keychainRef = viewModel.values[fieldKey] { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Group {
|
||||
if isRevealed {
|
||||
TextField(placeholder ?? "", text: $typedValue)
|
||||
} else {
|
||||
SecureField(placeholder ?? "", text: $typedValue)
|
||||
}
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: typedValue) { _, new in
|
||||
viewModel.setSecret(fieldKey, new)
|
||||
}
|
||||
Button {
|
||||
isRevealed.toggle()
|
||||
} label: {
|
||||
Image(systemName: isRevealed ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(isRevealed ? "Hide" : "Show while typing")
|
||||
}
|
||||
if hasStoredRef && typedValue.isEmpty {
|
||||
Text("Saved in Keychain — leave empty to keep the stored value.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if !typedValue.isEmpty {
|
||||
Text("Will be saved to the Keychain on commit.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,6 @@ struct TemplateInstallSheet: View {
|
||||
progress("Inspecting template…")
|
||||
case .awaitingParentDirectory:
|
||||
pickParentView
|
||||
case .awaitingConfig:
|
||||
configureView
|
||||
case .planned:
|
||||
if let plan = viewModel.plan {
|
||||
plannedView(plan: plan)
|
||||
@@ -87,55 +85,12 @@ struct TemplateInstallSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure step for schemaful templates. Inlines
|
||||
/// `TemplateConfigSheet` into the install flow rather than pushing
|
||||
/// a second sheet on top — keeps the user in one window. The
|
||||
/// nested VM is created freshly each time `.awaitingConfig` is
|
||||
/// entered so a Cancel + retry doesn't carry stale form state.
|
||||
@ViewBuilder
|
||||
private var configureView: some View {
|
||||
if let plan = viewModel.plan,
|
||||
let schema = plan.configSchema,
|
||||
let manifest = viewModel.inspection?.manifest {
|
||||
TemplateConfigSheet(
|
||||
viewModel: TemplateConfigViewModel(
|
||||
schema: schema,
|
||||
templateId: manifest.id,
|
||||
templateSlug: manifest.slug,
|
||||
initialValues: plan.configValues,
|
||||
mode: .install
|
||||
),
|
||||
title: "Configure \(manifest.name)",
|
||||
commitLabel: "Continue",
|
||||
project: ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir),
|
||||
onCommit: { values in
|
||||
viewModel.submitConfig(values: values)
|
||||
},
|
||||
onCancel: {
|
||||
viewModel.cancelConfig()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
progress("Preparing…")
|
||||
}
|
||||
}
|
||||
|
||||
private func plannedView(plan: TemplateInstallPlan) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
manifestHeader(plan.manifest)
|
||||
.padding(.bottom, 8)
|
||||
Divider()
|
||||
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) {
|
||||
projectFilesSection(plan: plan)
|
||||
if plan.skillsNamespaceDir != nil {
|
||||
@@ -147,12 +102,8 @@ struct TemplateInstallSheet: View {
|
||||
if plan.memoryAppendix != nil {
|
||||
memorySection(plan: plan)
|
||||
}
|
||||
if let schema = plan.configSchema, !schema.isEmpty {
|
||||
configurationSection(plan: plan, schema: schema)
|
||||
}
|
||||
readmeSection
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical)
|
||||
}
|
||||
Divider()
|
||||
@@ -186,10 +137,7 @@ struct TemplateInstallSheet: View {
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// Inline-only markdown — descriptions are a sentence or two;
|
||||
// bold/italic/code/links are all that reasonable template
|
||||
// authors use there.
|
||||
TemplateMarkdown.inlineText(manifest.description)
|
||||
Text(manifest.description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if let author = manifest.author {
|
||||
@@ -234,40 +182,16 @@ struct TemplateInstallSheet: View {
|
||||
|
||||
private func cronSection(plan: TemplateInstallPlan) -> some View {
|
||||
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(plan.cronJobs, id: \.name) { job in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(job.name).font(.callout.monospaced())
|
||||
Text("schedule: \(job.schedule)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(job.name).font(.callout.monospaced())
|
||||
Text("schedule: \(job.schedule)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
// Prompt preview — disclosed in an expandable
|
||||
// group so the preview stays compact when the
|
||||
// user doesn't care to read it. Markdown-rendered
|
||||
// so prompts that include `code`, **bold**, or
|
||||
// enumerated steps look right. Tokens like
|
||||
// {{PROJECT_DIR}} are still visible here — they
|
||||
// get substituted when the installer calls
|
||||
// `hermes cron create`.
|
||||
if let prompt = job.prompt, !prompt.isEmpty {
|
||||
DisclosureGroup("Prompt") {
|
||||
ScrollView {
|
||||
TemplateMarkdown.render(prompt)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 140)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.4))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.leading, 26)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,50 +213,6 @@ struct TemplateInstallSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration values the user entered in the configure step.
|
||||
/// Secrets display masked so the preview never echoes a freshly
|
||||
/// typed API key back on screen.
|
||||
private func configurationSection(plan: TemplateInstallPlan, schema: TemplateConfigSchema) -> some View {
|
||||
section(title: "Configuration", subtitle: "written to \(plan.projectDir)/.scarf/config.json") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(schema.fields) { field in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(field.key)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 120, alignment: .leading)
|
||||
Text(displayValue(for: field, in: plan.configValues))
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line display form for a value in the preview. Secrets are
|
||||
/// always masked; lists show a count + first entry; strings are
|
||||
/// truncated by `.lineLimit(1)` at the view level.
|
||||
private func displayValue(
|
||||
for field: TemplateConfigField,
|
||||
in values: [String: TemplateConfigValue]
|
||||
) -> String {
|
||||
switch field.type {
|
||||
case .secret:
|
||||
return values[field.key] == nil ? "(not set)" : "••••••• (Keychain)"
|
||||
case .list:
|
||||
if case .list(let items) = values[field.key] {
|
||||
if items.isEmpty { return "(none)" }
|
||||
if items.count == 1 { return items[0] }
|
||||
return "\(items[0]) + \(items.count - 1) more"
|
||||
}
|
||||
return "(none)"
|
||||
default:
|
||||
return values[field.key]?.displayString ?? "(not set)"
|
||||
}
|
||||
}
|
||||
|
||||
private var readmeSection: some View {
|
||||
Group {
|
||||
// The body is preloaded in the VM off MainActor when inspection
|
||||
@@ -340,10 +220,11 @@ struct TemplateInstallSheet: View {
|
||||
if let readme = viewModel.readmeBody {
|
||||
section(title: "README", subtitle: nil) {
|
||||
ScrollView {
|
||||
TemplateMarkdown.render(readme)
|
||||
Text(readme)
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 260)
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
/// Minimal markdown renderer used by the template install/config UIs.
|
||||
///
|
||||
/// SwiftUI `Text` has built-in inline-markdown support via
|
||||
/// `AttributedString(markdown:)` — bold, italic, inline code, links.
|
||||
/// That's enough for field descriptions + template taglines. For
|
||||
/// longer content (README preview, full doc blocks), this helper adds
|
||||
/// block-level handling: lines starting with `#`/`##`/`###` render
|
||||
/// as bigger bold text; lines starting with `-`/`*`/`1.` render as
|
||||
/// list items with a hanging indent; fenced ``` ``` blocks render as
|
||||
/// monospaced; blank lines become paragraph breaks.
|
||||
///
|
||||
/// Scope is intentionally small. This isn't a full CommonMark
|
||||
/// renderer — it's "enough markdown to make template READMEs look
|
||||
/// right in the install sheet without pulling in a dependency." If
|
||||
/// the set of templates needs more over time, evolve this file or
|
||||
/// graduate to a proper library.
|
||||
enum TemplateMarkdown {
|
||||
|
||||
/// Render a markdown source string as a SwiftUI view. Preserves
|
||||
/// reading order and approximate visual hierarchy. Safe with
|
||||
/// untrusted input — we never execute HTML or scripts.
|
||||
@ViewBuilder
|
||||
static func render(_ source: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
let blocks = parse(source)
|
||||
ForEach(blocks.indices, id: \.self) { i in
|
||||
block(blocks[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline-only markdown (bold/italic/code/links) as a single
|
||||
/// `Text`. Use for short strings where block structure doesn't
|
||||
/// apply — field labels, one-line descriptions.
|
||||
static func inlineText(_ source: String) -> Text {
|
||||
if let attr = try? AttributedString(
|
||||
markdown: source,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
) {
|
||||
return Text(attr)
|
||||
}
|
||||
return Text(source)
|
||||
}
|
||||
|
||||
// MARK: - Block model
|
||||
|
||||
fileprivate enum Block {
|
||||
case paragraph(AttributedString)
|
||||
case heading(level: Int, text: AttributedString)
|
||||
case bullet(AttributedString)
|
||||
case numbered(index: Int, text: AttributedString)
|
||||
case code(String)
|
||||
}
|
||||
|
||||
// MARK: - Parser
|
||||
|
||||
fileprivate static func parse(_ source: String) -> [Block] {
|
||||
var blocks: [Block] = []
|
||||
var lines = source.components(separatedBy: "\n")
|
||||
var i = 0
|
||||
while i < lines.count {
|
||||
let line = lines[i]
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Fenced code block.
|
||||
if trimmed.hasPrefix("```") {
|
||||
var body: [String] = []
|
||||
i += 1
|
||||
while i < lines.count {
|
||||
let inner = lines[i]
|
||||
if inner.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||
i += 1
|
||||
break
|
||||
}
|
||||
body.append(inner)
|
||||
i += 1
|
||||
}
|
||||
blocks.append(.code(body.joined(separator: "\n")))
|
||||
continue
|
||||
}
|
||||
|
||||
// Heading.
|
||||
if let headingMatch = trimmed.firstMatch(of: /^(#{1,6})\s+(.*)$/) {
|
||||
let level = (headingMatch.1).count
|
||||
let text = String(headingMatch.2)
|
||||
blocks.append(.heading(level: level, text: renderInline(text)))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Bullet list.
|
||||
if let bulletMatch = line.firstMatch(of: /^\s*[-*]\s+(.*)$/) {
|
||||
let text = String(bulletMatch.1)
|
||||
blocks.append(.bullet(renderInline(text)))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Numbered list.
|
||||
if let numMatch = line.firstMatch(of: /^\s*(\d+)\.\s+(.*)$/) {
|
||||
let index = Int(String(numMatch.1)) ?? 1
|
||||
let text = String(numMatch.2)
|
||||
blocks.append(.numbered(index: index, text: renderInline(text)))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Blank line — skip.
|
||||
if trimmed.isEmpty {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Paragraph — collect contiguous non-blank lines that
|
||||
// aren't headings/lists/fences into one paragraph block.
|
||||
var paragraphLines: [String] = [line]
|
||||
i += 1
|
||||
while i < lines.count {
|
||||
let next = lines[i]
|
||||
let nextTrim = next.trimmingCharacters(in: .whitespaces)
|
||||
if nextTrim.isEmpty { break }
|
||||
if nextTrim.hasPrefix("```") { break }
|
||||
if nextTrim.firstMatch(of: /^#{1,6}\s/) != nil { break }
|
||||
if next.firstMatch(of: /^\s*[-*]\s+/) != nil { break }
|
||||
if next.firstMatch(of: /^\s*\d+\.\s+/) != nil { break }
|
||||
paragraphLines.append(next)
|
||||
i += 1
|
||||
}
|
||||
let joined = paragraphLines.joined(separator: " ")
|
||||
blocks.append(.paragraph(renderInline(joined)))
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/// Parse inline markdown (bold, italic, inline code, links) into
|
||||
/// an AttributedString. Falls back to plain text on parse failure.
|
||||
fileprivate static func renderInline(_ source: String) -> AttributedString {
|
||||
if let attr = try? AttributedString(
|
||||
markdown: source,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
) {
|
||||
return attr
|
||||
}
|
||||
return AttributedString(source)
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
@ViewBuilder
|
||||
fileprivate static func block(_ b: Block) -> some View {
|
||||
switch b {
|
||||
case .paragraph(let text):
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
case .heading(let level, let text):
|
||||
headingText(text: text, level: level)
|
||||
case .bullet(let text):
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("•").font(.callout)
|
||||
Text(text).font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
case .numbered(let index, let text):
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("\(index).").font(.callout.monospacedDigit())
|
||||
Text(text).font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
case .code(let src):
|
||||
Text(src)
|
||||
.font(.caption.monospaced())
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
fileprivate static func headingText(text: AttributedString, level: Int) -> some View {
|
||||
switch level {
|
||||
case 1: Text(text).font(.title2.bold()).padding(.top, 8)
|
||||
case 2: Text(text).font(.title3.bold()).padding(.top, 6)
|
||||
case 3: Text(text).font(.headline).padding(.top, 4)
|
||||
default: Text(text).font(.subheadline.bold()).padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,19 +277,6 @@ struct TemplateUninstallSheet: View {
|
||||
.foregroundStyle(.green)
|
||||
Text("Removed \(removed.name)")
|
||||
.font(.title2.bold())
|
||||
|
||||
// Preserved-files banner. Only renders when the project dir
|
||||
// stayed and at least one file was left behind — that's the
|
||||
// case the user keeps getting surprised by ("I uninstalled
|
||||
// but my project folder is still there?"). Explicit
|
||||
// explanation + file list makes it obvious the files the
|
||||
// user (or the cron job) created are intentionally kept.
|
||||
if let outcome = viewModel.preservedOutcome,
|
||||
outcome.projectDirRemoved == false,
|
||||
outcome.preservedPaths.isEmpty == false {
|
||||
preservedFilesBanner(outcome: outcome)
|
||||
}
|
||||
|
||||
Button("Done") {
|
||||
onCompleted(removed)
|
||||
dismiss()
|
||||
@@ -298,53 +285,6 @@ struct TemplateUninstallSheet: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
/// Orange informational banner listing the files the uninstaller
|
||||
/// left in the project directory. Caps the visible list at 8 rows
|
||||
/// with a "+N more…" tail so a long log (many runs = many status
|
||||
/// file entries) doesn't blow out the sheet height.
|
||||
private func preservedFilesBanner(
|
||||
outcome: TemplateUninstallerViewModel.PreservedOutcome
|
||||
) -> some View {
|
||||
let visible = Array(outcome.preservedPaths.prefix(8))
|
||||
let overflow = outcome.preservedPaths.count - visible.count
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "folder.badge.questionmark")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Project folder kept")
|
||||
.font(.headline)
|
||||
}
|
||||
Text("These files weren't installed by the template (the agent or you created them after install), so Scarf left them in place along with the folder itself.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(visible, id: \.self) { path in
|
||||
Text(path)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
}
|
||||
if overflow > 0 {
|
||||
Text("+ \(overflow) more…")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text("Delete \(outcome.projectDir) from Finder if you don't need these files anymore.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: 520, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.orange.opacity(0.10))
|
||||
)
|
||||
}
|
||||
|
||||
private func failureView(message: String) -> some View {
|
||||
|
||||
@@ -49,10 +49,6 @@
|
||||
},
|
||||
"(%lld tokens)" : {
|
||||
|
||||
},
|
||||
"*" : {
|
||||
"comment" : "A required asterisk.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"/%@" : {
|
||||
|
||||
@@ -889,10 +885,6 @@
|
||||
},
|
||||
"••••••••••" : {
|
||||
|
||||
},
|
||||
"+ %lld more…" : {
|
||||
"comment" : "A button that shows the number of files that were left behind by the template uninstaller.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"<%@>" : {
|
||||
|
||||
@@ -1028,10 +1020,6 @@
|
||||
"comment" : "A message that appears when a memory block is no longer present in MEMORY.md.",
|
||||
"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." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -1395,10 +1383,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2245,9 +2229,6 @@
|
||||
"already gone" : {
|
||||
"comment" : "A tag for a file that is already gone (no longer in the template).",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Also works: %@" : {
|
||||
|
||||
},
|
||||
"API Key" : {
|
||||
"localizations" : {
|
||||
@@ -2529,10 +2510,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Archived (%lld)" : {
|
||||
"comment" : "A label that opens a group of archived projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Args (one per line)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3757,14 +3734,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3805,10 +3774,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5059,14 +5024,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Configuration saved" : {
|
||||
"comment" : "A title displayed when a configuration is saved.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Configuration…" : {
|
||||
"comment" : "A contextual menu item that opens a configuration editor for a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Configure" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5107,10 +5064,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Configure %@" : {
|
||||
"comment" : "The title of the configuration sheet. The argument is the name of the template.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Connect timeout" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5351,10 +5304,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Continue" : {
|
||||
"comment" : "Button label for continuing with the template configuration.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Continue Last Session" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5635,10 +5584,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Couldn't save" : {
|
||||
"comment" : "A title displayed when a configuration save fails.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Create" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -6692,10 +6637,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Delete %@ from Finder if you don't need these files anymore." : {
|
||||
"comment" : "A note that lets the user delete",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Delete %@?" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -6898,10 +6839,6 @@
|
||||
},
|
||||
"Description" : {
|
||||
|
||||
},
|
||||
"Destination" : {
|
||||
"comment" : "A label for the folder picker in the move-to-folder sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Details" : {
|
||||
"localizations" : {
|
||||
@@ -7720,10 +7657,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Edit configuration" : {
|
||||
"comment" : "A button that opens a configuration editor for a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Edit User Profile" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -8830,10 +8763,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Filter projects" : {
|
||||
"comment" : "A label for a search field in the sidebar.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Filter servers..." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -9038,10 +8967,6 @@
|
||||
"comment" : "A placeholder for a comma-separated list of tags.",
|
||||
"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)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -9734,10 +9659,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Hide archived projects" : {
|
||||
"comment" : "A toggle that hides archived projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hide details" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -10627,9 +10548,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Internal state inconsistency — please close and re-open." : {
|
||||
|
||||
},
|
||||
"Invalid URL" : {
|
||||
"localizations" : {
|
||||
@@ -11238,10 +11156,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Loading configuration…" : {
|
||||
"comment" : "A message displayed while loading the configuration.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loading session…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -12226,22 +12140,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" : {
|
||||
|
||||
},
|
||||
@@ -12365,17 +12263,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 '%@'" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -12778,9 +12665,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No configuration" : {
|
||||
|
||||
},
|
||||
"No credential pools configured" : {
|
||||
"localizations" : {
|
||||
@@ -13026,10 +12910,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No fields" : {
|
||||
"comment" : "A label that describes a template with no configuration fields.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No headers configured." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -13394,10 +13274,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No project selected" : {
|
||||
"comment" : "A label that indicates that no project is selected.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No Projects" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -14382,10 +14258,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Opens on launch" : {
|
||||
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Optional" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -15526,13 +15398,6 @@
|
||||
},
|
||||
"Project directory will also be removed (nothing user-owned left inside)." : {
|
||||
|
||||
},
|
||||
"Project folder kept" : {
|
||||
|
||||
},
|
||||
"Project name" : {
|
||||
"comment" : "A label for a text field that lets the user enter a project name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Project Name" : {
|
||||
"localizations" : {
|
||||
@@ -16262,10 +16127,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recommended model" : {
|
||||
"comment" : "A label that indicates a recommended model.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Reconnect" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16597,10 +16458,6 @@
|
||||
"comment" : "A label that instructs the user to remove a project from Scarf's list of installed projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove %@ from Scarf's project list (files are kept on disk)" : {
|
||||
"comment" : "A confirmation dialog that",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove %@?" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16681,16 +16538,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Remove from List" : {
|
||||
"comment" : "A confirmation dialog that asks whether a user is sure they want to remove a project from Scarf's list.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove from List (keep files)…" : {
|
||||
"comment" : "A button that removes a project from Scarf's list, but not from disk.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove from Scarf's project list?" : {
|
||||
"comment" : "Title of a dialog that asks the user to confirm removing a project from Scarf's project list.",
|
||||
"Remove from Scarf" : {
|
||||
"comment" : "A context menu option to remove a project from Scarf.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove the entire namespace dir recursively" : {
|
||||
@@ -16945,10 +16794,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rename project" : {
|
||||
"comment" : "A title for a sheet that renames a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rename Session" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -17028,9 +16873,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rename…" : {
|
||||
|
||||
},
|
||||
"required" : {
|
||||
|
||||
@@ -18158,14 +18000,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Saved in Keychain — leave empty to keep the stored value." : {
|
||||
"comment" : "A message that appears when a user has filled in a secret but has not yet saved it.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Saving…" : {
|
||||
"comment" : "A label displayed while the configuration is being saved.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Scarf" : {
|
||||
|
||||
},
|
||||
@@ -18209,10 +18043,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scarf doesn't auto-switch your active model. Change it in Settings if you'd like." : {
|
||||
"comment" : "A description of the warning about not switching models.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -19461,14 +19291,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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." : {
|
||||
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Settings" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -19709,10 +19531,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show archived projects" : {
|
||||
"comment" : "A toggle that shows/hides archived projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Show details" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -19876,10 +19694,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show while typing" : {
|
||||
"comment" : "A hint for the user on how to show/hide the secret.",
|
||||
"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." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -21525,10 +21339,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -21689,10 +21499,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"These files weren't installed by the template (the agent or you created them after install), so Scarf left them in place along with the folder itself." : {
|
||||
"comment" : "A description of the files Scarf left in place when uninstalling a template.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"These list fields must be edited directly in config.yaml." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -21732,13 +21538,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"This Mac" : {
|
||||
"comment" : "A description of the local machine.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"This project wasn't installed from a schemaful template." : {
|
||||
|
||||
},
|
||||
"This provider has no catalogued models." : {
|
||||
"localizations" : {
|
||||
@@ -21940,10 +21739,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"This template has no configuration fields." : {
|
||||
"comment" : "A description of a template with no configuration fields.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -22516,10 +22311,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Top Level" : {
|
||||
"comment" : "A folder in the sidebar.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Top Tools" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -22680,10 +22471,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unarchive" : {
|
||||
"comment" : "A button that unarchives a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Uninstall" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -22731,8 +22518,8 @@
|
||||
"comment" : "A button that uninstalls a template.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Uninstall Template (remove installed files)…" : {
|
||||
"comment" : "A button that removes a project's files from the system.",
|
||||
"Uninstall Template…" : {
|
||||
"comment" : "A contextual menu item that uninstalls a template.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Unknown: %@" : {
|
||||
@@ -23999,10 +23786,6 @@
|
||||
},
|
||||
"Where should this project live?" : {
|
||||
|
||||
},
|
||||
"Will be saved to the Keychain on commit." : {
|
||||
"comment" : "A description of a secret field that will be saved to the Keychain on commit.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Working" : {
|
||||
"localizations" : {
|
||||
|
||||
@@ -91,15 +91,4 @@ final class AppCoordinator {
|
||||
var selectedSection: SidebarSection = .dashboard
|
||||
var selectedSessionId: 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?
|
||||
}
|
||||
|
||||
@@ -86,19 +86,6 @@ struct ScarfApp: App {
|
||||
registry.defaultServerID
|
||||
}
|
||||
.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 {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates…") { updater.checkForUpdates() }
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@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
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -2,42 +2,6 @@ import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
/// Cross-suite serialization lock for tests that touch the real
|
||||
/// `~/.hermes/scarf/projects.json`. Swift Testing's `.serialized` trait
|
||||
/// only serializes tests WITHIN a suite — multiple suites still run in
|
||||
/// parallel. Three suites in this file write to the same file and
|
||||
/// previously raced each other silently (saveRegistry used to swallow
|
||||
/// write failures); now that saveRegistry throws, the race surfaces.
|
||||
///
|
||||
/// The lock is acquired by `acquireAndSnapshot()` at the top of each
|
||||
/// registry-touching test and released by `restore(_:)` via the test's
|
||||
/// `defer`. Asymmetric acquire-in-one-fn / release-in-another looks
|
||||
/// unusual but the snapshot/restore pairing is so tight (every test
|
||||
/// defers the restore) that it's reliable in practice.
|
||||
final class TestRegistryLock: @unchecked Sendable {
|
||||
static let shared = TestRegistryLock()
|
||||
private let lock = NSLock()
|
||||
|
||||
/// Acquire the cross-suite lock and snapshot the registry. Pair
|
||||
/// every call with a `defer { TestRegistryLock.restore(snapshot) }`.
|
||||
static func acquireAndSnapshot() -> Data? {
|
||||
shared.lock.lock()
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
/// Restore the registry from snapshot and release the lock.
|
||||
static func restore(_ snapshot: Data?) {
|
||||
defer { shared.lock.unlock() }
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
if let snapshot {
|
||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||
} else {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercises the service's ability to unpack, parse, and validate bundles.
|
||||
/// Doesn't touch the installer — see `ProjectTemplateInstallerTests` — so
|
||||
/// these don't need write access to ~/.hermes.
|
||||
@@ -142,15 +106,10 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
id: String = "test/example",
|
||||
cron: Int? = nil,
|
||||
skills: [String]? = nil,
|
||||
instructions: [String]? = nil,
|
||||
configFieldCount: Int? = nil,
|
||||
configSchema: TemplateConfigSchema? = nil
|
||||
instructions: [String]? = nil
|
||||
) -> ProjectTemplateManifest {
|
||||
// schemaVersion auto-bumps to 2 when a schema is present so tests
|
||||
// that exercise the schema path mirror real manifest behaviour.
|
||||
let version = (configSchema != nil) ? 2 : 1
|
||||
return ProjectTemplateManifest(
|
||||
schemaVersion: version,
|
||||
ProjectTemplateManifest(
|
||||
schemaVersion: 1,
|
||||
id: id,
|
||||
name: "Example",
|
||||
version: "1.0.0",
|
||||
@@ -168,10 +127,8 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
instructions: instructions,
|
||||
skills: skills,
|
||||
cron: cron,
|
||||
memory: nil,
|
||||
config: configFieldCount ?? configSchema?.fields.count
|
||||
),
|
||||
config: configSchema
|
||||
memory: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -289,7 +246,7 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
/// are exhaustively tested; global-state side effects (skills namespace,
|
||||
/// cron CLI, memory append) are covered by manual verification per the
|
||||
/// plan's step 7.
|
||||
@Suite(.serialized) struct ProjectTemplateInstallerTests {
|
||||
@Suite struct ProjectTemplateInstallerTests {
|
||||
|
||||
@Test func installsMinimalBundleAndWritesLockFile() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
@@ -382,69 +339,23 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cron prompt token substitution
|
||||
|
||||
@Test func substituteCronTokensResolvesProjectDir() throws {
|
||||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||
let raw = "Read {{PROJECT_DIR}}/.scarf/config.json"
|
||||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||
#expect(resolved == "Read \(plan.projectDir)/.scarf/config.json")
|
||||
// Original placeholder must be fully replaced — a lingering
|
||||
// {{PROJECT_DIR}} would leave the cron job trying to read a
|
||||
// literal file named `{{PROJECT_DIR}}` which doesn't exist.
|
||||
#expect(resolved.contains("{{PROJECT_DIR}}") == false)
|
||||
}
|
||||
|
||||
@Test func substituteCronTokensResolvesIdAndSlug() throws {
|
||||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||
let raw = "Log as {{TEMPLATE_ID}} (slug {{TEMPLATE_SLUG}})"
|
||||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||
#expect(resolved.contains(plan.manifest.id))
|
||||
#expect(resolved.contains(plan.manifest.slug))
|
||||
#expect(resolved.contains("{{TEMPLATE_ID}}") == false)
|
||||
#expect(resolved.contains("{{TEMPLATE_SLUG}}") == false)
|
||||
}
|
||||
|
||||
@Test func substituteCronTokensLeavesUnknownTokensUntouched() throws {
|
||||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||
let raw = "{{PROJECT_DIR}} but keep {{UNSUPPORTED}} literal"
|
||||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||
#expect(resolved.contains(plan.projectDir))
|
||||
// Unsupported placeholders pass through verbatim — template
|
||||
// authors will notice in testing that their token didn't get
|
||||
// replaced and either use a supported one or request a new one.
|
||||
#expect(resolved.contains("{{UNSUPPORTED}}"))
|
||||
}
|
||||
|
||||
@Test func substituteCronTokensRepeatsWithinString() throws {
|
||||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||
let raw = "Read {{PROJECT_DIR}}/a and write {{PROJECT_DIR}}/b"
|
||||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||
// Both occurrences should be replaced — not just the first.
|
||||
// A single-replace bug here would leave the second relative,
|
||||
// causing the same CWD issue this whole feature was meant to
|
||||
// fix.
|
||||
let count = resolved.components(separatedBy: plan.projectDir).count - 1
|
||||
#expect(count == 2)
|
||||
}
|
||||
|
||||
// MARK: - Registry snapshot helpers
|
||||
|
||||
/// Read the raw bytes of the current projects.json so we can restore
|
||||
/// it byte-for-byte after the test. `nil` means the file didn't exist
|
||||
/// — restore by deleting whatever got created.
|
||||
// Delegates to TestRegistryLock so tests across this suite + the
|
||||
// two other registry-touching suites share one lock. Every
|
||||
// `snapshotRegistry()` call acquires; the paired
|
||||
// `restoreRegistry(_:)` defer releases. Without this, parallel
|
||||
// test runs race on `~/.hermes/scarf/projects.json` writes and
|
||||
// the saveRegistry throw surfaces the collision as a test failure.
|
||||
nonisolated private static func snapshotRegistry() -> Data? {
|
||||
TestRegistryLock.acquireAndSnapshot()
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||
TestRegistryLock.restore(snapshot)
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
if let snapshot {
|
||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||
} else {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +363,7 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
/// it, verify every tracked file is gone, the registry is restored to its
|
||||
/// pre-install state, and user-added files (if any) are preserved. Scoped
|
||||
/// to bundles with no skills/cron/memory so no global state is touched.
|
||||
@Suite(.serialized) struct ProjectTemplateUninstallerTests {
|
||||
@Suite struct ProjectTemplateUninstallerTests {
|
||||
|
||||
@Test func roundTripsInstallThenUninstall() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
@@ -558,400 +469,18 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
// ProjectTemplateInstallerTests — small helper, not worth a shared
|
||||
// fixture file for one more suite).
|
||||
|
||||
// Delegates to TestRegistryLock so tests across this suite + the
|
||||
// two other registry-touching suites share one lock. Every
|
||||
// `snapshotRegistry()` call acquires; the paired
|
||||
// `restoreRegistry(_:)` defer releases. Without this, parallel
|
||||
// test runs race on `~/.hermes/scarf/projects.json` writes and
|
||||
// the saveRegistry throw surfaces the collision as a test failure.
|
||||
nonisolated private static func snapshotRegistry() -> Data? {
|
||||
TestRegistryLock.acquireAndSnapshot()
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||
TestRegistryLock.restore(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end tests for manifest schemaVersion 2 (template configuration).
|
||||
/// Exercises the full cycle: inspect → buildPlan → install → uninstall
|
||||
/// against a synthesized schemaful bundle. Uses an isolated Keychain
|
||||
/// service suffix so no leftover login-Keychain items remain after the
|
||||
/// test — every secret we write is deleted on teardown.
|
||||
@Suite(.serialized) struct ProjectTemplateConfigInstallTests {
|
||||
|
||||
/// Minimal schemaful manifest with one non-secret field + one
|
||||
/// secret field. Written into the synthesized `.scarftemplate`
|
||||
/// bundle for the round-trip tests.
|
||||
static func makeSchemafulManifest() -> ProjectTemplateManifest {
|
||||
ProjectTemplateServiceTests.sampleManifest(
|
||||
id: "tester/configured",
|
||||
configSchema: TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "site_url", type: .string, label: "Site URL",
|
||||
description: "where to ping", required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil),
|
||||
.init(key: "api_token", type: .secret, label: "API Token",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil),
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func inspectAcceptsSchemaV2Bundle() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
|
||||
let manifest = Self.makeSchemafulManifest()
|
||||
let manifestData = try JSONEncoder().encode(manifest)
|
||||
let manifestString = String(data: manifestData, encoding: .utf8)!
|
||||
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestString,
|
||||
"README.md": "# r",
|
||||
"AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
|
||||
#expect(inspection.manifest.schemaVersion == 2)
|
||||
#expect(inspection.manifest.config?.fields.count == 2)
|
||||
}
|
||||
|
||||
@Test func buildPlanSurfacesSchemaAndQueuesConfigFiles() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
|
||||
let manifest = Self.makeSchemafulManifest()
|
||||
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestJSON,
|
||||
"README.md": "# r", "AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||
|
||||
// Schema carried through the plan.
|
||||
#expect(plan.configSchema?.fields.count == 2)
|
||||
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||||
// config.json + manifest.json entries in projectFiles.
|
||||
let destinations = plan.projectFiles.map(\.destinationPath)
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||||
}
|
||||
|
||||
@Test func verifyClaimsRejectsConfigCountMismatch() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
|
||||
// Hand-build a manifest whose contents.config claim (2) doesn't
|
||||
// match its schema.fields.count (1) — validator should reject.
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "only", type: .string, label: "Only",
|
||||
description: nil, required: false, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let bogus = ProjectTemplateServiceTests.sampleManifest(
|
||||
id: "tester/mismatch",
|
||||
configFieldCount: 2, // claim lies
|
||||
configSchema: schema // reality is 1
|
||||
)
|
||||
let manifestJSON = String(data: try JSONEncoder().encode(bogus), encoding: .utf8)!
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestJSON,
|
||||
"README.md": "# r", "AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
#expect(throws: ProjectTemplateError.self) {
|
||||
try service.inspect(zipPath: bundle)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func installWritesConfigJsonAndManifestCache() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
let parentDir = scratch + "/parent"
|
||||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||
|
||||
let manifest = Self.makeSchemafulManifest()
|
||||
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestJSON,
|
||||
"README.md": "# r", "AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||
|
||||
// Isolated Keychain service suffix so the test doesn't touch
|
||||
// the real login Keychain.
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let configService = ProjectConfigService(keychain: keychain)
|
||||
|
||||
// Store secret via the service (VM would do this before install).
|
||||
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||||
let secretRef = try configService.storeSecret(
|
||||
templateSlug: manifest.slug,
|
||||
fieldKey: "api_token",
|
||||
project: project,
|
||||
secret: Data("sk-top-secret".utf8)
|
||||
)
|
||||
plan.configValues = [
|
||||
"site_url": .string("https://example.com"),
|
||||
"api_token": secretRef
|
||||
]
|
||||
|
||||
let registryBefore = Self.snapshotRegistry()
|
||||
defer { Self.restoreRegistry(registryBefore) }
|
||||
|
||||
let installer = ProjectTemplateInstaller(context: .local)
|
||||
_ = try installer.install(plan: plan)
|
||||
|
||||
// config.json landed with non-secret values + keychain ref.
|
||||
let configPath = plan.projectDir + "/.scarf/config.json"
|
||||
#expect(FileManager.default.fileExists(atPath: configPath))
|
||||
let configData = try Data(contentsOf: URL(fileURLWithPath: configPath))
|
||||
let configFile = try JSONDecoder().decode(ProjectConfigFile.self, from: configData)
|
||||
#expect(configFile.values["site_url"] == .string("https://example.com"))
|
||||
if case .keychainRef(let uri) = configFile.values["api_token"] {
|
||||
#expect(uri.hasPrefix("keychain://"))
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
if let snapshot {
|
||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||
} else {
|
||||
Issue.record("api_token should have been stored as keychainRef")
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
|
||||
// manifest.json cache landed for the post-install editor.
|
||||
let cachePath = plan.projectDir + "/.scarf/manifest.json"
|
||||
#expect(FileManager.default.fileExists(atPath: cachePath))
|
||||
let cachedManifest = try JSONDecoder().decode(
|
||||
ProjectTemplateManifest.self,
|
||||
from: Data(contentsOf: URL(fileURLWithPath: cachePath))
|
||||
)
|
||||
#expect(cachedManifest.config?.fields.count == 2)
|
||||
|
||||
// Lock file records the keychain item so uninstall can clean up.
|
||||
let lockPath = plan.projectDir + "/.scarf/template.lock.json"
|
||||
let lockData = try Data(contentsOf: URL(fileURLWithPath: lockPath))
|
||||
let lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||||
#expect(lock.configKeychainItems?.count == 1)
|
||||
#expect(lock.configFields == ["site_url", "api_token"])
|
||||
|
||||
// Clean up the real Keychain entry we created outside the
|
||||
// test-suffixed namespace (storeSecret uses real service name
|
||||
// because the test's config-service wasn't isolated for this
|
||||
// call's secret; we manually delete via our test keychain).
|
||||
if let ref = TemplateKeychainRef.parse(
|
||||
(configFile.values["api_token"].flatMap { v -> String? in
|
||||
if case .keychainRef(let u) = v { return u } else { return nil }
|
||||
}) ?? ""
|
||||
) {
|
||||
try? ProjectConfigKeychain().delete(ref: ref)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func uninstallDeletesKeychainItemsViaLock() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
let parentDir = scratch + "/parent"
|
||||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||
|
||||
let manifest = Self.makeSchemafulManifest()
|
||||
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestJSON,
|
||||
"README.md": "# r", "AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||
|
||||
// Real Keychain — we store, install, then uninstall and verify
|
||||
// the item is gone. Uses the real service name (no test suffix)
|
||||
// because the installer + uninstaller go through their own
|
||||
// ProjectConfigKeychain instances without a suffix.
|
||||
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||||
let configService = ProjectConfigService()
|
||||
let secretRef = try configService.storeSecret(
|
||||
templateSlug: manifest.slug,
|
||||
fieldKey: "api_token",
|
||||
project: project,
|
||||
secret: Data("delete-me".utf8)
|
||||
)
|
||||
plan.configValues = [
|
||||
"site_url": .string("https://example.com"),
|
||||
"api_token": secretRef
|
||||
]
|
||||
|
||||
let registryBefore = Self.snapshotRegistry()
|
||||
defer { Self.restoreRegistry(registryBefore) }
|
||||
|
||||
let installer = ProjectTemplateInstaller(context: .local)
|
||||
let entry = try installer.install(plan: plan)
|
||||
|
||||
// Verify the secret is there before uninstall.
|
||||
guard case .keychainRef(let uri) = secretRef,
|
||||
let ref = TemplateKeychainRef.parse(uri) else {
|
||||
Issue.record("expected secret to be a keychainRef")
|
||||
return
|
||||
}
|
||||
#expect((try ProjectConfigKeychain().get(ref: ref)) == Data("delete-me".utf8))
|
||||
|
||||
// Uninstall → secret should be gone.
|
||||
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||||
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
|
||||
try uninstaller.uninstall(plan: uninstallPlan)
|
||||
|
||||
#expect((try ProjectConfigKeychain().get(ref: ref)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests)
|
||||
|
||||
// Delegates to TestRegistryLock so tests across this suite + the
|
||||
// two other registry-touching suites share one lock. Every
|
||||
// `snapshotRegistry()` call acquires; the paired
|
||||
// `restoreRegistry(_:)` defer releases. Without this, parallel
|
||||
// test runs race on `~/.hermes/scarf/projects.json` writes and
|
||||
// the saveRegistry throw surfaces the collision as a test failure.
|
||||
nonisolated private static func snapshotRegistry() -> Data? {
|
||||
TestRegistryLock.acquireAndSnapshot()
|
||||
}
|
||||
|
||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||
TestRegistryLock.restore(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// State-machine tests for `TemplateInstallerViewModel`. The install
|
||||
/// flow's configure step is driven entirely through the VM — the view
|
||||
/// transitions `.awaitingParentDirectory → .awaitingConfig → .planned`
|
||||
/// based on `submitConfig(values:)` / `cancelConfig()` calls. If those
|
||||
/// transitions break, the user lands on the wrong sheet stage (or no
|
||||
/// sheet at all, as in the v1.1.0 regression where the config sheet's
|
||||
/// internal `dismiss()` tore down the outer install sheet before
|
||||
/// submitConfig had a chance to fire).
|
||||
@Suite(.serialized) @MainActor struct TemplateInstallerViewModelTests {
|
||||
|
||||
@Test func submitConfigStashesValuesAndTransitionsToPlanned() throws {
|
||||
let vm = TemplateInstallerViewModel(context: .local)
|
||||
// Seed the VM with an awaiting-config plan (schema-ful).
|
||||
let plan = try Self.makePlanWithConfigSchema()
|
||||
vm.plan = plan
|
||||
vm.stage = .awaitingConfig
|
||||
|
||||
let values: [String: TemplateConfigValue] = [
|
||||
"site_url": .string("https://example.com")
|
||||
]
|
||||
vm.submitConfig(values: values)
|
||||
|
||||
// Stage must advance past the configure step, values must land
|
||||
// on the plan where install() will pick them up.
|
||||
if case .planned = vm.stage {
|
||||
// ok
|
||||
} else {
|
||||
Issue.record("expected .planned, got \(vm.stage)")
|
||||
}
|
||||
#expect(vm.plan?.configValues["site_url"] == .string("https://example.com"))
|
||||
}
|
||||
|
||||
@Test func cancelConfigReturnsToAwaitingParentDirectory() throws {
|
||||
let vm = TemplateInstallerViewModel(context: .local)
|
||||
vm.plan = try Self.makePlanWithConfigSchema()
|
||||
vm.stage = .awaitingConfig
|
||||
|
||||
vm.cancelConfig()
|
||||
|
||||
if case .awaitingParentDirectory = vm.stage {
|
||||
// ok — user can re-pick the parent dir or fully cancel
|
||||
} else {
|
||||
Issue.record("expected .awaitingParentDirectory, got \(vm.stage)")
|
||||
}
|
||||
// Plan is preserved so re-entering the configure step doesn't
|
||||
// re-run buildPlan.
|
||||
#expect(vm.plan != nil)
|
||||
}
|
||||
|
||||
@Test func submitConfigNoOpWhenPlanIsNil() {
|
||||
let vm = TemplateInstallerViewModel(context: .local)
|
||||
vm.plan = nil
|
||||
vm.stage = .awaitingConfig
|
||||
vm.submitConfig(values: ["k": .string("v")])
|
||||
// With no plan, the call should be silent — no crash, stage
|
||||
// stays where it was. (Defensive guard in submitConfig.)
|
||||
if case .awaitingConfig = vm.stage {
|
||||
// ok
|
||||
} else {
|
||||
Issue.record("expected stage to remain .awaitingConfig when plan is nil; got \(vm.stage)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
/// Build a `TemplateInstallPlan` carrying a single-field config
|
||||
/// schema. Exists as a local helper rather than a shared one
|
||||
/// because no other suite needs it.
|
||||
nonisolated static func makePlanWithConfigSchema() throws -> TemplateInstallPlan {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "site_url", type: .string, label: "Site URL",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let manifest = ProjectTemplateServiceTests.sampleManifest(
|
||||
id: "tester/vm-transitions",
|
||||
configSchema: schema
|
||||
)
|
||||
let tmp = try ProjectTemplateServiceTests.makeTempDir()
|
||||
// Not a real bundle dir — we never unzip or install from this
|
||||
// plan, we only test state transitions that don't touch disk.
|
||||
return TemplateInstallPlan(
|
||||
manifest: manifest,
|
||||
unpackedDir: tmp,
|
||||
projectDir: tmp + "/project",
|
||||
projectFiles: [],
|
||||
skillsNamespaceDir: nil,
|
||||
skillsFiles: [],
|
||||
cronJobs: [],
|
||||
memoryAppendix: nil,
|
||||
memoryPath: ServerContext.local.paths.memoryMD,
|
||||
projectRegistryName: "VM Transitions",
|
||||
configSchema: schema,
|
||||
configValues: [:],
|
||||
manifestCachePath: tmp + "/project/.scarf/manifest.json"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,31 +497,13 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
|
||||
#expect(inspection.manifest.id == "awizemann/site-status-checker")
|
||||
#expect(inspection.manifest.schemaVersion == 2) // config-enabled
|
||||
#expect(inspection.manifest.contents.dashboard)
|
||||
#expect(inspection.manifest.contents.agentsMd)
|
||||
#expect(inspection.manifest.contents.cron == 1)
|
||||
#expect(inspection.manifest.contents.config == 2)
|
||||
#expect(inspection.cronJobs.count == 1)
|
||||
#expect(inspection.cronJobs.first?.name == "Check site status")
|
||||
#expect(inspection.cronJobs.first?.schedule == "0 9 * * *")
|
||||
|
||||
// Schema assertions — the two fields we declared should survive
|
||||
// unzip + parse + validate with their constraints intact.
|
||||
let schema = try #require(inspection.manifest.config)
|
||||
#expect(schema.fields.count == 2)
|
||||
let sitesField = try #require(schema.field(for: "sites"))
|
||||
#expect(sitesField.type == .list)
|
||||
#expect(sitesField.itemType == "string")
|
||||
#expect(sitesField.required == true)
|
||||
#expect(sitesField.minItems == 1)
|
||||
#expect(sitesField.maxItems == 25)
|
||||
let timeoutField = try #require(schema.field(for: "timeout_seconds"))
|
||||
#expect(timeoutField.type == .number)
|
||||
#expect(timeoutField.minNumber == 1)
|
||||
#expect(timeoutField.maxNumber == 60)
|
||||
#expect(schema.modelRecommendation?.preferred == "claude-haiku-4")
|
||||
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||
@@ -1000,12 +511,6 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
#expect(plan.skillsFiles.isEmpty)
|
||||
#expect(plan.memoryAppendix == nil)
|
||||
#expect(plan.cronJobs.count == 1)
|
||||
#expect(plan.configSchema?.fields.count == 2)
|
||||
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||||
// Plan queues both config.json + manifest.json in projectFiles.
|
||||
let destinations = plan.projectFiles.map(\.destinationPath)
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||||
// Cron job name gets prefixed with the template tag so users can
|
||||
// find + remove it later.
|
||||
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/site-status-checker] Check site status")
|
||||
@@ -1019,9 +524,7 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
|
||||
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
|
||||
#expect(dashboard.title == "Site Status")
|
||||
// Four sections: Current Status (stats), Watched Sites (list),
|
||||
// Live Site Preview (webview — drives the Site tab), How to Use (text).
|
||||
#expect(dashboard.sections.count == 4)
|
||||
#expect(dashboard.sections.count == 3)
|
||||
|
||||
// First section should have three stat widgets that the cron job
|
||||
// updates by value. Assert titles + types so the AGENTS.md contract
|
||||
@@ -1033,96 +536,12 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
#expect(statTitles.contains("Sites Down"))
|
||||
#expect(statTitles.contains("Last Checked"))
|
||||
|
||||
// Live Site Preview section must contain exactly one webview
|
||||
// widget. The presence of any webview widget is what makes Scarf
|
||||
// expose the Site tab next to Dashboard, so losing this section
|
||||
// would silently drop a user-visible feature. The cron job
|
||||
// rewrites this widget's `url` to the first configured site on
|
||||
// every run — AGENTS.md documents the contract.
|
||||
let previewSection = dashboard.sections[2]
|
||||
#expect(previewSection.title == "Live Site Preview")
|
||||
let webviews = previewSection.widgets.filter { $0.type == "webview" }
|
||||
#expect(webviews.count == 1)
|
||||
#expect(webviews.first?.title == "First Watched Site")
|
||||
#expect((webviews.first?.url ?? "").isEmpty == false)
|
||||
|
||||
// Cron prompt references .scarf/config.json (where values.sites
|
||||
// + values.timeout_seconds live), the dashboard/log it writes,
|
||||
// and the {{PROJECT_DIR}} placeholder the installer resolves
|
||||
// at install time. If either stops being referenced, the cron
|
||||
// wouldn't know which data to read or where to write results.
|
||||
// The cron prompt mentions sites.txt and dashboard.json — if it
|
||||
// ever stops doing that, the agent won't know what files to touch.
|
||||
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
||||
#expect(cronPrompt.contains("config.json"))
|
||||
#expect(cronPrompt.contains("values.sites"))
|
||||
#expect(cronPrompt.contains("sites.txt"))
|
||||
#expect(cronPrompt.contains("dashboard.json"))
|
||||
#expect(cronPrompt.contains("status-log.md"))
|
||||
// {{PROJECT_DIR}} must remain UNRESOLVED in the bundle — the
|
||||
// installer substitutes it at install time. If someone
|
||||
// accidentally baked an absolute path into the template, that
|
||||
// path would follow every install to every user's machine.
|
||||
#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 198–227.)
|
||||
#expect(plan.manifestCachePath == nil)
|
||||
}
|
||||
|
||||
/// Resolve the example bundle path robustly. Unit-test working dirs
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
// MARK: - Schema validation
|
||||
|
||||
@Suite struct TemplateConfigSchemaValidationTests {
|
||||
|
||||
@Test func acceptsMinimalValidSchema() throws {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "name", type: .string, label: "Name",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
|
||||
@Test func rejectsDuplicateKeys() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "same", type: .string, label: "A", description: nil,
|
||||
required: false, placeholder: nil, defaultValue: nil,
|
||||
options: nil, minLength: nil, maxLength: nil,
|
||||
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||
step: nil, itemType: nil, minItems: nil, maxItems: nil),
|
||||
.init(key: "same", type: .bool, label: "B", description: nil,
|
||||
required: false, placeholder: nil, defaultValue: nil,
|
||||
options: nil, minLength: nil, maxLength: nil,
|
||||
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||
step: nil, itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsSecretWithDefault() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "api_key", type: .secret, label: "API Key",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: .string("leaked-by-accident"),
|
||||
options: nil, minLength: nil, maxLength: nil,
|
||||
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||
step: nil, itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsEnumWithoutOptions() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "choice", type: .enum, label: "Choice",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: [],
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsEnumWithDuplicateValues() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "choice", type: .enum, label: "Choice",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil,
|
||||
options: [.init(value: "a", label: "A"),
|
||||
.init(value: "a", label: "Another A")],
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsUnsupportedListItemType() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "items", type: .list, label: "Items",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil,
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: "number", minItems: 1, maxItems: 10)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsEmptyModelPreferred() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [],
|
||||
modelRecommendation: .init(preferred: " ", rationale: nil, alternatives: nil)
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Value validation
|
||||
|
||||
@Suite struct TemplateConfigValueValidationTests {
|
||||
|
||||
@Test func requiredFieldRejectsEmptyString() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "name", type: .string, label: "Name",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["name": .string("")], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.fieldKey == "name")
|
||||
}
|
||||
|
||||
@Test func patternRejectsBadInput() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "email", type: .string, label: "Email",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: "^[^@]+@[^@]+$",
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["email": .string("not-an-email")], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test func numberRangeEnforced() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "port", type: .number, label: "Port",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: 1024,
|
||||
maxNumber: 65535, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["port": .number(80)], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test func enumRejectsUnknownValue() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "mode", type: .enum, label: "Mode",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil,
|
||||
options: [.init(value: "fast", label: "Fast"),
|
||||
.init(value: "slow", label: "Slow")],
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["mode": .string("medium")], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test func listItemBoundsEnforced() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "urls", type: .list, label: "URLs",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: "string",
|
||||
minItems: 1, maxItems: 3)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let tooFew = ProjectConfigService.validateValues(
|
||||
["urls": .list([])], against: schema
|
||||
)
|
||||
let tooMany = ProjectConfigService.validateValues(
|
||||
["urls": .list(["a", "b", "c", "d"])], against: schema
|
||||
)
|
||||
let justRight = ProjectConfigService.validateValues(
|
||||
["urls": .list(["a", "b"])], against: schema
|
||||
)
|
||||
#expect(tooFew.count == 1)
|
||||
#expect(tooMany.count == 1)
|
||||
#expect(justRight.isEmpty)
|
||||
}
|
||||
|
||||
@Test func secretFieldAcceptsKeychainRef() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "tok", type: .secret, label: "Token",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["tok": .keychainRef("keychain://test/tok:abc")],
|
||||
against: schema
|
||||
)
|
||||
#expect(errors.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keychain ref helpers
|
||||
|
||||
@Suite struct TemplateKeychainRefTests {
|
||||
|
||||
@Test func uriRoundTrips() {
|
||||
let ref = TemplateKeychainRef(
|
||||
service: "com.scarf.template.alice-foo",
|
||||
account: "api_key:deadbeef"
|
||||
)
|
||||
#expect(ref.uri == "keychain://com.scarf.template.alice-foo/api_key:deadbeef")
|
||||
let parsed = TemplateKeychainRef.parse(ref.uri)
|
||||
#expect(parsed == ref)
|
||||
}
|
||||
|
||||
@Test func parseRejectsMalformedUris() {
|
||||
#expect(TemplateKeychainRef.parse("") == nil)
|
||||
#expect(TemplateKeychainRef.parse("keychain://") == nil)
|
||||
#expect(TemplateKeychainRef.parse("keychain:///account-only") == nil)
|
||||
#expect(TemplateKeychainRef.parse("keychain://service-only") == nil)
|
||||
#expect(TemplateKeychainRef.parse("https://example.com/foo") == nil)
|
||||
}
|
||||
|
||||
@Test func hashDiffersByProjectPath() {
|
||||
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p2")
|
||||
#expect(a.service == b.service) // same template
|
||||
#expect(a.account != b.account) // different project → different hash suffix
|
||||
}
|
||||
|
||||
@Test func hashStableForSamePath() {
|
||||
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||
#expect(a == b)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-disk config round-trip
|
||||
|
||||
@Suite struct ProjectConfigFileTests {
|
||||
|
||||
@Test func roundTripsNonSecretValues() throws {
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: "alice/example",
|
||||
values: [
|
||||
"name": .string("Alice"),
|
||||
"enabled": .bool(true),
|
||||
"count": .number(42),
|
||||
"tags": .list(["a", "b", "c"]),
|
||||
],
|
||||
updatedAt: "2026-04-25T00:00:00Z"
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(file)
|
||||
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||
#expect(decoded.schemaVersion == 2)
|
||||
#expect(decoded.templateId == "alice/example")
|
||||
#expect(decoded.values["name"] == .string("Alice"))
|
||||
#expect(decoded.values["enabled"] == .bool(true))
|
||||
#expect(decoded.values["count"] == .number(42))
|
||||
#expect(decoded.values["tags"] == .list(["a", "b", "c"]))
|
||||
}
|
||||
|
||||
@Test func preservesKeychainRefsOnRoundTrip() throws {
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: "alice/example",
|
||||
values: ["tok": .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef")],
|
||||
updatedAt: "2026-04-25T00:00:00Z"
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(file)
|
||||
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||
// Keychain refs must NOT demote to plain strings on round-trip
|
||||
// — otherwise a post-install editor would lose the secret
|
||||
// binding when saving unchanged values.
|
||||
#expect(decoded.values["tok"] == .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProjectConfigService + Keychain integration
|
||||
|
||||
/// Exercises the full secret-storage path through a real macOS Keychain
|
||||
/// with a test-only service suffix so nothing leaks into the user's
|
||||
/// login Keychain. Every test sets + reads + deletes within a unique
|
||||
/// service name so parallel runs don't collide.
|
||||
@Suite struct ProjectConfigSecretsTests {
|
||||
|
||||
@Test func storeAndResolveSecret() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let service = ProjectConfigService(keychain: keychain)
|
||||
let project = ProjectEntry(name: "Scratch", path: NSTemporaryDirectory() + UUID().uuidString)
|
||||
|
||||
let stored = try service.storeSecret(
|
||||
templateSlug: "alice-example",
|
||||
fieldKey: "api_key",
|
||||
project: project,
|
||||
secret: Data("hunter2".utf8)
|
||||
)
|
||||
|
||||
// What goes into config.json is a keychainRef, not the bytes.
|
||||
guard case .keychainRef(let uri) = stored else {
|
||||
Issue.record("expected keychainRef, got \(stored)")
|
||||
return
|
||||
}
|
||||
#expect(uri.hasPrefix("keychain://"))
|
||||
|
||||
// Resolve brings the bytes back.
|
||||
let resolved = try service.resolveSecret(ref: stored)
|
||||
#expect(resolved == Data("hunter2".utf8))
|
||||
|
||||
// Clean up so we don't leave a test item in the Keychain.
|
||||
if let ref = TemplateKeychainRef.parse(uri) {
|
||||
try keychain.delete(ref: ref)
|
||||
#expect((try keychain.get(ref: ref)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func setOverwritesExistingSecret() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let ref = TemplateKeychainRef(service: "com.scarf.template.overwrite", account: "k:1")
|
||||
try keychain.set(ref: ref, secret: Data("first".utf8))
|
||||
try keychain.set(ref: ref, secret: Data("second".utf8))
|
||||
#expect((try keychain.get(ref: ref)) == Data("second".utf8))
|
||||
try keychain.delete(ref: ref)
|
||||
}
|
||||
|
||||
@Test func deleteOfMissingItemSucceeds() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let ref = TemplateKeychainRef(service: "com.scarf.template.absent", account: "never:set")
|
||||
// Deleting a non-existent item is a no-op — must not throw.
|
||||
try keychain.delete(ref: ref)
|
||||
}
|
||||
|
||||
@Test func deleteMultipleSecretsClearsAll() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let service = ProjectConfigService(keychain: keychain)
|
||||
|
||||
let refs = (0..<3).map { i in
|
||||
TemplateKeychainRef(service: "com.scarf.template.bulk", account: "k:\(i)")
|
||||
}
|
||||
for ref in refs {
|
||||
try keychain.set(ref: ref, secret: Data("v".utf8))
|
||||
}
|
||||
try service.deleteSecrets(refs: refs)
|
||||
for ref in refs {
|
||||
#expect((try keychain.get(ref: ref)) == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,7 @@ need_builder() {
|
||||
}
|
||||
|
||||
need_ghpages() {
|
||||
# `.git` is a directory in a regular clone but a pointer FILE in a
|
||||
# `git worktree add` worktree — `-e` covers both. The earlier `-d`
|
||||
# check falsely rejected worktrees, so the script's own error
|
||||
# message told users to re-run `git worktree add` on a worktree
|
||||
# that was already there and valid.
|
||||
[[ -e "$GHPAGES_DIR/.git" ]] || die "no gh-pages worktree at $GHPAGES_DIR
|
||||
[[ -d "$GHPAGES_DIR/.git" ]] || die "no gh-pages worktree at $GHPAGES_DIR
|
||||
Run: git worktree add .gh-pages-worktree gh-pages"
|
||||
}
|
||||
|
||||
|
||||
@@ -233,106 +233,6 @@ h1, h2, h3 { line-height: 1.25; }
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ---------- config schema panel (v2.3) ---------- */
|
||||
|
||||
.detail-config { margin-bottom: 32px; }
|
||||
.detail-config:empty, .detail-config > div:empty { display: none; }
|
||||
|
||||
.config-schema {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
}
|
||||
.config-schema-header { margin-top: 0; }
|
||||
.config-schema-desc {
|
||||
color: var(--fg-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.config-schema-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.config-field-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.config-field-key { font-family: var(--mono); font-size: 13px; }
|
||||
.config-field-type {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,0.08);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.config-field-required {
|
||||
font-size: 11px;
|
||||
color: var(--red);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(217,83,79,0.12);
|
||||
}
|
||||
.config-field-body {
|
||||
margin: 0 0 4px 0;
|
||||
padding-left: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.config-field-label {
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.config-field-description {
|
||||
color: var(--fg-muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.config-field-constraint {
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.config-model-rec {
|
||||
margin-top: 20px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(42,168,118,0.08);
|
||||
border: 1px solid rgba(42,168,118,0.2);
|
||||
}
|
||||
.config-model-label {
|
||||
font-size: 11px;
|
||||
color: var(--accent-dark);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.config-model-preferred {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.config-model-rationale {
|
||||
color: var(--fg-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.config-model-alternatives {
|
||||
color: var(--fg-muted);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------- dashboard preview ---------- */
|
||||
|
||||
.dashboard-header h1.dashboard-title { margin: 0 0 4px; font-size: 22px; }
|
||||
|
||||
@@ -48,10 +48,6 @@
|
||||
<div id="dashboard-preview"></div>
|
||||
</section>
|
||||
|
||||
<section class="detail-config">
|
||||
<div id="config-schema"></div>
|
||||
</section>
|
||||
|
||||
<section class="detail-readme">
|
||||
<h2>README</h2>
|
||||
<div id="readme-body"></div>
|
||||
@@ -67,14 +63,11 @@
|
||||
|
||||
<script src="../widgets.js"></script>
|
||||
<script>
|
||||
// Fetch + render dashboard + README + config schema at page load.
|
||||
// Dashboard + README live next to index.html in this template's
|
||||
// detail dir; the config schema comes from the sibling manifest.json
|
||||
// that the build-catalog renderer also copies in.
|
||||
// Fetch + render dashboard + README at page load. Both files live
|
||||
// alongside index.html in this template's detail dir.
|
||||
(async function () {
|
||||
const dashboardEl = document.getElementById("dashboard-preview");
|
||||
const readmeEl = document.getElementById("readme-body");
|
||||
const configEl = document.getElementById("config-schema");
|
||||
try {
|
||||
const d = await fetch("dashboard.json").then(r => r.json());
|
||||
ScarfWidgets.renderDashboard(dashboardEl, d);
|
||||
@@ -87,17 +80,6 @@
|
||||
} catch (e) {
|
||||
readmeEl.textContent = "Could not load README.";
|
||||
}
|
||||
try {
|
||||
// manifest.json may not exist for schema-less templates — that's
|
||||
// fine, we just leave the config section empty.
|
||||
const res = await fetch("manifest.json");
|
||||
if (res.ok) {
|
||||
const manifest = await res.json();
|
||||
ScarfWidgets.renderConfigSchema(configEl, manifest.config);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent — config-schema display is optional.
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -408,116 +408,12 @@
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Config-schema display (v2.3 — template configuration).
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// Renders the author-declared schema as a read-only listing on the
|
||||
// catalog detail page. The site itself never collects values — the
|
||||
// form UI lives inside the Scarf app. This is purely informational
|
||||
// so visitors know what they'll need to fill in before installing.
|
||||
|
||||
/**
|
||||
* Render a manifest.config block into `container` as a summary.
|
||||
* Safe to call with a null schema (no-op).
|
||||
* @param {HTMLElement} container
|
||||
* @param {{schema: Array, modelRecommendation?: object} | null | undefined} config
|
||||
*/
|
||||
function renderConfigSchema(container, config) {
|
||||
container.innerHTML = "";
|
||||
if (!config || !Array.isArray(config.schema) || config.schema.length === 0) {
|
||||
return;
|
||||
}
|
||||
const wrap = elt("div", "config-schema");
|
||||
const header = elt("h3", "config-schema-header", "Configuration");
|
||||
wrap.appendChild(header);
|
||||
const desc = elt("p", "config-schema-desc",
|
||||
"Fields you'll fill in during install. Secrets are stored in the macOS Keychain; non-secret values live at <project>/.scarf/config.json.");
|
||||
wrap.appendChild(desc);
|
||||
|
||||
const list = elt("dl", "config-schema-list");
|
||||
for (const field of config.schema) {
|
||||
const dt = elt("dt", "config-field-header");
|
||||
dt.appendChild(elt("span", "config-field-key", field.key || ""));
|
||||
dt.appendChild(elt("span", "config-field-type", field.type || ""));
|
||||
if (field.required) {
|
||||
const req = elt("span", "config-field-required", "required");
|
||||
dt.appendChild(req);
|
||||
}
|
||||
list.appendChild(dt);
|
||||
|
||||
const dd = elt("dd", "config-field-body");
|
||||
if (field.label) {
|
||||
dd.appendChild(elt("div", "config-field-label", field.label));
|
||||
}
|
||||
if (field.description) {
|
||||
const descEl = elt("div", "config-field-description");
|
||||
descEl.innerHTML = renderInline(field.description);
|
||||
dd.appendChild(descEl);
|
||||
}
|
||||
const constraint = summariseConstraint(field);
|
||||
if (constraint) {
|
||||
dd.appendChild(elt("div", "config-field-constraint", constraint));
|
||||
}
|
||||
list.appendChild(dd);
|
||||
}
|
||||
wrap.appendChild(list);
|
||||
|
||||
if (config.modelRecommendation) {
|
||||
const rec = config.modelRecommendation;
|
||||
const recBlock = elt("div", "config-model-rec");
|
||||
recBlock.appendChild(elt("div", "config-model-label", "Recommended model"));
|
||||
recBlock.appendChild(elt("div", "config-model-preferred", rec.preferred || ""));
|
||||
if (rec.rationale) {
|
||||
recBlock.appendChild(elt("div", "config-model-rationale", rec.rationale));
|
||||
}
|
||||
if (Array.isArray(rec.alternatives) && rec.alternatives.length > 0) {
|
||||
recBlock.appendChild(elt("div", "config-model-alternatives",
|
||||
"Also works: " + rec.alternatives.join(", ")));
|
||||
}
|
||||
wrap.appendChild(recBlock);
|
||||
}
|
||||
|
||||
container.appendChild(wrap);
|
||||
}
|
||||
|
||||
/** One-line human summary of a field's type-specific constraints.
|
||||
* Empty string if nothing noteworthy to say. */
|
||||
function summariseConstraint(field) {
|
||||
const type = field.type;
|
||||
if (type === "enum") {
|
||||
const opts = Array.isArray(field.options) ? field.options : [];
|
||||
const values = opts.map(o => o && o.label ? o.label : (o && o.value) || "").filter(Boolean);
|
||||
if (values.length > 0) return "Choices: " + values.join(", ");
|
||||
} else if (type === "list") {
|
||||
const min = field.minItems, max = field.maxItems;
|
||||
if (min && max) return `${min}–${max} items`;
|
||||
if (min) return `At least ${min} item${min === 1 ? "" : "s"}`;
|
||||
if (max) return `At most ${max} item${max === 1 ? "" : "s"}`;
|
||||
} else if (type === "string" || type === "text") {
|
||||
if (field.pattern) return `Pattern: ${field.pattern}`;
|
||||
const min = field.minLength, max = field.maxLength;
|
||||
if (min && max) return `${min}–${max} characters`;
|
||||
if (min) return `At least ${min} characters`;
|
||||
if (max) return `At most ${max} characters`;
|
||||
} else if (type === "number") {
|
||||
const min = field.min, max = field.max;
|
||||
if (min !== undefined && max !== undefined) return `${min}–${max}`;
|
||||
if (min !== undefined) return `≥ ${min}`;
|
||||
if (max !== undefined) return `≤ ${max}`;
|
||||
} else if (type === "secret") {
|
||||
return "Stored in the macOS Keychain on install — never in git, never in config.json.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
global.ScarfWidgets = {
|
||||
renderDashboard,
|
||||
renderMarkdown, // used by the detail page's README block
|
||||
renderConfigSchema, // used by the detail page's Configuration block
|
||||
renderMarkdown, // exposed for the template detail page's README block
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
|
||||
@@ -73,10 +73,7 @@ Optional:
|
||||
|
||||
- `instructions/CLAUDE.md`, `instructions/GEMINI.md`, `instructions/.cursorrules`, `instructions/.github/copilot-instructions.md` — agent-specific shims beyond `AGENTS.md`.
|
||||
- `skills/<skill-name>/SKILL.md` — shipped skills, installed into `~/.hermes/skills/templates/<slug>/` on the user's side.
|
||||
- `cron/jobs.json` — an array of cron job specs. Each has `name`, `schedule` (e.g. `0 9 * * *` or `every 2h`), `prompt`, optional `deliver`, `skills[]`, `repeat`. The prompt may use these install-time placeholders — the installer substitutes them before registering the cron job with Hermes:
|
||||
- `{{PROJECT_DIR}}` — absolute path of the newly-installed project dir. **Required for any cron prompt that reads or writes project files** — Hermes doesn't set a CWD when firing cron jobs, so relative paths (`.scarf/config.json`) won't resolve. Write `{{PROJECT_DIR}}/.scarf/config.json` instead.
|
||||
- `{{TEMPLATE_ID}}` — the `owner/name` id from your manifest.
|
||||
- `{{TEMPLATE_SLUG}}` — the sanitised slug used for the project dir name + skills namespace.
|
||||
- `cron/jobs.json` — an array of cron job specs. Each has `name`, `schedule` (e.g. `0 9 * * *` or `every 2h`), `prompt`, optional `deliver`, `skills[]`, `repeat`.
|
||||
- `memory/append.md` — markdown appended to the user's `MEMORY.md` between template-specific markers. Use sparingly — most templates don't need this.
|
||||
|
||||
### 4. Build the bundle
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
# Site Status Checker — Agent Instructions
|
||||
|
||||
This project maintains a daily uptime check for a list of URLs the user configured during install. The same instructions apply whether you're Hermes, Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
|
||||
This project maintains a daily uptime check for a short list of URLs. The same instructions apply whether you're Hermes, Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
|
||||
|
||||
## Project layout
|
||||
|
||||
- `.scarf/config.json` — **the source of truth for what to check.** Written by Scarf's install/configure UI; holds a `values.sites` field (a JSON array of URL strings) and a `values.timeout_seconds` field (a number, default 10).
|
||||
- `.scarf/manifest.json` — cached copy of `template.json`, used by Scarf's Configuration editor to re-render the form. Don't modify.
|
||||
- `status-log.md` — append-only markdown log. Newest run at the top. Each run is a section with the ISO-8601 timestamp as the heading. Created on the first run if it doesn't exist.
|
||||
- `sites.txt` — one URL per line. Lines starting with `#` are comments. This is the source of truth for what to check. **Not shipped with the template** — created on first run (see below).
|
||||
- `status-log.md` — append-only markdown log. Newest run at the top. Each run is a section with the ISO-8601 timestamp as the heading. Also created on first run.
|
||||
- `.scarf/dashboard.json` — Scarf dashboard. **Only the `value` fields of the three stat widgets and the `items` array of the "Watched Sites" list widget should be updated.** The section titles, widget types, and structure must stay intact.
|
||||
|
||||
## How configuration works
|
||||
|
||||
The user configures this project through Scarf's UI — not by editing files directly. On install, a form asked them for the list of sites and a request timeout; those values landed in `.scarf/config.json`. They can edit those values any time via the **Configuration** button on the project dashboard header.
|
||||
|
||||
Read configuration like this (JSON, via whatever file-read tool you have):
|
||||
|
||||
```
|
||||
cat .scarf/config.json
|
||||
# → { "values": { "sites": ["https://foo.com", "https://bar.com"],
|
||||
# "timeout_seconds": 10 }, ... }
|
||||
```
|
||||
|
||||
**Never** edit `.scarf/config.json` yourself. If the user asks "add a site" in chat, tell them to open the Configuration button on the dashboard. (A future Scarf release may expose a tool for agents to write config programmatically; until then, configuration is a user action.)
|
||||
|
||||
## First-run bootstrap
|
||||
|
||||
If `sites.txt` doesn't exist in the project root, create it with this starter content and tell the user you did:
|
||||
|
||||
```
|
||||
# One URL per line. Lines starting with # are comments.
|
||||
# Replace these placeholders with the sites you want to watch.
|
||||
https://example.com
|
||||
https://example.org
|
||||
```
|
||||
|
||||
If `status-log.md` doesn't exist, create it with a one-line header:
|
||||
|
||||
```
|
||||
@@ -33,14 +27,12 @@ If `status-log.md` doesn't exist, create it with a one-line header:
|
||||
Newest run at the top. Each section is a single check.
|
||||
```
|
||||
|
||||
No `sites.txt` anymore — sites come from `.scarf/config.json`.
|
||||
|
||||
## What to do when the cron job fires
|
||||
|
||||
The cron prompt Scarf registers for this project carries **absolute paths** (the installer substitutes `{{PROJECT_DIR}}` at install time) — you don't need to figure out the project's location yourself. Use whatever absolute paths appear in the prompt you received; if you're working in the project's interactive chat instead, the paths below are relative to the project root.
|
||||
The cron job runs this project's "Check site status" prompt. When invoked:
|
||||
|
||||
1. Read `.scarf/config.json`. Extract `values.sites` (array of URLs) and `values.timeout_seconds` (number). If `sites` is empty or missing, write a `status-log.md` entry noting "no sites configured — open Configuration to add some" and leave the dashboard untouched.
|
||||
2. For each URL in `sites`, make an HTTP GET request with the configured timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**.
|
||||
1. Read `sites.txt` in the project root. Ignore empty lines and `#`-prefixed comments. Expect plain URLs; be tolerant of whitespace around them.
|
||||
2. For each URL, make an HTTP GET request with a 10-second timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**.
|
||||
3. Build a results table: URL, status (up/down), HTTP code (or error reason), response time in milliseconds.
|
||||
4. Prepend a new section to `status-log.md`:
|
||||
```
|
||||
@@ -56,20 +48,19 @@ The cron prompt Scarf registers for this project carries **absolute paths** (the
|
||||
- `Sites Down` stat widget: `value` = count of down results.
|
||||
- `Last Checked` stat widget: `value` = the ISO-8601 timestamp you just wrote.
|
||||
- `Watched Sites` list widget `items`: one entry per URL with `text` = URL and `status` = `"up"` or `"down"` (lowercase).
|
||||
- `First Watched Site` **webview widget** (in the "Live Site Preview" section): set its `url` field to the **first** URL from `values.sites`. This is what the user sees rendered in the Scarf **Site** tab. If `values.sites` is empty, leave the webview's existing `url` alone.
|
||||
6. If the cron job has a `deliver` target set, emit a one-line summary (`3 up, 1 down — example.com timed out`) as the agent's final response so the delivery mechanism picks it up.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Don't modify the structure of `dashboard.json` (section titles, widget types, widget titles, `columns`). Only the values listed above are writable.
|
||||
- Don't edit `.scarf/config.json` — that's the user's responsibility via the Configuration UI.
|
||||
- Don't truncate `status-log.md` — it's the historical record. If it grows past 1 MB, add a one-line note at the top of the file asking the user to archive it.
|
||||
- Don't invent URLs or pull them from anywhere other than `values.sites`.
|
||||
- Don't invent URLs. If `sites.txt` is empty or missing, leave the dashboard untouched and write a single `status-log.md` entry noting "no sites configured."
|
||||
- Don't run browsers or headless Chrome. Plain HTTP GET is sufficient.
|
||||
|
||||
## When the user asks you things
|
||||
|
||||
- "What's the status of my sites?" — read the top section of `status-log.md` and summarize.
|
||||
- "Add a site" / "Remove a site" — tell them: *"Click the Configuration button on the dashboard header (the slider icon, next to the folder). Add or remove the URL there and save. The next cron run will pick it up."* Don't try to edit config.json yourself.
|
||||
- "Add a site" — append the URL to `sites.txt` on its own line. Don't sort or reorder existing entries. Confirm back to the user which URL you added.
|
||||
- "Remove a site" — delete the matching line from `sites.txt`. If multiple match, ask before choosing.
|
||||
- "Run the check now" — do everything in the cron flow above, then summarize the results in chat.
|
||||
- "Why is [site] down?" — read the last 3–5 entries for that URL in `status-log.md` and report any pattern you see (consistent timeouts, intermittent 5xx, DNS failures, etc.). Don't speculate beyond what the log shows.
|
||||
- "Why is [site] down?" — read the last 3-5 entries for that URL in `status-log.md` and report any pattern you see (consistent timeouts, intermittent 5xx, DNS failures, etc.). Don't speculate beyond what the log shows.
|
||||
|
||||
@@ -2,38 +2,32 @@
|
||||
|
||||
A minimal uptime watchdog that pings a list of URLs once a day, records pass/fail results, and keeps a simple Scarf dashboard up to date.
|
||||
|
||||
**Requires Scarf 2.3+** — this template uses the configuration feature (a form during install, and a Configuration button on the dashboard for editing later).
|
||||
|
||||
## What you get
|
||||
|
||||
- **Configurable site list** — you tell Scarf which URLs to watch during install, via a form. No file editing required. Edit the list later via the **Configuration** button on the project dashboard (slider icon next to the folder).
|
||||
- **Configurable timeout** — how long to wait per URL before giving up, also set via the form.
|
||||
- **`.scarf/config.json`** — where your configured values land. The agent reads this at run time; you never need to open it by hand.
|
||||
- **`status-log.md`** — the agent's append-only log of check results. New runs append a section at the top. Created automatically on first run.
|
||||
- **`sites.txt`** — one URL per line. This is the source of truth for what the cron job checks. Edit it to add or remove sites.
|
||||
- **`status-log.md`** — the agent's append-only log of check results. New runs append a section at the top.
|
||||
- **`.scarf/dashboard.json`** — Scarf dashboard with live stat widgets (sites up, sites down, last checked), the full list of watched sites with their last-known status, and a usage guide.
|
||||
- **Cron job `Check site status`** — registered (paused) by the installer; tag `[tmpl:awizemann/site-status-checker]`. Runs daily at 9:00 AM when enabled. Reads your configured sites + timeout, hits each URL, writes results to `status-log.md`, and updates the dashboard.
|
||||
- **Cron job `Check site status`** — registered (paused) by the installer; tag `[tmpl:awizemann/site-status-checker]`. Runs daily at 9:00 AM when enabled. The prompt tells the agent to read `sites.txt`, check each URL, write results to `status-log.md`, and update the stat widgets in `dashboard.json`.
|
||||
|
||||
## First steps
|
||||
|
||||
1. During install, fill in the Configuration form: add the URLs you want to watch and (optionally) adjust the timeout. Hit Continue, then Install.
|
||||
2. After install, open the **Cron** sidebar and enable the `[tmpl:awizemann/site-status-checker] Check site status` job. It's paused on install so nothing runs without your explicit say-so.
|
||||
3. From the project's dashboard, ask your agent to run the job now: *"Run the site status check and update the dashboard."*
|
||||
1. Open the **Cron** sidebar and enable the `[tmpl:awizemann/site-status-checker] Check site status` job. It's paused on install so nothing runs without your explicit say-so.
|
||||
2. Edit `sites.txt` in your project root — replace the two placeholder URLs with the sites you actually want to watch.
|
||||
3. From the project's dashboard, ask your agent to run the job now: "Run the site status check and update the dashboard."
|
||||
4. Future runs happen automatically at 9 AM daily.
|
||||
|
||||
## Changing sites or timeout later
|
||||
|
||||
Click the **Configuration** button (slider icon, dashboard toolbar) to re-open the form pre-filled with your current values. Add, remove, or edit URLs. Save. The next cron run picks up the changes.
|
||||
|
||||
## Customizing
|
||||
|
||||
- **Change the schedule.** Edit the cron job in the Cron sidebar — the schedule field accepts `30m`, `every 2h`, or standard cron expressions like `0 9 * * *`.
|
||||
- **Change what "down" means.** By default the agent treats any non-2xx/3xx HTTP response as down. If you want to check for specific strings in the body (e.g. "Maintenance"), tell the agent in `AGENTS.md` and it will adapt.
|
||||
- **Change what "down" means.** By default the agent treats any non-2xx HTTP response as down. If you want to check for specific strings in the body (e.g. "Maintenance"), tell the agent in `AGENTS.md` and it will adapt.
|
||||
- **Add alerting.** Set a `deliver` target on the cron job (Discord, Slack, Telegram) — the agent will post the run summary there instead of just writing to `status-log.md`.
|
||||
|
||||
## Recommended model
|
||||
|
||||
`claude-haiku-4` works well — this is a simple tool-use task (HTTP GETs + a short summary). Haiku keeps costs low when the cron runs daily. The recommendation appears in the Configuration form; Scarf doesn't auto-switch your active model, so adjust via Settings if you'd like.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Right-click the project in the sidebar → **Uninstall Template…** (or click the shippingbox icon on the dashboard header). Scarf walks you through exactly what's about to be removed: template-installed files in the project dir, the `[tmpl:…]` cron job, and the Configuration values you entered (`config.json` + Keychain items for any secrets — though this template has none). User-created files (like `status-log.md`) are preserved.
|
||||
Templates don't auto-uninstall in Scarf 2.2. To remove this one by hand:
|
||||
|
||||
1. Delete this project directory (removes the dashboard, AGENTS.md, sites.txt, status-log.md).
|
||||
2. Remove the project entry from the Scarf sidebar (click the `−` next to the project name).
|
||||
3. Delete the `[tmpl:awizemann/site-status-checker] Check site status` cron job from the Cron sidebar.
|
||||
|
||||
No memory appendix or skills were installed, so nothing else needs cleanup.
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
{
|
||||
"name": "Check site status",
|
||||
"schedule": "0 9 * * *",
|
||||
"prompt": "Run the site status check for the Scarf project at {{PROJECT_DIR}}. Read {{PROJECT_DIR}}/.scarf/config.json to get `values.sites` (the URL list) and `values.timeout_seconds` (the per-URL HTTP timeout). HTTP GET each URL with that timeout, following up to 3 redirects; treat 2xx/3xx as up and anything else (including timeouts and DNS failures) as down. Prepend a new timestamped results section to {{PROJECT_DIR}}/status-log.md — create the file with a one-line header if it doesn't exist yet. Update {{PROJECT_DIR}}/.scarf/dashboard.json: set the Sites Up / Sites Down / Last Checked stat widgets' `value` fields; replace the 'Watched Sites' list widget's `items` array with one entry per URL (text = URL, status = \"up\" or \"down\"); and if `values.sites` is non-empty, set the 'First Watched Site' webview widget's `url` field to the FIRST URL from `values.sites` (otherwise leave the webview's existing url alone). Preserve every other field in dashboard.json as-is. Reply with a one-line summary like '3 up, 1 down — example.com timed out'."
|
||||
"prompt": "Run the site status check for this project. Follow the instructions in AGENTS.md: read sites.txt, HTTP GET each URL, prepend a results section to status-log.md, and update the three stat widgets plus the Watched Sites list items in .scarf/dashboard.json. When done, reply with a one-line summary like '3 up, 1 down — example.com timed out'."
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Site Status",
|
||||
"description": "Daily uptime check for your watched URLs. The stat widgets, the sites list, and the Site tab's preview URL all update automatically when the cron job runs. Switch to the Site tab to see your first watched site live.",
|
||||
"description": "Daily uptime check for your watched URLs. The stat widgets and list update automatically when the cron job runs.",
|
||||
"theme": { "accent": "green" },
|
||||
"sections": [
|
||||
{
|
||||
@@ -40,25 +40,14 @@
|
||||
"widgets": [
|
||||
{
|
||||
"type": "list",
|
||||
"title": "Watched Sites (populated after first run)",
|
||||
"title": "Configured Sites (from sites.txt)",
|
||||
"items": [
|
||||
{ "text": "Run the check once to populate — the agent reads your Configuration and fills this list with live status.", "status": "pending" }
|
||||
{ "text": "https://example.com", "status": "unknown" },
|
||||
{ "text": "https://example.org", "status": "unknown" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Live Site Preview",
|
||||
"columns": 1,
|
||||
"widgets": [
|
||||
{
|
||||
"type": "webview",
|
||||
"title": "First Watched Site",
|
||||
"url": "https://awizemann.github.io/scarf/",
|
||||
"height": 420
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "How to Use",
|
||||
"columns": 1,
|
||||
@@ -67,7 +56,7 @@
|
||||
"type": "text",
|
||||
"title": "Quick Start",
|
||||
"format": "markdown",
|
||||
"content": "**1.** Review your configuration — click the **slider icon** (top-right of this dashboard) to open Configuration. The sites you enter there are what the cron job will check.\n\n**2.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The Watched Sites list populates, the stat widgets update, the Site tab's URL switches to your first watched site, and a new entry lands at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSwitch to the **Site** tab (next to Dashboard, above) to see your first watched site rendered in a browser. Useful to eyeball a site when the status says up but something still looks off.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec."
|
||||
"content": "**1.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**2.** Edit `sites.txt` in this project's folder to replace the placeholder URLs with the sites you actually want to watch.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The dashboard refreshes and a new entry appears at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,50 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"schemaVersion": 1,
|
||||
"id": "awizemann/site-status-checker",
|
||||
"name": "Site Status Checker",
|
||||
"version": "1.1.0",
|
||||
"minScarfVersion": "2.3.0",
|
||||
"version": "1.0.0",
|
||||
"minScarfVersion": "2.2.0",
|
||||
"minHermesVersion": "0.9.0",
|
||||
"author": {
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann/scarf"
|
||||
},
|
||||
"description": "A daily uptime check for a list of URLs you configure on install. Writes status to status-log.md and updates the dashboard with current counts.",
|
||||
"description": "A daily uptime check for a short list of URLs. Writes status to status-log.md and updates the dashboard with current counts.",
|
||||
"category": "monitoring",
|
||||
"tags": ["monitoring", "uptime", "cron", "starter", "configurable"],
|
||||
"tags": ["monitoring", "uptime", "cron", "starter"],
|
||||
"contents": {
|
||||
"dashboard": true,
|
||||
"agentsMd": true,
|
||||
"cron": 1,
|
||||
"config": 2
|
||||
},
|
||||
"config": {
|
||||
"schema": [
|
||||
{
|
||||
"key": "sites",
|
||||
"type": "list",
|
||||
"itemType": "string",
|
||||
"label": "Sites to Watch",
|
||||
"description": "One URL per item. HTTP or HTTPS. You can add and remove entries after install via the Configuration button on the dashboard.",
|
||||
"required": true,
|
||||
"minItems": 1,
|
||||
"maxItems": 25,
|
||||
"default": ["https://example.com", "https://example.org"]
|
||||
},
|
||||
{
|
||||
"key": "timeout_seconds",
|
||||
"type": "number",
|
||||
"label": "Request Timeout (seconds)",
|
||||
"description": "How long to wait for each URL before giving up.",
|
||||
"required": false,
|
||||
"min": 1,
|
||||
"max": 60,
|
||||
"default": 10
|
||||
}
|
||||
],
|
||||
"modelRecommendation": {
|
||||
"preferred": "claude-haiku-4",
|
||||
"rationale": "Simple tool-use task — HTTP GETs + a short summary. Haiku is plenty and keeps cost low when the cron runs daily."
|
||||
}
|
||||
"cron": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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 "<name>" 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.
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
---
|
||||
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` (1–4, default 3), `widgets`)
|
||||
|
||||
Keep section titles short. Group related widgets. First section is usually "Current Status" or similar with the key stats.
|
||||
|
||||
### Step 4 — write `manifest.json` (only if the project has a config schema)
|
||||
|
||||
Put the full manifest shape from Config Schema Design above. Use `schemaVersion: 2`, match `contents.config` to the actual field count, and ensure every secret field has no `default`.
|
||||
|
||||
If there's no config schema, skip this file — the project still works, it just won't have a Configuration button. You can add it later.
|
||||
|
||||
### Step 5 — write `AGENTS.md`
|
||||
|
||||
Every scaffolded project needs an `AGENTS.md` that covers:
|
||||
|
||||
- **Purpose** — what the project does.
|
||||
- **Layout** — which files exist and what they're for.
|
||||
- **Configuration** — if there's a config schema, document every field: what it's for, what valid values look like, what happens when it's missing.
|
||||
- **Dashboard** — list every widget the cron job (if any) updates, by title. If the cron updates a webview widget's URL, document that explicitly.
|
||||
- **Cron behaviour** — what the cron job does, what it reads, what it writes, what its exit criteria are.
|
||||
- **Chat prompts** — common user questions and how to answer them (e.g., *"What's the status of my sites?"* → "read the top section of `status-log.md` and summarise").
|
||||
- **What NOT to do** — e.g., *don't modify `.scarf/config.json` yourself; tell the user to open the Configuration button.*
|
||||
|
||||
Use `{{PROJECT_DIR}}` placeholders in AGENTS.md only if the template will be installed through the installer (which substitutes the token). For a hand-scaffolded local-only project, substitute the absolute path yourself — `{{PROJECT_DIR}}` only resolves at install time.
|
||||
|
||||
### Step 6 — write `README.md`
|
||||
|
||||
User-facing. Keep it short:
|
||||
|
||||
- One-paragraph purpose.
|
||||
- How to install / first run (for an unexported project: "click + in Scarf's Projects sidebar").
|
||||
- How to trigger the cron job manually (Cron sidebar → Run Now).
|
||||
- A pointer at `AGENTS.md` for agents.
|
||||
|
||||
### Step 7 — register the cron job (if any)
|
||||
|
||||
For a local non-exported project:
|
||||
|
||||
```bash
|
||||
hermes cron create --name "<descriptive name>" "<schedule>" "<prompt with absolute project dir substituted>"
|
||||
# Then pause it so it doesn't fire until the user's ready:
|
||||
hermes cron pause <newly-created-job-id>
|
||||
```
|
||||
|
||||
Read the id back from `hermes cron list --json` or parse the create output.
|
||||
|
||||
For an exportable template (one you're staging in `templates/<author>/<name>/staging/`): just author `cron/jobs.json` — the installer registers + pauses at install time, and prefixes the name with `[tmpl:<id>]`.
|
||||
|
||||
### Step 8 — register the project with Scarf
|
||||
|
||||
Tell the user: *"I've written the files. Click the **+** button in Scarf's Projects sidebar and pick `<absolute-project-dir>`. The dashboard will appear."*
|
||||
|
||||
Do NOT edit `~/.hermes/scarf/projects.json` directly — Scarf owns that file and reloads it on its own. The UI path is safer.
|
||||
|
||||
### Step 9 (optional) — log to the Template Author project's list
|
||||
|
||||
If the user has the `awizemann/template-author` project installed (the one that shipped this skill), append an entry to its `dashboard.json`'s `Scaffolded Projects` list widget:
|
||||
|
||||
```json
|
||||
{ "text": "<absolute-project-dir> — <one-line purpose>", "status": "ok" }
|
||||
```
|
||||
|
||||
This gives the user a running audit trail of everything you've scaffolded for them. Preserve every other field in the dashboard as-is.
|
||||
|
||||
## Testing your scaffold
|
||||
|
||||
### Minimum smoke test
|
||||
|
||||
1. Tell the user to click **+** in Scarf's Projects sidebar and pick the directory.
|
||||
2. Dashboard appears — sanity check every widget renders correctly.
|
||||
3. If there's a cron job: click the job in Scarf's Cron sidebar → **Run Now**. The agent executes the prompt; dashboard updates when it finishes.
|
||||
|
||||
### Configuration-form test (only if schema was declared)
|
||||
|
||||
To verify the Configuration form renders, you need to *install* the project as a template — scaffolded projects don't go through the installer, so the form never runs. Export the project first:
|
||||
|
||||
1. Projects → Templates → **Export "<name>" as Template…** → save the `.scarftemplate` somewhere.
|
||||
2. Projects → Templates → **Install from File…** → pick the bundle → the Configure step should render the form you designed.
|
||||
3. Cancel the install (the preview sheet has a Cancel button) — you just wanted to verify the form shape.
|
||||
|
||||
### Catalog validation (only if publishing)
|
||||
|
||||
If the user plans to submit this to the public catalog at `awizemann.github.io/scarf/templates/`:
|
||||
|
||||
```bash
|
||||
# From the repo root
|
||||
./scripts/catalog.sh check
|
||||
```
|
||||
|
||||
Validates every template in `templates/<author>/<name>/` against the Python validator — the same one the PR CI uses. Catches schema issues, claim mismatches, size violations, common secret patterns.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
Things to check before declaring the scaffold done:
|
||||
|
||||
- [ ] Every cron prompt uses `{{PROJECT_DIR}}` (for exported) OR an absolute path (for local-only). Relative paths will fail.
|
||||
- [ ] `contents.config` in the manifest equals the actual field count. Claim mismatch = rejected.
|
||||
- [ ] No `default` on any `secret` field.
|
||||
- [ ] Every enum field has non-empty `options`.
|
||||
- [ ] Every list field has `itemType: "string"`.
|
||||
- [ ] Every table widget has rows of length equal to `columns`.
|
||||
- [ ] Every webview widget has an https URL that renders something meaningful even pre-first-run (Scarf homepage is a decent placeholder).
|
||||
- [ ] `dashboard.json` has `version: 1` at the top.
|
||||
- [ ] `AGENTS.md` documents every config field, every updated widget, and the cron behaviour — the user relies on it as the source of truth when things drift.
|
||||
- [ ] **No raw URLs in field descriptions.** Use `[link text](https://…)` markdown syntax instead — raw URLs read as long unbreakable tokens in the Configuration sheet. Same rule for long paths and other unbreakable strings; wrap in `` ` `` if they must appear verbatim.
|
||||
- [ ] **Leave the `<!-- scarf-project:begin -->` / `<!-- scarf-project:end -->` region alone in the project's `AGENTS.md`.** As of Scarf v2.3, the app auto-injects a project-identity block at chat-start time (project name, directory, template id, configuration field names, cron jobs). Anything you write inside that region will be overwritten on the next chat start. Put template-specific agent instructions BELOW the block so they're preserved across refreshes.
|
||||
|
||||
## Reference — source of truth files
|
||||
|
||||
- **Dashboard widget schema** — `scarf/scarf/Core/Models/ProjectDashboard.swift` in the Scarf repo. If you need exact field types or defaults, read it.
|
||||
- **Config schema + validation** — `scarf/scarf/Core/Models/TemplateConfig.swift` and `scarf/scarf/Core/Services/ProjectConfigService.swift`.
|
||||
- **Exporter behaviour** — `scarf/scarf/Core/Services/ProjectTemplateExporter.swift`. Verifies what files the exporter will pick up from a live project and what it'll carry into a bundle.
|
||||
- **Installer contract** — `scarf/scarf/Core/Services/ProjectTemplateInstaller.swift`. Verifies what `{{PROJECT_DIR}}` substitution covers and where installed files land.
|
||||
- **Catalog validator** — `tools/build-catalog.py` in the Scarf repo. Run with `./scripts/catalog.sh check` for the same rules CI uses.
|
||||
- **Worked example** — `templates/awizemann/site-status-checker/staging/` in the Scarf repo. Complete end-to-end: dashboard with stats + list + webview, a config schema with a list + a number, a cron job, an AGENTS.md that documents every moving part. Read it first whenever you're unsure how a piece should look.
|
||||
- **User-facing docs** — [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates).
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
@@ -7,91 +7,26 @@
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann/scarf"
|
||||
},
|
||||
"bundleSha256": "0a20802a8830a7cfdd1afa2888e42e113c9a17a37439384a3037d32ad1f24c1f",
|
||||
"bundleSize": 7569,
|
||||
"bundleSha256": "32b8c12706de8596be63dcdda32d46fc5bf478d5b9f7c1fc4c6d96ced251186a",
|
||||
"bundleSize": 5410,
|
||||
"category": "monitoring",
|
||||
"config": {
|
||||
"modelRecommendation": {
|
||||
"preferred": "claude-haiku-4",
|
||||
"rationale": "Simple tool-use task \u2014 HTTP GETs + a short summary. Haiku is plenty and keeps cost low when the cron runs daily."
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"default": [
|
||||
"https://example.com",
|
||||
"https://example.org"
|
||||
],
|
||||
"description": "One URL per item. HTTP or HTTPS. You can add and remove entries after install via the Configuration button on the dashboard.",
|
||||
"itemType": "string",
|
||||
"key": "sites",
|
||||
"label": "Sites to Watch",
|
||||
"maxItems": 25,
|
||||
"minItems": 1,
|
||||
"required": true,
|
||||
"type": "list"
|
||||
},
|
||||
{
|
||||
"default": 10,
|
||||
"description": "How long to wait for each URL before giving up.",
|
||||
"key": "timeout_seconds",
|
||||
"label": "Request Timeout (seconds)",
|
||||
"max": 60,
|
||||
"min": 1,
|
||||
"required": false,
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"contents": {
|
||||
"agentsMd": true,
|
||||
"config": 2,
|
||||
"cron": 1,
|
||||
"dashboard": true
|
||||
},
|
||||
"description": "A daily uptime check for a list of URLs you configure on install. Writes status to status-log.md and updates the dashboard with current counts.",
|
||||
"description": "A daily uptime check for a short list of URLs. Writes status to status-log.md and updates the dashboard with current counts.",
|
||||
"detailSlug": "awizemann-site-status-checker",
|
||||
"id": "awizemann/site-status-checker",
|
||||
"installUrl": "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/site-status-checker/site-status-checker.scarftemplate",
|
||||
"minHermesVersion": "0.9.0",
|
||||
"minScarfVersion": "2.3.0",
|
||||
"minScarfVersion": "2.2.0",
|
||||
"name": "Site Status Checker",
|
||||
"tags": [
|
||||
"monitoring",
|
||||
"uptime",
|
||||
"cron",
|
||||
"starter",
|
||||
"configurable"
|
||||
],
|
||||
"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"
|
||||
"starter"
|
||||
],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
@@ -45,18 +45,11 @@ from typing import Iterable
|
||||
# Schema + invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCHEMA_VERSION_V1 = 1 # original v2.2 bundle
|
||||
SCHEMA_VERSION_V2 = 2 # v2.3 — adds optional manifest.config block
|
||||
SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1, SCHEMA_VERSION_V2}
|
||||
SCHEMA_VERSION = 1
|
||||
MAX_BUNDLE_BYTES = 5 * 1024 * 1024 # 5 MB cap on submissions; installer is 50 MB
|
||||
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
|
||||
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
|
||||
|
||||
# Mirror of Swift's TemplateConfigField.FieldType. Order matters only
|
||||
# for error messages that echo this set.
|
||||
SUPPORTED_CONFIG_FIELD_TYPES = {"string", "text", "number", "bool", "enum", "list", "secret"}
|
||||
SUPPORTED_CONFIG_LIST_ITEM_TYPES = {"string"}
|
||||
|
||||
# Common secret patterns — keep in sync with `scripts/wiki.sh` and reuse a
|
||||
# conservative subset. The validator rejects hard matches; the site's
|
||||
# CONTRIBUTING guide covers the rest.
|
||||
@@ -107,9 +100,7 @@ class TemplateRecord:
|
||||
|
||||
def to_catalog_entry(self) -> dict:
|
||||
"""Subset suitable for catalog.json. Keep fields stable — the
|
||||
site's widgets.js reads this shape. The optional `config` key
|
||||
mirrors the manifest's `config` block so the site can render
|
||||
the Configuration section on the detail page."""
|
||||
site's widgets.js reads this shape."""
|
||||
m = self.manifest
|
||||
return {
|
||||
"id": m["id"],
|
||||
@@ -120,7 +111,6 @@ class TemplateRecord:
|
||||
"category": m.get("category"),
|
||||
"tags": m.get("tags") or [],
|
||||
"contents": m["contents"],
|
||||
"config": m.get("config"), # None for schema-less
|
||||
"installUrl": self.install_url,
|
||||
"detailSlug": self.detail_slug,
|
||||
"bundleSha256": self.bundle_sha256,
|
||||
@@ -164,12 +154,8 @@ def _validate_manifest(manifest: dict, template_dir: Path, errors: list[Validati
|
||||
for field in required:
|
||||
if field not in manifest:
|
||||
errors.append(ValidationError(template_dir, f"manifest missing required field: {field}"))
|
||||
if manifest.get("schemaVersion") not in SUPPORTED_SCHEMA_VERSIONS:
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"unsupported schemaVersion: {manifest.get('schemaVersion')} "
|
||||
f"(supported: {sorted(SUPPORTED_SCHEMA_VERSIONS)})"
|
||||
))
|
||||
if manifest.get("schemaVersion") != SCHEMA_VERSION:
|
||||
errors.append(ValidationError(template_dir, f"unsupported schemaVersion: {manifest.get('schemaVersion')}"))
|
||||
# Manifest id must match the directory layout.
|
||||
mid = manifest.get("id", "")
|
||||
if "/" not in mid:
|
||||
@@ -246,114 +232,6 @@ def _validate_contents_claim(
|
||||
f"contents.memory.append={claimed_memory} disagrees with memory/append.md presence={has_memory_file}"
|
||||
))
|
||||
|
||||
# Config (schemaVersion 2+) — claim field-count must match schema
|
||||
# field count. `None`/`0` on both sides means schema-less, which is
|
||||
# always legal.
|
||||
claimed_config = int(contents.get("config") or 0)
|
||||
schema = manifest.get("config")
|
||||
schema_field_count = len((schema or {}).get("schema") or []) if schema else 0
|
||||
if claimed_config != schema_field_count:
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"contents.config={claimed_config} but config.schema has {schema_field_count} field(s)"
|
||||
))
|
||||
|
||||
|
||||
def _validate_config_schema(manifest: dict, template_dir: Path, errors: list[ValidationError]) -> None:
|
||||
"""Mirrors Swift `ProjectConfigService.validateSchema`. Structural
|
||||
invariants only — user-value validation happens in the app at
|
||||
commit time, not at catalog-build time."""
|
||||
schema = manifest.get("config")
|
||||
if schema is None:
|
||||
return
|
||||
if not isinstance(schema, dict):
|
||||
errors.append(ValidationError(template_dir, "manifest.config must be an object"))
|
||||
return
|
||||
fields = schema.get("schema")
|
||||
if not isinstance(fields, list):
|
||||
errors.append(ValidationError(template_dir, "manifest.config.schema must be a list"))
|
||||
return
|
||||
|
||||
seen_keys: set[str] = set()
|
||||
for i, field in enumerate(fields):
|
||||
if not isinstance(field, dict):
|
||||
errors.append(ValidationError(template_dir, f"config.schema[{i}] must be an object"))
|
||||
continue
|
||||
key = field.get("key")
|
||||
ftype = field.get("type")
|
||||
label = field.get("label")
|
||||
if not isinstance(key, str) or not key:
|
||||
errors.append(ValidationError(template_dir, f"config.schema[{i}] missing/empty key"))
|
||||
continue
|
||||
if key in seen_keys:
|
||||
errors.append(ValidationError(template_dir, f"config.schema has duplicate key: {key!r}"))
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
if not isinstance(label, str) or not label:
|
||||
errors.append(ValidationError(template_dir, f"config.schema[{key}] missing/empty label"))
|
||||
if ftype not in SUPPORTED_CONFIG_FIELD_TYPES:
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"config.schema[{key}] uses unsupported type {ftype!r} "
|
||||
f"(supported: {sorted(SUPPORTED_CONFIG_FIELD_TYPES)})"
|
||||
))
|
||||
continue
|
||||
# Type-specific rules.
|
||||
if ftype == "enum":
|
||||
options = field.get("options") or []
|
||||
if not isinstance(options, list) or not options:
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"config.schema[{key}] (enum) must declare at least one option"
|
||||
))
|
||||
else:
|
||||
seen_values: set[str] = set()
|
||||
for opt in options:
|
||||
if not isinstance(opt, dict):
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"config.schema[{key}] option must be an object"
|
||||
))
|
||||
continue
|
||||
val = opt.get("value")
|
||||
if not isinstance(val, str) or not val:
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"config.schema[{key}] option missing/empty value"
|
||||
))
|
||||
continue
|
||||
if val in seen_values:
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"config.schema[{key}] has duplicate option value: {val!r}"
|
||||
))
|
||||
seen_values.add(val)
|
||||
elif ftype == "list":
|
||||
item_type = field.get("itemType", "string")
|
||||
if item_type not in SUPPORTED_CONFIG_LIST_ITEM_TYPES:
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"config.schema[{key}] (list) uses unsupported itemType {item_type!r}"
|
||||
))
|
||||
elif ftype == "secret":
|
||||
if "default" in field:
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"config.schema[{key}] is a secret field and must not declare a default"
|
||||
))
|
||||
# modelRecommendation — preferred must be non-empty when present.
|
||||
rec = schema.get("modelRecommendation")
|
||||
if rec is not None:
|
||||
if not isinstance(rec, dict):
|
||||
errors.append(ValidationError(template_dir, "config.modelRecommendation must be an object"))
|
||||
else:
|
||||
preferred = rec.get("preferred")
|
||||
if not isinstance(preferred, str) or not preferred.strip():
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
"config.modelRecommendation.preferred must be a non-empty string"
|
||||
))
|
||||
|
||||
|
||||
def _validate_dashboard(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
|
||||
"""Decode dashboard.json against the widget-type vocabulary the Swift
|
||||
@@ -473,7 +351,6 @@ def validate_template(template_dir: Path) -> tuple[TemplateRecord | None, list[V
|
||||
return None, errors
|
||||
|
||||
_validate_manifest(manifest, template_dir, errors)
|
||||
_validate_config_schema(manifest, template_dir, errors)
|
||||
cron_count = _parse_cron_jobs(zf, template_dir, errors)
|
||||
_validate_contents_claim(manifest, bundle_files, cron_count, template_dir, errors)
|
||||
_validate_dashboard(zf, template_dir, errors)
|
||||
@@ -566,10 +443,7 @@ def _check_staging_matches_bundle(record: TemplateRecord) -> list[ValidationErro
|
||||
|
||||
def write_catalog_json(records: list[TemplateRecord], out_path: Path) -> None:
|
||||
catalog = {
|
||||
# The aggregate catalog itself is versioned independently of
|
||||
# individual bundle manifests — bumping template manifest schema
|
||||
# from 1 → 2 doesn't change the catalog.json shape.
|
||||
"schemaVersion": 1,
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"generated": True, # human reminder; a timestamp would churn the diff every run
|
||||
"templates": [r.to_catalog_entry() for r in records],
|
||||
}
|
||||
@@ -693,20 +567,12 @@ def render_site(records: list[TemplateRecord], out_dir: Path, repo_root: Path) -
|
||||
render_detail(template_tmpl, r),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Copy the unpacked dashboard.json, README.md, and template.json
|
||||
# (as manifest.json so the site can fetch the config schema for
|
||||
# the Configuration section without conflicting with any file
|
||||
# named `template.json` somewhere else in the served tree).
|
||||
# Copy the unpacked dashboard.json so widgets.js can fetch it
|
||||
# without cross-directory relative paths.
|
||||
with zipfile.ZipFile(r.bundle_path, "r") as zf:
|
||||
(detail_dir / "dashboard.json").write_bytes(zf.read("dashboard.json"))
|
||||
if "README.md" in zf.namelist():
|
||||
(detail_dir / "README.md").write_bytes(zf.read("README.md"))
|
||||
# Only copy the manifest when the template has a config
|
||||
# schema — avoids bloating the served tree for schema-less
|
||||
# templates and makes the 404 fallback in widgets.js a
|
||||
# meaningful signal ("no config to show here").
|
||||
if r.manifest.get("config"):
|
||||
(detail_dir / "manifest.json").write_bytes(zf.read("template.json"))
|
||||
|
||||
# The aggregate catalog.json is copied in so the frontend can fetch
|
||||
# /templates/catalog.json without reaching back into the repo.
|
||||
|
||||
@@ -335,194 +335,6 @@ class ValidationTests(unittest.TestCase):
|
||||
return records, errors
|
||||
|
||||
|
||||
class ConfigSchemaValidationTests(unittest.TestCase):
|
||||
"""Mirrors the Swift `ProjectConfigServiceTests` schema-validation
|
||||
suite. Every rule enforced on the Swift side must be enforced on
|
||||
the Python side — schema drift is a catastrophic failure for the
|
||||
catalog (CI would accept bundles the app later refuses at install)."""
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.repo = make_fake_repo(Path(self._dir.name))
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
|
||||
def _make_schema_manifest(self, fields, cron: int = 0):
|
||||
"""Convenience — build a v2 manifest with the given config fields."""
|
||||
return {
|
||||
"schemaVersion": 2,
|
||||
"id": "tester/configured",
|
||||
"name": "Configured",
|
||||
"version": "1.0.0",
|
||||
"description": "test",
|
||||
"contents": {
|
||||
"dashboard": True,
|
||||
"agentsMd": True,
|
||||
"cron": cron,
|
||||
"config": len(fields),
|
||||
},
|
||||
"config": {"schema": fields},
|
||||
}
|
||||
|
||||
def test_accepts_schemaful_bundle(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "name", "type": "string", "label": "Name", "required": True},
|
||||
{"key": "enabled", "type": "bool", "label": "Enabled"},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "configured",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
records = []
|
||||
errors = []
|
||||
for tdir in build_catalog._iter_templates(self.repo):
|
||||
rec, errs = build_catalog.validate_template(tdir)
|
||||
errors.extend(errs)
|
||||
if rec is not None:
|
||||
records.append(rec)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(records[0].manifest["schemaVersion"], 2)
|
||||
|
||||
def test_rejects_duplicate_keys(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "same", "type": "string", "label": "A"},
|
||||
{"key": "same", "type": "bool", "label": "B"},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "dup",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("duplicate key" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_secret_with_default(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{
|
||||
"key": "api_key", "type": "secret", "label": "API Key",
|
||||
"required": True, "default": "sk-leaked-in-template"
|
||||
},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "secret-default",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("must not declare a default" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_enum_without_options(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "choice", "type": "enum", "label": "Choice", "options": []},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "enum-empty",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("at least one option" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_unsupported_field_type(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "wat", "type": "hologram", "label": "W"},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "bad-type",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("unsupported type" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_contents_config_count_mismatch(self):
|
||||
# Schema has 1 field; contents.config claims 2.
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "only", "type": "string", "label": "Only"},
|
||||
])
|
||||
manifest["contents"]["config"] = 2
|
||||
make_template_dir(
|
||||
self.repo, "tester", "mismatch",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("contents.config=2" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_unsupported_list_item_type(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "items", "type": "list", "label": "Items", "itemType": "number"},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "list-type",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("unsupported itemType" in str(e) for e in errors), errors)
|
||||
|
||||
def test_accepts_schemaless_v1_manifest_unchanged(self):
|
||||
# Pre-v2.3 bundles without any config block should keep working.
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/legacy",
|
||||
"name": "Legacy",
|
||||
"version": "1.0.0",
|
||||
"description": "no config",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "legacy",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def _collect_errors(self):
|
||||
errors = []
|
||||
for tdir in build_catalog._iter_templates(self.repo):
|
||||
rec, errs = build_catalog.validate_template(tdir)
|
||||
errors.extend(errs)
|
||||
if rec is not None:
|
||||
errors.extend(build_catalog._check_staging_matches_bundle(rec))
|
||||
return errors
|
||||
|
||||
|
||||
class CatalogJsonTests(unittest.TestCase):
|
||||
"""Shape of the emitted catalog.json must stay stable — the site's
|
||||
widgets.js reads these fields by name."""
|
||||
|
||||