mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55229a2f91 | |||
| 99859c06fd | |||
| 9f3600ae01 | |||
| b34f432f00 | |||
| b289a83944 |
@@ -21,14 +21,11 @@
|
||||
|
||||
## What's New 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 new **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`. A slider icon in the dashboard header opens the same form post-install for edits — rotate a token, change a site, toggle a feature, and the next cron run picks it up.
|
||||
- **Public template catalog** — [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) is a static catalog site generated from `templates/<author>/<name>/` in this repo. Each template has a detail page with a live dashboard preview, the schema rendered with constraint summaries, and a one-click install button. Community submissions go through a CI-enforced Python validator that mirrors the Swift-side invariants.
|
||||
- **Preview-before-apply** — Every install shows a preview sheet listing the exact project directory that will be created, every file inside it, every skill that will be namespaced, every cron job that will be registered (paused by default), every Keychain secret that will be written, and a live diff of any memory appendix. Markdown fields render inline. Nothing writes until you click Install.
|
||||
- **Site tab** — A dashboard with at least one `webview` widget gets a second tab next to Dashboard. The example `awizemann/site-status-checker` template uses this to render whatever URL you configured as your first watched site, updating on every cron run.
|
||||
- **Safe-by-design** — Skills install into `~/.hermes/skills/templates/<slug>/` so they never collide with your own. Cron jobs carry a `[tmpl:<id>]` tag and start paused. A `template.lock.json` records every file, cron job, Keychain ref, and memory block for one-click uninstall. Exports carry the configuration schema but never the user's values — safe on projects with live config. Templates **never** touch `config.yaml`, `auth.json`, sessions, or credentials.
|
||||
- **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.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0) and the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates).
|
||||
See the full [v2.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.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Scarf iOS
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var items: [Item]
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
||||
} label: {
|
||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
Text("Select an item")
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
withAnimation {
|
||||
let newItem = Item(timestamp: Date())
|
||||
modelContext.insert(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(items[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.modelContainer(for: Item.self, inMemory: true)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Item.swift
|
||||
// Scarf iOS
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Item {
|
||||
var timestamp: Date
|
||||
|
||||
init(timestamp: Date) {
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Scarf_iOSApp.swift
|
||||
// Scarf iOS
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct Scarf_iOSApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
Item.self,
|
||||
])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// Scarf_iOSTests.swift
|
||||
// Scarf iOSTests
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import Scarf_iOS
|
||||
|
||||
struct Scarf_iOSTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// Scarf_iOSUITests.swift
|
||||
// Scarf iOSUITests
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class Scarf_iOSUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// Scarf_iOSUITestsLaunchTests.swift
|
||||
// Scarf iOSUITests
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class Scarf_iOSUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,20 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
4EAC233A2F99930100654F42 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 4EAC23282F99930000654F42;
|
||||
remoteInfo = "Scarf iOS";
|
||||
};
|
||||
4EAC23442F99930100654F42 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 4EAC23282F99930000654F42;
|
||||
remoteInfo = "Scarf iOS";
|
||||
};
|
||||
534959502F7B83B700BD31AD /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||
@@ -29,12 +43,22 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
4EAC23292F99930000654F42 /* scarf mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "scarf mobile.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Scarf iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Scarf iOSUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
534959402F7B83B600BD31AD /* scarf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = scarf.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5349594F2F7B83B700BD31AD /* scarfTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
4EAC234B2F99930100654F42 /* Exceptions for "Scarf iOS" folder in "scarf mobile" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 4EAC23282F99930000654F42 /* scarf mobile */;
|
||||
};
|
||||
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
@@ -45,6 +69,24 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
4EAC232A2F99930000654F42 /* Scarf iOS */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
4EAC234B2F99930100654F42 /* Exceptions for "Scarf iOS" folder in "scarf mobile" target */,
|
||||
);
|
||||
path = "Scarf iOS";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EAC233C2F99930100654F42 /* Scarf iOSTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "Scarf iOSTests";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EAC23462F99930100654F42 /* Scarf iOSUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "Scarf iOSUITests";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534959422F7B83B600BD31AD /* scarf */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
@@ -66,6 +108,27 @@
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
4EAC23262F99930000654F42 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
4EAC23362F99930100654F42 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
4EAC23402F99930100654F42 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5349593D2F7B83B600BD31AD /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -98,6 +161,9 @@
|
||||
534959422F7B83B600BD31AD /* scarf */,
|
||||
534959522F7B83B700BD31AD /* scarfTests */,
|
||||
5349595C2F7B83B700BD31AD /* scarfUITests */,
|
||||
4EAC232A2F99930000654F42 /* Scarf iOS */,
|
||||
4EAC233C2F99930100654F42 /* Scarf iOSTests */,
|
||||
4EAC23462F99930100654F42 /* Scarf iOSUITests */,
|
||||
534959412F7B83B600BD31AD /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -108,6 +174,9 @@
|
||||
534959402F7B83B600BD31AD /* scarf.app */,
|
||||
5349594F2F7B83B700BD31AD /* scarfTests.xctest */,
|
||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */,
|
||||
4EAC23292F99930000654F42 /* scarf mobile.app */,
|
||||
4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */,
|
||||
4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -115,6 +184,74 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
4EAC23282F99930000654F42 /* scarf mobile */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 4EAC234C2F99930100654F42 /* Build configuration list for PBXNativeTarget "scarf mobile" */;
|
||||
buildPhases = (
|
||||
4EAC23252F99930000654F42 /* Sources */,
|
||||
4EAC23262F99930000654F42 /* Frameworks */,
|
||||
4EAC23272F99930000654F42 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
4EAC232A2F99930000654F42 /* Scarf iOS */,
|
||||
);
|
||||
name = "scarf mobile";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "Scarf iOS";
|
||||
productReference = 4EAC23292F99930000654F42 /* scarf mobile.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
4EAC23382F99930100654F42 /* Scarf iOSTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 4EAC234F2F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSTests" */;
|
||||
buildPhases = (
|
||||
4EAC23352F99930100654F42 /* Sources */,
|
||||
4EAC23362F99930100654F42 /* Frameworks */,
|
||||
4EAC23372F99930100654F42 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
4EAC233B2F99930100654F42 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
4EAC233C2F99930100654F42 /* Scarf iOSTests */,
|
||||
);
|
||||
name = "Scarf iOSTests";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "Scarf iOSTests";
|
||||
productReference = 4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
4EAC23422F99930100654F42 /* Scarf iOSUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 4EAC23522F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSUITests" */;
|
||||
buildPhases = (
|
||||
4EAC233F2F99930100654F42 /* Sources */,
|
||||
4EAC23402F99930100654F42 /* Frameworks */,
|
||||
4EAC23412F99930100654F42 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
4EAC23452F99930100654F42 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
4EAC23462F99930100654F42 /* Scarf iOSUITests */,
|
||||
);
|
||||
name = "Scarf iOSUITests";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "Scarf iOSUITests";
|
||||
productReference = 4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
5349593F2F7B83B600BD31AD /* scarf */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */;
|
||||
@@ -192,9 +329,20 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastSwiftUpdateCheck = 2620;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
4EAC23282F99930000654F42 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
4EAC23382F99930100654F42 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
TestTargetID = 4EAC23282F99930000654F42;
|
||||
};
|
||||
4EAC23422F99930100654F42 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
TestTargetID = 4EAC23282F99930000654F42;
|
||||
};
|
||||
5349593F2F7B83B600BD31AD = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
@@ -235,11 +383,35 @@
|
||||
5349593F2F7B83B600BD31AD /* scarf */,
|
||||
5349594E2F7B83B700BD31AD /* scarfTests */,
|
||||
534959582F7B83B700BD31AD /* scarfUITests */,
|
||||
4EAC23282F99930000654F42 /* scarf mobile */,
|
||||
4EAC23382F99930100654F42 /* Scarf iOSTests */,
|
||||
4EAC23422F99930100654F42 /* Scarf iOSUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
4EAC23272F99930000654F42 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
4EAC23372F99930100654F42 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
4EAC23412F99930100654F42 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5349593E2F7B83B600BD31AD /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -264,6 +436,27 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
4EAC23252F99930000654F42 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
4EAC23352F99930100654F42 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
4EAC233F2F99930100654F42 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5349593C2F7B83B600BD31AD /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -288,6 +481,16 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
4EAC233B2F99930100654F42 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 4EAC23282F99930000654F42 /* scarf mobile */;
|
||||
targetProxy = 4EAC233A2F99930100654F42 /* PBXContainerItemProxy */;
|
||||
};
|
||||
4EAC23452F99930100654F42 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 4EAC23282F99930000654F42 /* scarf mobile */;
|
||||
targetProxy = 4EAC23442F99930100654F42 /* PBXContainerItemProxy */;
|
||||
};
|
||||
534959512F7B83B700BD31AD /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||
@@ -301,6 +504,175 @@
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
4EAC234D2F99930100654F42 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Scarf iOS/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
4EAC234E2F99930100654F42 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Scarf iOS/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
4EAC23502F99930100654F42 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Scarf iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Scarf iOS";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
4EAC23512F99930100654F42 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Scarf iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Scarf iOS";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
4EAC23532F99930100654F42 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSUITests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = "Scarf iOS";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
4EAC23542F99930100654F42 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSUITests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = "Scarf iOS";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
534959612F7B83B700BD31AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -436,7 +808,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;
|
||||
@@ -444,12 +816,13 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@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 +844,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;
|
||||
@@ -479,12 +852,13 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@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 +876,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 +898,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 +919,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 +939,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;
|
||||
@@ -584,6 +958,33 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
4EAC234C2F99930100654F42 /* Build configuration list for PBXNativeTarget "scarf mobile" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
4EAC234D2F99930100654F42 /* Debug */,
|
||||
4EAC234E2F99930100654F42 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
4EAC234F2F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
4EAC23502F99930100654F42 /* Debug */,
|
||||
4EAC23512F99930100654F42 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
4EAC23522F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
4EAC23532F99930100654F42 /* Debug */,
|
||||
4EAC23542F99930100654F42 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -82,11 +82,6 @@ final class ServerRegistry {
|
||||
/// Flip the default server to `id`. Passing `ServerContext.local.id`
|
||||
/// clears the flag on every remote entry, making Local the implicit
|
||||
/// default. Passing an unknown ID is a no-op. Persisted on return.
|
||||
///
|
||||
/// Intentionally doesn't fire `onEntriesChanged` — that hook means "the
|
||||
/// set of servers changed" and drives the menu-bar fanout rebuild. A
|
||||
/// default-flag flip doesn't change the set; SwiftUI views reading
|
||||
/// `defaultServerID` redraw via `@Observable`'s tracking of `entries`.
|
||||
func setDefaultServer(_ id: ServerID) {
|
||||
var changed = false
|
||||
for idx in entries.indices {
|
||||
@@ -98,6 +93,7 @@ final class ServerRegistry {
|
||||
}
|
||||
if changed {
|
||||
save()
|
||||
onEntriesChanged?()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
|
||||
@@ -179,17 +179,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,45 +211,10 @@ 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(
|
||||
|
||||
@@ -206,17 +206,7 @@ struct ProjectTemplateUninstaller: Sendable {
|
||||
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)")
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -27,12 +27,6 @@ struct ProjectsView: View {
|
||||
@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?
|
||||
|
||||
private let uninstaller: ProjectTemplateUninstaller
|
||||
|
||||
@@ -127,44 +121,6 @@ struct ProjectsView: View {
|
||||
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
|
||||
@@ -286,23 +242,14 @@ struct ProjectsView: View {
|
||||
}
|
||||
}
|
||||
if uninstaller.isTemplateInstalled(project: project) {
|
||||
// "Uninstall Template…" only appears for projects
|
||||
// installed from a `.scarftemplate`. Trailing
|
||||
// ellipsis signals a confirmation sheet follows
|
||||
// (macOS HIG convention); the sheet itself lists
|
||||
// every file/cron/skill that will be removed.
|
||||
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||
Button("Uninstall Template…", systemImage: "trash") {
|
||||
uninstallerViewModel.begin(project: project)
|
||||
showingUninstallSheet = true
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
// "Remove from List" used to be "Remove from Scarf",
|
||||
// which users read as a full delete. Clarified label +
|
||||
// ellipsis + confirmation dialog all spell out that
|
||||
// this is registry-only; nothing on disk is touched.
|
||||
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||
pendingRemoveFromList = project
|
||||
Button("Remove from Scarf", systemImage: "minus.circle") {
|
||||
viewModel.removeProject(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,16 +263,10 @@ struct ProjectsView: View {
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
if let selected = viewModel.selectedProject {
|
||||
// Route through the same confirmation dialog as the
|
||||
// context-menu "Remove from List" entry. The minus
|
||||
// icon is a drive-by click target right next to "+" —
|
||||
// confirming before mutating the registry stops the
|
||||
// "I clicked by accident and my project's gone" case.
|
||||
Button(action: { pendingRemoveFromList = selected }) {
|
||||
Button(action: { viewModel.removeProject(selected) }) {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
|
||||
@@ -143,20 +143,19 @@ struct ManageServersView: View {
|
||||
}
|
||||
|
||||
/// A star button that marks the open-on-launch default. Filled + yellow
|
||||
/// on the current default row (disabled, since clicking would be a
|
||||
/// no-op); outline + secondary elsewhere, clicking promotes that row
|
||||
/// to default.
|
||||
/// on the current default row (and non-interactive — clicking it is a
|
||||
/// no-op since the flag is already set); outline + secondary elsewhere,
|
||||
/// clicking promotes that row to default.
|
||||
@ViewBuilder
|
||||
private func defaultStar(for id: ServerID, currentDefault: ServerID) -> some View {
|
||||
let isDefault = id == currentDefault
|
||||
Button {
|
||||
registry.setDefaultServer(id)
|
||||
if !isDefault { registry.setDefaultServer(id) }
|
||||
} label: {
|
||||
Image(systemName: isDefault ? "star.fill" : "star")
|
||||
.foregroundStyle(isDefault ? .yellow : .secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(isDefault)
|
||||
.help(isDefault ? "Opens on launch" : "Set as default — open this server when Scarf launches.")
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ final class SettingsViewModel {
|
||||
var hermesRunning = false
|
||||
var rawConfigYAML = ""
|
||||
var personalities: [String] = []
|
||||
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
|
||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||
var browserBackends = ["browseruse", "firecrawl", "local"]
|
||||
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,20 +23,6 @@ struct TemplateConfigSheet: View {
|
||||
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(
|
||||
@@ -54,7 +40,6 @@ struct TemplateConfigSheet: View {
|
||||
modelRecommendation(rec)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
Divider()
|
||||
@@ -83,26 +68,16 @@ struct TemplateConfigSheet: View {
|
||||
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()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button(commitLabel) {
|
||||
if let finalized = viewModel.commit(project: project) {
|
||||
onCommit(finalized)
|
||||
dismiss()
|
||||
}
|
||||
// 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)
|
||||
@@ -128,15 +103,7 @@ struct TemplateConfigSheet: View {
|
||||
.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)
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -148,12 +115,6 @@ struct TemplateConfigSheet: View {
|
||||
.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)
|
||||
@@ -312,17 +273,17 @@ 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.
|
||||
// Segmented for ≤ 4 options, dropdown otherwise — fits Scarf's
|
||||
// existing settings UI.
|
||||
if options.count <= 4 {
|
||||
Picker("", selection: $value) {
|
||||
ForEach(options) { opt in
|
||||
Text(opt.label).tag(opt.value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
} else {
|
||||
Picker("", selection: $value) {
|
||||
ForEach(options) { opt in
|
||||
Text(opt.label).tag(opt.value)
|
||||
@@ -331,6 +292,7 @@ private struct EnumControl: View {
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Variable-length list of string values. Each row is a text field
|
||||
/// with an inline remove button; a + button adds a trailing row.
|
||||
|
||||
@@ -126,16 +126,6 @@ struct TemplateInstallSheet: View {
|
||||
.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 {
|
||||
@@ -152,7 +142,6 @@ struct TemplateInstallSheet: View {
|
||||
}
|
||||
readmeSection
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical)
|
||||
}
|
||||
Divider()
|
||||
@@ -186,10 +175,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,9 +220,8 @@ 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) {
|
||||
ForEach(plan.cronJobs, id: \.name) { job in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(plan.cronJobs, id: \.name) { job in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -247,29 +232,6 @@ struct TemplateInstallSheet: View {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,10 +302,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
|
||||
},
|
||||
"<%@>" : {
|
||||
|
||||
@@ -2237,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" : {
|
||||
@@ -5035,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" : {
|
||||
@@ -5083,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" : {
|
||||
@@ -5327,10 +5304,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Continue" : {
|
||||
"comment" : "Button label for continuing with the template configuration.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Continue Last Session" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5611,10 +5584,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Couldn't save" : {
|
||||
"comment" : "A title displayed when a configuration save fails.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Create" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -6668,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" : {
|
||||
@@ -7692,10 +7657,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Edit configuration" : {
|
||||
"comment" : "A button that opens a configuration editor for a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Edit User Profile" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -10587,9 +10548,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Internal state inconsistency — please close and re-open." : {
|
||||
|
||||
},
|
||||
"Invalid URL" : {
|
||||
"localizations" : {
|
||||
@@ -11198,10 +11156,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Loading configuration…" : {
|
||||
"comment" : "A message displayed while loading the configuration.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loading session…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -12711,9 +12665,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No configuration" : {
|
||||
|
||||
},
|
||||
"No credential pools configured" : {
|
||||
"localizations" : {
|
||||
@@ -12959,10 +12910,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No fields" : {
|
||||
"comment" : "A label that describes a template with no configuration fields.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No headers configured." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -14311,10 +14258,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Opens on launch" : {
|
||||
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Optional" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -15455,9 +15398,6 @@
|
||||
},
|
||||
"Project directory will also be removed (nothing user-owned left inside)." : {
|
||||
|
||||
},
|
||||
"Project folder kept" : {
|
||||
|
||||
},
|
||||
"Project Name" : {
|
||||
"localizations" : {
|
||||
@@ -16187,10 +16127,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recommended model" : {
|
||||
"comment" : "A label that indicates a recommended model.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Reconnect" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16522,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" : {
|
||||
@@ -16606,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" : {
|
||||
@@ -18076,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" : {
|
||||
|
||||
},
|
||||
@@ -18127,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" : {
|
||||
@@ -19379,10 +19291,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" : {
|
||||
@@ -19786,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" : {
|
||||
@@ -21595,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" : {
|
||||
@@ -21638,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" : {
|
||||
@@ -21846,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" : {
|
||||
@@ -22629,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: %@" : {
|
||||
@@ -23897,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" : {
|
||||
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
|
||||
var body: some View {
|
||||
@Bindable var coordinator = coordinator
|
||||
@@ -60,6 +59,6 @@ struct SidebarView: View {
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationTitle("Scarf")
|
||||
.splitViewAutosaveName("ScarfMainSidebar.\(serverContext.id)")
|
||||
.splitViewAutosaveName("ScarfMainSidebar")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -382,69 +346,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,18 +476,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)
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
if let snapshot {
|
||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||
} else {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,123 +753,18 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
|
||||
// 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()
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
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
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
if let snapshot {
|
||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||
} else {
|
||||
Issue.record("expected .planned, got \(vm.stage)")
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
#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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1019,9 +832,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 +844,15 @@ 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.
|
||||
// + values.timeout_seconds live) and the dashboard/log it writes.
|
||||
// If either stops being referenced, the cron wouldn't know which
|
||||
// data to read or where to write results.
|
||||
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
||||
#expect(cronPrompt.contains("config.json"))
|
||||
#expect(cronPrompt.contains("values.sites"))
|
||||
#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
-6
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -37,7 +37,7 @@ 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**.
|
||||
@@ -56,7 +56,6 @@ 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
|
||||
|
||||
@@ -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 .scarf/config.json to get values.sites (the URL list) and values.timeout_seconds, HTTP GET each URL with the configured timeout, prepend a results section to status-log.md (creating it with the stub header if it doesn't exist yet), 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,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,409 +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.
|
||||
|
||||
## 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"]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+2
-33
@@ -7,8 +7,8 @@
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann/scarf"
|
||||
},
|
||||
"bundleSha256": "0a20802a8830a7cfdd1afa2888e42e113c9a17a37439384a3037d32ad1f24c1f",
|
||||
"bundleSize": 7569,
|
||||
"bundleSha256": "ce68cc20cc67fe688a7ddf0638d35dce3247ba7ed234e6f9d99a1ad3964a81e0",
|
||||
"bundleSize": 6797,
|
||||
"category": "monitoring",
|
||||
"config": {
|
||||
"modelRecommendation": {
|
||||
@@ -63,37 +63,6 @@
|
||||
"configurable"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
},
|
||||
{
|
||||
"author": {
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann"
|
||||
},
|
||||
"bundleSha256": "bebc30551dc92717da96608bbdf448c5d7c47bdb66807037b139a242ef8c3b74",
|
||||
"bundleSize": 14423,
|
||||
"category": "developer-tools",
|
||||
"config": null,
|
||||
"contents": {
|
||||
"agentsMd": true,
|
||||
"dashboard": true,
|
||||
"skills": [
|
||||
"scarf-template-author"
|
||||
]
|
||||
},
|
||||
"description": "Install this to give your agent a skill that scaffolds new Scarf projects \u2014 dashboards, optional configuration schemas, cron jobs, and AGENTS.md \u2014 from a short conversational interview. Scaffolded projects are usable locally and cleanly exportable as .scarftemplate bundles.",
|
||||
"detailSlug": "awizemann-template-author",
|
||||
"id": "awizemann/template-author",
|
||||
"installUrl": "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/template-author/template-author.scarftemplate",
|
||||
"minHermesVersion": null,
|
||||
"minScarfVersion": "2.2.0",
|
||||
"name": "Scarf Template Author",
|
||||
"tags": [
|
||||
"meta",
|
||||
"authoring",
|
||||
"skill",
|
||||
"scaffolding"
|
||||
],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user