mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de5b278da4 | |||
| fb7a80f191 | |||
| 18640293f7 | |||
| 19750597cd | |||
| 69e9cc6c7b | |||
| 03bf5262bb | |||
| 3af99d9d9c | |||
| 3bd95de8f4 | |||
| 81e8da91d6 | |||
| bb750e237e | |||
| 68f6b98fcf | |||
| f8c086ee7a | |||
| eb34aec1f1 | |||
| 64b7d3beaf | |||
| 385c3a2e4d | |||
| e76fbf9937 | |||
| c9b8da9ec5 | |||
| 6175bee27d | |||
| 11732baa3c | |||
| d8a0a89db2 | |||
| 38c075d61d | |||
| c800b93804 | |||
| 7311320bfd | |||
| 4663697942 | |||
| 41635955b0 | |||
| 1989feee22 | |||
| 8773254d11 | |||
| a1aa653a33 | |||
| e256196397 | |||
| 50880efe81 | |||
| b1bc7e8494 | |||
| f47034d4ad | |||
| 1726a613a5 | |||
| de34a80807 | |||
| d9a25b3997 | |||
| b40182f2da | |||
| 6817c95681 | |||
| 89748fdfee | |||
| c8208dedb1 | |||
| a68e0c5f42 | |||
| 0384c6ef17 | |||
| f36fb55ebe | |||
| 1823160546 | |||
| d2a447fcc4 | |||
| 76bfeb34d4 | |||
| 85a4ec0e14 | |||
| 1453c7a841 | |||
| bd21a539e6 | |||
| d3055702ef | |||
| ee1d705abc | |||
| 8e3dafe4c6 | |||
| c51241dc72 | |||
| ec03627bcd | |||
| f8069a4481 | |||
| 110170d6e9 | |||
| 1293cfa23b |
@@ -0,0 +1,42 @@
|
|||||||
|
<!--
|
||||||
|
Use this template when submitting a new Scarf project template or updating
|
||||||
|
an existing one. For regular code/docs PRs, delete this template and write
|
||||||
|
your own summary.
|
||||||
|
|
||||||
|
Switch to this template by adding `?template=template-submission.md` to the
|
||||||
|
compare URL, or let GitHub pick it up automatically when you touch files
|
||||||
|
under templates/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What's in this PR
|
||||||
|
|
||||||
|
- [ ] New template: `templates/<your-handle>/<your-template-name>/`
|
||||||
|
- [ ] Update to existing template: `templates/<author>/<name>/` (which one and why)
|
||||||
|
|
||||||
|
## One-line pitch
|
||||||
|
|
||||||
|
_What does this template do for its installers? Two sentences max._
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I wrote this template, or have the author's explicit permission to submit it.
|
||||||
|
- [ ] `AGENTS.md` is present and tells any cross-agent what the project does and how to run it.
|
||||||
|
- [ ] `README.md` includes install, customize, and uninstall instructions.
|
||||||
|
- [ ] The bundle's `template.json` `contents` claim matches what's actually in the zip.
|
||||||
|
- [ ] Cron jobs (if any) ship paused and use self-contained prompts.
|
||||||
|
- [ ] No secrets in any file (API keys, tokens, hostnames, IPs, credentials).
|
||||||
|
- [ ] No writes to `config.yaml`, `auth.json`, or credential paths — v1 installer will refuse.
|
||||||
|
- [ ] `python3 tools/build-catalog.py --check` passes locally.
|
||||||
|
- [ ] I installed + uninstalled this template on my machine and verified the `AGENTS.md` contract works end-to-end.
|
||||||
|
- [ ] I did **not** edit `templates/catalog.json` — the maintainer regenerates it post-merge.
|
||||||
|
|
||||||
|
## Testing notes
|
||||||
|
|
||||||
|
_What did you run, what did you see? Paste the log output of the cron job
|
||||||
|
firing once, or the chat transcript of asking the agent to do the main
|
||||||
|
thing. Reviewers don't have your machine — show, don't tell._
|
||||||
|
|
||||||
|
## Screenshots (optional)
|
||||||
|
|
||||||
|
_Drop screenshots of the installed dashboard, or the catalog detail page
|
||||||
|
rendered locally (`./scripts/catalog.sh preview && open /tmp/scarf-catalog-preview/templates/<slug>/index.html`)._
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Validates `.scarftemplate` bundles on PRs that touch templates/.
|
||||||
|
#
|
||||||
|
# Mirrors the invariants `ProjectTemplateService.verifyClaims` enforces at
|
||||||
|
# install time. Runs the same Python script the maintainer uses locally
|
||||||
|
# (tools/build-catalog.py --check) so a bundle can't reach main unless the
|
||||||
|
# validator is happy.
|
||||||
|
#
|
||||||
|
# Also runs tools/test_build_catalog.py so drift between the validator and
|
||||||
|
# its own test suite is caught on the same PR.
|
||||||
|
|
||||||
|
name: Validate template submissions
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'templates/**'
|
||||||
|
- 'tools/build-catalog.py'
|
||||||
|
- 'tools/test_build_catalog.py'
|
||||||
|
- '.github/workflows/validate-template-pr.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full clone so we can diff against the PR base and scope
|
||||||
|
# --only to just the changed templates if we want to later.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
# The validator is stdlib-only and tested against 3.9+ (the
|
||||||
|
# system Python on current macOS, what most maintainers run
|
||||||
|
# locally). CI uses 3.11 for faster cold-cache times on
|
||||||
|
# GitHub Actions runners — same stdlib APIs, same code paths.
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Run validator unit tests
|
||||||
|
run: python3 tools/test_build_catalog.py -v
|
||||||
|
|
||||||
|
- name: Validate every template
|
||||||
|
id: validate
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
python3 tools/build-catalog.py --check 2>&1 | tee /tmp/validator.log
|
||||||
|
|
||||||
|
- name: Post failure comment
|
||||||
|
if: failure() && steps.validate.outcome == 'failure'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
let body = '## Template validation failed\n\n';
|
||||||
|
try {
|
||||||
|
const log = fs.readFileSync('/tmp/validator.log', 'utf8');
|
||||||
|
body += '```\n' + log.slice(-3000) + '\n```\n';
|
||||||
|
} catch (e) {
|
||||||
|
body += 'See the failed job log for details.\n';
|
||||||
|
}
|
||||||
|
body += '\nFix the issues above and push again — the check reruns automatically.\n';
|
||||||
|
body += '\nLocal reproduction: `python3 tools/build-catalog.py --check`\n';
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body,
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Xcode
|
# Xcode
|
||||||
build/
|
build/
|
||||||
.gh-pages-worktree/
|
.gh-pages-worktree/
|
||||||
|
.wiki-worktree/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
@@ -52,3 +53,6 @@ scarf/standards/backups/
|
|||||||
# history. RELEASE_NOTES.md stays tracked (committed with the version bump).
|
# history. RELEASE_NOTES.md stays tracked (committed with the version bump).
|
||||||
releases/v*/*.zip
|
releases/v*/*.zip
|
||||||
releases/v*/appcast-entry.xml
|
releases/v*/appcast-entry.xml
|
||||||
|
|
||||||
|
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
|
||||||
|
scripts/wiki-blocklist.txt
|
||||||
|
|||||||
@@ -59,6 +59,108 @@ The script bumps version, archives Universal (arm64 + x86_64) + ARM64-only varia
|
|||||||
|
|
||||||
**Prerequisites (one-time, already set up on Alan's machine):** Developer ID Application cert in login Keychain (team `3Q6X2L86C4`), notarytool keychain profile `scarf-notary`, Sparkle EdDSA private key in Keychain item `https://sparkle-project.org`, `gh-pages` branch + GitHub Pages enabled. See the header of [scripts/release.sh](scripts/release.sh) and the Releases section in [README.md](README.md) for details.
|
**Prerequisites (one-time, already set up on Alan's machine):** Developer ID Application cert in login Keychain (team `3Q6X2L86C4`), notarytool keychain profile `scarf-notary`, Sparkle EdDSA private key in Keychain item `https://sparkle-project.org`, `gh-pages` branch + GitHub Pages enabled. See the header of [scripts/release.sh](scripts/release.sh) and the Releases section in [README.md](README.md) for details.
|
||||||
|
|
||||||
|
## Wiki
|
||||||
|
|
||||||
|
Public documentation lives in the GitHub wiki at https://github.com/awizemann/scarf/wiki. The wiki is a separate git repo cloned to `.wiki-worktree/` in the repo root (gitignored, sibling to `.gh-pages-worktree/`). Internal dev notes stay in `scarf/docs/`; the wiki is for public-facing reference.
|
||||||
|
|
||||||
|
**Update the wiki when:**
|
||||||
|
- A new feature module is added under `scarf/scarf/scarf/Features/` → extend the relevant User Guide page.
|
||||||
|
- A new core service is added under `Core/Services/` → extend `Core-Services.md`.
|
||||||
|
- Architecture changes (AppCoordinator, transport, MVVM-F rule, sandbox) → `Architecture-Overview.md` + the specific sub-page.
|
||||||
|
- Hermes version bumps in this file → `Hermes-Version-Compatibility.md`.
|
||||||
|
- `scripts/release.sh` completes a full (non-draft) release → bump latest-version on `Home.md` + append to `Release-Notes-Index.md`.
|
||||||
|
- Keyboard shortcut or sidebar section changes → `Keyboard-Shortcuts.md` / `Sidebar-and-Navigation.md`.
|
||||||
|
|
||||||
|
**Skip for:** bug fixes with no user-observable change, pure refactors, typos, test-only changes, internal cleanups.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/wiki.sh pull # always first
|
||||||
|
# edit .wiki-worktree/*.md with normal tools
|
||||||
|
./scripts/wiki.sh commit "docs: describe X" # runs secret-scan
|
||||||
|
./scripts/wiki.sh push # runs secret-scan again, then push
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never** commit API keys, tokens, `.env` files, private keys, or real hostnames/IPs to the wiki. The script's two-pass secret-scan blocks common token patterns and a user-maintained blocklist at `scripts/wiki-blocklist.txt` (gitignored). Do not bypass without explicit approval. Full workflow on the wiki itself at `.wiki-worktree/Wiki-Maintenance.md`.
|
||||||
|
|
||||||
## Hermes Version
|
## Hermes Version
|
||||||
|
|
||||||
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
||||||
|
|
||||||
|
## Project Templates
|
||||||
|
|
||||||
|
Scarf ships a `.scarftemplate` format (v1 as of 2.2.0) for sharing pre-packaged projects across users and machines. A bundle is a zip containing:
|
||||||
|
|
||||||
|
- `template.json` — manifest (id, name, version, `contents` claim)
|
||||||
|
- `README.md` — shown in the install preview sheet
|
||||||
|
- `AGENTS.md` — required; the [Linux Foundation cross-agent instructions standard](https://agents.md/) — every template is agent-portable out of the box
|
||||||
|
- `dashboard.json` — copied to `<project>/.scarf/dashboard.json`
|
||||||
|
- `instructions/…` — optional per-agent shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`)
|
||||||
|
- `skills/<name>/…` — optional; installed to `~/.hermes/skills/templates/<slug>/` (namespaced so uninstall is `rm -rf` on one folder)
|
||||||
|
- `cron/jobs.json` — optional; registered via `hermes cron create` with a `[tmpl:<id>] …` name prefix and immediately paused
|
||||||
|
- `memory/append.md` — optional; appended to `~/.hermes/memories/MEMORY.md` between `<!-- scarf-template:<id>:begin/end -->` markers
|
||||||
|
|
||||||
|
Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTemplateService.swift) (inspect + validate + plan), [ProjectTemplateInstaller.swift](scarf/scarf/Core/Services/ProjectTemplateInstaller.swift) (execute a plan), [ProjectTemplateExporter.swift](scarf/scarf/Core/Services/ProjectTemplateExporter.swift) (build a bundle from a project), [ProjectTemplateUninstaller.swift](scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift) (reverse an install using the lock file). UI in [Features/Templates/](scarf/scarf/Features/Templates/). The `scarf://install?url=<https URL>` deep link + `file://` URLs for `.scarftemplate` files are handled by [TemplateURLRouter.swift](scarf/scarf/Core/Services/TemplateURLRouter.swift) and `onOpenURL` in `scarfApp.swift`. A `<project>/.scarf/template.lock.json` uninstall manifest is written after every install and drives the uninstall flow.
|
||||||
|
|
||||||
|
**Uninstall semantics:** driven by the lock file. Only files listed in `lock.projectFiles` are removed from the project dir; user-added files (e.g. a `sites.txt` created on first run) are preserved. If every file in the dir was installed by the template, the dir is removed too; otherwise the dir stays with just the user's files. Skills namespace is always removed wholesale (it's isolated). Cron jobs are removed via `hermes cron remove <id>` after resolving each lock-recorded name. Memory block is stripped between the `begin`/`end` markers, leaving the rest of MEMORY.md intact. No "undo" — uninstall is destructive; to re-install, run the install flow again. Uninstall UI lives on the project-list context menu and the dashboard header (only shown when the selected project has a lock file).
|
||||||
|
|
||||||
|
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
|
||||||
|
|
||||||
|
### Template configuration (v2.3, schemaVersion 2)
|
||||||
|
|
||||||
|
Templates can declare a typed configuration schema in `template.json`'s new `config` block. The installer renders a **Configure** step between the parent-directory pick and the preview sheet; values land at `<project>/.scarf/config.json` (non-secret) and in the login Keychain (secret). A post-install **Configuration** button on the dashboard header (shown when `<project>/.scarf/manifest.json` exists) opens the same form pre-filled for editing.
|
||||||
|
|
||||||
|
Manifest shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
|
||||||
|
"config": {
|
||||||
|
"schema": [
|
||||||
|
{"key": "site_url", "type": "string", "label": "Site URL", "required": true},
|
||||||
|
{"key": "api_token", "type": "secret", "label": "API Token", "required": true}
|
||||||
|
],
|
||||||
|
"modelRecommendation": {
|
||||||
|
"preferred": "claude-sonnet-4.5",
|
||||||
|
"rationale": "Tool-heavy workload — reasoning helps."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported field types: `string`, `text`, `number`, `bool`, `enum` (with `options: [{value, label}]`), `list` (itemType `"string"` only in v1), `secret`. Type-specific constraints (`pattern`, `min`/`max`, `minLength`/`maxLength`, `minItems`/`maxItems`) are optional. `secret` fields **must not** declare a `default` — the validator refuses.
|
||||||
|
|
||||||
|
Key services: [TemplateConfig.swift](scarf/scarf/Core/Models/TemplateConfig.swift) (schema + value models + Keychain ref helpers), [ProjectConfigKeychain.swift](scarf/scarf/Core/Services/ProjectConfigKeychain.swift) (thin `SecItemAdd`/`Copy`/`Delete` wrapper; the only Keychain user in Scarf today), [ProjectConfigService.swift](scarf/scarf/Core/Services/ProjectConfigService.swift) (load/save config.json, resolve secrets, cache manifest, validate schema + values). UI in [Features/Templates/ViewModels/TemplateConfigViewModel.swift](scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift) + [Features/Templates/Views/TemplateConfigSheet.swift](scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift).
|
||||||
|
|
||||||
|
**Secret storage.** Keychain service name is `com.scarf.template.<slug>`, account is `<fieldKey>:<project-path-hash-short>`. The path-hash suffix means two installs of the same template in different dirs don't collide on Keychain entries. Values in `config.json` are `"keychain://service/account"` URIs — never plaintext. The bytes hit the Keychain only on form commit, so cancelling never leaves orphan entries.
|
||||||
|
|
||||||
|
**Uninstall.** `TemplateLock` v2 gains `config_keychain_items` and `config_fields` arrays. The uninstaller iterates each URI through `SecItemDelete` before removing the lock file. Absent items (user hand-cleaned) are no-ops.
|
||||||
|
|
||||||
|
**Exporter.** Carries the *schema* from `<project>/.scarf/manifest.json` through into exported bundles, never values. Exporting never leaks anyone's secrets. `schemaVersion` bumps to 2 only when a schema is forwarded; schema-less exports stay at 1.
|
||||||
|
|
||||||
|
**Catalog site.** [tools/build-catalog.py](tools/build-catalog.py) mirrors the Swift schema validator. Each v2 template's `template.json` is copied into `.gh-pages-worktree/templates/<slug>/manifest.json` and the site's `widgets.js` calls `ScarfWidgets.renderConfigSchema` to display the schema on the detail page (display-only — the form lives in-app).
|
||||||
|
|
||||||
|
**Schema is Swift-primary.** If `TemplateConfigField.FieldType` gains a new case, update in order: `TemplateConfig.swift` (model + validation), `tools/build-catalog.py` (`SUPPORTED_CONFIG_FIELD_TYPES` + type-specific rules), `widgets.js` (`summariseConstraint`), `TemplateConfigSheet.swift` (new control subview), tests on both sides. Schema drift between validator + installer is the kind of bug users only notice after shipping.
|
||||||
|
|
||||||
|
## Template Catalog
|
||||||
|
|
||||||
|
Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
- **Validator + regenerator:** [tools/build-catalog.py](tools/build-catalog.py) is stdlib-only Python (3.9+). It walks `templates/*/*/`, validates every `.scarftemplate` against its manifest claim (mirrors the Swift `ProjectTemplateService.verifyClaims` invariants), enforces a 5 MB bundle-size cap, scans for high-confidence secret patterns, checks `staging/` matches the built bundle byte-for-byte, and emits `templates/catalog.json`. Tested by [tools/test_build_catalog.py](tools/test_build_catalog.py) — 16 tests covering every validation path.
|
||||||
|
- **Wrapper:** [scripts/catalog.sh](scripts/catalog.sh) mirrors the `scripts/wiki.sh` shape with `check / build / preview / serve / publish` subcommands. `publish` runs a second-pass secret-scan against the rendered site before committing + pushing `gh-pages`.
|
||||||
|
- **Site source:** `site/index.html.tmpl` + `site/template.html.tmpl` are `{{TOKEN}}`-substitution templates. `site/widgets.js` (~300 lines of vanilla JS) is the dogfood — renders a `ProjectDashboard` JSON into HTML using the same widget vocabulary the Swift app uses, so each template's detail page shows a live preview of its post-install dashboard.
|
||||||
|
- **Install-URL hosting:** raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template Releases ceremony.
|
||||||
|
- **CI gate:** [.github/workflows/validate-template-pr.yml](.github/workflows/validate-template-pr.yml) runs the Python validator + its own test suite on every PR that touches `templates/`, the validator, or its tests. Failures post a comment on the PR with the last 3 KB of the validator log.
|
||||||
|
|
||||||
|
Maintainer workflow on merge to main:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/catalog.sh build # regenerate templates/catalog.json + .gh-pages-worktree/templates/
|
||||||
|
./scripts/catalog.sh publish # secret-scan rendered output + commit + push gh-pages
|
||||||
|
```
|
||||||
|
|
||||||
|
Same cadence as `scripts/release.sh` (manual, auditable, no auto-deploy). Runs stay isolated: release.sh only touches `appcast.xml` on gh-pages; catalog.sh only touches `templates/` on gh-pages. Never push catalog output on a release cadence or vice versa.
|
||||||
|
|
||||||
|
**Schema is Swift-primary.** When `ProjectDashboardWidget.type` gains a new case or `ProjectTemplateManifest` adds a field, update Swift first, then mirror into `tools/build-catalog.py` (`SUPPORTED_WIDGET_TYPES`, `_validate_manifest`, `_validate_contents_claim`) so the web validator stays honest. The Python test suite's real-bundle test catches drift on the example template but not on the full widget vocabulary — add a synthetic fixture to `test_build_catalog.py` for any new widget type.
|
||||||
|
|||||||
@@ -33,6 +33,27 @@ Rules:
|
|||||||
- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception.
|
- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception.
|
||||||
- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods.
|
- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Public docs live in the [GitHub wiki](https://github.com/awizemann/scarf/wiki). Small fixes (typos, clarifications) can be made via the "Edit" button on any wiki page — you need push access to the main repo. For larger changes, clone the wiki locally (`git clone git@github.com:awizemann/scarf.wiki.git`) or open an issue describing the proposed change.
|
||||||
|
|
||||||
|
## Adding a Language
|
||||||
|
|
||||||
|
Scarf ships with English + Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese. To add another locale (or improve an existing one):
|
||||||
|
|
||||||
|
1. **Fork** the repo and create a branch.
|
||||||
|
2. **Add the locale to `knownRegions`** in `scarf/scarf.xcodeproj/project.pbxproj` — follow the existing list (e.g. add `it` after `"pt-BR"`).
|
||||||
|
3. **Drop a new JSON file at `tools/translations/<locale>.json`** — copy an existing one (say `tools/translations/es.json`) as a starting point. Each entry maps the English source string to your translation. Keys you omit fall back to English at runtime — do that for proper nouns (Scarf, Hermes, Anthropic, OAuth, SSH, …) and for anything technical that shouldn't translate.
|
||||||
|
4. **Preserve format specifiers exactly**: `%@`, `%lld`, `%d`, positional `%1$@` / `%2$lld`, etc. If word order needs to change in your language, use positional forms (`%1$@ … %2$@`).
|
||||||
|
5. **Add your locale to `tools/merge-translations.py`'s `LOCALES` list** and run `python3 tools/merge-translations.py` — this writes your translations into `scarf/scarf/Localizable.xcstrings`.
|
||||||
|
6. **Translate `scarf/scarf/InfoPlist.xcstrings`** (the macOS microphone-permission prompt) for your locale. Add a new `stringUnit` under `localizations`.
|
||||||
|
7. **Build** (`xcodebuild -project scarf/scarf.xcodeproj -scheme scarf build`) and **sanity-check in Xcode**: Scheme → Run → App Language → your locale. Walk the main views (Dashboard, Chat, Settings) and look for clipping or obvious leaks.
|
||||||
|
8. **Open a PR** including the new JSON file, the updated catalog, and the pbxproj / script changes. Mention which routes you spot-checked.
|
||||||
|
|
||||||
|
AI translation is fine for the first pass — it's how the initial six locales landed. Native-speaker review improves quality and is always welcome, either as a follow-up PR or as review comments on the initial one.
|
||||||
|
|
||||||
|
See [scarf/docs/I18N.md](scarf/docs/I18N.md) for deeper context on the String Catalog setup and which strings are intentionally kept verbatim.
|
||||||
|
|
||||||
## Reporting Issues
|
## Reporting Issues
|
||||||
|
|
||||||
Open an issue with:
|
Open an issue with:
|
||||||
|
|||||||
@@ -13,18 +13,40 @@
|
|||||||
<img src="https://img.shields.io/badge/macOS-14.6+%20Sonoma-blue" alt="macOS">
|
<img src="https://img.shields.io/badge/macOS-14.6+%20Sonoma-blue" alt="macOS">
|
||||||
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
|
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
|
<br>
|
||||||
|
<em>Available in English, 简体中文, Deutsch, Français, Español, 日本語, and Português (Brasil).</em>
|
||||||
<br><br>
|
<br><br>
|
||||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## What's New in 2.0
|
## 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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
### Previously, in 2.1
|
||||||
|
|
||||||
|
- **Seven languages** — Full UI translations for Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese on top of English. Scarf respects the system language by default; override per-app via **System Settings → Language & Region → Apps → Scarf**. Contributor workflow for adding more locales is documented in [CONTRIBUTING.md → Adding a Language](CONTRIBUTING.md#adding-a-language).
|
||||||
|
- **Locale-aware number formatting** — Currency, byte sizes, compact token counts (`15K`, `1.5M`), and day-of-week charts now follow the user's locale instead of POSIX / English defaults.
|
||||||
|
- **Chat slash-command menu** — Type `/` in Rich Chat to browse every command the agent has advertised plus any user-defined `quick_commands:` from config.yaml. ↑/↓ to navigate, Tab/Enter to complete, Esc to dismiss.
|
||||||
|
- **Chat polish** — Auto-scroll on send and on prompt completion, a non-blocking loading spinner during session reconnects, properly centered empty state, and the long-standing "session loads with whitespace" bug fixed (LazyVStack → VStack in the message list).
|
||||||
|
|
||||||
|
See the full [v2.1.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.1.0).
|
||||||
|
|
||||||
|
### Previously, in 2.0
|
||||||
|
|
||||||
- **Multi-server** — Manage multiple Hermes installations (local + any number of remotes) from one app. Each window binds to one server; open them side-by-side.
|
- **Multi-server** — Manage multiple Hermes installations (local + any number of remotes) from one app. Each window binds to one server; open them side-by-side.
|
||||||
- **Remote Hermes over SSH** — Every feature that worked against your local `~/.hermes/` now works against a remote host. File I/O routes through `scp`/`sftp`; chat ACP runs over `ssh -T`; SQLite is served from atomic `.backup` snapshots pulled on file-watcher ticks.
|
- **Remote Hermes over SSH** — Every feature that worked against your local `~/.hermes/` now works against a remote host. File I/O routes through `scp`/`sftp`; chat ACP runs over `ssh -T`; SQLite is served from atomic `.backup` snapshots pulled on file-watcher ticks.
|
||||||
- **Chat UX overhaul** — No more white-screen flash on first message, no more scroll jumping into whitespace during streaming, failed prompts explain themselves instead of silently spinning forever.
|
- **Chat UX overhaul** — No more white-screen flash on first message, no more scroll jumping into whitespace during streaming, failed prompts explain themselves instead of silently spinning forever.
|
||||||
- **Correctness pass** — Fixed remote WAL error spam, stale-snapshot session resume, auto-resume of dead cron sessions, 230+ Swift 6 concurrency warnings.
|
- **Correctness pass** — Fixed remote WAL error spam, stale-snapshot session resume, auto-resume of dead cron sessions, 230+ Swift 6 concurrency warnings.
|
||||||
|
|
||||||
See the full [v2.0.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.0.0).
|
See the [v2.0.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.0.0) for the full 2.0 series.
|
||||||
|
|
||||||
### Previously, in 1.6
|
### Previously, in 1.6
|
||||||
|
|
||||||
@@ -42,6 +64,21 @@ Scarf 2.0 is a multi-window app. Each window is bound to exactly one Hermes serv
|
|||||||
|
|
||||||
Remote Hermes is reached over system SSH — the same `~/.ssh/config`, ssh-agent, ProxyJump, and ControlMaster pooling your terminal uses. File I/O flows through `scp`/`sftp`; SQLite is served from atomic `sqlite3 .backup` snapshots cached under `~/Library/Caches/scarf/snapshots/<server-id>/`; chat (ACP) tunnels as `ssh -T host -- hermes acp` with JSON-RPC over stdio end-to-end. Everything in the feature list below works against remote identically to local.
|
Remote Hermes is reached over system SSH — the same `~/.ssh/config`, ssh-agent, ProxyJump, and ControlMaster pooling your terminal uses. File I/O flows through `scp`/`sftp`; SQLite is served from atomic `sqlite3 .backup` snapshots cached under `~/Library/Caches/scarf/snapshots/<server-id>/`; chat (ACP) tunnels as `ssh -T host -- hermes acp` with JSON-RPC over stdio end-to-end. Everything in the feature list below works against remote identically to local.
|
||||||
|
|
||||||
|
### Remote setup requirements
|
||||||
|
|
||||||
|
The remote host must have:
|
||||||
|
|
||||||
|
1. **SSH access** — key-based auth via your local ssh-agent. Scarf never prompts for passphrases; run `ssh-add` once in Terminal before connecting.
|
||||||
|
2. **`sqlite3`** on the remote `$PATH` — needed for the atomic DB snapshots. Install on the remote with `apt install sqlite3` (Ubuntu/Debian), `yum install sqlite` (RHEL/Fedora), or `apk add sqlite` (Alpine).
|
||||||
|
3. **`pgrep`** on the remote `$PATH` — used by the Dashboard "is Hermes running" check. Standard on every distro; install `procps` if missing.
|
||||||
|
4. **`~/.hermes/` readable by the SSH user**. When Hermes runs as a separate user (systemd service, Docker container), the SSH user needs read access to `config.yaml` and `state.db`. Either (a) SSH as the Hermes user, (b) `chmod` Hermes's home to be group-readable and add your SSH user to that group, or (c) set the **Hermes data directory** field when adding the server to point at the right location (e.g. `/var/lib/hermes/.hermes`).
|
||||||
|
|
||||||
|
### Troubleshooting remote connections
|
||||||
|
|
||||||
|
If the connection pill is green but the Dashboard shows "Stopped", "unknown", or empty values, the SSH user can't read the Hermes state files. Open **Manage Servers → 🩺 Run Diagnostics** (or click the yellow "Can't read Hermes state" pill in the toolbar). The diagnostics sheet runs fourteen checks in one SSH session — connectivity, `sqlite3` presence, read access to `config.yaml` and `state.db`, the effective non-login `$PATH` — and tells you exactly which one fails and why, with remediation hints for each. Use the **Copy Full Report** button to paste the full output into a bug report.
|
||||||
|
|
||||||
|
For the common "Hermes isn't at the default path" case (systemd services, Docker), **Test Connection** in the Add Server sheet now probes `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, and `/root/.hermes` when it can't find `state.db` at `~/.hermes/`, and offers a one-click fill if it finds any of them.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Scarf mirrors Hermes's surface area through a sidebar-based UI. Sections below map 1:1 to the app's sidebar.
|
Scarf mirrors Hermes's surface area through a sidebar-based UI. Sections below map 1:1 to the app's sidebar.
|
||||||
@@ -361,6 +398,16 @@ Signing prerequisites (one-time):
|
|||||||
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
|
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
|
||||||
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
|
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
|
||||||
|
|
||||||
|
## Template Catalog
|
||||||
|
|
||||||
|
Community-contributed Scarf project templates live under [`templates/`](templates/) in this repo and are browsable at **[awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/)** with live dashboard previews and one-click `scarf://install?url=…` links.
|
||||||
|
|
||||||
|
- **Install from the web** — click "Install with Scarf" on any template's detail page; the app takes over from there.
|
||||||
|
- **Install from a local file** — Scarf → Projects → Templates → Install from File…, or double-click any `.scarftemplate` in Finder.
|
||||||
|
- **Author a template** — see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) for the full walkthrough. Fork, drop a template under `templates/<your-github-handle>/<your-name>/`, open a PR; CI validates the bundle automatically.
|
||||||
|
|
||||||
|
The catalog's site is a static HTML + vanilla JS build generated by [`tools/build-catalog.py`](tools/build-catalog.py) and driven by [`scripts/catalog.sh`](scripts/catalog.sh) (check / build / preview / publish). Appcast and main landing page are independent — updating the catalog never disturbs Sparkle.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||||
@@ -371,6 +418,8 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
|
|||||||
4. Push to the branch (`git push origin feature/my-feature`)
|
4. Push to the branch (`git push origin feature/my-feature`)
|
||||||
5. Open a Pull Request
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
For template submissions, see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) — same flow, with a catalog-specific checklist + automated CI validation.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
If you find Scarf useful, consider buying me a coffee.
|
If you find Scarf useful, consider buying me a coffee.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
## What's New in 2.0.1
|
||||||
|
|
||||||
|
Hotfix for [#19](https://github.com/awizemann/scarf/issues/19) and the related reports from the first day of v2.0: users' remote SSH connections would show a green "Connected" pill but every view (Dashboard, Sessions, Activity, Chat) read as empty / "not running" / "not configured". Three distinct environments reported it — Docker Hermes on a LAN, homelab VM over Tailscale, Ubuntu VPS — and every one was a silent file-access failure on the remote that Scarf wasn't surfacing.
|
||||||
|
|
||||||
|
### Errors no longer disappear
|
||||||
|
|
||||||
|
Every remote read (`config.yaml`, `gateway_state.json`, `state.db`, `pgrep`) used to silently substitute an empty value on *any* failure — permission denied, missing file, `sqlite3` not installed, connection drop — they all looked identical to the UI. Now:
|
||||||
|
|
||||||
|
- Each failure logs a specific warning via `os.Logger` (visible in Console.app under subsystem `com.scarf`).
|
||||||
|
- The Dashboard shows an orange banner above the stats with the exact error (e.g. "Permission denied reading `~/.hermes/state.db`") and a **Run Diagnostics…** button.
|
||||||
|
- `HermesDataService` exposes a `lastOpenError` so views can explain *why* state.db couldn't be opened, rather than just rendering zeros.
|
||||||
|
- Routine "file doesn't exist" cases (optional `skill.yaml` metadata, `gateway_state.json` before Hermes starts, `memories/USER.md` on fresh installs) are detected and **not** logged as warnings — only real errors (permission denied, connection drops, `sqlite3` missing) hit the log. Prevents Console from filling with false-positive noise when directory walks encounter optional files.
|
||||||
|
|
||||||
|
### New Remote Diagnostics sheet
|
||||||
|
|
||||||
|
Accessible from **Manage Servers → 🩺** per-server button, or by clicking the orange connection pill when Scarf can see the server but can't read Hermes state. Runs fourteen checks in a single SSH session and shows pass/fail for each, plus a targeted hint per failure:
|
||||||
|
|
||||||
|
- SSH connectivity and auth
|
||||||
|
- Remote user identity and `$HOME` resolution
|
||||||
|
- `~/.hermes` directory existence and readability
|
||||||
|
- `config.yaml` readable (existence *and* actual read access — the old probe only checked existence)
|
||||||
|
- `state.db` readable
|
||||||
|
- `sqlite3` installed on the remote (required for the atomic snapshot Scarf pulls)
|
||||||
|
- `sqlite3` can actually open `state.db`
|
||||||
|
- `hermes` binary on the non-login `$PATH` (what runtime uses)
|
||||||
|
- `hermes` binary on the login `$PATH` (what the Test Connection probe uses)
|
||||||
|
- `pgrep` available (for the "is Hermes running" check)
|
||||||
|
|
||||||
|
One **Copy Full Report** button dumps every check as plain text for bug reports, and a raw-output disclosure panel shows the exact stdout/stderr the remote returned whenever any probe fails — so transport-level problems are self-diagnosing.
|
||||||
|
|
||||||
|
The diagnostics script is piped to `/bin/sh -s` on stdin rather than passed as `sh -c <script>` argv. The latter was getting split line-by-line by the remote's login shell (newlines parsed as command separators), which stranded variables set on line 1 in an ephemeral `sh` subprocess that exited before line 2 could use them. Stdin-piping runs the whole script in one `sh` process with variable scope preserved.
|
||||||
|
|
||||||
|
### Connection pill gains a "degraded" state
|
||||||
|
|
||||||
|
The pill used to be green as long as SSH connected; now after connectivity passes it runs a second-tier check (`test -r $HOME/.hermes/config.yaml`). If that fails, the pill turns **orange** with "Connected — can't read Hermes state" and clicking it opens Remote Diagnostics directly. This is the exact symptom mode in #19, and it's now one click away from a specific answer.
|
||||||
|
|
||||||
|
The pill's visual also got a pass: the colored dot is replaced with a state-specific SF Symbol (`checkmark.circle.fill` / `stethoscope` / `arrow.triangle.2.circlepath` / `exclamationmark.triangle.fill`), which reads more like a clickable toolbar tool and doubles as the status signal. No custom pill background anymore — the toolbar's native `.principal` bezel is the frame.
|
||||||
|
|
||||||
|
### Auto-suggest the correct `remoteHome` during Add Server
|
||||||
|
|
||||||
|
When Test Connection can't find `state.db` at the configured (or default) path, it now also probes the common alternate locations — `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, `/root/.hermes` — and offers a one-click "Use this" fill if it finds one. Removes the need to know that systemd-installed Hermes lives at `/var/lib/hermes/.hermes` by convention.
|
||||||
|
|
||||||
|
### Clearer copy for the `remoteHome` field
|
||||||
|
|
||||||
|
The Add Server sheet field is now labeled "Hermes data directory" with a description explaining when you'd override it (systemd service installs, Docker sidecars) and noting that Test Connection auto-suggests.
|
||||||
|
|
||||||
|
### README has a new "Remote setup requirements" section
|
||||||
|
|
||||||
|
Four concrete prerequisites (SSH, `sqlite3`, `pgrep`, read access to `~/.hermes`) and a troubleshooting paragraph pointing at Remote Diagnostics.
|
||||||
|
|
||||||
|
### Migrating from 2.0.0
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. Settings and server list are preserved verbatim — this is purely additive (new diagnostics surface, new error banners, auto-suggest in Test Connection). If you were affected by #19, run Remote Diagnostics after updating; the sheet should pinpoint the specific file access issue and suggest a fix.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- New types: `RemoteDiagnosticsViewModel`, `RemoteDiagnosticsView`. Both are local to Scarf; no new transport protocol.
|
||||||
|
- `HermesFileService` gains `loadConfigResult()`, `loadGatewayStateResult()`, `hermesPIDResult()`, `readFileResult()`, `readFileDataResult()` — Result-returning variants that preserve the error. Legacy `loadConfig()` etc. still exist as thin forwarders for callers that don't need diagnostics.
|
||||||
|
- `HermesDataService.open()` records `lastOpenError` with humanized hints for "sqlite3 not installed", "permission denied", and "file not found" — the three failure modes that produce 90% of issue #19 symptoms.
|
||||||
|
- `ConnectionStatusViewModel` status enum gains `.degraded(reason:)` between `.connected` and `.error`.
|
||||||
|
- `TestConnectionProbe` result enum gains `suggestedRemoteHome: String?` carrying any alternate-location hit.
|
||||||
|
|
||||||
|
### Known follow-ups (not in 2.0.1)
|
||||||
|
|
||||||
|
- `TestConnectionProbe` uses a direct-argv ssh invocation that's functionally correct but fragile (works by accident when split across the login shell). Should be ported to the stdin-pipe pattern the diagnostics sheet now uses.
|
||||||
|
- Remaining `try?`-swallowed read paths beyond the four Dashboard-surfacing ones — Cron, Memory, Skills, MCP Servers, Platforms still silently render empty on read errors. Same fix pattern applies, low priority.
|
||||||
|
- `hermesBinaryHint` is only populated when the user clicks Test Connection; if they skip it, ACP chat and CLI calls fall back to bare `hermes` which requires it on the non-interactive PATH (rarely true for `~/.local/bin` installs). The connection-pill's second-tier probe could auto-populate this.
|
||||||
|
- Docker-host support: when users SSH to a Docker host, `pgrep` and `~/.hermes/` on the host don't see what's inside the container. Needs a `docker exec` wrapping option per server.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
## What's New in 2.0.2
|
||||||
|
|
||||||
|
The actual root cause of [#19](https://github.com/awizemann/scarf/issues/19), found and patched by Scarf's first external contributor. v2.0.1 added the diagnostics UI assuming file-perm root cause; v2.0.2 fixes the underlying bug for everyone, regardless of perms.
|
||||||
|
|
||||||
|
### macOS Unix domain socket path limit (the real #19)
|
||||||
|
|
||||||
|
OpenSSH's ControlMaster multiplexes our bursty stat/cat/cp traffic over one TCP session per host. The socket path is bound by `bind(2)` to a Unix domain socket — and macOS' `sun_path` is **104 bytes including the NUL terminator**.
|
||||||
|
|
||||||
|
Scarf's old socket path was `~/Library/Caches/scarf/ssh/<%C>` where `%C` is OpenSSH's 64-char SHA1 hash of `(local user, host, port, remote user)`. For a username like `alex.maksimchuk`, the full path landed at **105 bytes** — one byte over the limit. ssh exited 255 with `unix_listener: path "..." too long for Unix domain socket`. Our `LogLevel=QUIET` flag (set so ACP's line-delimited JSON stays binary-clean) suppressed the diagnostic, and the user just saw "Remote command exited 255" — which the UI rendered as the silent empty-data state every reporter in #19 described.
|
||||||
|
|
||||||
|
The fix is to use a much shorter path:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
"/tmp/scarf-ssh-\(getuid())" // ~17 bytes + 64 hash + sep + NUL = ~83 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-user uid suffix keeps two local users' sockets from colliding in the shared `/tmp`, and 0700 perms on the dir keep them inaccessible to other users.
|
||||||
|
|
||||||
|
**Massive thanks to Alex Maksimchuk ([@aliatx2017](https://github.com/aliatx2017)) — Scarf's first external PR contributor — for diagnosing and patching this in [#20](https://github.com/awizemann/scarf/pull/20).** That diagnosis only happened because Alex bothered to read the codebase, reproduce against multiple usernames including a Termux/Android instance, and walk back from the cryptic exit code to the actual `bind()` failure. This release wouldn't exist without that work.
|
||||||
|
|
||||||
|
### Hardening on top of the fix
|
||||||
|
|
||||||
|
Three additions on top of Alex's patch, layered in via separate commits to keep the original change reviewable:
|
||||||
|
|
||||||
|
- **Defensive ownership check on the socket dir.** `/tmp` is world-writable, so a malicious local user could pre-create `/tmp/scarf-ssh-<uid>` and trick Scarf into using a hostile directory (we'd silently fail to chmod it back to 0700, since we wouldn't own it). `ensureControlDir` now uses POSIX `mkdir(0700)` (atomic, sets perms at create time) and on `EEXIST` runs `lstat` to verify the entry is a directory we own with mode 0700 — symlink → refuse, wrong owner → refuse + log to `os.Logger`, wrong mode → repair. Closes the `/tmp` pre-creation hole that's the standard concern for any per-user `/tmp` path.
|
||||||
|
- **Launch-time sweep of stale sockets.** `ServerRegistry.sweepOrphanCaches` already prunes orphaned snapshot directories on launch; it now also removes ControlMaster socket files older than 30 minutes. Socket basenames are `%C` hashes (not ServerIDs), so we can't keep "still registered" sockets the way the snapshot sweep does — but `ControlPersist` is 600s, so anything older than 30 minutes is guaranteed to be a dead orphan from a crashed master, an unclean app exit, or a server removed while another Scarf instance was holding the dir. Keeps `/tmp/scarf-ssh-<uid>/` from accumulating indefinitely until reboot, while leaving any concurrent Scarf instance's live sockets untouched.
|
||||||
|
- **Regression test for the path-length invariant.** `scarfTests` was a stub — it now has two tests: one asserting `controlDirPath().utf8.count + 1 + 64 + 1 ≤ 104` (would have caught the original #19 bug in CI), one asserting the path includes the current uid (pins the per-user-isolation invariant against a future "simplification" that drops it).
|
||||||
|
|
||||||
|
### v2.0.1 diagnostics work is still useful
|
||||||
|
|
||||||
|
The diagnostics sheet, orange "degraded" pill, dashboard error banner, and `remoteHome` auto-suggest from v2.0.1 all still ship — they just turn out not to have been the right diagnosis for the original three reporters. They remain valuable for the *other* connection-failure modes they were designed to surface (missing `sqlite3` on the remote, real permission errors, container/host visibility gaps, custom Hermes data directories). If you upgrade to v2.0.2 and *still* see incomplete data, run Remote Diagnostics from **Manage Servers → 🩺** and the sheet will tell you why.
|
||||||
|
|
||||||
|
### Migrating from 2.0.0 / 2.0.1 / draft 2.0.1
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. Settings and server list are preserved verbatim. The first time v2.0.2 connects to a remote, it'll create `/tmp/scarf-ssh-<uid>/` with mode 0700; the old `~/Library/Caches/scarf/ssh/` directory becomes unused (you can delete it manually, or leave it — macOS will sweep it eventually).
|
||||||
|
|
||||||
|
The previous v2.0.1 draft download remains available for anyone who already grabbed it — it's still a valid build with the diagnostics work. v2.0.2 is the recommended upgrade path.
|
||||||
|
|
||||||
|
### Reporters of #19
|
||||||
|
|
||||||
|
@cmalpass, @flyespresso, @maikokan — please grab v2.0.2 and confirm the dashboard populates without needing to run Remote Diagnostics first. If it still doesn't, the diagnostics sheet should now have a much better chance of pinpointing what's left.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## What's New in 2.1.0
|
||||||
|
|
||||||
|
Scarf now speaks seven languages and has a proper slash-command menu in the chat. The language work closes [#13](https://github.com/awizemann/scarf/issues/13) and opens the door for community contributions of additional locales.
|
||||||
|
|
||||||
|
### Multi-language support
|
||||||
|
|
||||||
|
The UI is now fully translated to **Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese** on top of the existing English. Scarf respects the system language by default; override per-app from **System Settings → Language & Region → Apps → Scarf**.
|
||||||
|
|
||||||
|
- **644 source strings** catalogued. **583 translated per locale** — the remaining ~60 are deliberate fall-throughs to English: proper nouns (Scarf, Hermes, OAuth, MCP, SSH), brand names (Docker, Daytona, Singularity, BlueBubbles), format-only tokens (`%lld`, `·`, `•`), and config-literal placeholders (`my_server`, `npx`, `sk-…`).
|
||||||
|
- **Locale-aware number and date formatting.** Previous builds hardcoded POSIX-style decimal separators (`$12.34`) and English unit names (`"MB"`, `"K"`, `"M"`). Currency now routes through `.formatted(.currency(code: "USD"))`, byte sizes through `.byteCount(style: .file)`, token counts through `.notation(.compactName)`, and the day-of-week chart through `Calendar.current.shortWeekdaySymbols` — so German users see `15,2 MB`, Japanese users see `15.5万 tokens`, and the activity heatmap starts on the locale's first weekday.
|
||||||
|
- **Microphone permission prompt localized** — the system dialog that appears the first time you enable voice chat now reads in the user's language.
|
||||||
|
|
||||||
|
#### How the translation work shipped
|
||||||
|
|
||||||
|
Three stacked PRs to keep each piece independently reviewable, all AI-translated with the bar explicitly set low so native speakers can iterate:
|
||||||
|
|
||||||
|
1. **[#22](https://github.com/awizemann/scarf/pull/22) — String Catalog infrastructure.** Added `Localizable.xcstrings` + `InfoPlist.xcstrings`, expanded `knownRegions` with the six new locales, and fixed the locale-aware number formatters mentioned above. No user-visible English-locale change; the groundwork only.
|
||||||
|
2. **[#24](https://github.com/awizemann/scarf/pull/24) — Audit burn-down.** Swept the codebase for "silently un-localizable" patterns that look fine in Xcode's catalog but leak English at runtime: `Text(cond ? "A" : "B")` routes through the String overload instead of `LocalizedStringKey`, as do `Label(stringVar, systemImage:)`, `.help(stringVar)`, and composite format strings with translatable text suffixes. ~40 sites refactored, covering Chat voice/TTS toggles, Logs pickers, Insights period + day names, MCPServer test result, Profiles, SignalSetup, QuickCommands, ConnectionStatusPill. Without this PR the translations would have landed but ~40 visible strings would still have rendered in English.
|
||||||
|
3. **[#25](https://github.com/awizemann/scarf/pull/25) — Translations + contributor path.** The six locale JSONs + a 90-line merge script + a "Adding a Language" section in `CONTRIBUTING.md`. The sidebar and Settings tab bar fix also shipped here after smoke-testing revealed they were still missed — `Label(section.rawValue, …)` goes to the String overload just like the audit cases.
|
||||||
|
|
||||||
|
#### Contributing a new language
|
||||||
|
|
||||||
|
Per-locale source of truth lives in [`tools/translations/<locale>.json`](https://github.com/awizemann/scarf/tree/main/tools/translations). Each entry is a plain `{ "English": "Translation" }` map — keys you omit fall through to English at runtime. Workflow is: fork, drop a JSON, run `python3 tools/merge-translations.py`, open a PR. The full bar is documented in [CONTRIBUTING.md → Adding a Language](https://github.com/awizemann/scarf/blob/main/CONTRIBUTING.md#adding-a-language).
|
||||||
|
|
||||||
|
Native-speaker review of the initial six locales is welcome — AI translation gets us most of the way, but idiom and tone are better with someone who actually uses the language. Post a PR against the relevant `<locale>.json` and it'll land as a follow-up.
|
||||||
|
|
||||||
|
### Chat slash-command menu
|
||||||
|
|
||||||
|
Type `/` in Rich Chat and a floating menu appears above the input with every command the connected agent has advertised via ACP's `available_commands_update`, plus any user-defined `quick_commands:` from `~/.hermes/config.yaml`. ↑/↓ to navigate, Tab or Enter to complete, Esc to dismiss. Commands with argument hints (e.g. `/compress <topic>`) insert a trailing space so you can start typing the argument immediately.
|
||||||
|
|
||||||
|
The filter uses pure-prefix match and re-renders on every query — the old menu had a description-fallback filter and a cached child view that together pinned `/help` on-screen regardless of what you typed. The dedicated `/compress` button is hidden once the menu has more than one command; it only surfaces when `/compress` is the single advertised slash command, preserving the v2.0 one-click compression flow for that case.
|
||||||
|
|
||||||
|
### Chat UX polish
|
||||||
|
|
||||||
|
- **Auto-scroll on send and on completion.** `.defaultScrollAnchor(.bottom)` handles slow streaming fine, but rapid slash-command responses (common once the menu lands) outran the anchor and left the reply off-screen. Now the list explicitly scrolls to the latest message when you submit and again when the prompt finishes.
|
||||||
|
- **Loading state.** `ChatViewModel.isPreparingSession` is true during Starting / Creating / Loading / Reconnecting. While true, the message list swaps its empty-state placeholder for a spinner — non-blocking, just a view inside the ScrollView.
|
||||||
|
- **Empty-state centering.** The "Start a new session or resume an existing one" placeholder was positioned with a fixed `.padding(.vertical, 80)` that looked wrong at extreme window sizes. Replaced with Spacers inside `.containerRelativeFrame(.vertical)` so it sits in the true vertical center of the chat pane.
|
||||||
|
- **Session-load whitespace bug.** Opening a session used to render a blank viewport you'd have to scroll up from — the fix was `LazyVStack` → `VStack` in `RichChatMessageList`. LazyVStack's estimated row heights were fooling `.defaultScrollAnchor(.bottom)` into overshooting real content; VStack measures every row upfront so the anchor has real heights to work with.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- **String Catalog build pipeline.** `SWIFT_EMIT_LOC_STRINGS` + `STRING_CATALOG_GENERATE_SYMBOLS` are enabled; keys extract automatically on IDE build. Headless builds use `xcrun xcstringstool sync` to merge the per-source `.stringsdata` files into the catalog (wrapped by [`tools/merge-translations.py`](https://github.com/awizemann/scarf/blob/main/tools/merge-translations.py) when applying JSON translations).
|
||||||
|
- **New docs.** [`scarf/docs/I18N.md`](https://github.com/awizemann/scarf/blob/main/scarf/docs/I18N.md) covers the catalog setup, the patterns that silently bypass localization (and their fixes), and which strings are intentionally kept verbatim. Anyone adding UI copy should read the "Guardrails when writing new UI code" section to avoid re-introducing the leaks #24 cleaned up.
|
||||||
|
|
||||||
|
### Migrating from 2.0.x
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. No config migration needed. The first launch after update picks up the system locale — if you want English even on a non-English macOS, set **System Settings → Language & Region → Apps → Scarf → English**.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
- [Onion3](https://github.com/Onion3) for filing [#13](https://github.com/awizemann/scarf/issues/13) back in April. The single-locale ask turned into a six-locale rollout.
|
||||||
|
- Future translators: if you spot a weird AI translation in your language, open a PR against `tools/translations/<locale>.json`. The bar is explicitly low — we'd rather have a 95%-correct translation shipped and iterated on than hold everything for perfection.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
## 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
### Using templates
|
||||||
|
|
||||||
|
- **Install from file:** Projects → Templates → *Install from File…*, pick a `.scarftemplate` from disk.
|
||||||
|
- **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).
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
### 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).
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Internationalization (i18n)
|
||||||
|
|
||||||
|
Scarf uses Apple's modern **String Catalog** workflow. Source strings are auto-extracted from `Text("…")` and `String(localized: …)` literals into [`scarf/scarf/Localizable.xcstrings`](../scarf/Localizable.xcstrings) at build time (when built in Xcode.app; `xcodebuild` alone emits per-source `.stringsdata` but does not merge back into the catalog). Info.plist keys are localized via [`scarf/scarf/InfoPlist.xcstrings`](../scarf/InfoPlist.xcstrings).
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
|
||||||
|
| Locale | Status |
|
||||||
|
|---|---|
|
||||||
|
| `en` (English) | Base / source |
|
||||||
|
| `zh-Hans` (Simplified Chinese) | AI-translated, native-speaker review welcome |
|
||||||
|
| `de` (German) | AI-translated, native-speaker review welcome |
|
||||||
|
| `fr` (French) | AI-translated, native-speaker review welcome |
|
||||||
|
| `es` (Spanish) | AI-translated, native-speaker review welcome |
|
||||||
|
| `ja` (Japanese) | AI-translated, native-speaker review welcome |
|
||||||
|
| `pt-BR` (Portuguese, Brazil) | AI-translated, native-speaker review welcome |
|
||||||
|
|
||||||
|
Canadian French users are served by base `fr`. `fr-CA` will be added only if a concrete Québec-specific bug is reported.
|
||||||
|
|
||||||
|
### Translation workflow
|
||||||
|
|
||||||
|
Source-of-truth per locale lives in `tools/translations/<locale>.json` — a flat `{ "English": "Translation" }` map. The merge step writes those into `scarf/scarf/Localizable.xcstrings` via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/merge-translations.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys absent from a locale file fall back to English at runtime — this is deliberate for proper nouns (Scarf, Hermes, Anthropic, OAuth, SSH…) and format-only strings (`%lld`, `%@ → %@`, `•`). Re-running the merge is idempotent; iterate on a JSON and re-merge.
|
||||||
|
|
||||||
|
Contributor path for new languages is documented in the repo root [CONTRIBUTING.md](../../CONTRIBUTING.md#adding-a-language).
|
||||||
|
|
||||||
|
## Adding a new language
|
||||||
|
|
||||||
|
1. Xcode → Project → Info → Localizations → `+` (add locale).
|
||||||
|
2. Ensure the locale code is also listed in `knownRegions` of `scarf.xcodeproj/project.pbxproj`.
|
||||||
|
3. Open `Localizable.xcstrings` in Xcode; the new locale appears as an empty column — translate or use Xcode's AI suggestions.
|
||||||
|
4. Repeat for `InfoPlist.xcstrings` (microphone usage, etc.).
|
||||||
|
5. Smoke-test via scheme language override (Edit Scheme → Run → App Language).
|
||||||
|
|
||||||
|
## Adding translations (AI-first workflow)
|
||||||
|
|
||||||
|
For the three supported non-English locales we use Xcode's built-in AI translation:
|
||||||
|
|
||||||
|
1. Open `Localizable.xcstrings` in Xcode.
|
||||||
|
2. Select untranslated rows for a locale → right-click → **Translate** (Xcode 26+ provides GPT-backed suggestions with context from the surrounding code comment).
|
||||||
|
3. Review each suggestion before marking **Translated**.
|
||||||
|
4. For terms that should NOT translate (proper nouns like *Scarf*, *Hermes*, *Anthropic*; env var names; file paths), wrap the source site in `Text(verbatim: "…")` so the key never hits the catalog.
|
||||||
|
|
||||||
|
## Guardrails when writing new UI code
|
||||||
|
|
||||||
|
`Text("literal")` auto-localizes. These patterns **silently leak English** and need explicit handling:
|
||||||
|
|
||||||
|
| Pattern | Fix |
|
||||||
|
|---|---|
|
||||||
|
| `Text(someStringVar)` | `Text(LocalizedStringResource("key"))` or pass a `LocalizedStringKey` down the view tree |
|
||||||
|
| `"Hello " + name` | `String(localized: "Hello \(name)")` |
|
||||||
|
| `String(format: "$%.2f", cost)` | `cost.formatted(.currency(code: "USD").precision(.fractionLength(2)))` |
|
||||||
|
| `String(format: "%.1f MB", size)` | `Int64(size).formatted(.byteCount(style: .file))` |
|
||||||
|
| `String(format: "%.1fM", n)` | `n.formatted(.number.notation(.compactName))` |
|
||||||
|
| Custom `DateFormatter` with fixed `dateFormat` | `date.formatted(.dateTime.month().day().year())` |
|
||||||
|
| `.help(stringVar)` | Compute a `LocalizedStringKey` or use `.help(Text(…))` |
|
||||||
|
| `Button(stringVar)` | `Button(LocalizedStringResource("key")) { … }` |
|
||||||
|
|
||||||
|
Strings that are **user data** (session titles, memory file contents, log lines, shell commands shown in UI, file paths) should pass through without localization — this happens naturally when the value is a `String` variable, since those overloads skip the catalog.
|
||||||
|
|
||||||
|
## Audit status
|
||||||
|
|
||||||
|
Phase 1b (the `multi-language` PR) closed every tracked site from the original audit:
|
||||||
|
|
||||||
|
- **Category A high-priority (ternary UI copy)** — converted to `Text`-ternary form so each branch routes through `LocalizedStringKey`.
|
||||||
|
- **Category A medium-priority (enum `.rawValue` displays)** — each enum now exposes `displayName: LocalizedStringResource` and call sites use it. `LogEntry.LogLevel` (technical jargon) stays verbatim.
|
||||||
|
- **Category A lower-priority (displayName passthroughs)** — wrapped with `Text(verbatim:)` for proper nouns / user data (`HermesToolPlatform`, `ServerRegistry.Entry`, `MCPServerPreset`). `MCPTransport.displayName` promoted to `LocalizedStringResource`.
|
||||||
|
- **Category B (composite format strings)** — migrated to `Text("\(arg) suffix")` with `LocalizedStringKey` or to `.percent` / `.currency` FormatStyle.
|
||||||
|
- **Category C (hard-coded day names)** — replaced with `Calendar.current.shortWeekdaySymbols`, re-indexed to match the existing Mon=0 data model.
|
||||||
|
- **Category D (`.help(stringVar)` sites)** — `ConnectionStatusPill` now returns `Text` from its `labelText` / `tooltipText` properties.
|
||||||
|
|
||||||
|
If you spot a new silently-un-localizable site during translation review, prefer the patterns in the table above over one-off workarounds.
|
||||||
|
|
||||||
|
### Non-blocking (intentional verbatim)
|
||||||
|
|
||||||
|
The following are correct as-is because they pass user data or machine-readable content through to the UI:
|
||||||
|
|
||||||
|
- Session titles, message content, memory / skill / YAML file contents, log lines, shell commands, file paths, session IDs, model IDs, credential sources, URL strings.
|
||||||
|
|
||||||
|
If we later need to badge these (e.g. "(empty)" placeholder), the badge itself becomes a localizable key while the data passthrough stays verbatim.
|
||||||
@@ -214,6 +214,12 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"zh-Hans",
|
||||||
|
de,
|
||||||
|
fr,
|
||||||
|
es,
|
||||||
|
ja,
|
||||||
|
"pt-BR",
|
||||||
);
|
);
|
||||||
mainGroup = 534959372F7B83B600BD31AD;
|
mainGroup = 534959372F7B83B600BD31AD;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
@@ -300,6 +306,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -329,6 +336,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -354,6 +362,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@@ -364,6 +373,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -393,6 +403,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
@@ -411,6 +422,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -424,7 +436,8 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -436,7 +449,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 2.0.0;
|
MARKETING_VERSION = 2.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -458,7 +471,8 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -470,7 +484,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 2.0.0;
|
MARKETING_VERSION = 2.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -488,11 +502,12 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.0.0;
|
MARKETING_VERSION = 2.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -509,11 +524,12 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.0.0;
|
MARKETING_VERSION = 2.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -529,10 +545,11 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 2.0.0;
|
MARKETING_VERSION = 2.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -548,10 +565,11 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 2.0.0;
|
MARKETING_VERSION = 2.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "5349593F2F7B83B600BD31AD"
|
||||||
|
BuildableName = "scarf.app"
|
||||||
|
BlueprintName = "scarf"
|
||||||
|
ReferencedContainer = "container:scarf.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "5349594E2F7B83B700BD31AD"
|
||||||
|
BuildableName = "scarfTests.xctest"
|
||||||
|
BlueprintName = "scarfTests"
|
||||||
|
ReferencedContainer = "container:scarf.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "534959582F7B83B700BD31AD"
|
||||||
|
BuildableName = "scarfUITests.xctest"
|
||||||
|
BlueprintName = "scarfUITests"
|
||||||
|
ReferencedContainer = "container:scarf.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "5349593F2F7B83B600BD31AD"
|
||||||
|
BuildableName = "scarf.app"
|
||||||
|
BlueprintName = "scarf"
|
||||||
|
ReferencedContainer = "container:scarf.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "5349593F2F7B83B600BD31AD"
|
||||||
|
BuildableName = "scarf.app"
|
||||||
|
BlueprintName = "scarf"
|
||||||
|
ReferencedContainer = "container:scarf.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -14,6 +14,7 @@ struct ContentView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SidebarView()
|
SidebarView()
|
||||||
|
.navigationSplitViewColumnWidth(min: 180, ideal: 240, max: 360)
|
||||||
} detail: {
|
} detail: {
|
||||||
detailView
|
detailView
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -21,6 +22,10 @@ struct ContentView: View {
|
|||||||
ServerSwitcherToolbar()
|
ServerSwitcherToolbar()
|
||||||
}
|
}
|
||||||
if serverContext.isRemote {
|
if serverContext.isRemote {
|
||||||
|
// `.principal` centers the pill in the toolbar —
|
||||||
|
// the native emphasis bezel is the intended frame;
|
||||||
|
// the pill's own visual content (icon + label, no
|
||||||
|
// background) sits inside it in balance.
|
||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
ConnectionStatusPill(status: connectionStatus)
|
ConnectionStatusPill(status: connectionStatus)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: LocalizedStringResource {
|
||||||
switch self {
|
switch self {
|
||||||
case .stdio: return "Local (stdio)"
|
case .stdio: return "Local (stdio)"
|
||||||
case .http: return "Remote (HTTP)"
|
case .http: return "Remote (HTTP)"
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ enum ToolKind: String, Sendable, CaseIterable {
|
|||||||
case browser
|
case browser
|
||||||
case other
|
case other
|
||||||
|
|
||||||
|
var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .read: return "Read"
|
||||||
|
case .edit: return "Edit"
|
||||||
|
case .execute: return "Execute"
|
||||||
|
case .fetch: return "Fetch"
|
||||||
|
case .browser: return "Browser"
|
||||||
|
case .other: return "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .read: return "doc.text.magnifyingglass"
|
case .read: return "doc.text.magnifyingglass"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A slash command available in chat. Sourced either from the ACP server
|
||||||
|
/// (`available_commands_update`) or from user-defined `quick_commands` in
|
||||||
|
/// `config.yaml`.
|
||||||
|
struct HermesSlashCommand: Identifiable, Sendable, Equatable {
|
||||||
|
enum Source: Sendable, Equatable {
|
||||||
|
case acp
|
||||||
|
case quickCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let argumentHint: String?
|
||||||
|
let source: Source
|
||||||
|
}
|
||||||
@@ -86,8 +86,8 @@ enum WidgetValue: Codable, Sendable, Hashable {
|
|||||||
case .string(let s): return s
|
case .string(let s): return s
|
||||||
case .number(let n):
|
case .number(let n):
|
||||||
return n.truncatingRemainder(dividingBy: 1) == 0
|
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||||
? String(Int(n))
|
? Int(n).formatted(.number)
|
||||||
: String(format: "%.1f", n)
|
: n.formatted(.number.precision(.fractionLength(1)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Manifest (what lives inside the .scarftemplate zip)
|
||||||
|
|
||||||
|
/// On-disk manifest for a Scarf project template. Shipped as `template.json`
|
||||||
|
/// at the root of a `.scarftemplate` (zip) bundle.
|
||||||
|
///
|
||||||
|
/// The `contents` block is a claim the author makes about what the bundle
|
||||||
|
/// ships; the installer verifies the claim against the actual unpacked files
|
||||||
|
/// before showing the preview sheet so a malicious bundle can't hide extra
|
||||||
|
/// files from the user.
|
||||||
|
struct ProjectTemplateManifest: Codable, Sendable, Equatable {
|
||||||
|
let schemaVersion: Int
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let version: String
|
||||||
|
let minScarfVersion: String?
|
||||||
|
let minHermesVersion: String?
|
||||||
|
let author: TemplateAuthor?
|
||||||
|
let description: String
|
||||||
|
let category: String?
|
||||||
|
let tags: [String]?
|
||||||
|
let icon: String?
|
||||||
|
let screenshots: [String]?
|
||||||
|
let contents: TemplateContents
|
||||||
|
/// Optional configuration schema (added in manifest schemaVersion 2).
|
||||||
|
/// When present, the installer presents a form during install and
|
||||||
|
/// writes values to `<project>/.scarf/config.json` + the Keychain.
|
||||||
|
/// Schema-v1 manifests omit this field entirely — Codable's
|
||||||
|
/// optional-field decoding keeps them working unchanged.
|
||||||
|
let config: TemplateConfigSchema?
|
||||||
|
|
||||||
|
/// Filesystem-safe slug derived from `id` (`"owner/name"` → `"owner-name"`).
|
||||||
|
/// Used for the install directory name, skills namespace, and cron-job tag.
|
||||||
|
nonisolated var slug: String {
|
||||||
|
let ascii = id.unicodeScalars.map { scalar -> Character in
|
||||||
|
let c = Character(scalar)
|
||||||
|
if c.isLetter || c.isNumber || c == "-" || c == "_" { return c }
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
let collapsed = String(ascii)
|
||||||
|
.split(separator: "-", omittingEmptySubsequences: true)
|
||||||
|
.joined(separator: "-")
|
||||||
|
return collapsed.isEmpty ? "template" : collapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateAuthor: Codable, Sendable, Equatable {
|
||||||
|
let name: String
|
||||||
|
let url: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateContents: Codable, Sendable, Equatable {
|
||||||
|
let dashboard: Bool
|
||||||
|
let agentsMd: Bool
|
||||||
|
let instructions: [String]?
|
||||||
|
let skills: [String]?
|
||||||
|
let cron: Int?
|
||||||
|
let memory: TemplateMemoryClaim?
|
||||||
|
/// Number of configuration fields the template ships (schemaVersion 2+).
|
||||||
|
/// Cross-checked against `manifest.config?.fields.count` by the
|
||||||
|
/// validator so a bundle can't hide a schema from the preview.
|
||||||
|
/// `nil` or `0` means schema-less (v1-compatible behaviour).
|
||||||
|
let config: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
|
||||||
|
let append: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inspection (what we learn by unpacking the zip)
|
||||||
|
|
||||||
|
/// Result of unpacking a `.scarftemplate` into a temp directory and validating
|
||||||
|
/// it. Callers hand this to `buildInstallPlan` to produce the concrete
|
||||||
|
/// filesystem plan.
|
||||||
|
struct TemplateInspection: Sendable {
|
||||||
|
let manifest: ProjectTemplateManifest
|
||||||
|
/// Absolute path to the temp directory holding the unpacked bundle. The
|
||||||
|
/// installer reads files from here; the caller is responsible for
|
||||||
|
/// cleaning it up after install (or cancel).
|
||||||
|
let unpackedDir: String
|
||||||
|
/// Every file found in the unpacked dir, as paths relative to
|
||||||
|
/// `unpackedDir`. Verified against the manifest's `contents` claim.
|
||||||
|
let files: [String]
|
||||||
|
/// Parsed cron jobs (may be empty even if the manifest claims some —
|
||||||
|
/// verification catches that mismatch).
|
||||||
|
let cronJobs: [TemplateCronJobSpec]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subset of a Hermes cron job that a template can ship. Only the fields
|
||||||
|
/// the `hermes cron create` CLI accepts are included; runtime state
|
||||||
|
/// (`enabled`, `state`, `next_run_at`, …) is deliberately omitted so a
|
||||||
|
/// template can't arrive already-running.
|
||||||
|
struct TemplateCronJobSpec: Codable, Sendable, Equatable {
|
||||||
|
let name: String
|
||||||
|
let schedule: String
|
||||||
|
let prompt: String?
|
||||||
|
let deliver: String?
|
||||||
|
let skills: [String]?
|
||||||
|
let repeatCount: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, schedule, prompt, deliver, skills
|
||||||
|
case repeatCount = "repeat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Install Plan (the preview sheet reads this)
|
||||||
|
|
||||||
|
/// Concrete, reviewed-before-apply filesystem operations the installer will
|
||||||
|
/// perform. Every side effect the installer can cause is represented here so
|
||||||
|
/// the preview sheet is an honest accounting of what's about to happen.
|
||||||
|
struct TemplateInstallPlan: Sendable {
|
||||||
|
let manifest: ProjectTemplateManifest
|
||||||
|
let unpackedDir: String
|
||||||
|
|
||||||
|
/// Absolute path of the new project directory. Installer refuses if this
|
||||||
|
/// already exists.
|
||||||
|
let projectDir: String
|
||||||
|
/// Files that will be created under `projectDir`, keyed by relative path.
|
||||||
|
let projectFiles: [TemplateFileCopy]
|
||||||
|
|
||||||
|
/// Absolute path of the skills namespace dir
|
||||||
|
/// (`~/.hermes/skills/templates/<slug>/`). Created if skills are present.
|
||||||
|
let skillsNamespaceDir: String?
|
||||||
|
/// Files that will be created under the skills namespace dir.
|
||||||
|
let skillsFiles: [TemplateFileCopy]
|
||||||
|
|
||||||
|
/// Cron job definitions to register via `hermes cron create`. Each job's
|
||||||
|
/// name is already prefixed with the template tag. All will be paused
|
||||||
|
/// immediately after creation.
|
||||||
|
let cronJobs: [TemplateCronJobSpec]
|
||||||
|
|
||||||
|
/// Memory appendix text (already wrapped in begin/end markers). `nil`
|
||||||
|
/// means no memory write happens.
|
||||||
|
let memoryAppendix: String?
|
||||||
|
/// Target memory path (`~/.hermes/memories/MEMORY.md`). Only used when
|
||||||
|
/// `memoryAppendix` is non-nil.
|
||||||
|
let memoryPath: String
|
||||||
|
|
||||||
|
/// `ProjectEntry.name` that will be appended to the projects registry.
|
||||||
|
let projectRegistryName: String
|
||||||
|
|
||||||
|
/// Configuration schema declared by the template (manifest schemaVersion 2).
|
||||||
|
/// `nil` means the template is schema-less — the installer skips the
|
||||||
|
/// config sheet and writes no `.scarf/config.json` or manifest cache.
|
||||||
|
let configSchema: TemplateConfigSchema?
|
||||||
|
|
||||||
|
/// Values the user entered in the configure sheet. Populated by the
|
||||||
|
/// VM just before `install()` runs; empty when `configSchema` is nil.
|
||||||
|
/// Secrets appear here as `.keychainRef(...)` — the bytes themselves
|
||||||
|
/// were routed straight from the form field into the Keychain and
|
||||||
|
/// never held in memory past that point.
|
||||||
|
var configValues: [String: TemplateConfigValue]
|
||||||
|
|
||||||
|
/// Path at which the installer will stash a copy of `template.json`
|
||||||
|
/// so the post-install Configuration editor can render the form
|
||||||
|
/// offline. `nil` when `configSchema` is nil.
|
||||||
|
let manifestCachePath: String?
|
||||||
|
|
||||||
|
/// Convenience: total number of writes (files + cron jobs + optional
|
||||||
|
/// memory append + registry append + optional config.json + one
|
||||||
|
/// entry per secret written to the Keychain). Displayed in the
|
||||||
|
/// preview sheet.
|
||||||
|
nonisolated var totalWriteCount: Int {
|
||||||
|
let configFileCount = (configSchema?.isEmpty ?? true) ? 0 : 1
|
||||||
|
let secretCount = configValues.values.filter {
|
||||||
|
if case .keychainRef = $0 { return true } else { return false }
|
||||||
|
}.count
|
||||||
|
return projectFiles.count
|
||||||
|
+ skillsFiles.count
|
||||||
|
+ cronJobs.count
|
||||||
|
+ (memoryAppendix == nil ? 0 : 1)
|
||||||
|
+ 1 // registry entry
|
||||||
|
+ configFileCount
|
||||||
|
+ secretCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single file to copy from the unpacked bundle into a target directory.
|
||||||
|
struct TemplateFileCopy: Sendable, Equatable {
|
||||||
|
/// Path inside `unpackedDir`, e.g. `"AGENTS.md"` or
|
||||||
|
/// `"skills/timer/SKILL.md"`.
|
||||||
|
let sourceRelativePath: String
|
||||||
|
/// Absolute path where the file should land.
|
||||||
|
let destinationPath: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock file (uninstall manifest, dropped into <project>/.scarf/)
|
||||||
|
|
||||||
|
/// Dropped at `<project>/.scarf/template.lock.json` after a successful
|
||||||
|
/// install. Records exactly what was written so a future "Uninstall Template"
|
||||||
|
/// action can reverse it without guessing.
|
||||||
|
struct TemplateLock: Codable, Sendable {
|
||||||
|
let templateId: String
|
||||||
|
let templateVersion: String
|
||||||
|
let templateName: String
|
||||||
|
let installedAt: String
|
||||||
|
let projectFiles: [String]
|
||||||
|
let skillsNamespaceDir: String?
|
||||||
|
let skillsFiles: [String]
|
||||||
|
let cronJobNames: [String]
|
||||||
|
let memoryBlockId: String?
|
||||||
|
/// Every `keychain://service/account` URI the installer stored in
|
||||||
|
/// the Keychain for this project's secret fields. Empty/nil for
|
||||||
|
/// schema-less (v1-style) installs. The uninstaller iterates this
|
||||||
|
/// list and calls `SecItemDelete` for each entry; absent on older
|
||||||
|
/// lock files so Codable's optional decoding keeps pre-2.3 installs
|
||||||
|
/// uninstallable.
|
||||||
|
let configKeychainItems: [String]?
|
||||||
|
/// Field keys the installer wrote to `<project>/.scarf/config.json`.
|
||||||
|
/// Informational — the actual removal of config.json rides on
|
||||||
|
/// `projectFiles`. Optional for back-compat.
|
||||||
|
let configFields: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case templateId = "template_id"
|
||||||
|
case templateVersion = "template_version"
|
||||||
|
case templateName = "template_name"
|
||||||
|
case installedAt = "installed_at"
|
||||||
|
case projectFiles = "project_files"
|
||||||
|
case skillsNamespaceDir = "skills_namespace_dir"
|
||||||
|
case skillsFiles = "skills_files"
|
||||||
|
case cronJobNames = "cron_job_names"
|
||||||
|
case memoryBlockId = "memory_block_id"
|
||||||
|
case configKeychainItems = "config_keychain_items"
|
||||||
|
case configFields = "config_fields"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uninstall Plan (the uninstall-preview sheet reads this)
|
||||||
|
|
||||||
|
/// Symmetric with `TemplateInstallPlan` but for removal. Built from the
|
||||||
|
/// `<project>/.scarf/template.lock.json` the installer wrote. The preview
|
||||||
|
/// sheet lists every path the uninstall would touch; the uninstaller
|
||||||
|
/// executes the listed ops and nothing else.
|
||||||
|
struct TemplateUninstallPlan: Sendable {
|
||||||
|
/// The parsed lock file that seeded this plan. Kept so the sheet can
|
||||||
|
/// display the template id, version, and install timestamp.
|
||||||
|
let lock: TemplateLock
|
||||||
|
/// The registry entry that will be removed on success.
|
||||||
|
let project: ProjectEntry
|
||||||
|
|
||||||
|
/// Lock-tracked files still present on disk that will be removed.
|
||||||
|
let projectFilesToRemove: [String]
|
||||||
|
/// Lock-tracked files that were already missing (e.g. user deleted them
|
||||||
|
/// after install). Shown in the sheet so the user isn't surprised that
|
||||||
|
/// a file isn't removed; uninstaller skips these.
|
||||||
|
let projectFilesAlreadyGone: [String]
|
||||||
|
/// User-added files/dirs in the project dir that are NOT in the lock.
|
||||||
|
/// These are preserved — the sheet lists them so the user knows the
|
||||||
|
/// project dir stays if any exist.
|
||||||
|
let extraProjectEntries: [String]
|
||||||
|
/// If `true`, the project dir ends up empty after removal and will be
|
||||||
|
/// removed along with its files. `false` means user content lives in
|
||||||
|
/// the dir and we leave it.
|
||||||
|
let projectDirBecomesEmpty: Bool
|
||||||
|
|
||||||
|
/// Lock-recorded skills namespace dir. `nil` means the template never
|
||||||
|
/// installed skills. Uninstaller removes the entire dir recursively.
|
||||||
|
let skillsNamespaceDir: String?
|
||||||
|
|
||||||
|
/// Cron jobs that will be removed, as (id, name) pairs. Ids were looked
|
||||||
|
/// up at plan time by matching lock names against the live cron list.
|
||||||
|
let cronJobsToRemove: [(id: String, name: String)]
|
||||||
|
/// Names recorded in the lock that we couldn't find in the current cron
|
||||||
|
/// list (user-deleted, renamed, etc.). Shown in the sheet; skipped on
|
||||||
|
/// uninstall.
|
||||||
|
let cronJobsAlreadyGone: [String]
|
||||||
|
|
||||||
|
/// `true` if MEMORY.md still contains the template's begin/end markers
|
||||||
|
/// and those bytes will be stripped on uninstall. `false` means no
|
||||||
|
/// memory block was ever installed OR the user removed it by hand.
|
||||||
|
let memoryBlockPresent: Bool
|
||||||
|
/// Hermes-side path to MEMORY.md. Only touched when
|
||||||
|
/// `memoryBlockPresent` is true.
|
||||||
|
let memoryPath: String
|
||||||
|
|
||||||
|
nonisolated var totalRemoveCount: Int {
|
||||||
|
projectFilesToRemove.count
|
||||||
|
+ (skillsNamespaceDir == nil ? 0 : 1)
|
||||||
|
+ cronJobsToRemove.count
|
||||||
|
+ (memoryBlockPresent ? 1 : 0)
|
||||||
|
+ 1 // registry entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum ProjectTemplateError: LocalizedError, Sendable {
|
||||||
|
case unzipFailed(String)
|
||||||
|
case manifestMissing
|
||||||
|
case manifestParseFailed(String)
|
||||||
|
case unsupportedSchemaVersion(Int)
|
||||||
|
case requiredFileMissing(String)
|
||||||
|
case contentClaimMismatch(String)
|
||||||
|
case projectDirExists(String)
|
||||||
|
case conflictingFile(String)
|
||||||
|
case memoryBlockAlreadyExists(String)
|
||||||
|
case cronCreateFailed(job: String, output: String)
|
||||||
|
case unsafeZipEntry(String)
|
||||||
|
case lockFileMissing(String)
|
||||||
|
case lockFileParseFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unzipFailed(let s):
|
||||||
|
return "Couldn't unpack template archive: \(s)"
|
||||||
|
case .manifestMissing:
|
||||||
|
return "Template is missing template.json at the archive root."
|
||||||
|
case .manifestParseFailed(let s):
|
||||||
|
return "Template manifest couldn't be parsed: \(s)"
|
||||||
|
case .unsupportedSchemaVersion(let v):
|
||||||
|
return "Template uses schemaVersion \(v), which this version of Scarf doesn't understand."
|
||||||
|
case .requiredFileMissing(let f):
|
||||||
|
return "Template is missing a required file: \(f)"
|
||||||
|
case .contentClaimMismatch(let s):
|
||||||
|
return "Template manifest doesn't match its contents: \(s)"
|
||||||
|
case .projectDirExists(let p):
|
||||||
|
return "A directory already exists at \(p). Refusing to overwrite — choose a different parent folder."
|
||||||
|
case .conflictingFile(let p):
|
||||||
|
return "An existing file would be overwritten at \(p). Refusing to clobber."
|
||||||
|
case .memoryBlockAlreadyExists(let id):
|
||||||
|
return "A memory block for template '\(id)' already exists in MEMORY.md. Remove it first or install a fresh copy."
|
||||||
|
case .cronCreateFailed(let job, let output):
|
||||||
|
return "Failed to register cron job '\(job)': \(output)"
|
||||||
|
case .unsafeZipEntry(let p):
|
||||||
|
return "Template archive contains an unsafe entry: \(p)"
|
||||||
|
case .lockFileMissing(let path):
|
||||||
|
return "No template.lock.json found at \(path). This project wasn't installed by Scarf's template system — remove it by hand."
|
||||||
|
case .lockFileParseFailed(let s):
|
||||||
|
return "Couldn't read template.lock.json: \(s)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Schema (ships inside template.json as manifest.config)
|
||||||
|
|
||||||
|
/// Author-declared configuration schema for a template. Published as the
|
||||||
|
/// `config` block of `template.json` (manifest schemaVersion 2). Users fill
|
||||||
|
/// in values at install time via `TemplateConfigSheet`; values land in
|
||||||
|
/// `<project>/.scarf/config.json` with secrets resolved through the
|
||||||
|
/// macOS Keychain.
|
||||||
|
struct TemplateConfigSchema: Codable, Sendable, Equatable {
|
||||||
|
let fields: [TemplateConfigField]
|
||||||
|
let modelRecommendation: TemplateModelRecommendation?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case fields = "schema"
|
||||||
|
case modelRecommendation
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var isEmpty: Bool { fields.isEmpty }
|
||||||
|
|
||||||
|
/// Fast lookup by key. Validators guarantee keys are unique within a
|
||||||
|
/// schema at manifest-parse time, so this is safe.
|
||||||
|
nonisolated func field(for key: String) -> TemplateConfigField? {
|
||||||
|
fields.first { $0.key == key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One configurable field the user fills in. Discriminated by `type`.
|
||||||
|
/// We keep one flat struct rather than an enum-associated-value encoding
|
||||||
|
/// so JSON reads cleanly as a record and authors can hand-edit manifests
|
||||||
|
/// without fighting Swift's `"case"` discriminator syntax.
|
||||||
|
struct TemplateConfigField: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
nonisolated var id: String { key }
|
||||||
|
|
||||||
|
let key: String
|
||||||
|
let type: FieldType
|
||||||
|
let label: String
|
||||||
|
let description: String?
|
||||||
|
let required: Bool
|
||||||
|
let placeholder: String?
|
||||||
|
|
||||||
|
// Type-specific constraints — all optional. The validator enforces
|
||||||
|
// only the ones that apply to `type`; extras are ignored.
|
||||||
|
let defaultValue: TemplateConfigValue?
|
||||||
|
let options: [EnumOption]? // type == .enum
|
||||||
|
let minLength: Int? // type == .string / .text
|
||||||
|
let maxLength: Int?
|
||||||
|
let pattern: String? // type == .string (regex)
|
||||||
|
let minNumber: Double? // type == .number
|
||||||
|
let maxNumber: Double?
|
||||||
|
let step: Double?
|
||||||
|
let itemType: String? // type == .list — only "string" supported in v1
|
||||||
|
let minItems: Int?
|
||||||
|
let maxItems: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case key, type, label, description, required, placeholder
|
||||||
|
case defaultValue = "default"
|
||||||
|
case options
|
||||||
|
case minLength, maxLength, pattern
|
||||||
|
case minNumber = "min"
|
||||||
|
case maxNumber = "max"
|
||||||
|
case step
|
||||||
|
case itemType, minItems, maxItems
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FieldType: String, Codable, Sendable, Equatable {
|
||||||
|
case string
|
||||||
|
case text
|
||||||
|
case number
|
||||||
|
case bool
|
||||||
|
case `enum`
|
||||||
|
case list
|
||||||
|
case secret
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One option of an `enum` field. `value` is what ends up in
|
||||||
|
/// `config.json`; `label` is the human-readable text shown in the UI.
|
||||||
|
struct EnumOption: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
nonisolated var id: String { value }
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Author's model-of-choice hint, shown in the install preview + on the
|
||||||
|
/// catalog detail page. Purely advisory — Scarf never auto-switches the
|
||||||
|
/// active model. Individual cron jobs can override via
|
||||||
|
/// `HermesCronJob.model` if the author wants enforcement.
|
||||||
|
struct TemplateModelRecommendation: Codable, Sendable, Equatable {
|
||||||
|
let preferred: String
|
||||||
|
let rationale: String?
|
||||||
|
let alternatives: [String]?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Values (what lands in config.json and the Keychain)
|
||||||
|
|
||||||
|
/// One configured value. Secrets don't carry their raw bytes — only a
|
||||||
|
/// Keychain reference of the form `"keychain://<service>/<account>"` so
|
||||||
|
/// serialising config.json to disk never leaks the secret into git or
|
||||||
|
/// into backups.
|
||||||
|
enum TemplateConfigValue: Codable, Sendable, Equatable {
|
||||||
|
case string(String)
|
||||||
|
case number(Double)
|
||||||
|
case bool(Bool)
|
||||||
|
case list([String])
|
||||||
|
case keychainRef(String)
|
||||||
|
|
||||||
|
/// Convenience: the string representation suitable for display or
|
||||||
|
/// for writing into a placeholder that the agent reads. Keychain
|
||||||
|
/// refs return the ref string, not the resolved secret — callers
|
||||||
|
/// resolve through `ProjectConfigKeychain` explicitly when they
|
||||||
|
/// actually need the plaintext.
|
||||||
|
nonisolated var displayString: String {
|
||||||
|
switch self {
|
||||||
|
case .string(let s): return s
|
||||||
|
case .number(let n):
|
||||||
|
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(Int(n))
|
||||||
|
: String(n)
|
||||||
|
case .bool(let b): return b ? "true" : "false"
|
||||||
|
case .list(let items): return items.joined(separator: ", ")
|
||||||
|
case .keychainRef(let ref): return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let s = try? container.decode(String.self) {
|
||||||
|
// Preserve the keychain:// scheme so secrets round-trip as
|
||||||
|
// references, not as plaintext.
|
||||||
|
if s.hasPrefix("keychain://") {
|
||||||
|
self = .keychainRef(s)
|
||||||
|
} else {
|
||||||
|
self = .string(s)
|
||||||
|
}
|
||||||
|
} else if let b = try? container.decode(Bool.self) {
|
||||||
|
self = .bool(b)
|
||||||
|
} else if let n = try? container.decode(Double.self) {
|
||||||
|
self = .number(n)
|
||||||
|
} else if let arr = try? container.decode([String].self) {
|
||||||
|
self = .list(arr)
|
||||||
|
} else {
|
||||||
|
throw DecodingError.typeMismatch(
|
||||||
|
TemplateConfigValue.self,
|
||||||
|
.init(codingPath: decoder.codingPath,
|
||||||
|
debugDescription: "Expected String, Bool, Number, or [String]")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .string(let s): try container.encode(s)
|
||||||
|
case .number(let n): try container.encode(n)
|
||||||
|
case .bool(let b): try container.encode(b)
|
||||||
|
case .list(let items): try container.encode(items)
|
||||||
|
case .keychainRef(let ref): try container.encode(ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - On-disk shape (what's in <project>/.scarf/config.json)
|
||||||
|
|
||||||
|
/// The JSON file the installer writes + the editor reads. Non-secret
|
||||||
|
/// values appear inline; secrets are `"keychain://<service>/<account>"`
|
||||||
|
/// references that `ProjectConfigService` resolves through the Keychain
|
||||||
|
/// on demand.
|
||||||
|
struct ProjectConfigFile: Codable, Sendable {
|
||||||
|
let schemaVersion: Int
|
||||||
|
let templateId: String
|
||||||
|
var values: [String: TemplateConfigValue]
|
||||||
|
let updatedAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case schemaVersion
|
||||||
|
case templateId
|
||||||
|
case values
|
||||||
|
case updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain reference helpers
|
||||||
|
|
||||||
|
/// One secret stored via `ProjectConfigKeychain`. We derive both halves
|
||||||
|
/// (service + account) from the template slug + project-path hash so two
|
||||||
|
/// installs of the same template in different dirs don't collide in the
|
||||||
|
/// login Keychain.
|
||||||
|
struct TemplateKeychainRef: Sendable, Equatable {
|
||||||
|
/// Macro service name, e.g. `com.scarf.template.awizemann-site-status-checker`.
|
||||||
|
let service: String
|
||||||
|
/// Account name: `<fieldKey>:<projectPathHashShort>`. The hash suffix
|
||||||
|
/// guarantees uniqueness across multiple installs of the same template.
|
||||||
|
let account: String
|
||||||
|
|
||||||
|
/// `"keychain://<service>/<account>"` — what lands in `config.json`.
|
||||||
|
nonisolated var uri: String { "keychain://\(service)/\(account)" }
|
||||||
|
|
||||||
|
/// Parse a `keychain://…` URI back into a ref. Returns `nil` when the
|
||||||
|
/// input isn't well-formed so callers can distinguish a missing ref
|
||||||
|
/// from a malformed one.
|
||||||
|
nonisolated static func parse(_ uri: String) -> TemplateKeychainRef? {
|
||||||
|
guard uri.hasPrefix("keychain://") else { return nil }
|
||||||
|
let rest = String(uri.dropFirst("keychain://".count))
|
||||||
|
guard let slash = rest.firstIndex(of: "/") else { return nil }
|
||||||
|
let service = String(rest[..<slash])
|
||||||
|
let account = String(rest[rest.index(after: slash)...])
|
||||||
|
guard !service.isEmpty, !account.isEmpty else { return nil }
|
||||||
|
return TemplateKeychainRef(service: service, account: account)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a ref from a template slug + field key + project path.
|
||||||
|
/// The hash suffix is a SHA-256-truncated-to-8-hex-chars fingerprint
|
||||||
|
/// of the absolute project path. Stable across launches, different
|
||||||
|
/// between `/Users/a/proj1` and `/Users/a/proj2`.
|
||||||
|
nonisolated static func make(
|
||||||
|
templateSlug: String,
|
||||||
|
fieldKey: String,
|
||||||
|
projectPath: String
|
||||||
|
) -> TemplateKeychainRef {
|
||||||
|
TemplateKeychainRef(
|
||||||
|
service: "com.scarf.template.\(templateSlug)",
|
||||||
|
account: "\(fieldKey):\(Self.shortHash(of: projectPath))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func shortHash(of string: String) -> String {
|
||||||
|
// 8 hex chars is 32 bits of uniqueness — plenty for
|
||||||
|
// distinguishing a handful of project dirs per template install.
|
||||||
|
let data = Data(string.utf8)
|
||||||
|
var hash: UInt32 = 0x811c9dc5
|
||||||
|
for byte in data {
|
||||||
|
hash ^= UInt32(byte)
|
||||||
|
hash &*= 0x01000193
|
||||||
|
}
|
||||||
|
return String(format: "%08x", hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
/// One schema- or value-validation problem. Carries `fieldKey` so the
|
||||||
|
/// UI can surface the error inline with the field rather than at the
|
||||||
|
/// top of the form.
|
||||||
|
struct TemplateConfigValidationError: Error, Sendable, Equatable {
|
||||||
|
let fieldKey: String?
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TemplateConfigSchemaError: LocalizedError, Sendable {
|
||||||
|
case duplicateKey(String)
|
||||||
|
case unsupportedType(String)
|
||||||
|
case emptyEnumOptions(String)
|
||||||
|
case duplicateEnumValue(key: String, value: String)
|
||||||
|
case unsupportedListItemType(key: String, itemType: String)
|
||||||
|
case secretFieldHasDefault(String)
|
||||||
|
case emptyModelPreferred
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .duplicateKey(let k):
|
||||||
|
return "Config schema has duplicate key: \(k)"
|
||||||
|
case .unsupportedType(let t):
|
||||||
|
return "Config schema uses unsupported field type: \(t)"
|
||||||
|
case .emptyEnumOptions(let k):
|
||||||
|
return "Enum field '\(k)' must declare at least one option"
|
||||||
|
case .duplicateEnumValue(let k, let v):
|
||||||
|
return "Enum field '\(k)' has duplicate option value: \(v)"
|
||||||
|
case .unsupportedListItemType(let k, let t):
|
||||||
|
return "List field '\(k)' uses unsupported itemType '\(t)'. Only 'string' is supported in v1."
|
||||||
|
case .secretFieldHasDefault(let k):
|
||||||
|
return "Secret field '\(k)' cannot declare a default value — secrets belong only in the Keychain."
|
||||||
|
case .emptyModelPreferred:
|
||||||
|
return "modelRecommendation.preferred must be a non-empty model id."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ struct ServerEntry: Identifiable, Codable, Hashable, Sendable {
|
|||||||
var id: ServerID
|
var id: ServerID
|
||||||
var displayName: String
|
var displayName: String
|
||||||
var kind: ServerKind
|
var kind: ServerKind
|
||||||
/// User preference: open this server in a window on launch. Phase 3
|
/// User preference: this server is the one Scarf opens into when a
|
||||||
/// multi-window uses this; Phase 2 ignores it.
|
/// fresh window has no prior binding (first launch or File → New).
|
||||||
|
/// At most one entry should have this set — `ServerRegistry` enforces
|
||||||
|
/// mutual exclusivity. If none do, Local is the implicit default.
|
||||||
var openOnLaunch: Bool = false
|
var openOnLaunch: Bool = false
|
||||||
|
|
||||||
var context: ServerContext {
|
var context: ServerContext {
|
||||||
@@ -69,6 +71,32 @@ final class ServerRegistry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The server a fresh window should open into. Returns the ID of the
|
||||||
|
/// remote entry flagged `openOnLaunch`, or Local's ID if none is
|
||||||
|
/// flagged (or if the flagged entry was removed out from under us).
|
||||||
|
/// Consumed by the `WindowGroup`'s `defaultValue` closure.
|
||||||
|
var defaultServerID: ServerID {
|
||||||
|
entries.first(where: { $0.openOnLaunch })?.id ?? ServerContext.local.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
func setDefaultServer(_ id: ServerID) {
|
||||||
|
var changed = false
|
||||||
|
for idx in entries.indices {
|
||||||
|
let shouldBeDefault = (entries[idx].id == id)
|
||||||
|
if entries[idx].openOnLaunch != shouldBeDefault {
|
||||||
|
entries[idx].openOnLaunch = shouldBeDefault
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
save()
|
||||||
|
onEntriesChanged?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Mutations
|
// MARK: - Mutations
|
||||||
|
|
||||||
/// Optional callback fired whenever `entries` changes. The app wires
|
/// Optional callback fired whenever `entries` changes. The app wires
|
||||||
@@ -129,6 +157,7 @@ final class ServerRegistry {
|
|||||||
var keep: Set<ServerID> = [ServerContext.local.id]
|
var keep: Set<ServerID> = [ServerContext.local.id]
|
||||||
for entry in entries { keep.insert(entry.id) }
|
for entry in entries { keep.insert(entry.id) }
|
||||||
SSHTransport.sweepOrphanSnapshots(keeping: keep)
|
SSHTransport.sweepOrphanSnapshots(keeping: keep)
|
||||||
|
SSHTransport.sweepStaleControlSockets()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SQLite3
|
import SQLite3
|
||||||
|
import os
|
||||||
|
|
||||||
/// Dedupes concurrent `snapshotSQLite` calls for the same server. When the
|
/// Dedupes concurrent `snapshotSQLite` calls for the same server. When the
|
||||||
/// file watcher ticks, Dashboard + Sessions + Activity (+ Chat's loadHistory)
|
/// file watcher ticks, Dashboard + Sessions + Activity (+ Chat's loadHistory)
|
||||||
@@ -29,11 +30,18 @@ actor SnapshotCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actor HermesDataService {
|
actor HermesDataService {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "HermesDataService")
|
||||||
|
|
||||||
private var db: OpaquePointer?
|
private var db: OpaquePointer?
|
||||||
private var hasV07Schema = false
|
private var hasV07Schema = false
|
||||||
/// Local filesystem path we last opened. For remote contexts this is
|
/// Local filesystem path we last opened. For remote contexts this is
|
||||||
/// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`.
|
/// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`.
|
||||||
private var openedAtPath: String?
|
private var openedAtPath: String?
|
||||||
|
/// Last error from `open()` / `refresh()`, user-presentable. `nil` means
|
||||||
|
/// the last attempt succeeded. Views surface this when their own load
|
||||||
|
/// path fails, so the user sees "Permission denied reading state.db"
|
||||||
|
/// instead of an empty Dashboard with no explanation.
|
||||||
|
private(set) var lastOpenError: String?
|
||||||
|
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
private let transport: any ServerTransport
|
private let transport: any ServerTransport
|
||||||
@@ -52,16 +60,25 @@ actor HermesDataService {
|
|||||||
// corrupt. Routed through SnapshotCoordinator so concurrent
|
// corrupt. Routed through SnapshotCoordinator so concurrent
|
||||||
// view models don't each spawn a parallel SSH backup for the
|
// view models don't each spawn a parallel SSH backup for the
|
||||||
// same server.
|
// same server.
|
||||||
let url = try? await SnapshotCoordinator.shared.snapshot(
|
do {
|
||||||
|
let url = try await SnapshotCoordinator.shared.snapshot(
|
||||||
remotePath: context.paths.stateDB,
|
remotePath: context.paths.stateDB,
|
||||||
contextID: context.id,
|
contextID: context.id,
|
||||||
transport: transport
|
transport: transport
|
||||||
)
|
)
|
||||||
guard let url else { return false }
|
|
||||||
localPath = url.path
|
localPath = url.path
|
||||||
|
lastOpenError = nil
|
||||||
|
} catch {
|
||||||
|
lastOpenError = humanize(error)
|
||||||
|
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
localPath = context.paths.stateDB
|
localPath = context.paths.stateDB
|
||||||
guard FileManager.default.fileExists(atPath: localPath) else { return false }
|
guard FileManager.default.fileExists(atPath: localPath) else {
|
||||||
|
lastOpenError = "Hermes state database not found at \(localPath)."
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Remote snapshots are point-in-time copies that no one writes to;
|
// Remote snapshots are point-in-time copies that no one writes to;
|
||||||
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
|
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
|
||||||
@@ -81,14 +98,41 @@ actor HermesDataService {
|
|||||||
}
|
}
|
||||||
let result = sqlite3_open_v2(openPath, &db, flags, nil)
|
let result = sqlite3_open_v2(openPath, &db, flags, nil)
|
||||||
guard result == SQLITE_OK else {
|
guard result == SQLITE_OK else {
|
||||||
|
let msg: String
|
||||||
|
if let db {
|
||||||
|
msg = String(cString: sqlite3_errmsg(db))
|
||||||
|
} else {
|
||||||
|
msg = "sqlite3_open_v2 returned \(result)"
|
||||||
|
}
|
||||||
|
lastOpenError = "Couldn't open state.db: \(msg)"
|
||||||
|
Self.logger.warning("sqlite3_open_v2 failed (\(result)) at \(localPath, privacy: .public): \(msg, privacy: .public)")
|
||||||
db = nil
|
db = nil
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
openedAtPath = localPath
|
openedAtPath = localPath
|
||||||
|
lastOpenError = nil
|
||||||
detectSchema()
|
detectSchema()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turn a transport error into the one-line string Dashboard shows. Adds
|
||||||
|
/// hints for the common "sqlite3 not installed" and "permission denied"
|
||||||
|
/// cases so users know what to do.
|
||||||
|
private nonisolated func humanize(_ error: Error) -> String {
|
||||||
|
let desc = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||||
|
let lower = desc.lowercased()
|
||||||
|
if lower.contains("sqlite3: command not found") || lower.contains("sqlite3: not found") {
|
||||||
|
return "sqlite3 is not installed on \(context.displayName). Install it with `apt install sqlite3` (Ubuntu/Debian) or `yum install sqlite` (RHEL/Fedora)."
|
||||||
|
}
|
||||||
|
if lower.contains("permission denied") {
|
||||||
|
return "Permission denied reading Hermes state on \(context.displayName). The SSH user may not have read access to ~/.hermes/state.db — try Run Diagnostics."
|
||||||
|
}
|
||||||
|
if lower.contains("no such file") {
|
||||||
|
return "Hermes state not found at ~/.hermes on \(context.displayName). If Hermes is installed elsewhere, set its data directory in Manage Servers."
|
||||||
|
}
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
|
||||||
/// Force a fresh snapshot pull + reopen. Used on session-load and in
|
/// Force a fresh snapshot pull + reopen. Used on session-load and in
|
||||||
/// any path that needs the UI to reflect writes Hermes just made.
|
/// any path that needs the UI to reflect writes Hermes just made.
|
||||||
/// Without this, remote snapshots would be frozen at the first `open()`
|
/// Without this, remote snapshots would be frozen at the first `open()`
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
struct HermesFileService: Sendable {
|
struct HermesFileService: Sendable {
|
||||||
|
|
||||||
|
nonisolated static let logger = Logger(subsystem: "com.scarf", category: "HermesFileService")
|
||||||
|
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
let transport: any ServerTransport
|
let transport: any ServerTransport
|
||||||
|
|
||||||
@@ -17,6 +20,14 @@ struct HermesFileService: Sendable {
|
|||||||
return parseConfig(content)
|
return parseConfig(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error-surfacing config load. Used by Dashboard to show the user a
|
||||||
|
/// specific reason when config.yaml can't be read on a remote host
|
||||||
|
/// (permission denied, missing file, sqlite3 not installed, etc.)
|
||||||
|
/// instead of silently falling back to `.empty`.
|
||||||
|
nonisolated func loadConfigResult() -> Result<HermesConfig, Error> {
|
||||||
|
readFileResult(context.paths.configYAML).map { parseConfig($0) }
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private func parseConfig(_ yaml: String) -> HermesConfig {
|
nonisolated private func parseConfig(_ yaml: String) -> HermesConfig {
|
||||||
let parsed = Self.parseNestedYAML(yaml)
|
let parsed = Self.parseNestedYAML(yaml)
|
||||||
let values = parsed.values
|
let values = parsed.values
|
||||||
@@ -385,11 +396,34 @@ struct HermesFileService: Sendable {
|
|||||||
do {
|
do {
|
||||||
return try JSONDecoder().decode(GatewayState.self, from: data)
|
return try JSONDecoder().decode(GatewayState.self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
print("[Scarf] Failed to decode gateway state: \(error.localizedDescription)")
|
Self.logger.warning("Failed to decode gateway state: \(error.localizedDescription, privacy: .public)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error-surfacing gateway-state load. `.success(nil)` means the file
|
||||||
|
/// doesn't exist yet (gateway hasn't written state — normal when Hermes
|
||||||
|
/// is stopped). `.failure` means the file exists but couldn't be read
|
||||||
|
/// (permission denied, connection down, JSON corruption).
|
||||||
|
nonisolated func loadGatewayStateResult() -> Result<GatewayState?, Error> {
|
||||||
|
// Distinguish "file doesn't exist yet" (normal, returns .success(nil))
|
||||||
|
// from "file exists but we can't read or parse it" (error).
|
||||||
|
if !transport.fileExists(context.paths.gatewayStateJSON) {
|
||||||
|
return .success(nil)
|
||||||
|
}
|
||||||
|
switch readFileDataResult(context.paths.gatewayStateJSON) {
|
||||||
|
case .success(let data):
|
||||||
|
do {
|
||||||
|
return .success(try JSONDecoder().decode(GatewayState.self, from: data))
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("Failed to decode gateway state: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
case .failure(let err):
|
||||||
|
return .failure(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Memory
|
// MARK: - Memory
|
||||||
|
|
||||||
nonisolated func loadMemoryProfiles() -> [String] {
|
nonisolated func loadMemoryProfiles() -> [String] {
|
||||||
@@ -1173,22 +1207,45 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func hermesPID() -> pid_t? {
|
nonisolated func hermesPID() -> pid_t? {
|
||||||
// Run `pgrep -f hermes` either locally or via the transport. On
|
switch hermesPIDResult() {
|
||||||
// remote hosts we trust `pgrep` to be present — it's standard on
|
case .success(let pid): return pid
|
||||||
// Linux and macOS. On failure we conservatively return nil rather
|
case .failure: return nil
|
||||||
// than pretending Hermes is down: the caller will see
|
}
|
||||||
// isHermesRunning==false, which is already the "unknown" UX.
|
}
|
||||||
let result = try? transport.runProcess(
|
|
||||||
|
/// Error-surfacing variant. `.success(nil)` means `pgrep` ran successfully
|
||||||
|
/// and found no hermes process (Hermes is genuinely not running).
|
||||||
|
/// `.failure` means we couldn't probe at all (pgrep missing, connection
|
||||||
|
/// down, permission issue) — a *different* UX from "not running".
|
||||||
|
nonisolated func hermesPIDResult() -> Result<pid_t?, Error> {
|
||||||
|
do {
|
||||||
|
let result = try transport.runProcess(
|
||||||
executable: "/usr/bin/pgrep",
|
executable: "/usr/bin/pgrep",
|
||||||
args: ["-f", "hermes"],
|
args: ["-f", "hermes"],
|
||||||
stdin: nil,
|
stdin: nil,
|
||||||
timeout: 5
|
timeout: 5
|
||||||
)
|
)
|
||||||
guard let result, let firstLine = result.stdoutString
|
// pgrep exits 1 when nothing matches — that's "not running", NOT an
|
||||||
|
// error. Anything else (127=command not found, 255=ssh failure) is.
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
if let firstLine = result.stdoutString
|
||||||
.components(separatedBy: "\n")
|
.components(separatedBy: "\n")
|
||||||
.first(where: { !$0.isEmpty }),
|
.first(where: { !$0.isEmpty }),
|
||||||
let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) else { return nil }
|
let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) {
|
||||||
return pid
|
return .success(pid)
|
||||||
|
}
|
||||||
|
return .success(nil)
|
||||||
|
} else if result.exitCode == 1 {
|
||||||
|
return .success(nil) // genuinely not running
|
||||||
|
} else {
|
||||||
|
let err = TransportError.commandFailed(exitCode: result.exitCode, stderr: result.stderrString)
|
||||||
|
Self.logger.warning("pgrep failed (exit \(result.exitCode)): \(result.stderrString, privacy: .public)")
|
||||||
|
return .failure(err)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("pgrep transport error: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -1488,15 +1545,80 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - File I/O
|
// MARK: - File I/O
|
||||||
|
|
||||||
/// Read a UTF-8 text file through the transport. Missing files and any
|
/// Read a UTF-8 text file through the transport. Missing files and any
|
||||||
/// transport error surface as `nil` — callers treat missing/unreadable
|
/// transport error surface as `nil` — callers that don't need the
|
||||||
/// the same way they always have.
|
/// specific error reason keep using this. New call sites that want to
|
||||||
|
/// show a user-actionable message should use `readFileResult`.
|
||||||
nonisolated private func readFile(_ path: String) -> String? {
|
nonisolated private func readFile(_ path: String) -> String? {
|
||||||
guard let data = try? transport.readFile(path) else { return nil }
|
switch readFileResult(path) {
|
||||||
return String(data: data, encoding: .utf8)
|
case .success(let s):
|
||||||
|
return s
|
||||||
|
case .failure:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private func readFileData(_ path: String) -> Data? {
|
nonisolated private func readFileData(_ path: String) -> Data? {
|
||||||
try? transport.readFile(path)
|
switch readFileDataResult(path) {
|
||||||
|
case .success(let d):
|
||||||
|
return d
|
||||||
|
case .failure:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error-surfacing read. Returns the decoded text on success, or the
|
||||||
|
/// underlying `TransportError` (or raw error for local failures) on
|
||||||
|
/// failure. Every failure is also logged via `os.Logger` — the warning
|
||||||
|
/// trail in Console.app is how we diagnose "connection green, data
|
||||||
|
/// empty" bug reports without needing to wire the error through every
|
||||||
|
/// existing call site.
|
||||||
|
nonisolated func readFileResult(_ path: String) -> Result<String, Error> {
|
||||||
|
switch readFileDataResult(path) {
|
||||||
|
case .success(let data):
|
||||||
|
guard let s = String(data: data, encoding: .utf8) else {
|
||||||
|
let err = TransportError.fileIO(path: path, underlying: "file is not valid UTF-8")
|
||||||
|
Self.logger.warning("readFile(\(path, privacy: .public)): not UTF-8")
|
||||||
|
return .failure(err)
|
||||||
|
}
|
||||||
|
return .success(s)
|
||||||
|
case .failure(let err):
|
||||||
|
return .failure(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func readFileDataResult(_ path: String) -> Result<Data, Error> {
|
||||||
|
do {
|
||||||
|
let data = try transport.readFile(path)
|
||||||
|
return .success(data)
|
||||||
|
} catch {
|
||||||
|
// Don't log "No such file" — that's a routine, expected case
|
||||||
|
// for optional files (skill.yaml, gateway_state.json before
|
||||||
|
// Hermes starts, ~/.hermes/memories/USER.md on fresh installs,
|
||||||
|
// etc.). The caller still gets the Result.failure so it can
|
||||||
|
// distinguish missing from present-but-unreadable.
|
||||||
|
// Log everything else — permission denied, connection drops,
|
||||||
|
// sqlite3 missing — since those are actionable diagnostics.
|
||||||
|
if !Self.isFileNotFound(error) {
|
||||||
|
Self.logger.warning("readFile(\(path, privacy: .public)) failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` iff the error represents "file does not exist" as opposed to
|
||||||
|
/// a permission / transport / parse failure. Used to suppress routine
|
||||||
|
/// logging for optional files while still surfacing real problems.
|
||||||
|
nonisolated private static func isFileNotFound(_ error: Error) -> Bool {
|
||||||
|
if let transportErr = error as? TransportError,
|
||||||
|
case .fileIO(_, let underlying) = transportErr {
|
||||||
|
return underlying.lowercased().contains("no such file")
|
||||||
|
}
|
||||||
|
// Cocoa NSFileNoSuchFileError (returned by LocalTransport when
|
||||||
|
// reading a missing file via FileManager).
|
||||||
|
let ns = error as NSError
|
||||||
|
if ns.domain == NSCocoaErrorDomain && ns.code == 260 { return true }
|
||||||
|
if ns.domain == NSPOSIXErrorDomain && ns.code == 2 { return true } // ENOENT
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a UTF-8 text file atomically through the transport. Matches the
|
/// Write a UTF-8 text file atomically through the transport. Matches the
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
|||||||
/// Display-friendly cost string, or nil if cost is unknown.
|
/// Display-friendly cost string, or nil if cost is unknown.
|
||||||
var costDisplay: String? {
|
var costDisplay: String? {
|
||||||
guard let input = costInput, let output = costOutput else { return nil }
|
guard let input = costInput, let output = costOutput else { return nil }
|
||||||
return String(format: "$%.2f / $%.2f", input, output)
|
let currency = FloatingPointFormatStyle<Double>.Currency.currency(code: "USD").precision(.fractionLength(2))
|
||||||
|
return "\(input.formatted(currency)) / \(output.formatted(currency))"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display-friendly context window ("200K", "1M", etc.).
|
/// Display-friendly context window ("200K", "1M", etc.).
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Thin wrapper around the macOS Keychain for template-config secrets.
|
||||||
|
/// Scarf doesn't have other Keychain users yet so this file is the one
|
||||||
|
/// place that touches the `Security` framework; keep it small and
|
||||||
|
/// auditable so a reader can tell at a glance what we store, under what
|
||||||
|
/// identifiers, and when items are removed.
|
||||||
|
///
|
||||||
|
/// **What we store.** Generic passwords (kSecClassGenericPassword) in
|
||||||
|
/// the login Keychain. Each item is identified by a (service, account)
|
||||||
|
/// pair derived from the template slug + field key + project-path hash
|
||||||
|
/// — see `TemplateKeychainRef.make`. The stored Data is the user's
|
||||||
|
/// raw secret bytes; we never transform or encode them.
|
||||||
|
///
|
||||||
|
/// **When items are written.** By `ProjectTemplateInstaller` after the
|
||||||
|
/// install preview is confirmed and the user has filled in the
|
||||||
|
/// configure sheet. By `TemplateConfigSheet` when the user edits a
|
||||||
|
/// secret field post-install.
|
||||||
|
///
|
||||||
|
/// **When items are removed.** By `ProjectTemplateUninstaller`,
|
||||||
|
/// iterating the lock file's `configKeychainItems` list. The login
|
||||||
|
/// Keychain is never swept for stray entries — if the lock is out of
|
||||||
|
/// sync we log + skip rather than guess which items are ours.
|
||||||
|
///
|
||||||
|
/// **What shows to the user.** macOS prompts "Scarf wants to access
|
||||||
|
/// the Keychain" the first time we read a secret in a given session.
|
||||||
|
/// User approves; subsequent reads in that session are silent. We
|
||||||
|
/// never bypass this — the prompt is the user's trust boundary.
|
||||||
|
struct ProjectConfigKeychain: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigKeychain")
|
||||||
|
|
||||||
|
/// Which Keychain to target. The default is the login Keychain
|
||||||
|
/// (`nil` uses the user's default chain). Tests pass an explicit
|
||||||
|
/// namespace suffix via `testServiceSuffix` — see `TemplateConfigTests` —
|
||||||
|
/// so integration tests can roundtrip without polluting real
|
||||||
|
/// user state.
|
||||||
|
let testServiceSuffix: String?
|
||||||
|
|
||||||
|
nonisolated init(testServiceSuffix: String? = nil) {
|
||||||
|
self.testServiceSuffix = testServiceSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write or overwrite the secret for (service, account). Tests
|
||||||
|
/// route their items through a distinct service prefix via
|
||||||
|
/// `testServiceSuffix` so they can't leak into the user's real
|
||||||
|
/// Keychain.
|
||||||
|
nonisolated func set(service: String, account: String, secret: Data) throws {
|
||||||
|
let svc = resolved(service: service)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: svc,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
// Try update first — cheaper than delete-then-add and doesn't
|
||||||
|
// trip macOS's "item already exists" if another thread raced us.
|
||||||
|
let update: [String: Any] = [
|
||||||
|
kSecValueData as String: secret,
|
||||||
|
]
|
||||||
|
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||||
|
if updateStatus == errSecSuccess { return }
|
||||||
|
if updateStatus != errSecItemNotFound {
|
||||||
|
throw Self.error(status: updateStatus, op: "update")
|
||||||
|
}
|
||||||
|
var insert = query
|
||||||
|
insert[kSecValueData as String] = secret
|
||||||
|
// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — stays in
|
||||||
|
// this device's Keychain, not synced via iCloud, usable after
|
||||||
|
// first unlock (so background cron triggers can read).
|
||||||
|
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
let addStatus = SecItemAdd(insert as CFDictionary, nil)
|
||||||
|
if addStatus != errSecSuccess {
|
||||||
|
throw Self.error(status: addStatus, op: "add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the secret for (service, account). Returns `nil` when
|
||||||
|
/// the item simply doesn't exist (user never set it, or an
|
||||||
|
/// uninstall already removed it). Throws on every other Keychain
|
||||||
|
/// error so callers don't silently treat "access denied" or
|
||||||
|
/// "corrupt keychain" as "no value."
|
||||||
|
nonisolated func get(service: String, account: String) throws -> Data? {
|
||||||
|
let svc = resolved(service: service)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: svc,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var result: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecItemNotFound { return nil }
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw Self.error(status: status, op: "get")
|
||||||
|
}
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the secret for (service, account). Absent item is a
|
||||||
|
/// no-op; any other failure throws. Called by
|
||||||
|
/// `ProjectTemplateUninstaller` for every item in
|
||||||
|
/// `TemplateLock.configKeychainItems`.
|
||||||
|
nonisolated func delete(service: String, account: String) throws {
|
||||||
|
let svc = resolved(service: service)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: svc,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
if status == errSecItemNotFound || status == errSecSuccess { return }
|
||||||
|
throw Self.error(status: status, op: "delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: apply the test suffix when in test mode.
|
||||||
|
nonisolated private func resolved(service: String) -> String {
|
||||||
|
guard let suffix = testServiceSuffix, !suffix.isEmpty else { return service }
|
||||||
|
return "\(service).\(suffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a useful NSError from a Keychain OSStatus. Logs at warning
|
||||||
|
/// — callers decide whether the failure is fatal.
|
||||||
|
nonisolated private static func error(status: OSStatus, op: String) -> NSError {
|
||||||
|
let description = (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error"
|
||||||
|
logger.warning("Keychain \(op, privacy: .public) failed: \(status) \(description, privacy: .public)")
|
||||||
|
return NSError(
|
||||||
|
domain: "com.scarf.keychain",
|
||||||
|
code: Int(status),
|
||||||
|
userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Keychain \(op) failed (\(status)): \(description)"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ref-shaped convenience layer
|
||||||
|
|
||||||
|
extension ProjectConfigKeychain {
|
||||||
|
/// Set a secret using a pre-built `TemplateKeychainRef`. Mirrors the
|
||||||
|
/// service/account plumbing every caller would otherwise repeat.
|
||||||
|
nonisolated func set(ref: TemplateKeychainRef, secret: Data) throws {
|
||||||
|
try set(service: ref.service, account: ref.account, secret: secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func get(ref: TemplateKeychainRef) throws -> Data? {
|
||||||
|
try get(service: ref.service, account: ref.account)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func delete(ref: TemplateKeychainRef) throws {
|
||||||
|
try delete(service: ref.service, account: ref.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Per-project configuration I/O: reads `<project>/.scarf/config.json`
|
||||||
|
/// into typed values, writes them back, resolves Keychain-backed secrets
|
||||||
|
/// on demand, and validates user-entered values against the schema.
|
||||||
|
///
|
||||||
|
/// Separation of concerns:
|
||||||
|
///
|
||||||
|
/// - **Schema authority.** `TemplateConfigSchema` lives in the bundle's
|
||||||
|
/// `template.json` and a copy is stashed at `<project>/.scarf/manifest.json`
|
||||||
|
/// at install time so the post-install editor works offline. This
|
||||||
|
/// service treats the schema as read-only input; `validateSchema`
|
||||||
|
/// checks structural invariants and is called by
|
||||||
|
/// `ProjectTemplateService` during install-plan building.
|
||||||
|
/// - **Value storage.** Non-secret values live inline in `config.json`;
|
||||||
|
/// secret values are Keychain references of the form
|
||||||
|
/// `"keychain://<service>/<account>"`. The service owns both halves
|
||||||
|
/// of that storage — callers never open `config.json` or touch the
|
||||||
|
/// Keychain directly.
|
||||||
|
/// - **Remote readiness.** All file I/O goes through
|
||||||
|
/// `ServerContext.makeTransport()` so when `ProjectTemplateInstaller`
|
||||||
|
/// eventually supports remote contexts, the config store comes along
|
||||||
|
/// for the ride. Keychain access stays local (it's a macOS-side thing
|
||||||
|
/// by definition — agents on remote Hermes installs would fetch
|
||||||
|
/// values via Scarf's channel, same as today).
|
||||||
|
struct ProjectConfigService: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigService")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
let keychain: ProjectConfigKeychain
|
||||||
|
|
||||||
|
nonisolated init(
|
||||||
|
context: ServerContext = .local,
|
||||||
|
keychain: ProjectConfigKeychain = ProjectConfigKeychain()
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paths
|
||||||
|
|
||||||
|
nonisolated static func configPath(for project: ProjectEntry) -> String {
|
||||||
|
project.path + "/.scarf/config.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func manifestCachePath(for project: ProjectEntry) -> String {
|
||||||
|
project.path + "/.scarf/manifest.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load / save on-disk config
|
||||||
|
|
||||||
|
/// Read + decode `<project>/.scarf/config.json`. Returns `nil`
|
||||||
|
/// cleanly when the file is absent (e.g. a project installed from
|
||||||
|
/// a schema-less template, or a hand-added project). Throws on
|
||||||
|
/// malformed JSON so the caller can surface a concrete error
|
||||||
|
/// rather than silently treating a corrupt file as missing.
|
||||||
|
nonisolated func load(project: ProjectEntry) throws -> ProjectConfigFile? {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let path = Self.configPath(for: project)
|
||||||
|
guard transport.fileExists(path) else { return nil }
|
||||||
|
let data = try transport.readFile(path)
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(ProjectConfigFile.self, from: data)
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("couldn't decode config.json at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `<project>/.scarf/config.json`. Secrets should already be
|
||||||
|
/// represented as `TemplateConfigValue.keychainRef` references here
|
||||||
|
/// — this service never inspects their plaintext.
|
||||||
|
nonisolated func save(
|
||||||
|
project: ProjectEntry,
|
||||||
|
templateId: String,
|
||||||
|
values: [String: TemplateConfigValue]
|
||||||
|
) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let file = ProjectConfigFile(
|
||||||
|
schemaVersion: 2,
|
||||||
|
templateId: templateId,
|
||||||
|
values: values,
|
||||||
|
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||||
|
)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(file)
|
||||||
|
let parent = (Self.configPath(for: project) as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
try transport.writeFile(Self.configPath(for: project), data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manifest cache (schema used by post-install editor)
|
||||||
|
|
||||||
|
/// Copy a template's `template.json` into `<project>/.scarf/manifest.json`
|
||||||
|
/// so the post-install "Configuration" button can render the form
|
||||||
|
/// offline. Called once by the installer after unpack + validate.
|
||||||
|
nonisolated func cacheManifest(project: ProjectEntry, manifestData: Data) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let path = Self.manifestCachePath(for: project)
|
||||||
|
let parent = (path as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
try transport.writeFile(path, data: manifestData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the cached manifest into a `ProjectTemplateManifest` so the
|
||||||
|
/// editor can look up field types + labels. Returns `nil` when the
|
||||||
|
/// project wasn't installed from a schemaful template.
|
||||||
|
nonisolated func loadCachedManifest(project: ProjectEntry) throws -> ProjectTemplateManifest? {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let path = Self.manifestCachePath(for: project)
|
||||||
|
guard transport.fileExists(path) else { return nil }
|
||||||
|
let data = try transport.readFile(path)
|
||||||
|
return try JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Secrets
|
||||||
|
|
||||||
|
/// Resolve a `keychainRef` value into the actual secret bytes.
|
||||||
|
/// Returns `nil` if the Keychain entry has been removed (e.g.
|
||||||
|
/// external user cleanup, a previous uninstall that didn't finish).
|
||||||
|
nonisolated func resolveSecret(ref value: TemplateConfigValue) throws -> Data? {
|
||||||
|
guard case .keychainRef(let uri) = value,
|
||||||
|
let ref = TemplateKeychainRef.parse(uri) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try keychain.get(ref: ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a freshly-entered secret. Returns the `keychainRef` value
|
||||||
|
/// suitable for writing into `config.json`.
|
||||||
|
nonisolated func storeSecret(
|
||||||
|
templateSlug: String,
|
||||||
|
fieldKey: String,
|
||||||
|
project: ProjectEntry,
|
||||||
|
secret: Data
|
||||||
|
) throws -> TemplateConfigValue {
|
||||||
|
let ref = TemplateKeychainRef.make(
|
||||||
|
templateSlug: templateSlug,
|
||||||
|
fieldKey: fieldKey,
|
||||||
|
projectPath: project.path
|
||||||
|
)
|
||||||
|
try keychain.set(ref: ref, secret: secret)
|
||||||
|
return .keychainRef(ref.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete every Keychain item tracked in `refs`. Absent items are
|
||||||
|
/// fine (uninstall may run after the user manually cleaned an
|
||||||
|
/// entry). Any other failure is logged and re-thrown so the
|
||||||
|
/// uninstaller can surface it.
|
||||||
|
nonisolated func deleteSecrets(refs: [TemplateKeychainRef]) throws {
|
||||||
|
for ref in refs {
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schema validation (author-facing; called at bundle inspect time)
|
||||||
|
|
||||||
|
/// Verify structural invariants on a schema: unique keys, known
|
||||||
|
/// types, enum options, secret-without-default rule, model
|
||||||
|
/// recommendation non-empty when present. Called by
|
||||||
|
/// `ProjectTemplateService.inspect` before buildPlan runs.
|
||||||
|
nonisolated static func validateSchema(_ schema: TemplateConfigSchema) throws {
|
||||||
|
var seen = Set<String>()
|
||||||
|
for field in schema.fields {
|
||||||
|
if !seen.insert(field.key).inserted {
|
||||||
|
throw TemplateConfigSchemaError.duplicateKey(field.key)
|
||||||
|
}
|
||||||
|
switch field.type {
|
||||||
|
case .enum:
|
||||||
|
let opts = field.options ?? []
|
||||||
|
guard !opts.isEmpty else {
|
||||||
|
throw TemplateConfigSchemaError.emptyEnumOptions(field.key)
|
||||||
|
}
|
||||||
|
var seenValues = Set<String>()
|
||||||
|
for opt in opts {
|
||||||
|
if !seenValues.insert(opt.value).inserted {
|
||||||
|
throw TemplateConfigSchemaError.duplicateEnumValue(key: field.key, value: opt.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .list:
|
||||||
|
let item = field.itemType ?? "string"
|
||||||
|
if item != "string" {
|
||||||
|
throw TemplateConfigSchemaError.unsupportedListItemType(key: field.key, itemType: item)
|
||||||
|
}
|
||||||
|
case .secret:
|
||||||
|
if field.defaultValue != nil {
|
||||||
|
throw TemplateConfigSchemaError.secretFieldHasDefault(field.key)
|
||||||
|
}
|
||||||
|
case .string, .text, .number, .bool:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let rec = schema.modelRecommendation {
|
||||||
|
if rec.preferred.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
throw TemplateConfigSchemaError.emptyModelPreferred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Value validation (runs on user input in the configure sheet)
|
||||||
|
|
||||||
|
/// Validate user-entered values against the schema. Returns one
|
||||||
|
/// `TemplateConfigValidationError` per problem. Empty array means
|
||||||
|
/// the form is submittable.
|
||||||
|
nonisolated static func validateValues(
|
||||||
|
_ values: [String: TemplateConfigValue],
|
||||||
|
against schema: TemplateConfigSchema
|
||||||
|
) -> [TemplateConfigValidationError] {
|
||||||
|
var errors: [TemplateConfigValidationError] = []
|
||||||
|
for field in schema.fields {
|
||||||
|
let value = values[field.key]
|
||||||
|
if field.required && !Self.hasMeaningfulValue(value, type: field.type) {
|
||||||
|
errors.append(.init(fieldKey: field.key, message: "\(field.label) is required."))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard let value else { continue }
|
||||||
|
switch field.type {
|
||||||
|
case .string, .text:
|
||||||
|
if case .string(let s) = value {
|
||||||
|
if let min = field.minLength, s.count < min {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be at least \(min) characters."))
|
||||||
|
}
|
||||||
|
if let max = field.maxLength, s.count > max {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be at most \(max) characters."))
|
||||||
|
}
|
||||||
|
if let pattern = field.pattern,
|
||||||
|
s.range(of: pattern, options: .regularExpression) == nil {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) doesn't match the expected format."))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be a string."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .number:
|
||||||
|
if case .number(let n) = value {
|
||||||
|
if let min = field.minNumber, n < min {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be ≥ \(min)."))
|
||||||
|
}
|
||||||
|
if let max = field.maxNumber, n > max {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be ≤ \(max)."))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be a number."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .bool:
|
||||||
|
if case .bool = value { /* ok */ } else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be true or false."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .enum:
|
||||||
|
if case .string(let s) = value {
|
||||||
|
let options = (field.options ?? []).map(\.value)
|
||||||
|
if !options.contains(s) {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be one of \(options.joined(separator: ", "))."))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be one of the predefined options."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .list:
|
||||||
|
if case .list(let items) = value {
|
||||||
|
if let min = field.minItems, items.count < min {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) needs at least \(min) item(s)."))
|
||||||
|
}
|
||||||
|
if let max = field.maxItems, items.count > max {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) accepts at most \(max) item(s)."))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be a list."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .secret:
|
||||||
|
if case .keychainRef = value { /* opaque — trust it */ } else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be supplied (Keychain entry missing)."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func hasMeaningfulValue(
|
||||||
|
_ value: TemplateConfigValue?,
|
||||||
|
type: TemplateConfigField.FieldType
|
||||||
|
) -> Bool {
|
||||||
|
guard let value else { return false }
|
||||||
|
switch (type, value) {
|
||||||
|
case (.string, .string(let s)), (.text, .string(let s)), (.enum, .string(let s)):
|
||||||
|
return !s.isEmpty
|
||||||
|
case (.number, .number):
|
||||||
|
return true
|
||||||
|
case (.bool, .bool):
|
||||||
|
return true
|
||||||
|
case (.list, .list(let arr)):
|
||||||
|
return !arr.isEmpty
|
||||||
|
case (.secret, .keychainRef):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
struct ProjectDashboardService: Sendable {
|
struct ProjectDashboardService: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
||||||
|
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
let transport: any ServerTransport
|
let transport: any ServerTransport
|
||||||
@@ -19,23 +21,28 @@ struct ProjectDashboardService: Sendable {
|
|||||||
do {
|
do {
|
||||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
|
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
|
||||||
return ProjectRegistry(projects: [])
|
return ProjectRegistry(projects: [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveRegistry(_ registry: ProjectRegistry) {
|
/// 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 {
|
||||||
let dir = context.paths.scarfDir
|
let dir = context.paths.scarfDir
|
||||||
if !transport.fileExists(dir) {
|
if !transport.fileExists(dir) {
|
||||||
do {
|
|
||||||
try transport.createDirectory(dir)
|
try transport.createDirectory(dir)
|
||||||
} catch {
|
|
||||||
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
let data = try JSONEncoder().encode(registry)
|
||||||
guard let data = try? JSONEncoder().encode(registry) else { return }
|
// Pretty-print for readability (agents may read this file).
|
||||||
// Pretty-print for readability (agents may read this file)
|
|
||||||
let writeData: Data
|
let writeData: Data
|
||||||
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||||
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||||
@@ -43,7 +50,7 @@ struct ProjectDashboardService: Sendable {
|
|||||||
} else {
|
} else {
|
||||||
writeData = data
|
writeData = data
|
||||||
}
|
}
|
||||||
try? transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
try transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dashboard
|
// MARK: - Dashboard
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Builds a `.scarftemplate` bundle from an existing Scarf project plus the
|
||||||
|
/// caller's selection of skills and cron jobs. Symmetric with the
|
||||||
|
/// `ProjectTemplateService` + `ProjectTemplateInstaller` pair — the output
|
||||||
|
/// of this exporter can be fed straight back to `inspect()` + `install()`.
|
||||||
|
struct ProjectTemplateExporter: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateExporter")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known filenames in the project root that map to specific agents. When
|
||||||
|
/// the author opts to include them, each is copied verbatim into
|
||||||
|
/// `instructions/` in the bundle.
|
||||||
|
nonisolated static let knownInstructionFiles: [String] = [
|
||||||
|
"CLAUDE.md",
|
||||||
|
"GEMINI.md",
|
||||||
|
".cursorrules",
|
||||||
|
".github/copilot-instructions.md"
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Author-facing description of what `export` will do with the given
|
||||||
|
/// selections. Shown in the export sheet so the user knows exactly
|
||||||
|
/// what's about to go into the bundle before saving.
|
||||||
|
struct ExportPlan: Sendable {
|
||||||
|
let templateId: String
|
||||||
|
let templateName: String
|
||||||
|
let templateVersion: String
|
||||||
|
let projectDir: String
|
||||||
|
let dashboardPresent: Bool
|
||||||
|
let agentsMdPresent: Bool
|
||||||
|
let readmePresent: Bool
|
||||||
|
let instructionFiles: [String]
|
||||||
|
let skillIds: [String]
|
||||||
|
let cronJobs: [HermesCronJob]
|
||||||
|
let memoryAppendix: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inputs collected by the export sheet.
|
||||||
|
struct ExportInputs: Sendable {
|
||||||
|
let project: ProjectEntry
|
||||||
|
let templateId: String
|
||||||
|
let templateName: String
|
||||||
|
let templateVersion: String
|
||||||
|
let description: String
|
||||||
|
let authorName: String?
|
||||||
|
let authorUrl: String?
|
||||||
|
let category: String?
|
||||||
|
let tags: [String]
|
||||||
|
let includeSkillIds: [String]
|
||||||
|
let includeCronJobIds: [String]
|
||||||
|
/// Raw markdown the author wants appended to installers' MEMORY.md.
|
||||||
|
/// `nil` to skip.
|
||||||
|
let memoryAppendix: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the project dir and report what a fresh export would include
|
||||||
|
/// given the caller's inputs. Does not write anything.
|
||||||
|
///
|
||||||
|
/// Existence checks go through the context's transport — the project
|
||||||
|
/// path comes from the registry on the active server and may be on a
|
||||||
|
/// remote filesystem (future remote-install support), where
|
||||||
|
/// `FileManager.default.fileExists` would silently return `false`.
|
||||||
|
nonisolated func previewPlan(for inputs: ExportInputs) -> ExportPlan {
|
||||||
|
let dir = inputs.project.path
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let dashboard = transport.fileExists(dir + "/.scarf/dashboard.json")
|
||||||
|
let readme = transport.fileExists(dir + "/README.md")
|
||||||
|
let agents = transport.fileExists(dir + "/AGENTS.md")
|
||||||
|
let instructions = Self.knownInstructionFiles.filter {
|
||||||
|
transport.fileExists(dir + "/" + $0)
|
||||||
|
}
|
||||||
|
let allJobs = HermesFileService(context: context).loadCronJobs()
|
||||||
|
let picked = allJobs.filter { inputs.includeCronJobIds.contains($0.id) }
|
||||||
|
return ExportPlan(
|
||||||
|
templateId: inputs.templateId,
|
||||||
|
templateName: inputs.templateName,
|
||||||
|
templateVersion: inputs.templateVersion,
|
||||||
|
projectDir: dir,
|
||||||
|
dashboardPresent: dashboard,
|
||||||
|
agentsMdPresent: agents,
|
||||||
|
readmePresent: readme,
|
||||||
|
instructionFiles: instructions,
|
||||||
|
skillIds: inputs.includeSkillIds,
|
||||||
|
cronJobs: picked,
|
||||||
|
memoryAppendix: inputs.memoryAppendix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the bundle and write it to `outputZipPath`. Throws if any
|
||||||
|
/// required file is missing or the zip step fails.
|
||||||
|
nonisolated func export(
|
||||||
|
inputs: ExportInputs,
|
||||||
|
outputZipPath: String
|
||||||
|
) throws {
|
||||||
|
let stagingDir = NSTemporaryDirectory() + "scarf-template-export-" + UUID().uuidString
|
||||||
|
try FileManager.default.createDirectory(atPath: stagingDir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: stagingDir) }
|
||||||
|
|
||||||
|
let plan = previewPlan(for: inputs)
|
||||||
|
|
||||||
|
guard plan.dashboardPresent else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("dashboard.json (expected at \(plan.projectDir)/.scarf/dashboard.json)")
|
||||||
|
}
|
||||||
|
guard plan.readmePresent else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("README.md (expected at \(plan.projectDir)/README.md)")
|
||||||
|
}
|
||||||
|
guard plan.agentsMdPresent else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("AGENTS.md (expected at \(plan.projectDir)/AGENTS.md)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required files. All source reads go through the context's
|
||||||
|
// transport — project paths come from the registry on the active
|
||||||
|
// server and may be on a remote filesystem. Destinations are in
|
||||||
|
// the local staging dir so Foundation writes are correct.
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
try copyFromHermes(plan.projectDir + "/.scarf/dashboard.json", to: stagingDir + "/dashboard.json", transport: transport)
|
||||||
|
try copyFromHermes(plan.projectDir + "/README.md", to: stagingDir + "/README.md", transport: transport)
|
||||||
|
try copyFromHermes(plan.projectDir + "/AGENTS.md", to: stagingDir + "/AGENTS.md", transport: transport)
|
||||||
|
|
||||||
|
// Optional per-agent instruction shims
|
||||||
|
for relative in plan.instructionFiles {
|
||||||
|
let source = plan.projectDir + "/" + relative
|
||||||
|
let destination = stagingDir + "/instructions/" + relative
|
||||||
|
try createParent(of: destination)
|
||||||
|
try copyFromHermes(source, to: destination, transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills (copied from the global skills dir)
|
||||||
|
if !plan.skillIds.isEmpty {
|
||||||
|
let skillsRoot = stagingDir + "/skills"
|
||||||
|
try FileManager.default.createDirectory(atPath: skillsRoot, withIntermediateDirectories: true)
|
||||||
|
let allSkills = HermesFileService(context: context).loadSkills()
|
||||||
|
.flatMap(\.skills)
|
||||||
|
for skillId in plan.skillIds {
|
||||||
|
guard let skill = allSkills.first(where: { $0.id == skillId }) else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("skills/" + skillId)
|
||||||
|
}
|
||||||
|
// The bundle uses a flat `skills/<name>/` layout (no
|
||||||
|
// category), matching what the installer expects. If two
|
||||||
|
// categories ship skills with the same `name`, the second
|
||||||
|
// collides — warn by refusing rather than silently
|
||||||
|
// overwriting.
|
||||||
|
let targetDir = skillsRoot + "/" + skill.name
|
||||||
|
if FileManager.default.fileExists(atPath: targetDir) {
|
||||||
|
throw ProjectTemplateError.conflictingFile(targetDir)
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(atPath: targetDir, withIntermediateDirectories: true)
|
||||||
|
for file in skill.files {
|
||||||
|
try copyFromHermes(skill.path + "/" + file, to: targetDir + "/" + file, transport: transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cron jobs (stripped to the create-CLI-shaped spec)
|
||||||
|
if !plan.cronJobs.isEmpty {
|
||||||
|
let specs = plan.cronJobs.map { Self.strip($0) }
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(specs)
|
||||||
|
let cronDir = stagingDir + "/cron"
|
||||||
|
try FileManager.default.createDirectory(atPath: cronDir, withIntermediateDirectories: true)
|
||||||
|
try data.write(to: URL(fileURLWithPath: cronDir + "/jobs.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory appendix. A write failure here would silently produce a
|
||||||
|
// bundle whose manifest claims `memory.append = true` but ships an
|
||||||
|
// empty/missing file — installers would then fail on
|
||||||
|
// contentClaimMismatch with no breadcrumb pointing back at the
|
||||||
|
// export step. Let the error propagate.
|
||||||
|
if let appendix = plan.memoryAppendix, !appendix.isEmpty {
|
||||||
|
let memDir = stagingDir + "/memory"
|
||||||
|
try FileManager.default.createDirectory(atPath: memDir, withIntermediateDirectories: true)
|
||||||
|
guard let data = appendix.data(using: .utf8) else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
|
||||||
|
}
|
||||||
|
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the source project was itself installed from a schemaful
|
||||||
|
// template, its `.scarf/manifest.json` carries the schema we
|
||||||
|
// want to forward to the exported bundle. We carry only the
|
||||||
|
// SCHEMA — never user values. Exporting must be safe on a
|
||||||
|
// project with live config: the schema is author-supplied
|
||||||
|
// metadata; the values in `config.json` are the current user's
|
||||||
|
// secrets or personal settings.
|
||||||
|
let forwardedSchema: TemplateConfigSchema? = try Self.readCachedSchema(
|
||||||
|
from: plan.projectDir
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bump schemaVersion to 2 when a schema is carried through;
|
||||||
|
// remain on 1 otherwise so schema-less exports stay
|
||||||
|
// byte-compatible with existing v2.2 catalog validators.
|
||||||
|
let schemaVersion = forwardedSchema == nil ? 1 : 2
|
||||||
|
|
||||||
|
// Manifest — claims exactly what we just wrote
|
||||||
|
let manifest = ProjectTemplateManifest(
|
||||||
|
schemaVersion: schemaVersion,
|
||||||
|
id: inputs.templateId,
|
||||||
|
name: inputs.templateName,
|
||||||
|
version: inputs.templateVersion,
|
||||||
|
minScarfVersion: nil,
|
||||||
|
minHermesVersion: nil,
|
||||||
|
author: inputs.authorName.map {
|
||||||
|
TemplateAuthor(name: $0, url: inputs.authorUrl)
|
||||||
|
},
|
||||||
|
description: inputs.description,
|
||||||
|
category: inputs.category,
|
||||||
|
tags: inputs.tags.isEmpty ? nil : inputs.tags,
|
||||||
|
icon: nil,
|
||||||
|
screenshots: nil,
|
||||||
|
contents: TemplateContents(
|
||||||
|
dashboard: true,
|
||||||
|
agentsMd: true,
|
||||||
|
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
|
||||||
|
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
|
||||||
|
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
|
||||||
|
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil,
|
||||||
|
config: forwardedSchema?.fields.count
|
||||||
|
),
|
||||||
|
config: forwardedSchema
|
||||||
|
)
|
||||||
|
let manifestEncoder = JSONEncoder()
|
||||||
|
manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let manifestData = try manifestEncoder.encode(manifest)
|
||||||
|
try manifestData.write(to: URL(fileURLWithPath: stagingDir + "/template.json"))
|
||||||
|
|
||||||
|
try zip(stagingDir: stagingDir, outputPath: outputZipPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
/// Copy a file whose source lives on the Hermes side (possibly remote)
|
||||||
|
/// into a local destination path under the staging dir. Using the
|
||||||
|
/// transport for the read keeps the exporter remote-ready; the write
|
||||||
|
/// goes through Foundation because the staging dir is always local to
|
||||||
|
/// the Mac running Scarf.
|
||||||
|
nonisolated private func copyFromHermes(
|
||||||
|
_ source: String,
|
||||||
|
to destination: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws {
|
||||||
|
let data = try transport.readFile(source)
|
||||||
|
try createParent(of: destination)
|
||||||
|
try data.write(to: URL(fileURLWithPath: destination))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func createParent(of path: String) throws {
|
||||||
|
let parent = (path as NSString).deletingLastPathComponent
|
||||||
|
if !FileManager.default.fileExists(atPath: parent) {
|
||||||
|
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the cached manifest from `<project>/.scarf/manifest.json` (if
|
||||||
|
/// present) and pull out just the config schema. Values in
|
||||||
|
/// `.scarf/config.json` are intentionally ignored — an exported
|
||||||
|
/// bundle carries the schema's shape, never the current user's
|
||||||
|
/// configured values.
|
||||||
|
nonisolated private static func readCachedSchema(from projectDir: String) throws -> TemplateConfigSchema? {
|
||||||
|
let manifestPath = projectDir + "/.scarf/manifest.json"
|
||||||
|
guard FileManager.default.fileExists(atPath: manifestPath) else { return nil }
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
|
||||||
|
// Use a bespoke decode rather than ProjectTemplateManifest so
|
||||||
|
// this helper stays resilient if the manifest shape evolves
|
||||||
|
// incompatibly in a future release.
|
||||||
|
struct OnlyConfig: Decodable { let config: TemplateConfigSchema? }
|
||||||
|
let onlyConfig = try JSONDecoder().decode(OnlyConfig.self, from: data)
|
||||||
|
return onlyConfig.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a live cron job (with runtime state) into the spec the
|
||||||
|
/// installer will feed back to `hermes cron create`. Only preserves
|
||||||
|
/// fields the CLI accepts.
|
||||||
|
nonisolated private static func strip(_ job: HermesCronJob) -> TemplateCronJobSpec {
|
||||||
|
let schedule: String = {
|
||||||
|
if let expr = job.schedule.expression, !expr.isEmpty { return expr }
|
||||||
|
if let runAt = job.schedule.runAt, !runAt.isEmpty { return runAt }
|
||||||
|
return job.schedule.display ?? ""
|
||||||
|
}()
|
||||||
|
return TemplateCronJobSpec(
|
||||||
|
name: job.name,
|
||||||
|
schedule: schedule,
|
||||||
|
prompt: job.prompt.isEmpty ? nil : job.prompt,
|
||||||
|
deliver: job.deliver?.isEmpty == false ? job.deliver : nil,
|
||||||
|
skills: (job.skills?.isEmpty == false) ? job.skills : nil,
|
||||||
|
repeatCount: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell out to `/usr/bin/zip -r` so the file ordering is deterministic
|
||||||
|
/// and the archive is standard — Apple-provided tools (and the system
|
||||||
|
/// `unzip` the installer uses) will read it without trouble.
|
||||||
|
nonisolated private func zip(stagingDir: String, outputPath: String) throws {
|
||||||
|
// `zip` writes relative paths based on the cwd it's invoked in. Chdir
|
||||||
|
// via Process.currentDirectoryURL so entries are `template.json`,
|
||||||
|
// `AGENTS.md`, etc., not absolute paths.
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||||
|
process.currentDirectoryURL = URL(fileURLWithPath: stagingDir)
|
||||||
|
process.arguments = ["-qq", "-r", outputPath, "."]
|
||||||
|
|
||||||
|
let outPipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
process.standardOutput = outPipe
|
||||||
|
process.standardError = errPipe
|
||||||
|
|
||||||
|
// Close both ends of each Pipe so we don't leak 4 fds per zip call.
|
||||||
|
func closePipes() {
|
||||||
|
try? outPipe.fileHandleForReading.close()
|
||||||
|
try? outPipe.fileHandleForWriting.close()
|
||||||
|
try? errPipe.fileHandleForReading.close()
|
||||||
|
try? errPipe.fileHandleForWriting.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
closePipes()
|
||||||
|
throw ProjectTemplateError.unzipFailed("zip failed to launch: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
process.waitUntilExit()
|
||||||
|
let errData = try? errPipe.fileHandleForReading.readToEnd()
|
||||||
|
closePipes()
|
||||||
|
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||||
|
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Executes a `TemplateInstallPlan`. All writes happen in one pass with
|
||||||
|
/// early-fail semantics: if any step throws, later steps don't run (but
|
||||||
|
/// earlier ones aren't reversed — v1 doesn't ship an atomic rollback). The
|
||||||
|
/// plan has already verified `projectDir` doesn't exist and no conflicting
|
||||||
|
/// file exists at target paths, so by the time we start writing, the
|
||||||
|
/// expected-error surface is small (mostly I/O failures).
|
||||||
|
struct ProjectTemplateInstaller: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateInstaller")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the plan. On success, returns the `ProjectEntry` that was added
|
||||||
|
/// to the registry so the caller can set `AppCoordinator.selectedProjectName`.
|
||||||
|
@discardableResult
|
||||||
|
nonisolated func install(plan: TemplateInstallPlan) throws -> ProjectEntry {
|
||||||
|
try preflight(plan: plan)
|
||||||
|
try createProjectFiles(plan: plan)
|
||||||
|
try createSkillsFiles(plan: plan)
|
||||||
|
try appendMemoryIfNeeded(plan: plan)
|
||||||
|
let cronJobNames = try createCronJobs(plan: plan)
|
||||||
|
let entry = try registerProject(plan: plan)
|
||||||
|
try writeLockFile(plan: plan, cronJobNames: cronJobNames)
|
||||||
|
Self.logger.info("installed template \(plan.manifest.id, privacy: .public) v\(plan.manifest.version, privacy: .public) into \(plan.projectDir, privacy: .public)")
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preflight
|
||||||
|
|
||||||
|
nonisolated private func preflight(plan: TemplateInstallPlan) throws {
|
||||||
|
// Plan was built on a recent snapshot of the filesystem; re-check the
|
||||||
|
// invariants at install time so concurrent activity between
|
||||||
|
// preview-and-confirm can't slip past us.
|
||||||
|
//
|
||||||
|
// All existence and read checks for paths that come from
|
||||||
|
// `context.paths` go through the transport — not `FileManager` —
|
||||||
|
// so this code works identically against a future remote
|
||||||
|
// `ServerContext`. See the warning on `ServerContext.readText`:
|
||||||
|
// "Foundation file APIs are LOCAL ONLY — using them with a remote
|
||||||
|
// path silently returns nil because the remote path doesn't exist
|
||||||
|
// on this Mac."
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
if transport.fileExists(plan.projectDir) {
|
||||||
|
throw ProjectTemplateError.projectDirExists(plan.projectDir)
|
||||||
|
}
|
||||||
|
for copy in plan.projectFiles where transport.fileExists(copy.destinationPath) {
|
||||||
|
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
|
||||||
|
}
|
||||||
|
for copy in plan.skillsFiles where transport.fileExists(copy.destinationPath) {
|
||||||
|
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
|
||||||
|
}
|
||||||
|
// Memory appendix collision: re-scan MEMORY.md for an existing block
|
||||||
|
// with the same template id so two installs of v1.0.0 can't
|
||||||
|
// double-append. A missing MEMORY.md is fine (treated as empty),
|
||||||
|
// but any *other* read failure (permissions, bad file type) gets
|
||||||
|
// logged + surfaced so we don't silently pretend MEMORY.md is empty
|
||||||
|
// and append over a broken file.
|
||||||
|
if plan.memoryAppendix != nil {
|
||||||
|
let existing: String
|
||||||
|
if transport.fileExists(plan.memoryPath) {
|
||||||
|
do {
|
||||||
|
let data = try transport.readFile(plan.memoryPath)
|
||||||
|
existing = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("failed to read MEMORY.md at \(plan.memoryPath, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
existing = ""
|
||||||
|
}
|
||||||
|
let marker = ProjectTemplateService.memoryBlockBeginMarker(templateId: plan.manifest.id)
|
||||||
|
if existing.contains(marker) {
|
||||||
|
throw ProjectTemplateError.memoryBlockAlreadyExists(plan.manifest.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Project files
|
||||||
|
|
||||||
|
nonisolated private func createProjectFiles(plan: TemplateInstallPlan) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
try transport.createDirectory(plan.projectDir)
|
||||||
|
for copy in plan.projectFiles {
|
||||||
|
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
|
||||||
|
// Empty `sourceRelativePath` is the "synthesized content"
|
||||||
|
// sentinel used by `buildPlan` for `.scarf/config.json`.
|
||||||
|
// The installer materialises config.json from
|
||||||
|
// `plan.configValues` here rather than copying a bundle
|
||||||
|
// file that doesn't exist.
|
||||||
|
if copy.sourceRelativePath.isEmpty {
|
||||||
|
if copy.destinationPath.hasSuffix("/.scarf/config.json") {
|
||||||
|
let data = try encodeConfigFile(plan: plan)
|
||||||
|
try transport.writeFile(copy.destinationPath, data: data)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw ProjectTemplateError.requiredFileMissing(
|
||||||
|
"synthesized file with unknown destination: \(copy.destinationPath)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||||
|
try transport.writeFile(copy.destinationPath, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialise `plan.configValues` into the `<project>/.scarf/config.json`
|
||||||
|
/// shape. Secrets appear as `keychainRef` URIs — the raw bytes were
|
||||||
|
/// routed into the Keychain by the VM before `install()` was called.
|
||||||
|
nonisolated private func encodeConfigFile(plan: TemplateInstallPlan) throws -> Data {
|
||||||
|
let file = ProjectConfigFile(
|
||||||
|
schemaVersion: 2,
|
||||||
|
templateId: plan.manifest.id,
|
||||||
|
values: plan.configValues,
|
||||||
|
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||||
|
)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
return try encoder.encode(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Skills
|
||||||
|
|
||||||
|
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
|
||||||
|
guard let namespaceDir = plan.skillsNamespaceDir else { return }
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
try transport.createDirectory(namespaceDir)
|
||||||
|
for copy in plan.skillsFiles {
|
||||||
|
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||||
|
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
try transport.writeFile(copy.destinationPath, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Memory
|
||||||
|
|
||||||
|
nonisolated private func appendMemoryIfNeeded(plan: TemplateInstallPlan) throws {
|
||||||
|
guard let appendix = plan.memoryAppendix else { return }
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let existing = (try? transport.readFile(plan.memoryPath)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||||
|
let combined = existing + appendix
|
||||||
|
guard let data = combined.data(using: .utf8) else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
|
||||||
|
}
|
||||||
|
let parent = (plan.memoryPath as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
try transport.writeFile(plan.memoryPath, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cron
|
||||||
|
|
||||||
|
/// Create each cron job via `hermes cron create`, then immediately pause
|
||||||
|
/// it (Hermes creates jobs enabled). Returns the list of resolved job
|
||||||
|
/// names, which is what the lock file records — we don't know the job
|
||||||
|
/// ids without parsing the create output, but the name is enough to
|
||||||
|
/// find + remove them later.
|
||||||
|
nonisolated private func createCronJobs(plan: TemplateInstallPlan) throws -> [String] {
|
||||||
|
guard !plan.cronJobs.isEmpty else { return [] }
|
||||||
|
|
||||||
|
let existingBefore = Set(HermesFileService(context: context).loadCronJobs().map(\.id))
|
||||||
|
var createdNames: [String] = []
|
||||||
|
|
||||||
|
for job in plan.cronJobs {
|
||||||
|
var args = ["cron", "create", "--name", job.name]
|
||||||
|
if let deliver = job.deliver, !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||||
|
if let repeatCount = job.repeatCount { args += ["--repeat", String(repeatCount)] }
|
||||||
|
for skill in job.skills ?? [] where !skill.isEmpty {
|
||||||
|
args += ["--skill", skill]
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (output, exit) = context.runHermes(args)
|
||||||
|
guard exit == 0 else {
|
||||||
|
throw ProjectTemplateError.cronCreateFailed(job: job.name, output: output)
|
||||||
|
}
|
||||||
|
createdNames.append(job.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff the current job set against the snapshot we took before
|
||||||
|
// creating — anything new belongs to this install and gets paused.
|
||||||
|
// We pause by id (not name) because `cron pause` takes an id.
|
||||||
|
let currentJobs = HermesFileService(context: context).loadCronJobs()
|
||||||
|
let newJobs = currentJobs.filter { !existingBefore.contains($0.id) && createdNames.contains($0.name) }
|
||||||
|
for job in newJobs {
|
||||||
|
let (_, exit) = context.runHermes(["cron", "pause", job.id])
|
||||||
|
if exit != 0 {
|
||||||
|
Self.logger.warning("couldn't pause newly-created cron job \(job.id, privacy: .public) — leaving enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdNames
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Registry
|
||||||
|
|
||||||
|
nonisolated private func registerProject(plan: TemplateInstallPlan) throws -> ProjectEntry {
|
||||||
|
let service = ProjectDashboardService(context: context)
|
||||||
|
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)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token substitution (install-time placeholder resolution)
|
||||||
|
|
||||||
|
/// Supported placeholders for template-author prompts. Keep the set
|
||||||
|
/// intentionally small — every token here becomes a load-bearing
|
||||||
|
/// part of the template format that we can't rename without
|
||||||
|
/// breaking existing bundles.
|
||||||
|
///
|
||||||
|
/// - `{{PROJECT_DIR}}`: absolute path of the newly-created project
|
||||||
|
/// directory. Required for cron prompts because Hermes doesn't
|
||||||
|
/// establish a CWD when firing cron jobs; relative paths would
|
||||||
|
/// resolve against whatever dir Hermes happens to be in.
|
||||||
|
///
|
||||||
|
/// - `{{TEMPLATE_ID}}`: the `owner/name` id from the manifest.
|
||||||
|
/// Less load-bearing; occasionally useful for tagging or
|
||||||
|
/// delivery targets that reference the template.
|
||||||
|
///
|
||||||
|
/// - `{{TEMPLATE_SLUG}}`: the sanitised slug the installer used
|
||||||
|
/// for the skills namespace and project dir name.
|
||||||
|
nonisolated static func substituteCronTokens(
|
||||||
|
_ prompt: String,
|
||||||
|
plan: TemplateInstallPlan
|
||||||
|
) -> String {
|
||||||
|
var out = prompt
|
||||||
|
out = out.replacingOccurrences(of: "{{PROJECT_DIR}}", with: plan.projectDir)
|
||||||
|
out = out.replacingOccurrences(of: "{{TEMPLATE_ID}}", with: plan.manifest.id)
|
||||||
|
out = out.replacingOccurrences(of: "{{TEMPLATE_SLUG}}", with: plan.manifest.slug)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock file
|
||||||
|
|
||||||
|
nonisolated private func writeLockFile(
|
||||||
|
plan: TemplateInstallPlan,
|
||||||
|
cronJobNames: [String]
|
||||||
|
) throws {
|
||||||
|
// Every value that ended up as a keychainRef in config.json gets
|
||||||
|
// tracked in the lock so the uninstaller can SecItemDelete each
|
||||||
|
// entry. Field keys are recorded separately for informational
|
||||||
|
// display in the uninstall preview sheet.
|
||||||
|
let keychainItems: [String]? = {
|
||||||
|
let refs = plan.configValues.compactMap { (_, value) -> String? in
|
||||||
|
if case .keychainRef(let uri) = value { return uri } else { return nil }
|
||||||
|
}
|
||||||
|
return refs.isEmpty ? nil : refs.sorted()
|
||||||
|
}()
|
||||||
|
let configFields: [String]? = {
|
||||||
|
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
|
||||||
|
return schema.fields.map(\.key)
|
||||||
|
}()
|
||||||
|
|
||||||
|
let lock = TemplateLock(
|
||||||
|
templateId: plan.manifest.id,
|
||||||
|
templateVersion: plan.manifest.version,
|
||||||
|
templateName: plan.manifest.name,
|
||||||
|
installedAt: ISO8601DateFormatter().string(from: Date()),
|
||||||
|
projectFiles: plan.projectFiles.map(\.destinationPath),
|
||||||
|
skillsNamespaceDir: plan.skillsNamespaceDir,
|
||||||
|
skillsFiles: plan.skillsFiles.map(\.destinationPath),
|
||||||
|
cronJobNames: cronJobNames,
|
||||||
|
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
|
||||||
|
configKeychainItems: keychainItems,
|
||||||
|
configFields: configFields
|
||||||
|
)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(lock)
|
||||||
|
let path = plan.projectDir + "/.scarf/template.lock.json"
|
||||||
|
try context.makeTransport().writeFile(path, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Reads, validates, and plans the install of a `.scarftemplate` bundle. Pure
|
||||||
|
/// — owns no state across calls. The installer (see
|
||||||
|
/// `ProjectTemplateInstaller`) consumes the `TemplateInstallPlan` this
|
||||||
|
/// produces.
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// 1. Unpack a `.scarftemplate` zip into a caller-owned temp directory.
|
||||||
|
/// 2. Parse `template.json` and validate it against the schema we know about.
|
||||||
|
/// 3. Walk the unpacked contents and verify they match the manifest's
|
||||||
|
/// `contents` claim (so a malicious bundle can't hide files from the
|
||||||
|
/// preview sheet).
|
||||||
|
/// 4. Produce a `TemplateInstallPlan` describing every concrete filesystem
|
||||||
|
/// op the installer will perform, given a parent directory the user
|
||||||
|
/// picked.
|
||||||
|
struct ProjectTemplateService: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateService")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inspection
|
||||||
|
|
||||||
|
/// Unpack the zip at `zipPath` into a fresh temp directory, parse and
|
||||||
|
/// validate the manifest, and walk the contents. Throws on any
|
||||||
|
/// inconsistency. On success, the caller owns `inspection.unpackedDir`
|
||||||
|
/// and must remove it once they're done.
|
||||||
|
nonisolated func inspect(zipPath: String) throws -> TemplateInspection {
|
||||||
|
let unpackedDir = try makeTempDir()
|
||||||
|
try unzip(zipPath: zipPath, intoDir: unpackedDir)
|
||||||
|
|
||||||
|
let manifestPath = unpackedDir + "/template.json"
|
||||||
|
guard FileManager.default.fileExists(atPath: manifestPath) else {
|
||||||
|
throw ProjectTemplateError.manifestMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifestData: Data
|
||||||
|
do {
|
||||||
|
manifestData = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
let manifest: ProjectTemplateManifest
|
||||||
|
do {
|
||||||
|
manifest = try JSONDecoder().decode(ProjectTemplateManifest.self, from: manifestData)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaVersion 1 is the original v2.2 bundle; 2 adds the
|
||||||
|
// optional `config` block. Both are valid. Newer versions get
|
||||||
|
// refused so the installer never silently misinterprets a
|
||||||
|
// future-shape bundle.
|
||||||
|
guard manifest.schemaVersion == 1 || manifest.schemaVersion == 2 else {
|
||||||
|
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the optional config schema at inspect time — a
|
||||||
|
// malformed schema (duplicate keys, secret-with-default, etc.)
|
||||||
|
// gets rejected before the user ever sees the preview sheet.
|
||||||
|
if let schema = manifest.config {
|
||||||
|
do {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.manifestParseFailed(
|
||||||
|
"invalid config schema: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = try Self.walk(unpackedDir)
|
||||||
|
let cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
|
||||||
|
try Self.verifyClaims(manifest: manifest, files: files, cronJobCount: cronJobs.count)
|
||||||
|
|
||||||
|
return TemplateInspection(
|
||||||
|
manifest: manifest,
|
||||||
|
unpackedDir: unpackedDir,
|
||||||
|
files: files,
|
||||||
|
cronJobs: cronJobs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planning
|
||||||
|
|
||||||
|
/// Turn an inspection into a concrete install plan given the parent
|
||||||
|
/// directory the user picked. The plan is deterministic — two calls with
|
||||||
|
/// the same inputs produce the same ops.
|
||||||
|
nonisolated func buildPlan(
|
||||||
|
inspection: TemplateInspection,
|
||||||
|
parentDir: String
|
||||||
|
) throws -> TemplateInstallPlan {
|
||||||
|
let manifest = inspection.manifest
|
||||||
|
let slug = manifest.slug
|
||||||
|
let projectDir = parentDir + "/" + slug
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: projectDir) {
|
||||||
|
throw ProjectTemplateError.projectDirExists(projectDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectFiles: [TemplateFileCopy] = [
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "README.md",
|
||||||
|
destinationPath: projectDir + "/README.md"
|
||||||
|
),
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "AGENTS.md",
|
||||||
|
destinationPath: projectDir + "/AGENTS.md"
|
||||||
|
),
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "dashboard.json",
|
||||||
|
destinationPath: projectDir + "/.scarf/dashboard.json"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
// Optional per-agent instruction shims. Each is copied verbatim to
|
||||||
|
// its conventional project-root path; we don't try to be clever.
|
||||||
|
let instructionRoot = "instructions"
|
||||||
|
for relative in (manifest.contents.instructions ?? []) {
|
||||||
|
let source = instructionRoot + "/" + relative
|
||||||
|
guard inspection.files.contains(source) else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing(source)
|
||||||
|
}
|
||||||
|
projectFiles.append(
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: source,
|
||||||
|
destinationPath: projectDir + "/" + relative
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namespaced skills: copied wholesale from skills/<name>/** into
|
||||||
|
// ~/.hermes/skills/templates/<slug>/<name>/**.
|
||||||
|
var skillsFiles: [TemplateFileCopy] = []
|
||||||
|
var skillsNamespaceDir: String? = nil
|
||||||
|
if let skillNames = manifest.contents.skills, !skillNames.isEmpty {
|
||||||
|
let namespaceDir = context.paths.skillsDir + "/templates/" + slug
|
||||||
|
skillsNamespaceDir = namespaceDir
|
||||||
|
for skillName in skillNames {
|
||||||
|
let prefix = "skills/" + skillName + "/"
|
||||||
|
let skillFiles = inspection.files.filter { $0.hasPrefix(prefix) }
|
||||||
|
guard !skillFiles.isEmpty else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing(prefix)
|
||||||
|
}
|
||||||
|
for relative in skillFiles {
|
||||||
|
let suffix = String(relative.dropFirst("skills/".count))
|
||||||
|
skillsFiles.append(
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: relative,
|
||||||
|
destinationPath: namespaceDir + "/" + suffix
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cron jobs: always prefix name with the template tag so users can
|
||||||
|
// find and remove them later. Jobs ship disabled — the installer
|
||||||
|
// pauses each one immediately after `cron create`.
|
||||||
|
let cronJobs: [TemplateCronJobSpec] = inspection.cronJobs.map { job in
|
||||||
|
TemplateCronJobSpec(
|
||||||
|
name: "[tmpl:\(manifest.id)] \(job.name)",
|
||||||
|
schedule: job.schedule,
|
||||||
|
prompt: job.prompt,
|
||||||
|
deliver: job.deliver,
|
||||||
|
skills: job.skills,
|
||||||
|
repeatCount: job.repeatCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory appendix: wrap whatever the template ships in
|
||||||
|
// begin/end markers so an uninstall can find and remove exactly the
|
||||||
|
// bytes this template added. `verifyClaims` already guaranteed the
|
||||||
|
// file is present — so a read error here means something unusual
|
||||||
|
// (permissions, encoding, etc.); surface it with the real
|
||||||
|
// `error.localizedDescription` rather than hiding behind a
|
||||||
|
// generic "file missing."
|
||||||
|
var memoryAppendix: String? = nil
|
||||||
|
if manifest.contents.memory?.append == true {
|
||||||
|
let appendSource = inspection.unpackedDir + "/memory/append.md"
|
||||||
|
let raw: String
|
||||||
|
do {
|
||||||
|
raw = try String(contentsOf: URL(fileURLWithPath: appendSource), encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("failed to read memory/append.md in unpacked bundle: \(error.localizedDescription, privacy: .public)")
|
||||||
|
throw ProjectTemplateError.manifestParseFailed("memory/append.md: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
memoryAppendix = Self.wrapMemoryBlock(
|
||||||
|
templateId: manifest.id,
|
||||||
|
templateVersion: manifest.version,
|
||||||
|
body: raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration schema + manifest cache. The installer writes
|
||||||
|
// `.scarf/config.json` (non-secret values) + `.scarf/manifest.json`
|
||||||
|
// (schema cache used by the post-install editor) when the
|
||||||
|
// template declares a non-empty schema. Both paths go into
|
||||||
|
// projectFiles so the uninstaller picks them up via the lock.
|
||||||
|
var configSchema: TemplateConfigSchema? = nil
|
||||||
|
var manifestCachePath: String? = nil
|
||||||
|
if let schema = manifest.config, !schema.isEmpty {
|
||||||
|
configSchema = schema
|
||||||
|
let configPath = projectDir + "/.scarf/config.json"
|
||||||
|
projectFiles.append(
|
||||||
|
// Source is synthesized by the installer from configValues;
|
||||||
|
// no file in the unpacked bundle maps to this entry. We use
|
||||||
|
// an empty `sourceRelativePath` as the "no physical source"
|
||||||
|
// sentinel — the installer special-cases it below (see
|
||||||
|
// ProjectTemplateInstaller.createProjectFiles).
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "",
|
||||||
|
destinationPath: configPath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let cachePath = projectDir + "/.scarf/manifest.json"
|
||||||
|
manifestCachePath = cachePath
|
||||||
|
projectFiles.append(
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "template.json",
|
||||||
|
destinationPath: cachePath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TemplateInstallPlan(
|
||||||
|
manifest: manifest,
|
||||||
|
unpackedDir: inspection.unpackedDir,
|
||||||
|
projectDir: projectDir,
|
||||||
|
projectFiles: projectFiles,
|
||||||
|
skillsNamespaceDir: skillsNamespaceDir,
|
||||||
|
skillsFiles: skillsFiles,
|
||||||
|
cronJobs: cronJobs,
|
||||||
|
memoryAppendix: memoryAppendix,
|
||||||
|
memoryPath: context.paths.memoryMD,
|
||||||
|
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context),
|
||||||
|
configSchema: configSchema,
|
||||||
|
configValues: [:], // filled in by TemplateInstallerViewModel before install()
|
||||||
|
manifestCachePath: manifestCachePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
/// Remove a temp dir created by `inspect`. Safe to call if it already
|
||||||
|
/// doesn't exist (install or cancel flows both end here).
|
||||||
|
nonisolated func cleanupTempDir(_ path: String) {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Memory block helpers (installer + future uninstaller share these)
|
||||||
|
|
||||||
|
nonisolated static func memoryBlockBeginMarker(templateId: String) -> String {
|
||||||
|
"<!-- scarf-template:\(templateId):begin -->"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func memoryBlockEndMarker(templateId: String) -> String {
|
||||||
|
"<!-- scarf-template:\(templateId):end -->"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func wrapMemoryBlock(
|
||||||
|
templateId: String,
|
||||||
|
templateVersion: String,
|
||||||
|
body: String
|
||||||
|
) -> String {
|
||||||
|
let begin = memoryBlockBeginMarker(templateId: templateId)
|
||||||
|
let end = memoryBlockEndMarker(templateId: templateId)
|
||||||
|
return "\n\n\(begin) v\(templateVersion)\n\(body)\n\(end)\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private nonisolated func makeTempDir() throws -> String {
|
||||||
|
let base = NSTemporaryDirectory() + "scarf-template-" + UUID().uuidString
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
atPath: base,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell out to `/usr/bin/unzip` — matches the existing profile-export
|
||||||
|
/// pattern (`hermes profile import` shells to `unzip`) and avoids
|
||||||
|
/// pulling in a third-party zip library.
|
||||||
|
private nonisolated func unzip(zipPath: String, intoDir: String) throws {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||||
|
process.arguments = ["-qq", "-o", zipPath, "-d", intoDir]
|
||||||
|
|
||||||
|
let outPipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
process.standardOutput = outPipe
|
||||||
|
process.standardError = errPipe
|
||||||
|
|
||||||
|
// Foundation dup()s these handles into the child on `run()`, but the
|
||||||
|
// parent copies stay open until explicitly released. Both ends must
|
||||||
|
// be closed or each Process spawn leaks 4 fds.
|
||||||
|
func closePipes() {
|
||||||
|
try? outPipe.fileHandleForReading.close()
|
||||||
|
try? outPipe.fileHandleForWriting.close()
|
||||||
|
try? errPipe.fileHandleForReading.close()
|
||||||
|
try? errPipe.fileHandleForWriting.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
closePipes()
|
||||||
|
throw ProjectTemplateError.unzipFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
process.waitUntilExit()
|
||||||
|
let errData = try? errPipe.fileHandleForReading.readToEnd()
|
||||||
|
closePipes()
|
||||||
|
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||||
|
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively walk `dir` and return every file (not directory) as a
|
||||||
|
/// path relative to `dir`. Skips symlinks entirely — templates should
|
||||||
|
/// never contain them, and following them could escape the unpack dir.
|
||||||
|
///
|
||||||
|
/// Both the base dir and the enumerated URLs are resolved via
|
||||||
|
/// `resolvingSymlinksInPath` before comparison. On macOS, temp dirs
|
||||||
|
/// under `/var/folders/…` resolve to `/private/var/folders/…`, so a
|
||||||
|
/// naive string-prefix check would produce malformed relative paths
|
||||||
|
/// when the base is unresolved but enumerated URLs are resolved.
|
||||||
|
nonisolated private static func walk(_ dir: String) throws -> [String] {
|
||||||
|
var results: [String] = []
|
||||||
|
let baseURL = URL(fileURLWithPath: dir).resolvingSymlinksInPath()
|
||||||
|
let basePath = baseURL.path.hasSuffix("/") ? baseURL.path : baseURL.path + "/"
|
||||||
|
let enumerator = FileManager.default.enumerator(
|
||||||
|
at: baseURL,
|
||||||
|
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)
|
||||||
|
while let url = enumerator?.nextObject() as? URL {
|
||||||
|
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||||
|
if values.isSymbolicLink == true {
|
||||||
|
throw ProjectTemplateError.unsafeZipEntry(url.path)
|
||||||
|
}
|
||||||
|
guard values.isRegularFile == true else { continue }
|
||||||
|
var full = url.resolvingSymlinksInPath().path
|
||||||
|
if full.hasPrefix(basePath) {
|
||||||
|
full.removeFirst(basePath.count)
|
||||||
|
}
|
||||||
|
if full.contains("..") {
|
||||||
|
throw ProjectTemplateError.unsafeZipEntry(full)
|
||||||
|
}
|
||||||
|
results.append(full)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func readCronJobs(unpackedDir: String) throws -> [TemplateCronJobSpec] {
|
||||||
|
let path = unpackedDir + "/cron/jobs.json"
|
||||||
|
guard FileManager.default.fileExists(atPath: path) else { return [] }
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("cron/jobs.json")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([TemplateCronJobSpec].self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.manifestParseFailed("cron/jobs.json: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the manifest's `contents` claim exactly matches the unpacked
|
||||||
|
/// files. Any mismatch — claimed-but-missing or present-but-unclaimed —
|
||||||
|
/// throws, so the preview sheet the user sees is always accurate.
|
||||||
|
nonisolated private static func verifyClaims(
|
||||||
|
manifest: ProjectTemplateManifest,
|
||||||
|
files: [String],
|
||||||
|
cronJobCount: Int
|
||||||
|
) throws {
|
||||||
|
let fileSet = Set(files)
|
||||||
|
|
||||||
|
if manifest.contents.dashboard {
|
||||||
|
if !fileSet.contains("dashboard.json") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if manifest.contents.agentsMd {
|
||||||
|
if !fileSet.contains("AGENTS.md") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// README and AGENTS are always required; dashboard is always required
|
||||||
|
// per spec. `contents.dashboard`/`contents.agentsMd` exist so a future
|
||||||
|
// schema can relax those rules; for v1 we hard-require them regardless.
|
||||||
|
if !fileSet.contains("README.md") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("README.md")
|
||||||
|
}
|
||||||
|
if !fileSet.contains("AGENTS.md") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
|
||||||
|
}
|
||||||
|
if !fileSet.contains("dashboard.json") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let claimed = manifest.contents.instructions {
|
||||||
|
for rel in claimed {
|
||||||
|
let full = "instructions/" + rel
|
||||||
|
if !fileSet.contains(full) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest lists \(full) but the file is missing from the bundle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let present = fileSet.filter { $0.hasPrefix("instructions/") }
|
||||||
|
let claimedFull = Set(claimed.map { "instructions/" + $0 })
|
||||||
|
if let extra = present.first(where: { !claimedFull.contains($0) }) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"bundle contains \(extra) but it's not listed in manifest.contents.instructions"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if fileSet.contains(where: { $0.hasPrefix("instructions/") }) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"bundle has instructions/ files but manifest.contents.instructions is missing"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let claimed = manifest.contents.skills {
|
||||||
|
for name in claimed {
|
||||||
|
let prefix = "skills/" + name + "/"
|
||||||
|
if !fileSet.contains(where: { $0.hasPrefix(prefix) }) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest lists skill \(name) but skills/\(name)/ has no files"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let presentSkills = Set(fileSet.compactMap { path -> String? in
|
||||||
|
guard path.hasPrefix("skills/") else { return nil }
|
||||||
|
let rest = path.dropFirst("skills/".count)
|
||||||
|
return rest.split(separator: "/", maxSplits: 1).first.map(String.init)
|
||||||
|
})
|
||||||
|
let claimedSet = Set(claimed)
|
||||||
|
if let extra = presentSkills.subtracting(claimedSet).first {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"bundle contains skills/\(extra)/ but it's not listed in manifest.contents.skills"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if fileSet.contains(where: { $0.hasPrefix("skills/") }) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"bundle contains skills/ but manifest.contents.skills is missing"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let claimedCron = manifest.contents.cron ?? 0
|
||||||
|
if claimedCron != cronJobCount {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest.contents.cron=\(claimedCron) but bundle contains \(cronJobCount) cron jobs"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasMemoryFile = fileSet.contains("memory/append.md")
|
||||||
|
let claimsMemory = manifest.contents.memory?.append == true
|
||||||
|
if claimsMemory != hasMemoryFile {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest.contents.memory.append=\(claimsMemory) disagrees with memory/append.md presence=\(hasMemoryFile)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config claim must match the schema's actual field count so
|
||||||
|
// the preview sheet is honest about the size of the configure
|
||||||
|
// step. `nil` in contents means "no schema" just like `0`;
|
||||||
|
// we normalise both to 0 before comparing.
|
||||||
|
let claimedConfig = manifest.contents.config ?? 0
|
||||||
|
let actualConfig = manifest.config?.fields.count ?? 0
|
||||||
|
if claimedConfig != actualConfig {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest.contents.config=\(claimedConfig) but config.schema has \(actualConfig) field(s)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a project-registry name that doesn't collide. Deterministic
|
||||||
|
/// — given the same existing registry, always returns the same answer.
|
||||||
|
nonisolated private static func uniqueProjectName(
|
||||||
|
preferred: String,
|
||||||
|
context: ServerContext
|
||||||
|
) -> String {
|
||||||
|
let existing = Set(ProjectDashboardService(context: context).loadRegistry().projects.map(\.name))
|
||||||
|
if !existing.contains(preferred) { return preferred }
|
||||||
|
var i = 2
|
||||||
|
while existing.contains("\(preferred) \(i)") {
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return "\(preferred) \(i)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Reverses the work of `ProjectTemplateInstaller`, driven by the
|
||||||
|
/// `<project>/.scarf/template.lock.json` the installer dropped. Symmetric
|
||||||
|
/// with the installer: `loadUninstallPlan(for:)` builds a plan the preview
|
||||||
|
/// sheet can display honestly; `uninstall(plan:)` executes it. No hidden
|
||||||
|
/// side effects — every path the uninstaller touches is in the plan.
|
||||||
|
///
|
||||||
|
/// **User-added files are preserved.** The lock records exactly what the
|
||||||
|
/// installer wrote; any file the user created in the project dir after
|
||||||
|
/// install (e.g. a `sites.txt` or `status-log.md` authored by the agent
|
||||||
|
/// on first run) is listed as an "extra entry" in the plan and left on
|
||||||
|
/// disk. If the project dir ends up empty after removing lock-tracked
|
||||||
|
/// files, the dir itself is removed; otherwise the dir (with user content)
|
||||||
|
/// stays.
|
||||||
|
struct ProjectTemplateUninstaller: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateUninstaller")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detection
|
||||||
|
|
||||||
|
/// Is the given project installed from a template that we can
|
||||||
|
/// uninstall cleanly? Cheap — just a file-existence check on the lock
|
||||||
|
/// path.
|
||||||
|
nonisolated func isTemplateInstalled(project: ProjectEntry) -> Bool {
|
||||||
|
context.makeTransport().fileExists(lockPath(for: project))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planning
|
||||||
|
|
||||||
|
/// Read the lock file, walk the filesystem + cron list, and produce a
|
||||||
|
/// plan listing every op the uninstaller will perform. Does not
|
||||||
|
/// modify anything.
|
||||||
|
nonisolated func loadUninstallPlan(for project: ProjectEntry) throws -> TemplateUninstallPlan {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let path = lockPath(for: project)
|
||||||
|
guard transport.fileExists(path) else {
|
||||||
|
throw ProjectTemplateError.lockFileMissing(path)
|
||||||
|
}
|
||||||
|
let lockData: Data
|
||||||
|
do {
|
||||||
|
lockData = try transport.readFile(path)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
let lock: TemplateLock
|
||||||
|
do {
|
||||||
|
lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partition tracked project files into present vs. already-gone.
|
||||||
|
// The lock file itself is always in `projectFiles` — the installer
|
||||||
|
// doesn't explicitly record it, but the preview sheet and the
|
||||||
|
// execute step must remove it.
|
||||||
|
var lockTrackedFiles = lock.projectFiles
|
||||||
|
lockTrackedFiles.append(path)
|
||||||
|
var toRemove: [String] = []
|
||||||
|
var alreadyGone: [String] = []
|
||||||
|
for file in lockTrackedFiles {
|
||||||
|
if transport.fileExists(file) {
|
||||||
|
toRemove.append(file)
|
||||||
|
} else {
|
||||||
|
alreadyGone.append(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the project dir for entries that AREN'T in the lock — these
|
||||||
|
// are user-added and we preserve them. An empty project dir (after
|
||||||
|
// removing lock-tracked files) gets removed too.
|
||||||
|
let trackedSet = Set(lockTrackedFiles)
|
||||||
|
let extras = try enumerateProjectDirExtras(
|
||||||
|
projectDir: project.path,
|
||||||
|
trackedPaths: trackedSet,
|
||||||
|
transport: transport
|
||||||
|
)
|
||||||
|
let projectDirBecomesEmpty = extras.isEmpty
|
||||||
|
|
||||||
|
// Resolve cron job ids by matching lock names against the live
|
||||||
|
// list. Names that no longer exist go into the already-gone bucket
|
||||||
|
// — the user likely removed them by hand.
|
||||||
|
let currentJobs = HermesFileService(context: context).loadCronJobs()
|
||||||
|
var cronToRemove: [(id: String, name: String)] = []
|
||||||
|
var cronGone: [String] = []
|
||||||
|
for name in lock.cronJobNames {
|
||||||
|
if let match = currentJobs.first(where: { $0.name == name }) {
|
||||||
|
cronToRemove.append((id: match.id, name: match.name))
|
||||||
|
} else {
|
||||||
|
cronGone.append(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory block detection. The installer wraps its appendix between
|
||||||
|
// `<!-- scarf-template:<id>:begin -->` / `:end -->` markers; look
|
||||||
|
// for the begin marker in the current MEMORY.md. If it's missing
|
||||||
|
// (never installed, or removed by hand) we simply skip the memory
|
||||||
|
// strip step.
|
||||||
|
let memoryPath = context.paths.memoryMD
|
||||||
|
var memoryBlockPresent = false
|
||||||
|
if lock.memoryBlockId != nil {
|
||||||
|
if transport.fileExists(memoryPath),
|
||||||
|
let data = try? transport.readFile(memoryPath),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(
|
||||||
|
templateId: lock.memoryBlockId!
|
||||||
|
)
|
||||||
|
memoryBlockPresent = text.contains(beginMarker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TemplateUninstallPlan(
|
||||||
|
lock: lock,
|
||||||
|
project: project,
|
||||||
|
projectFilesToRemove: toRemove,
|
||||||
|
projectFilesAlreadyGone: alreadyGone,
|
||||||
|
extraProjectEntries: extras,
|
||||||
|
projectDirBecomesEmpty: projectDirBecomesEmpty,
|
||||||
|
skillsNamespaceDir: lock.skillsNamespaceDir,
|
||||||
|
cronJobsToRemove: cronToRemove,
|
||||||
|
cronJobsAlreadyGone: cronGone,
|
||||||
|
memoryBlockPresent: memoryBlockPresent,
|
||||||
|
memoryPath: memoryPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Execution
|
||||||
|
|
||||||
|
/// Execute the plan. Non-atomic: steps run in order, and if any step
|
||||||
|
/// throws, later steps don't run. v1 doesn't ship rollback — the lock
|
||||||
|
/// file itself is only removed at the very end, so a mid-flight
|
||||||
|
/// failure leaves enough breadcrumbs for the user to retry or finish
|
||||||
|
/// by hand.
|
||||||
|
nonisolated func uninstall(plan: TemplateUninstallPlan) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
|
||||||
|
// 1. Project files (tracked only — user additions untouched).
|
||||||
|
for file in plan.projectFilesToRemove {
|
||||||
|
do {
|
||||||
|
try transport.removeFile(file)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("couldn't remove project file \(file, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
// keep going — partial cleanup is better than bailing and
|
||||||
|
// leaving orphan skills/cron state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if plan.projectDirBecomesEmpty, transport.fileExists(plan.project.path) {
|
||||||
|
do {
|
||||||
|
try transport.removeFile(plan.project.path)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("couldn't remove empty project dir \(plan.project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Skills namespace dir (always removed wholesale — it's
|
||||||
|
// isolated, never mixed with user skills).
|
||||||
|
if let skillsDir = plan.skillsNamespaceDir, transport.fileExists(skillsDir) {
|
||||||
|
try removeRecursively(skillsDir, transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cron jobs via CLI — `hermes cron remove <id>`. A non-zero
|
||||||
|
// exit gets logged but doesn't abort the uninstall; leaving a
|
||||||
|
// stray cron job is better than leaving it AND the skills/memory
|
||||||
|
// state that was supposed to pair with it.
|
||||||
|
for job in plan.cronJobsToRemove {
|
||||||
|
let (output, exit) = context.runHermes(["cron", "remove", job.id])
|
||||||
|
if exit != 0 {
|
||||||
|
Self.logger.warning("failed to remove cron job \(job.id, privacy: .public) \(job.name, privacy: .public): \(output, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Memory block — strip the bracketed block in place. Safe
|
||||||
|
// when the block is absent; we already decided presence in the
|
||||||
|
// plan and only come here when `memoryBlockPresent` was true
|
||||||
|
// AND the plan recorded a memoryBlockId.
|
||||||
|
if plan.memoryBlockPresent, let blockId = plan.lock.memoryBlockId {
|
||||||
|
try stripMemoryBlock(blockId: blockId, memoryPath: plan.memoryPath, transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4a. Config Keychain items — remove every secret the template's
|
||||||
|
// install step stashed in the login Keychain. Items that were
|
||||||
|
// already deleted (e.g. user cleaned them with Keychain Access)
|
||||||
|
// hit the `errSecItemNotFound` no-op path inside the wrapper, so
|
||||||
|
// a stale lock doesn't abort the rest of the uninstall.
|
||||||
|
let keychain = ProjectConfigKeychain()
|
||||||
|
for uri in plan.lock.configKeychainItems ?? [] {
|
||||||
|
guard let ref = TemplateKeychainRef.parse(uri) else {
|
||||||
|
Self.logger.warning("lock recorded unparseable keychain uri \(uri, privacy: .public); skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("couldn't delete keychain item \(uri, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Projects registry — remove the entry by path (more stable
|
||||||
|
// than name: user may have renamed the project in the UI).
|
||||||
|
let dashboardService = ProjectDashboardService(context: context)
|
||||||
|
var registry = dashboardService.loadRegistry()
|
||||||
|
registry.projects.removeAll { $0.path == plan.project.path }
|
||||||
|
// saveRegistry throws now — log a write failure but don't abort
|
||||||
|
// the uninstall. Every earlier step already completed (files
|
||||||
|
// removed, skills removed, cron jobs removed, memory stripped,
|
||||||
|
// Keychain cleared); failing here leaves a stale registry row
|
||||||
|
// pointing at a deleted project — cosmetic and easy to fix
|
||||||
|
// from the sidebar.
|
||||||
|
do {
|
||||||
|
try dashboardService.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("uninstall couldn't rewrite projects registry: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
nonisolated private func lockPath(for project: ProjectEntry) -> String {
|
||||||
|
project.path + "/.scarf/template.lock.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the project dir and return the absolute paths of every entry
|
||||||
|
/// not in `trackedPaths`. `.scarf/` (and its remaining contents after
|
||||||
|
/// the lock is recorded) is filtered out because the installer owns
|
||||||
|
/// that directory entirely — if the user dropped a file into it,
|
||||||
|
/// that's on them, but the common case is that `.scarf/` only holds
|
||||||
|
/// our dashboard.json + template.lock.json.
|
||||||
|
nonisolated private func enumerateProjectDirExtras(
|
||||||
|
projectDir: String,
|
||||||
|
trackedPaths: Set<String>,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws -> [String] {
|
||||||
|
guard transport.fileExists(projectDir) else { return [] }
|
||||||
|
var extras: [String] = []
|
||||||
|
let entries: [String]
|
||||||
|
do {
|
||||||
|
entries = try transport.listDirectory(projectDir)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
for entry in entries {
|
||||||
|
let full = projectDir + "/" + entry
|
||||||
|
// Skip the .scarf/ dir entirely when deciding "does the
|
||||||
|
// project dir have user content?" — the only files we put
|
||||||
|
// there (dashboard.json + lock) are tracked already, and
|
||||||
|
// if they're still there the overall project is not yet
|
||||||
|
// "empty."
|
||||||
|
if entry == ".scarf" { continue }
|
||||||
|
if trackedPaths.contains(full) { continue }
|
||||||
|
extras.append(full)
|
||||||
|
}
|
||||||
|
return extras
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively delete a directory via the transport. The transport's
|
||||||
|
/// `removeFile` works on files and on empty directories; we walk
|
||||||
|
/// children first, then remove the now-empty parent.
|
||||||
|
nonisolated private func removeRecursively(
|
||||||
|
_ path: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws {
|
||||||
|
guard transport.fileExists(path) else { return }
|
||||||
|
if transport.stat(path)?.isDirectory != true {
|
||||||
|
try transport.removeFile(path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let entries = (try? transport.listDirectory(path)) ?? []
|
||||||
|
for entry in entries {
|
||||||
|
try removeRecursively(path + "/" + entry, transport: transport)
|
||||||
|
}
|
||||||
|
try transport.removeFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the `<!-- scarf-template:<id>:begin --> … :end -->` block
|
||||||
|
/// from MEMORY.md, preserving everything else. A missing end marker
|
||||||
|
/// is logged but doesn't fail — we strip from the begin marker to
|
||||||
|
/// EOF in that case, on the theory that a broken template block is
|
||||||
|
/// worse than a slightly aggressive strip.
|
||||||
|
nonisolated private func stripMemoryBlock(
|
||||||
|
blockId: String,
|
||||||
|
memoryPath: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws {
|
||||||
|
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(templateId: blockId)
|
||||||
|
let endMarker = ProjectTemplateService.memoryBlockEndMarker(templateId: blockId)
|
||||||
|
|
||||||
|
let data = try transport.readFile(memoryPath)
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else { return }
|
||||||
|
guard let beginRange = text.range(of: beginMarker) else { return }
|
||||||
|
|
||||||
|
let stripRange: Range<String.Index>
|
||||||
|
if let endRange = text.range(of: endMarker, range: beginRange.upperBound..<text.endIndex) {
|
||||||
|
// Include the end marker and one trailing newline if present.
|
||||||
|
var upper = endRange.upperBound
|
||||||
|
if upper < text.endIndex, text[upper] == "\n" {
|
||||||
|
upper = text.index(after: upper)
|
||||||
|
}
|
||||||
|
stripRange = beginRange.lowerBound..<upper
|
||||||
|
} else {
|
||||||
|
Self.logger.warning("memory block for \(blockId, privacy: .public) has begin marker but no end marker; stripping to EOF")
|
||||||
|
stripRange = beginRange.lowerBound..<text.endIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also consume one leading blank line that the installer inserts
|
||||||
|
// before the begin marker, so repeated install/uninstall cycles
|
||||||
|
// don't accumulate blank lines at the insertion site.
|
||||||
|
var lower = stripRange.lowerBound
|
||||||
|
if lower > text.startIndex {
|
||||||
|
let prev = text.index(before: lower)
|
||||||
|
if text[prev] == "\n", prev > text.startIndex {
|
||||||
|
let prevPrev = text.index(before: prev)
|
||||||
|
if text[prevPrev] == "\n" {
|
||||||
|
lower = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let updated = text.replacingCharacters(in: lower..<stripRange.upperBound, with: "")
|
||||||
|
guard let outData = updated.data(using: .utf8) else { return }
|
||||||
|
try transport.writeFile(memoryPath, data: outData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Process-wide router for `scarf://install?url=…` URLs. The app delegate's
|
||||||
|
/// `onOpenURL` hands the URL in here; the Projects feature observes
|
||||||
|
/// `pendingInstallURL` and presents the install sheet when it flips non-nil.
|
||||||
|
///
|
||||||
|
/// Lives outside SwiftUI so a URL can arrive before any window exists (cold
|
||||||
|
/// launch from a browser link) and still be picked up by the first
|
||||||
|
/// `ProjectsView` that appears.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateURLRouter {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateURLRouter")
|
||||||
|
|
||||||
|
static let shared = TemplateURLRouter()
|
||||||
|
|
||||||
|
/// Non-nil when an install request is waiting to be handled. Can be
|
||||||
|
/// either a remote `https://…` URL (from a `scarf://install?url=…` deep
|
||||||
|
/// link) or a local `file://…` URL (from a Finder double-click on a
|
||||||
|
/// `.scarftemplate` file, or a drag onto the app icon). Observers read
|
||||||
|
/// this, dispatch by scheme, present the install sheet, then call
|
||||||
|
/// `consume` to clear it. Only one pending install at a time — if a
|
||||||
|
/// second arrives before the first is consumed, it replaces the first
|
||||||
|
/// (matches browser-link intuition where the latest click wins).
|
||||||
|
var pendingInstallURL: URL?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// Parse and validate an inbound URL. Returns `true` if the URL was
|
||||||
|
/// recognized and staged for handling. Unknown schemes or malformed
|
||||||
|
/// payloads return `false` so the caller can log/ignore. Supports:
|
||||||
|
///
|
||||||
|
/// - `scarf://install?url=https://…` — remote template URL from a web link.
|
||||||
|
/// - `file:///…/foo.scarftemplate` — local file from a Finder
|
||||||
|
/// double-click or a drag onto the app icon.
|
||||||
|
@discardableResult
|
||||||
|
func handle(_ url: URL) -> Bool {
|
||||||
|
if url.isFileURL {
|
||||||
|
return handleFileURL(url)
|
||||||
|
}
|
||||||
|
if url.scheme?.lowercased() == "scarf" {
|
||||||
|
return handleScarfURL(url)
|
||||||
|
}
|
||||||
|
Self.logger.warning("Ignored URL with unknown scheme: \(url.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFileURL(_ url: URL) -> Bool {
|
||||||
|
guard url.pathExtension.lowercased() == "scarftemplate" else {
|
||||||
|
Self.logger.warning("file:// URL handed to Scarf but not a .scarftemplate: \(url.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pendingInstallURL = url
|
||||||
|
Self.logger.info("file:// install staged \(url.path, privacy: .public)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleScarfURL(_ url: URL) -> Bool {
|
||||||
|
guard url.host?.lowercased() == "install" else {
|
||||||
|
Self.logger.warning("Ignored unknown scarf:// host: \(url.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
|
let raw = components.queryItems?.first(where: { $0.name == "url" })?.value,
|
||||||
|
let remote = URL(string: raw) else {
|
||||||
|
Self.logger.warning("scarf://install missing or invalid ?url=: \(url.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Refuse anything but https — defense-in-depth against a browser or
|
||||||
|
// mail client that would happily hand us a javascript: or http://
|
||||||
|
// URL pointing at something unexpected.
|
||||||
|
guard remote.scheme?.lowercased() == "https" else {
|
||||||
|
Self.logger.warning("scarf://install refused non-https url=\(remote.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pendingInstallURL = remote
|
||||||
|
Self.logger.info("scarf://install staged \(remote.absoluteString, privacy: .public)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the install sheet once it has picked up the URL.
|
||||||
|
func consume() {
|
||||||
|
pendingInstallURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,10 +52,13 @@ struct SSHTransport: ServerTransport {
|
|||||||
/// per-host via OpenSSH's `%C` token). Exposed as a static so
|
/// per-host via OpenSSH's `%C` token). Exposed as a static so
|
||||||
/// cleanup paths (`ServerRegistry.removeServer`, app-launch sweep) can
|
/// cleanup paths (`ServerRegistry.removeServer`, app-launch sweep) can
|
||||||
/// compute it without instantiating a transport.
|
/// compute it without instantiating a transport.
|
||||||
|
///
|
||||||
|
/// Uses a short path under /tmp to stay within the 104-byte macOS
|
||||||
|
/// Unix domain socket limit. The Caches path
|
||||||
|
/// (~/Library/Caches/scarf/ssh/%C) can exceed this limit when the
|
||||||
|
/// username is long, causing ssh to exit 255.
|
||||||
nonisolated static func controlDirPath() -> String {
|
nonisolated static func controlDirPath() -> String {
|
||||||
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
|
return "/tmp/scarf-ssh-\(getuid())"
|
||||||
?? NSHomeDirectory() + "/Library/Caches"
|
|
||||||
return base + "/scarf/ssh"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot cache directory for a given server. Stable per-ID so repeated
|
/// Snapshot cache directory for a given server. Stable per-ID so repeated
|
||||||
@@ -94,6 +97,31 @@ struct SSHTransport: ServerTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove ControlMaster socket files older than `staleAfter` seconds.
|
||||||
|
///
|
||||||
|
/// Socket basenames are %C hashes (not ServerIDs), so we can't keep "still
|
||||||
|
/// registered" sockets the way `sweepOrphanSnapshots` does. But
|
||||||
|
/// `ControlPersist` is 600s — anything older than 30 minutes is guaranteed
|
||||||
|
/// to be a dead orphan from a crashed master, an unclean app exit, or a
|
||||||
|
/// server removed while another Scarf instance was holding the dir.
|
||||||
|
/// Wiping these on launch keeps `/tmp/scarf-ssh-<uid>/` from accumulating
|
||||||
|
/// indefinitely until reboot, while leaving any concurrent Scarf
|
||||||
|
/// instance's live sockets (always <600s old) untouched.
|
||||||
|
static func sweepStaleControlSockets(staleAfter: TimeInterval = 1800) {
|
||||||
|
let root = controlDirPath()
|
||||||
|
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
|
||||||
|
let cutoff = Date().addingTimeInterval(-staleAfter)
|
||||||
|
for name in entries {
|
||||||
|
let path = root + "/" + name
|
||||||
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
|
||||||
|
let mtime = attrs[.modificationDate] as? Date
|
||||||
|
else { continue }
|
||||||
|
if mtime < cutoff {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask OpenSSH to shut down this host's ControlMaster socket, so the TCP
|
/// Ask OpenSSH to shut down this host's ControlMaster socket, so the TCP
|
||||||
/// session isn't held open after the user removes this server. If no
|
/// session isn't held open after the user removes this server. If no
|
||||||
/// master is currently running, `ssh -O exit` exits non-zero — we ignore
|
/// master is currently running, `ssh -O exit` exits non-zero — we ignore
|
||||||
@@ -137,13 +165,47 @@ struct SSHTransport: ServerTransport {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the ControlMaster socket directory exists. Called before every
|
/// Ensure the ControlMaster socket directory exists, is a real directory
|
||||||
/// ssh invocation. Cheap — `createDirectory(withIntermediateDirectories: true)`
|
/// (not a symlink), is owned by us, and has mode 0700. Called before every
|
||||||
/// is a no-op when present.
|
/// ssh invocation.
|
||||||
|
///
|
||||||
|
/// Defensive against `/tmp` pre-creation: any local user can create
|
||||||
|
/// `/tmp/scarf-ssh-<uid>` before Scarf launches. Plain `mkdir -p` plus
|
||||||
|
/// `setAttributes` would silently accept a hostile dir (since the chmod
|
||||||
|
/// fails when we don't own it, and the Foundation API swallows that). So
|
||||||
|
/// we use POSIX `mkdir` (atomic, sets perms at create time, doesn't
|
||||||
|
/// follow symlinks) and `lstat` to verify ownership when the entry
|
||||||
|
/// already exists.
|
||||||
nonisolated private func ensureControlDir() {
|
nonisolated private func ensureControlDir() {
|
||||||
try? FileManager.default.createDirectory(atPath: controlDir, withIntermediateDirectories: true)
|
let path = controlDir
|
||||||
// 0700 so socket files aren't visible to other users on the Mac.
|
|
||||||
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: controlDir)
|
let mkResult = path.withCString { mkdir($0, 0o700) }
|
||||||
|
if mkResult == 0 { return }
|
||||||
|
|
||||||
|
let mkErr = errno
|
||||||
|
if mkErr != EEXIST {
|
||||||
|
Self.logger.error("Failed to create ControlDir \(path, privacy: .public): errno=\(mkErr)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var st = Darwin.stat()
|
||||||
|
let lstatResult = path.withCString { lstat($0, &st) }
|
||||||
|
guard lstatResult == 0 else {
|
||||||
|
Self.logger.error("Could not lstat existing ControlDir \(path, privacy: .public): errno=\(errno)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard (st.st_mode & S_IFMT) == S_IFDIR else {
|
||||||
|
Self.logger.error("ControlDir \(path, privacy: .public) exists but is not a directory (possibly a symlink) — refusing to use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard st.st_uid == getuid() else {
|
||||||
|
Self.logger.error("ControlDir \(path, privacy: .public) owned by uid \(st.st_uid), expected \(getuid()) — refusing to use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (st.st_mode & 0o777) != 0o700 {
|
||||||
|
Self.logger.warning("ControlDir \(path, privacy: .public) had mode \(String(st.st_mode & 0o777, radix: 8), privacy: .public), repairing to 700")
|
||||||
|
_ = path.withCString { chmod($0, 0o700) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shell-quote a single argument for remote execution. The remote shell
|
/// Shell-quote a single argument for remote execution. The remote shell
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ struct ActivityView: View {
|
|||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(entry.toolName)
|
Text(entry.toolName)
|
||||||
.font(.title3.bold().monospaced())
|
.font(.title3.bold().monospaced())
|
||||||
Text(entry.kind.rawValue.capitalized)
|
Text(entry.kind.displayName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ final class ChatViewModel {
|
|||||||
private var isHandlingDisconnect = false
|
private var isHandlingDisconnect = false
|
||||||
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
||||||
var acpStatus: String = ""
|
var acpStatus: String = ""
|
||||||
|
|
||||||
|
/// True while a session is being established or restored — from the user
|
||||||
|
/// kicking off "start chat" or "resume session" until the ACP session is
|
||||||
|
/// ready for messages. The chat pane uses this to show a loader in place
|
||||||
|
/// of the empty-state placeholder.
|
||||||
|
var isPreparingSession: Bool {
|
||||||
|
guard hasActiveProcess else { return false }
|
||||||
|
switch acpStatus {
|
||||||
|
case "Starting...",
|
||||||
|
"Creating session...",
|
||||||
|
"Creating new session...",
|
||||||
|
"Loading session...":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return acpStatus.hasPrefix("Reconnecting")
|
||||||
|
}
|
||||||
|
}
|
||||||
var acpError: String?
|
var acpError: String?
|
||||||
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
|
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
|
||||||
/// Shown above the raw error in the UI when present.
|
/// Shown above the raw error in the UI when present.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ final class RichChatViewModel {
|
|||||||
init(context: ServerContext = .local) {
|
init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.dataService = HermesDataService(context: context)
|
self.dataService = HermesDataService(context: context)
|
||||||
|
loadQuickCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,9 +50,21 @@ final class RichChatViewModel {
|
|||||||
private(set) var acpCachedReadTokens = 0
|
private(set) var acpCachedReadTokens = 0
|
||||||
|
|
||||||
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
||||||
private(set) var availableCommandNames: Set<String> = []
|
private(set) var acpCommands: [HermesSlashCommand] = []
|
||||||
|
/// User-defined commands parsed from `config.yaml` `quick_commands`.
|
||||||
|
private(set) var quickCommands: [HermesSlashCommand] = []
|
||||||
|
|
||||||
var supportsCompress: Bool { availableCommandNames.contains("compress") }
|
/// Merged list, ACP-first, de-duplicated by name.
|
||||||
|
var availableCommands: [HermesSlashCommand] {
|
||||||
|
let acpNames = Set(acpCommands.map(\.name))
|
||||||
|
return acpCommands + quickCommands.filter { !acpNames.contains($0.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
|
||||||
|
|
||||||
|
/// True when the menu carries more than just `/compress` — used to hide
|
||||||
|
/// the dedicated compress button in favor of the full slash menu.
|
||||||
|
var hasBroaderCommandMenu: Bool { availableCommands.count > 1 }
|
||||||
|
|
||||||
var hasMessages: Bool { !messages.isEmpty }
|
var hasMessages: Bool { !messages.isEmpty }
|
||||||
|
|
||||||
@@ -105,8 +118,9 @@ final class RichChatViewModel {
|
|||||||
acpOutputTokens = 0
|
acpOutputTokens = 0
|
||||||
acpThoughtTokens = 0
|
acpThoughtTokens = 0
|
||||||
acpCachedReadTokens = 0
|
acpCachedReadTokens = 0
|
||||||
availableCommandNames = []
|
acpCommands = []
|
||||||
pendingPermission = nil
|
pendingPermission = nil
|
||||||
|
loadQuickCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionId(_ id: String?) {
|
func setSessionId(_ id: String?) {
|
||||||
@@ -156,6 +170,11 @@ final class RichChatViewModel {
|
|||||||
streamingThinkingText = ""
|
streamingThinkingText = ""
|
||||||
streamingToolCalls = []
|
streamingToolCalls = []
|
||||||
buildMessageGroups()
|
buildMessageGroups()
|
||||||
|
// User just submitted — jump to the bottom so they see their message
|
||||||
|
// and the incoming response. `.defaultScrollAnchor(.bottom)` handles
|
||||||
|
// slow streaming fine, but rapid responses (slash commands especially)
|
||||||
|
// arrive faster than the anchor can track.
|
||||||
|
requestScrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a streaming ACP event and update the message list.
|
/// Process a streaming ACP event and update the message list.
|
||||||
@@ -181,19 +200,59 @@ final class RichChatViewModel {
|
|||||||
case .connectionLost(let reason):
|
case .connectionLost(let reason):
|
||||||
handleConnectionLost(reason: reason)
|
handleConnectionLost(reason: reason)
|
||||||
case .availableCommands(_, let commands):
|
case .availableCommands(_, let commands):
|
||||||
var names: Set<String> = []
|
acpCommands = parseACPCommands(commands)
|
||||||
for entry in commands {
|
|
||||||
if let name = entry["name"] as? String {
|
|
||||||
// Hermes sends names either as "compress" or "/compress"
|
|
||||||
names.insert(name.trimmingCharacters(in: CharacterSet(charactersIn: "/")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
availableCommandNames = names
|
|
||||||
case .unknown:
|
case .unknown:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func parseACPCommands(_ commands: [[String: Any]]) -> [HermesSlashCommand] {
|
||||||
|
var result: [HermesSlashCommand] = []
|
||||||
|
for entry in commands {
|
||||||
|
guard let rawName = entry["name"] as? String else { continue }
|
||||||
|
// Hermes sends names either as "compress" or "/compress"
|
||||||
|
let name = rawName.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
guard !name.isEmpty else { continue }
|
||||||
|
let description = (entry["description"] as? String) ?? ""
|
||||||
|
var hint: String? = nil
|
||||||
|
if let input = entry["input"] as? [String: Any],
|
||||||
|
let h = input["hint"] as? String,
|
||||||
|
!h.isEmpty {
|
||||||
|
hint = h
|
||||||
|
}
|
||||||
|
result.append(HermesSlashCommand(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
argumentHint: hint,
|
||||||
|
source: .acp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load `quick_commands` from `config.yaml` off the main actor and publish
|
||||||
|
/// them as slash commands. Safe to call repeatedly — replaces the existing list.
|
||||||
|
func loadQuickCommands() {
|
||||||
|
let ctx = context
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
|
||||||
|
let mapped = loaded.map { qc -> HermesSlashCommand in
|
||||||
|
let truncated = qc.command.count > 60
|
||||||
|
? String(qc.command.prefix(60)) + "…"
|
||||||
|
: qc.command
|
||||||
|
return HermesSlashCommand(
|
||||||
|
name: qc.name,
|
||||||
|
description: "Run: \(truncated)",
|
||||||
|
argumentHint: nil,
|
||||||
|
source: .quickCommand
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.quickCommands = mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func appendMessageChunk(text: String) {
|
private func appendMessageChunk(text: String) {
|
||||||
streamingAssistantText += text
|
streamingAssistantText += text
|
||||||
upsertStreamingMessage()
|
upsertStreamingMessage()
|
||||||
@@ -283,6 +342,10 @@ final class RichChatViewModel {
|
|||||||
acpCachedReadTokens += response.cachedReadTokens
|
acpCachedReadTokens += response.cachedReadTokens
|
||||||
isAgentWorking = false
|
isAgentWorking = false
|
||||||
buildMessageGroups()
|
buildMessageGroups()
|
||||||
|
// Final position after the prompt settles. Catches fast responses
|
||||||
|
// (slash commands, short replies) where `.defaultScrollAnchor(.bottom)`
|
||||||
|
// didn't quite track the abrupt content growth.
|
||||||
|
requestScrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleConnectionLost(reason: String) {
|
private func handleConnectionLost(reason: String) {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ struct ChatView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(.green)
|
.fill(.green)
|
||||||
.frame(width: 6, height: 6)
|
.frame(width: 6, height: 6)
|
||||||
Text(viewModel.acpStatus.isEmpty ? "Active" : viewModel.acpStatus)
|
(viewModel.acpStatus.isEmpty ? Text("Active") : Text(viewModel.acpStatus))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -238,7 +238,7 @@ struct ChatView: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash")
|
Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash")
|
||||||
.foregroundStyle(viewModel.voiceEnabled ? .green : .secondary)
|
.foregroundStyle(viewModel.voiceEnabled ? .green : .secondary)
|
||||||
Text(viewModel.voiceEnabled ? "Voice On" : "Voice Off")
|
(viewModel.voiceEnabled ? Text("Voice On") : Text("Voice Off"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary)
|
.foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary)
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ struct ChatView: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
|
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
|
||||||
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
|
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
|
||||||
Text(viewModel.ttsEnabled ? "TTS On" : "TTS Off")
|
(viewModel.ttsEnabled ? Text("TTS On") : Text("TTS Off"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
|
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ struct ChatView: View {
|
|||||||
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
|
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
|
||||||
.foregroundStyle(viewModel.isRecording ? .red : Color.accentColor)
|
.foregroundStyle(viewModel.isRecording ? .red : Color.accentColor)
|
||||||
.symbolEffect(.pulse, isActive: viewModel.isRecording)
|
.symbolEffect(.pulse, isActive: viewModel.isRecording)
|
||||||
Text(viewModel.isRecording ? "Recording..." : "Push to Talk")
|
(viewModel.isRecording ? Text("Recording…") : Text("Push to Talk"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,39 @@ import SwiftUI
|
|||||||
struct RichChatInputBar: View {
|
struct RichChatInputBar: View {
|
||||||
let onSend: (String) -> Void
|
let onSend: (String) -> Void
|
||||||
let isEnabled: Bool
|
let isEnabled: Bool
|
||||||
var supportsCompress: Bool = false
|
var commands: [HermesSlashCommand] = []
|
||||||
|
var showCompressButton: Bool = false
|
||||||
|
|
||||||
@State private var text = ""
|
@State private var text = ""
|
||||||
@State private var showCompressSheet = false
|
@State private var showCompressSheet = false
|
||||||
@State private var compressFocus = ""
|
@State private var compressFocus = ""
|
||||||
|
@State private var showMenu = false
|
||||||
|
@State private var selectedIndex = 0
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if showMenu {
|
||||||
|
SlashCommandMenu(
|
||||||
|
commands: filteredCommands,
|
||||||
|
agentHasCommands: !commands.isEmpty,
|
||||||
|
selectedIndex: $selectedIndex,
|
||||||
|
onSelect: insertCommand
|
||||||
|
)
|
||||||
|
.id(menuQuery)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.strokeBorder(.separator, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 2)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
if supportsCompress {
|
if showCompressButton {
|
||||||
Button {
|
Button {
|
||||||
compressFocus = ""
|
compressFocus = ""
|
||||||
showCompressSheet = true
|
showCompressSheet = true
|
||||||
@@ -45,10 +68,37 @@ struct RichChatInputBar: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onKeyPress(.upArrow, phases: .down) { _ in
|
||||||
|
guard showMenu, !filteredCommands.isEmpty else { return .ignored }
|
||||||
|
let n = filteredCommands.count
|
||||||
|
selectedIndex = (selectedIndex - 1 + n) % n
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
.onKeyPress(.downArrow, phases: .down) { _ in
|
||||||
|
guard showMenu, !filteredCommands.isEmpty else { return .ignored }
|
||||||
|
let n = filteredCommands.count
|
||||||
|
selectedIndex = (selectedIndex + 1) % n
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
.onKeyPress(.tab, phases: .down) { _ in
|
||||||
|
guard showMenu,
|
||||||
|
let command = filteredCommands[safe: selectedIndex] else { return .ignored }
|
||||||
|
insertCommand(command)
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
.onKeyPress(.escape, phases: .down) { _ in
|
||||||
|
guard showMenu else { return .ignored }
|
||||||
|
showMenu = false
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
.onKeyPress(.return, phases: .down) { press in
|
.onKeyPress(.return, phases: .down) { press in
|
||||||
if press.modifiers.contains(.shift) {
|
if press.modifiers.contains(.shift) {
|
||||||
return .ignored
|
return .ignored
|
||||||
}
|
}
|
||||||
|
if showMenu, let command = filteredCommands[safe: selectedIndex] {
|
||||||
|
insertCommand(command)
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
send()
|
send()
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
@@ -66,7 +116,14 @@ struct RichChatInputBar: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
.background(.bar)
|
.background(.bar)
|
||||||
|
.onChange(of: text) { _, _ in
|
||||||
|
updateMenuState()
|
||||||
|
}
|
||||||
|
.onChange(of: commands.map(\.id)) { _, _ in
|
||||||
|
updateMenuState()
|
||||||
|
}
|
||||||
.sheet(isPresented: $showCompressSheet) {
|
.sheet(isPresented: $showCompressSheet) {
|
||||||
compressSheet
|
compressSheet
|
||||||
}
|
}
|
||||||
@@ -101,10 +158,61 @@ struct RichChatInputBar: View {
|
|||||||
isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show the slash menu only while the user is typing the command token:
|
||||||
|
/// text starts with `/` and contains no whitespace (space or newline).
|
||||||
|
private var shouldShowMenu: Bool {
|
||||||
|
guard text.hasPrefix("/") else { return false }
|
||||||
|
return !text.contains(" ") && !text.contains("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var menuQuery: String {
|
||||||
|
guard text.hasPrefix("/") else { return "" }
|
||||||
|
return String(text.dropFirst())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredCommands: [HermesSlashCommand] {
|
||||||
|
SlashCommandMenu.filter(commands: commands, query: menuQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMenuState() {
|
||||||
|
let shouldShow = shouldShowMenu
|
||||||
|
if shouldShow != showMenu {
|
||||||
|
showMenu = shouldShow
|
||||||
|
}
|
||||||
|
// Re-clamp selection whenever the filtered list may have shrunk.
|
||||||
|
let count = filteredCommands.count
|
||||||
|
if count == 0 {
|
||||||
|
selectedIndex = 0
|
||||||
|
} else if selectedIndex >= count {
|
||||||
|
selectedIndex = count - 1
|
||||||
|
} else if selectedIndex < 0 {
|
||||||
|
selectedIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insertCommand(_ command: HermesSlashCommand) {
|
||||||
|
if command.argumentHint != nil {
|
||||||
|
text = "/\(command.name) "
|
||||||
|
} else {
|
||||||
|
text = "/\(command.name)"
|
||||||
|
}
|
||||||
|
showMenu = false
|
||||||
|
selectedIndex = 0
|
||||||
|
isFocused = true
|
||||||
|
}
|
||||||
|
|
||||||
private func send() {
|
private func send() {
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty, isEnabled else { return }
|
guard !trimmed.isEmpty, isEnabled else { return }
|
||||||
onSend(trimmed)
|
onSend(trimmed)
|
||||||
text = ""
|
text = ""
|
||||||
|
showMenu = false
|
||||||
|
selectedIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array {
|
||||||
|
subscript(safe index: Int) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,33 +3,53 @@ import SwiftUI
|
|||||||
struct RichChatMessageList: View {
|
struct RichChatMessageList: View {
|
||||||
let groups: [MessageGroup]
|
let groups: [MessageGroup]
|
||||||
let isWorking: Bool
|
let isWorking: Bool
|
||||||
|
/// True while the ACP session is being established or restored — used to
|
||||||
|
/// swap the empty-state placeholder for a progress indicator so the user
|
||||||
|
/// knows something is happening while history loads.
|
||||||
|
var isLoadingSession: Bool = false
|
||||||
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
||||||
var scrollTrigger: UUID = UUID()
|
var scrollTrigger: UUID = UUID()
|
||||||
|
|
||||||
/// Why `.defaultScrollAnchor(.bottom)` *alone* and no `proxy.scrollTo`.
|
/// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus
|
||||||
|
/// `.defaultScrollAnchor(.bottom)`.
|
||||||
///
|
///
|
||||||
/// `.defaultScrollAnchor(.bottom)` tells SwiftUI to pin the viewport to
|
/// `LazyVStack` was causing the classic "loaded session shows whitespace
|
||||||
/// the bottom of the content automatically — as messages stream in or
|
/// and the chat is above" bug: lazy rows return estimated heights before
|
||||||
/// new turns arrive, the scroll position tracks the bottom edge.
|
/// they render, `.defaultScrollAnchor(.bottom)` positions the viewport
|
||||||
|
/// at the *estimated* bottom (which overshoots the real content), and
|
||||||
|
/// when rows materialize and real heights land, the viewport ends up
|
||||||
|
/// past the content. Attempts to correct via `proxy.scrollTo(lastID)`
|
||||||
|
/// failed because unrendered rows have no resolvable ID.
|
||||||
///
|
///
|
||||||
/// We used to also call `proxy.scrollTo(lastID, anchor: .bottom)` from
|
/// Switching to `VStack` materializes every row immediately, so
|
||||||
/// six different `onChange` handlers during streaming. The two
|
/// `.defaultScrollAnchor(.bottom)` has real heights to work with and
|
||||||
/// mechanisms fought each other: the ScrollViewReader can resolve an ID
|
/// can't overshoot. For typical Hermes sessions (<500 messages) the
|
||||||
/// to a position **before** LazyVStack has finished laying out that
|
/// first-render cost is acceptable. If ever needed for huge sessions
|
||||||
/// row, so `scrollTo` would land past the actual content — the
|
/// we can reintroduce lazy with a preference-key-based height
|
||||||
/// "viewport showing whitespace, chat is above" symptom. Removing the
|
/// measurement, but that's a much larger change.
|
||||||
/// manual scroll and trusting `defaultScrollAnchor` eliminates the race.
|
|
||||||
///
|
|
||||||
/// The only remaining explicit scroll is `scrollTrigger` for the "Return
|
|
||||||
/// to Active Session" button; that fires rarely, after layout has
|
|
||||||
/// settled, so the overshoot doesn't happen.
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if groups.isEmpty && !isWorking {
|
if groups.isEmpty && !isWorking {
|
||||||
|
// Fill the scroll view's visible height so Spacers
|
||||||
|
// can vertically center the placeholder. Previously
|
||||||
|
// `.padding(.vertical, 80)` left the placeholder
|
||||||
|
// floating at whatever y-offset `.defaultScrollAnchor(.bottom)`
|
||||||
|
// settled on — usually near the bottom of the pane.
|
||||||
|
VStack {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if isLoadingSession {
|
||||||
|
loadingState
|
||||||
|
} else {
|
||||||
emptyState
|
emptyState
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.containerRelativeFrame(.vertical)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(groups) { group in
|
ForEach(groups) { group in
|
||||||
MessageGroupView(group: group)
|
MessageGroupView(group: group)
|
||||||
@@ -42,6 +62,8 @@ struct RichChatMessageList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: isLoadingSession)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: groups.isEmpty)
|
||||||
}
|
}
|
||||||
.defaultScrollAnchor(.bottom)
|
.defaultScrollAnchor(.bottom)
|
||||||
.onChange(of: scrollTrigger) {
|
.onChange(of: scrollTrigger) {
|
||||||
@@ -75,8 +97,16 @@ struct RichChatMessageList: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
}
|
||||||
.padding(.vertical, 80)
|
|
||||||
|
private var loadingState: some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.large)
|
||||||
|
Text("Loading session…")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var typingIndicator: some View {
|
private var typingIndicator: some View {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct RichChatView: View {
|
|||||||
RichChatMessageList(
|
RichChatMessageList(
|
||||||
groups: richChat.messageGroups,
|
groups: richChat.messageGroups,
|
||||||
isWorking: richChat.isAgentWorking,
|
isWorking: richChat.isAgentWorking,
|
||||||
|
isLoadingSession: chatViewModel.isPreparingSession,
|
||||||
scrollTrigger: richChat.scrollTrigger
|
scrollTrigger: richChat.scrollTrigger
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ struct RichChatView: View {
|
|||||||
onSend(text)
|
onSend(text)
|
||||||
},
|
},
|
||||||
isEnabled: isEnabled,
|
isEnabled: isEnabled,
|
||||||
supportsCompress: richChat.supportsCompress
|
commands: richChat.availableCommands,
|
||||||
|
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ struct SessionInfoBar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let cost = session.displayCostUSD {
|
if let cost = session.displayCostUSD {
|
||||||
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
|
||||||
|
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +76,6 @@ struct SessionInfoBar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func formatTokens(_ count: Int) -> String {
|
private func formatTokens(_ count: Int) -> String {
|
||||||
if count >= 1_000_000 {
|
count.formatted(.number.notation(.compactName).precision(.fractionLength(0...1)))
|
||||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
|
||||||
} else if count >= 1_000 {
|
|
||||||
return String(format: "%.1fK", Double(count) / 1_000)
|
|
||||||
}
|
|
||||||
return "\(count)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Floating menu of available slash commands shown above the chat input when
|
||||||
|
/// the user types `/` as the first character. Purely presentational — the
|
||||||
|
/// parent filters the list and owns selection state.
|
||||||
|
struct SlashCommandMenu: View {
|
||||||
|
/// Pre-filtered commands to display.
|
||||||
|
let commands: [HermesSlashCommand]
|
||||||
|
/// Whether the agent advertised any commands at all. Lets us distinguish
|
||||||
|
/// "agent hasn't sent commands yet" from "filter matched nothing".
|
||||||
|
let agentHasCommands: Bool
|
||||||
|
@Binding var selectedIndex: Int
|
||||||
|
var onSelect: (HermesSlashCommand) -> Void
|
||||||
|
|
||||||
|
/// Case-insensitive prefix match on the command name. Exposed as a static
|
||||||
|
/// helper so the parent can share filter logic with its key handlers.
|
||||||
|
static func filter(commands: [HermesSlashCommand], query: String) -> [HermesSlashCommand] {
|
||||||
|
let q = query.lowercased()
|
||||||
|
if q.isEmpty { return commands }
|
||||||
|
return commands.filter { $0.name.lowercased().hasPrefix(q) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !agentHasCommands {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("No commands available")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("The agent hasn't advertised any slash commands yet. Keep typing to send as a message, or press Esc.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(minWidth: 360, alignment: .leading)
|
||||||
|
} else if commands.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("No matching commands")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Keep typing to send as a message, or press Esc.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(minWidth: 360, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in
|
||||||
|
SlashCommandRow(
|
||||||
|
command: command,
|
||||||
|
isSelected: index == selectedIndex
|
||||||
|
)
|
||||||
|
.id(index)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedIndex = index
|
||||||
|
onSelect(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 360, maxHeight: 260)
|
||||||
|
.onChange(of: selectedIndex) { _, newValue in
|
||||||
|
withAnimation(.easeOut(duration: 0.1)) {
|
||||||
|
proxy.scrollTo(newValue, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SlashCommandRow: View {
|
||||||
|
let command: HermesSlashCommand
|
||||||
|
let isSelected: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("/\(command.name)")
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
if let hint = command.argumentHint {
|
||||||
|
Text("<\(hint)>")
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
if command.source == .quickCommand {
|
||||||
|
Text("user")
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(.quaternary.opacity(0.8))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !command.description.isEmpty {
|
||||||
|
Text(command.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.15) : Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ struct CredentialPoolsView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
||||||
SettingsSection(title: pool.provider, icon: "key.horizontal") {
|
SettingsSection(title: LocalizedStringKey(pool.provider), icon: "key.horizontal") {
|
||||||
PickerRow(label: "Rotation", selection: pool.strategy, options: viewModel.strategyOptions) { strategy in
|
PickerRow(label: "Rotation", selection: pool.strategy, options: viewModel.strategyOptions) { strategy in
|
||||||
viewModel.setStrategy(strategy, for: pool.provider)
|
viewModel.setStrategy(strategy, for: pool.provider)
|
||||||
}
|
}
|
||||||
@@ -194,6 +194,13 @@ private struct AddCredentialSheet: View {
|
|||||||
case apiKey = "API Key"
|
case apiKey = "API Key"
|
||||||
case oauth = "OAuth"
|
case oauth = "OAuth"
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .apiKey: return "API Key"
|
||||||
|
case .oauth: return "OAuth"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var providerID: String = ""
|
@State private var providerID: String = ""
|
||||||
@@ -262,7 +269,7 @@ private struct AddCredentialSheet: View {
|
|||||||
Text("Credential Type").font(.caption).foregroundStyle(.secondary)
|
Text("Credential Type").font(.caption).foregroundStyle(.secondary)
|
||||||
Picker("", selection: $authType) {
|
Picker("", selection: $authType) {
|
||||||
ForEach(AuthType.allCases) { type in
|
ForEach(AuthType.allCases) { type in
|
||||||
Text(type.rawValue).tag(type)
|
Text(type.displayName).tag(type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|||||||
@@ -65,7 +65,61 @@ final class CronViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runNow(_ job: HermesCronJob) {
|
func runNow(_ job: HermesCronJob) {
|
||||||
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
|
// `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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteJob(_ job: HermesCronJob) {
|
func deleteJob(_ job: HermesCronJob) {
|
||||||
|
|||||||
@@ -21,28 +21,73 @@ final class DashboardViewModel {
|
|||||||
var hermesRunning = false
|
var hermesRunning = false
|
||||||
var isLoading = true
|
var isLoading = true
|
||||||
|
|
||||||
|
/// User-presentable error banner. Set when any of the remote reads
|
||||||
|
/// (state.db snapshot, config.yaml, gateway_state.json, pgrep) failed
|
||||||
|
/// in a way that's not just "file doesn't exist yet". Dashboard renders
|
||||||
|
/// this above the stats with a "Run Diagnostics…" button. `nil` = no
|
||||||
|
/// surfaceable error.
|
||||||
|
var lastReadError: String?
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
// refresh() = close + reopen, forces a fresh remote snapshot. Cheap
|
// refresh() = close + reopen, forces a fresh remote snapshot. Cheap
|
||||||
// on local (live DB reopen).
|
// on local (live DB reopen).
|
||||||
let opened = await dataService.refresh()
|
let opened = await dataService.refresh()
|
||||||
|
var collectedErrors: [String] = []
|
||||||
if opened {
|
if opened {
|
||||||
stats = await dataService.fetchStats()
|
stats = await dataService.fetchStats()
|
||||||
recentSessions = await dataService.fetchSessions(limit: 5)
|
recentSessions = await dataService.fetchSessions(limit: 5)
|
||||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 5)
|
sessionPreviews = await dataService.fetchSessionPreviews(limit: 5)
|
||||||
await dataService.close()
|
await dataService.close()
|
||||||
|
} else if let msg = await dataService.lastOpenError {
|
||||||
|
collectedErrors.append(msg)
|
||||||
}
|
}
|
||||||
// The fileService methods are synchronous and route through the
|
// The fileService methods are synchronous and route through the
|
||||||
// transport. For remote contexts each call is a blocking ssh
|
// transport. For remote contexts each call is a blocking ssh
|
||||||
// round-trip — do them off the main thread to avoid spinning the
|
// round-trip — do them off the main thread to avoid spinning the
|
||||||
// beach ball during the load.
|
// beach ball during the load.
|
||||||
let svc = fileService
|
let svc = fileService
|
||||||
let (cfg, gw, running) = await Task.detached {
|
struct LoadResults: Sendable {
|
||||||
(svc.loadConfig(), svc.loadGatewayState(), svc.isHermesRunning())
|
let cfg: Result<HermesConfig, Error>
|
||||||
|
let gw: Result<GatewayState?, Error>
|
||||||
|
let running: Result<pid_t?, Error>
|
||||||
|
}
|
||||||
|
let results = await Task.detached { () -> LoadResults in
|
||||||
|
LoadResults(
|
||||||
|
cfg: svc.loadConfigResult(),
|
||||||
|
gw: svc.loadGatewayStateResult(),
|
||||||
|
running: svc.hermesPIDResult()
|
||||||
|
)
|
||||||
}.value
|
}.value
|
||||||
config = cfg
|
|
||||||
gatewayState = gw
|
switch results.cfg {
|
||||||
hermesRunning = running
|
case .success(let c): config = c
|
||||||
|
case .failure(let e):
|
||||||
|
config = .empty
|
||||||
|
collectedErrors.append("config.yaml — \(e.localizedDescription)")
|
||||||
|
}
|
||||||
|
switch results.gw {
|
||||||
|
case .success(let g): gatewayState = g
|
||||||
|
case .failure(let e):
|
||||||
|
gatewayState = nil
|
||||||
|
collectedErrors.append("gateway_state.json — \(e.localizedDescription)")
|
||||||
|
}
|
||||||
|
switch results.running {
|
||||||
|
case .success(let pid): hermesRunning = (pid != nil)
|
||||||
|
case .failure(let e):
|
||||||
|
hermesRunning = false
|
||||||
|
collectedErrors.append("pgrep — \(e.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only surface when there's a real error AND we're on a remote
|
||||||
|
// context. Local contexts rarely hit these paths (live DB, local
|
||||||
|
// filesystem), and a transient "file doesn't exist yet" on fresh
|
||||||
|
// installs shouldn't scare users.
|
||||||
|
if context.isRemote, !collectedErrors.isEmpty {
|
||||||
|
lastReadError = collectedErrors.joined(separator: "\n")
|
||||||
|
} else {
|
||||||
|
lastReadError = nil
|
||||||
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@State private var viewModel: DashboardViewModel
|
@State private var viewModel: DashboardViewModel
|
||||||
|
@State private var showDiagnostics = false
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ struct DashboardView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
if let err = viewModel.lastReadError {
|
||||||
|
readErrorBanner(err)
|
||||||
|
}
|
||||||
statusSection
|
statusSection
|
||||||
statsSection
|
statsSection
|
||||||
recentSessionsSection
|
recentSessionsSection
|
||||||
@@ -30,6 +34,44 @@ struct DashboardView: View {
|
|||||||
.onChange(of: fileWatcher.lastChangeDate) {
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
Task { await viewModel.load() }
|
Task { await viewModel.load() }
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showDiagnostics) {
|
||||||
|
RemoteDiagnosticsView(context: viewModel.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Banner shown above the Dashboard when one or more remote reads
|
||||||
|
/// failed (permission denied, missing sqlite3, wrong home dir, etc.).
|
||||||
|
/// Replaces the old silent-failure mode where empty values just
|
||||||
|
/// appeared as "Stopped / unknown / 0" with no explanation.
|
||||||
|
private func readErrorBanner(_ err: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Can't read Hermes state on \(viewModel.context.displayName)")
|
||||||
|
.font(.headline)
|
||||||
|
Text(err)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
showDiagnostics = true
|
||||||
|
} label: {
|
||||||
|
Label("Run Diagnostics…", systemImage: "stethoscope")
|
||||||
|
}
|
||||||
|
.controlSize(.regular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.strokeBorder(Color.orange.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusSection: some View {
|
private var statusSection: some View {
|
||||||
@@ -72,7 +114,7 @@ struct DashboardView: View {
|
|||||||
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
||||||
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
|
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
|
||||||
if cost > 0 {
|
if cost > 0 {
|
||||||
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
|
StatCard(label: "Cost", value: cost.formatted(.currency(code: "USD").precision(.fractionLength(2))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +217,7 @@ struct SessionRow: View {
|
|||||||
Label("\(session.messageCount)", systemImage: "bubble.left")
|
Label("\(session.messageCount)", systemImage: "bubble.left")
|
||||||
Label("\(session.toolCallCount)", systemImage: "wrench")
|
Label("\(session.toolCallCount)", systemImage: "wrench")
|
||||||
if let cost = session.displayCostUSD, cost > 0 {
|
if let cost = session.displayCostUSD, cost > 0 {
|
||||||
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
|
Label(cost.formatted(.currency(code: "USD").precision(.fractionLength(4))), systemImage: "dollarsign.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ struct GatewayView: View {
|
|||||||
Image(systemName: platform.icon)
|
Image(systemName: platform.icon)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
|
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
|
||||||
Text(platform.name.capitalized)
|
Text(verbatim: platform.name.capitalized)
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
StatusBadge(
|
StatusBadge(
|
||||||
label: platform.state,
|
label: platform.state,
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ struct HealthView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(viewModel.hermesRunning ? .green : .red)
|
.fill(viewModel.hermesRunning ? .green : .red)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
Text(viewModel.hermesRunning ? "Hermes Running" : "Hermes Stopped")
|
(viewModel.hermesRunning ? Text("Hermes Running") : Text("Hermes Stopped"))
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
if let pid = viewModel.hermesPID {
|
if let pid = viewModel.hermesPID {
|
||||||
Text("PID \(pid)")
|
Text("PID \(pid)")
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ enum InsightsPeriod: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .week: return "7 Days"
|
||||||
|
case .month: return "30 Days"
|
||||||
|
case .quarter: return "90 Days"
|
||||||
|
case .all: return "All Time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var sinceDate: Date {
|
var sinceDate: Date {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
switch self {
|
switch self {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct InsightsView: View {
|
|||||||
private var periodPicker: some View {
|
private var periodPicker: some View {
|
||||||
Picker("Period", selection: $viewModel.period) {
|
Picker("Period", selection: $viewModel.period) {
|
||||||
ForEach(InsightsPeriod.allCases) { period in
|
ForEach(InsightsPeriod.allCases) { period in
|
||||||
Text(period.rawValue).tag(period)
|
Text(period.displayName).tag(period)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
@@ -61,10 +61,10 @@ struct InsightsView: View {
|
|||||||
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
||||||
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
|
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
|
||||||
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
||||||
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
|
InsightCard(label: "Total Cost", value: viewModel.totalCost.formatted(.currency(code: "USD").precision(.fractionLength(2))))
|
||||||
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
||||||
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
||||||
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
|
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : (Double(viewModel.totalMessages) / Double(viewModel.sessions.count)).formatted(.number.precision(.fractionLength(1))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ struct InsightsView: View {
|
|||||||
VStack(alignment: .trailing, spacing: 2) {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
Text("\(model.sessions) sessions")
|
Text("\(model.sessions) sessions")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(formatTokens(model.totalTokens) + " tokens")
|
Text("\(formatTokens(model.totalTokens)) tokens")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ struct InsightsView: View {
|
|||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 40, alignment: .trailing)
|
.frame(width: 40, alignment: .trailing)
|
||||||
Text(String(format: "%.1f%%", tool.percentage))
|
Text((tool.percentage / 100).formatted(.percent.precision(.fractionLength(1))))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.frame(width: 50, alignment: .trailing)
|
.frame(width: 50, alignment: .trailing)
|
||||||
@@ -193,12 +193,12 @@ struct InsightsView: View {
|
|||||||
Text("By Day")
|
Text("By Day")
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
let dayNames = Calendar.current.shortWeekdaySymbols
|
||||||
let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1)
|
let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1)
|
||||||
ForEach(0..<7, id: \.self) { day in
|
ForEach(0..<7, id: \.self) { day in
|
||||||
let count = viewModel.dailyActivity[day] ?? 0
|
let count = viewModel.dailyActivity[day] ?? 0
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(dayNames[day])
|
Text(verbatim: dayNames[(day + 1) % 7])
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.frame(width: 30, alignment: .trailing)
|
.frame(width: 30, alignment: .trailing)
|
||||||
RoundedRectangle(cornerRadius: 2)
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ final class LogsViewModel {
|
|||||||
case gateway = "gateway.log"
|
case gateway = "gateway.log"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .agent: return "Agent"
|
||||||
|
case .errors: return "Errors"
|
||||||
|
case .gateway: return "Gateway"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func path(for file: LogFile) -> String {
|
private func path(for file: LogFile) -> String {
|
||||||
@@ -43,6 +51,17 @@ final class LogsViewModel {
|
|||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .all: return "All"
|
||||||
|
case .gateway: return "Gateway"
|
||||||
|
case .agent: return "Agent"
|
||||||
|
case .tools: return "Tools"
|
||||||
|
case .cli: return "CLI"
|
||||||
|
case .cron: return "Cron"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var loggerPrefix: String? {
|
var loggerPrefix: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .all: return nil
|
case .all: return nil
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ struct LogsView: View {
|
|||||||
set: { file in Task { await viewModel.switchLogFile(file) } }
|
set: { file in Task { await viewModel.switchLogFile(file) } }
|
||||||
)) {
|
)) {
|
||||||
ForEach(LogsViewModel.LogFile.allCases) { file in
|
ForEach(LogsViewModel.LogFile.allCases) { file in
|
||||||
Text(file.rawValue).tag(file)
|
Text(file.displayName).tag(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
@@ -35,7 +35,7 @@ struct LogsView: View {
|
|||||||
|
|
||||||
Picker("Component", selection: $viewModel.selectedComponent) {
|
Picker("Component", selection: $viewModel.selectedComponent) {
|
||||||
ForEach(LogsViewModel.LogComponent.allCases) { component in
|
ForEach(LogsViewModel.LogComponent.allCases) { component in
|
||||||
Text(component.rawValue).tag(component)
|
Text(component.displayName).tag(component)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 140)
|
.frame(maxWidth: 140)
|
||||||
@@ -45,7 +45,7 @@ struct LogsView: View {
|
|||||||
Picker("Level", selection: $viewModel.filterLevel) {
|
Picker("Level", selection: $viewModel.filterLevel) {
|
||||||
Text("All Levels").tag(LogEntry.LogLevel?.none)
|
Text("All Levels").tag(LogEntry.LogLevel?.none)
|
||||||
ForEach(LogEntry.LogLevel.allCases, id: \.rawValue) { level in
|
ForEach(LogEntry.LogLevel.allCases, id: \.rawValue) { level in
|
||||||
Text(level.rawValue).tag(LogEntry.LogLevel?.some(level))
|
Text(verbatim: level.rawValue).tag(LogEntry.LogLevel?.some(level))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 150)
|
.frame(maxWidth: 150)
|
||||||
@@ -66,7 +66,7 @@ struct LogsView: View {
|
|||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 140, alignment: .leading)
|
.frame(width: 140, alignment: .leading)
|
||||||
Text(entry.level.rawValue)
|
Text(verbatim: entry.level.rawValue)
|
||||||
.font(.caption.monospaced().bold())
|
.font(.caption.monospaced().bold())
|
||||||
.foregroundStyle(colorForLevel(entry.level))
|
.foregroundStyle(colorForLevel(entry.level))
|
||||||
.frame(width: 60, alignment: .leading)
|
.frame(width: 60, alignment: .leading)
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ struct MCPServerDetailView: View {
|
|||||||
Text(key)
|
Text(key)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(repeating: "•", count: 10))
|
Text("••••••••••")
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@ struct MCPServerDetailView: View {
|
|||||||
Text(key)
|
Text(key)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(repeating: "•", count: 10))
|
Text("••••••••••")
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ struct MCPServerPresetPickerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(selectedPreset?.displayName ?? "Add from Preset")
|
(selectedPreset.map { Text(verbatim: $0.displayName) } ?? Text("Add from Preset"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(selectedPreset?.description ?? "Pick an MCP server to add.")
|
(selectedPreset.map { Text(verbatim: $0.description) } ?? Text("Pick an MCP server to add."))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -83,14 +83,14 @@ struct MCPServerPresetPickerView: View {
|
|||||||
Image(systemName: preset.iconSystemName)
|
Image(systemName: preset.iconSystemName)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.accentColor)
|
||||||
Text(preset.displayName)
|
Text(verbatim: preset.displayName)
|
||||||
.font(.body.bold())
|
.font(.body.bold())
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: preset.transport == .http ? "network" : "terminal")
|
Image(systemName: preset.transport == .http ? "network" : "terminal")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(preset.description)
|
Text(verbatim: preset.description)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(3)
|
.lineLimit(3)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ struct MCPServerTestResultView: View {
|
|||||||
Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill")
|
Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill")
|
||||||
.foregroundStyle(result.succeeded ? .green : .red)
|
.foregroundStyle(result.succeeded ? .green : .red)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(result.succeeded ? "Test passed" : "Test failed")
|
(result.succeeded ? Text("Test passed") : Text("Test failed"))
|
||||||
.font(.subheadline.bold())
|
.font(.subheadline.bold())
|
||||||
Text(String(format: "%.1fs · %d tools", result.elapsed, result.tools.count))
|
Text("\(result.elapsed.formatted(.number.precision(.fractionLength(1))))s · \(result.tools.count) tools")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,11 @@ struct MCPServerTestResultView: View {
|
|||||||
Button {
|
Button {
|
||||||
showOutput.toggle()
|
showOutput.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Label(showOutput ? "Hide Output" : "Show Output", systemImage: showOutput ? "chevron.up" : "chevron.down")
|
Label {
|
||||||
|
showOutput ? Text("Hide Output") : Text("Show Output")
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: showOutput ? "chevron.up" : "chevron.down")
|
||||||
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ struct MCPServersView: View {
|
|||||||
} else if let result = viewModel.testResults[server.name] {
|
} else if let result = viewModel.testResults[server.name] {
|
||||||
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||||
.foregroundStyle(result.succeeded ? .green : .red)
|
.foregroundStyle(result.succeeded ? .green : .red)
|
||||||
.help(result.succeeded ? "\(result.tools.count) tools" : "Test failed")
|
.help(result.succeeded ? Text("\(result.tools.count) tools") : Text("Test failed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ struct SignalSetupView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: viewModel.signalCLIInstalled ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
Image(systemName: viewModel.signalCLIInstalled ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(viewModel.signalCLIInstalled ? .green : .orange)
|
.foregroundStyle(viewModel.signalCLIInstalled ? .green : .orange)
|
||||||
Text(viewModel.signalCLIInstalled ? "signal-cli is available on PATH" : "signal-cli not found on PATH — install it first")
|
(viewModel.signalCLIInstalled
|
||||||
|
? Text("signal-cli is available on PATH")
|
||||||
|
: Text("signal-cli not found on PATH — install it first"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(viewModel.signalCLIInstalled ? Color.primary : Color.orange)
|
.foregroundStyle(viewModel.signalCLIInstalled ? Color.primary : Color.orange)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ struct PlatformsView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: KnownPlatforms.icon(for: platform.name))
|
Image(systemName: KnownPlatforms.icon(for: platform.name))
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
Text(platform.displayName)
|
Text(verbatim: platform.displayName)
|
||||||
Spacer()
|
Spacer()
|
||||||
Circle()
|
Circle()
|
||||||
.fill(statusColor(viewModel.connectivity(for: platform)))
|
.fill(statusColor(viewModel.connectivity(for: platform)))
|
||||||
@@ -88,7 +88,7 @@ struct PlatformsView: View {
|
|||||||
Image(systemName: KnownPlatforms.icon(for: viewModel.selected.name))
|
Image(systemName: KnownPlatforms.icon(for: viewModel.selected.name))
|
||||||
.font(.title)
|
.font(.title)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(viewModel.selected.displayName)
|
Text(verbatim: viewModel.selected.displayName)
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
Text(statusDescription(viewModel.connectivity(for: viewModel.selected)))
|
Text(statusDescription(viewModel.connectivity(for: viewModel.selected)))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -139,7 +139,7 @@ struct PlatformsView: View {
|
|||||||
case "homeassistant": HomeAssistantSetupView(context: ctx)
|
case "homeassistant": HomeAssistantSetupView(context: ctx)
|
||||||
case "webhook": WebhookSetupView(context: ctx)
|
case "webhook": WebhookSetupView(context: ctx)
|
||||||
default:
|
default:
|
||||||
SettingsSection(title: viewModel.selected.displayName, icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
|
SettingsSection(title: LocalizedStringKey(viewModel.selected.displayName), icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
|
||||||
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
|
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ struct ProfilesView: View {
|
|||||||
.font(.title)
|
.font(.title)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(profile.name).font(.title2.bold())
|
Text(profile.name).font(.title2.bold())
|
||||||
Text(profile.isActive ? "Active profile" : "Inactive")
|
(profile.isActive ? Text("Active profile") : Text("Inactive"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ProjectsViewModel {
|
final class ProjectsViewModel {
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "ProjectsViewModel")
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
private let service: ProjectDashboardService
|
private let service: ProjectDashboardService
|
||||||
|
|
||||||
@@ -39,7 +41,19 @@ final class ProjectsViewModel {
|
|||||||
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
||||||
let entry = ProjectEntry(name: name, path: path)
|
let entry = ProjectEntry(name: name, path: path)
|
||||||
registry.projects.append(entry)
|
registry.projects.append(entry)
|
||||||
service.saveRegistry(registry)
|
// 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)")
|
||||||
|
}
|
||||||
projects = registry.projects
|
projects = registry.projects
|
||||||
selectProject(entry)
|
selectProject(entry)
|
||||||
}
|
}
|
||||||
@@ -47,7 +61,11 @@ final class ProjectsViewModel {
|
|||||||
func removeProject(_ project: ProjectEntry) {
|
func removeProject(_ project: ProjectEntry) {
|
||||||
var registry = service.loadRegistry()
|
var registry = service.loadRegistry()
|
||||||
registry.projects.removeAll { $0.name == project.name }
|
registry.projects.removeAll { $0.name == project.name }
|
||||||
service.saveRegistry(registry)
|
do {
|
||||||
|
try service.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
logger.error("removeProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
projects = registry.projects
|
projects = registry.projects
|
||||||
if selectedProject?.name == project.name {
|
if selectedProject?.name == project.name {
|
||||||
selectedProject = nil
|
selectedProject = nil
|
||||||
|
|||||||
@@ -1,18 +1,54 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
private enum DashboardTab: String, CaseIterable {
|
private enum DashboardTab: String, CaseIterable {
|
||||||
case dashboard = "Dashboard"
|
case dashboard = "Dashboard"
|
||||||
case site = "Site"
|
case site = "Site"
|
||||||
|
|
||||||
|
var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .dashboard: return "Dashboard"
|
||||||
|
case .site: return "Site"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProjectsView: View {
|
struct ProjectsView: View {
|
||||||
@State private var viewModel: ProjectsViewModel
|
@State private var viewModel: ProjectsViewModel
|
||||||
|
@State private var installerViewModel: TemplateInstallerViewModel
|
||||||
|
@State private var uninstallerViewModel: TemplateUninstallerViewModel
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
@Environment(\.serverContext) private var serverContext
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
|
@State private var showingInstallSheet = false
|
||||||
|
@State private var exportSheetProject: ProjectEntry?
|
||||||
|
@State private var showingInstallURLPrompt = false
|
||||||
|
@State private var installURLInput = ""
|
||||||
|
@State private var showingUninstallSheet = false
|
||||||
|
@State private var configEditorProject: ProjectEntry?
|
||||||
|
/// Project queued for the "remove from list" confirmation dialog.
|
||||||
|
/// Non-nil while the dialog is up; the `confirmationDialog` binding
|
||||||
|
/// flips based on presence. We store the full entry (not just a
|
||||||
|
/// flag) so the dialog's action closure knows which project to
|
||||||
|
/// drop from the registry.
|
||||||
|
@State private var pendingRemoveFromList: ProjectEntry?
|
||||||
|
|
||||||
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
init(context: ServerContext) {
|
init(context: ServerContext) {
|
||||||
_viewModel = State(initialValue: ProjectsViewModel(context: context))
|
_viewModel = State(initialValue: ProjectsViewModel(context: context))
|
||||||
|
_installerViewModel = State(initialValue: TemplateInstallerViewModel(context: context))
|
||||||
|
_uninstallerViewModel = State(initialValue: TemplateUninstallerViewModel(context: context))
|
||||||
|
self.uninstaller = ProjectTemplateUninstaller(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the given project has a cached manifest (i.e. was
|
||||||
|
/// installed from a schemaful template). Cheap — just a file
|
||||||
|
/// existence check via the transport.
|
||||||
|
private func isConfigurable(_ project: ProjectEntry) -> Bool {
|
||||||
|
let path = ProjectConfigService.manifestCachePath(for: project)
|
||||||
|
return serverContext.makeTransport().fileExists(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var selectedTab: DashboardTab = .dashboard
|
@State private var selectedTab: DashboardTab = .dashboard
|
||||||
@@ -25,6 +61,7 @@ struct ProjectsView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.navigationTitle("Projects")
|
.navigationTitle("Projects")
|
||||||
|
.toolbar { templatesToolbar }
|
||||||
.task {
|
.task {
|
||||||
viewModel.load()
|
viewModel.load()
|
||||||
if let name = coordinator.selectedProjectName,
|
if let name = coordinator.selectedProjectName,
|
||||||
@@ -32,11 +69,195 @@ struct ProjectsView: View {
|
|||||||
viewModel.selectProject(project)
|
viewModel.selectProject(project)
|
||||||
}
|
}
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
|
// Cold-launch deep link or Finder double-click: the router may
|
||||||
|
// have a URL staged before this view installed the onChange
|
||||||
|
// observer below. Without this first-appearance check,
|
||||||
|
// SwiftUI's .onChange would never fire (it only reacts to
|
||||||
|
// *changes* after installation) and the URL would sit on the
|
||||||
|
// singleton forever.
|
||||||
|
if let pending = TemplateURLRouter.shared.pendingInstallURL {
|
||||||
|
dispatchPendingInstall(pending)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: fileWatcher.lastChangeDate) {
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
viewModel.load()
|
viewModel.load()
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
}
|
}
|
||||||
|
.onChange(of: TemplateURLRouter.shared.pendingInstallURL) { _, new in
|
||||||
|
// A URL landed *while the app was already running*.
|
||||||
|
if let new {
|
||||||
|
dispatchPendingInstall(new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingInstallSheet) {
|
||||||
|
TemplateInstallSheet(viewModel: installerViewModel) { entry in
|
||||||
|
viewModel.load()
|
||||||
|
coordinator.selectedProjectName = entry.name
|
||||||
|
if let project = viewModel.projects.first(where: { $0.name == entry.name }) {
|
||||||
|
viewModel.selectProject(project)
|
||||||
|
}
|
||||||
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $exportSheetProject) { project in
|
||||||
|
TemplateExportSheet(
|
||||||
|
viewModel: TemplateExporterViewModel(context: serverContext, project: project)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingInstallURLPrompt) {
|
||||||
|
installURLSheet
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingUninstallSheet) {
|
||||||
|
TemplateUninstallSheet(viewModel: uninstallerViewModel) { removed in
|
||||||
|
// Refresh the registry and clear selection if we just
|
||||||
|
// removed the project the user was viewing.
|
||||||
|
if viewModel.selectedProject?.path == removed.path {
|
||||||
|
viewModel.selectedProject = nil
|
||||||
|
}
|
||||||
|
if coordinator.selectedProjectName == removed.name {
|
||||||
|
coordinator.selectedProjectName = nil
|
||||||
|
}
|
||||||
|
viewModel.load()
|
||||||
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $configEditorProject) { project in
|
||||||
|
ConfigEditorSheet(
|
||||||
|
context: serverContext,
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Confirmation dialog for the sidebar's "Remove from List" action.
|
||||||
|
// The action is registry-only (doesn't touch disk), but the name
|
||||||
|
// historically confused users into thinking it was a full delete.
|
||||||
|
// A confirmation with explicit wording clarifies scope before the
|
||||||
|
// click is destructive-looking but actually harmless.
|
||||||
|
.confirmationDialog(
|
||||||
|
removeFromListDialogTitle,
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { pendingRemoveFromList != nil },
|
||||||
|
set: { if !$0 { pendingRemoveFromList = nil } }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: pendingRemoveFromList
|
||||||
|
) { project in
|
||||||
|
Button("Remove from List") {
|
||||||
|
viewModel.removeProject(project)
|
||||||
|
if coordinator.selectedProjectName == project.name {
|
||||||
|
coordinator.selectedProjectName = nil
|
||||||
|
}
|
||||||
|
pendingRemoveFromList = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
pendingRemoveFromList = nil
|
||||||
|
}
|
||||||
|
} message: { project in
|
||||||
|
Text(
|
||||||
|
"\(project.name) will be removed from Scarf's project list. " +
|
||||||
|
"Nothing on disk is touched — the folder, cron job, skills, and memory block all stay. " +
|
||||||
|
"To actually remove installed files, use \"Uninstall Template…\" instead."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Title string for the remove-from-list confirmation dialog. Kept
|
||||||
|
/// as a computed property so the dialog and any future reuse share
|
||||||
|
/// the exact same copy.
|
||||||
|
private var removeFromListDialogTitle: LocalizedStringKey {
|
||||||
|
"Remove from Scarf's project list?"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var templatesToolbar: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Menu {
|
||||||
|
Button("Install from File…", systemImage: "tray.and.arrow.down") {
|
||||||
|
openInstallFilePicker()
|
||||||
|
}
|
||||||
|
Button("Install from URL…", systemImage: "link") {
|
||||||
|
installURLInput = ""
|
||||||
|
showingInstallURLPrompt = true
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
if let selected = viewModel.selectedProject {
|
||||||
|
Button("Export \"\(selected.name)\" as Template…", systemImage: "tray.and.arrow.up") {
|
||||||
|
exportSheetProject = selected
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button("Export as Template…", systemImage: "tray.and.arrow.up") {}
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Templates", systemImage: "shippingbox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var installURLSheet: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Install Template from URL")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Paste an https URL pointing at a .scarftemplate file.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("https://example.com/my.scarftemplate", text: $installURLInput)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { showingInstallURLPrompt = false }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Install") {
|
||||||
|
if let url = URL(string: installURLInput), url.scheme?.lowercased() == "https" {
|
||||||
|
installerViewModel.openRemoteURL(url)
|
||||||
|
showingInstallURLPrompt = false
|
||||||
|
showingInstallSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(URL(string: installURLInput)?.scheme?.lowercased() != "https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(minWidth: 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route a pending install URL to the right VM entry point. `file://`
|
||||||
|
/// URLs come from Finder double-clicks + the "Install from File…" flow
|
||||||
|
/// when routed via the router; `https://` URLs come from `scarf://`
|
||||||
|
/// deep links and the "Install from URL…" prompt.
|
||||||
|
private func dispatchPendingInstall(_ url: URL) {
|
||||||
|
if url.isFileURL {
|
||||||
|
installerViewModel.openLocalFile(url.path)
|
||||||
|
} else {
|
||||||
|
installerViewModel.openRemoteURL(url)
|
||||||
|
}
|
||||||
|
TemplateURLRouter.shared.consume()
|
||||||
|
showingInstallSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openInstallFilePicker() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
// Accept both the declared Scarf template UTI and plain zip — the
|
||||||
|
// custom UTI wins for files with the .scarftemplate extension, and
|
||||||
|
// the zip fallback means an author distributing under .zip (e.g.
|
||||||
|
// before the UTI is registered on the receiving Mac) still works.
|
||||||
|
var types: [UTType] = [.zip]
|
||||||
|
if let templateType = UTType("com.scarf.template") {
|
||||||
|
types.insert(templateType, at: 0)
|
||||||
|
}
|
||||||
|
panel.allowedContentTypes = types
|
||||||
|
panel.allowsOtherFileTypes = true
|
||||||
|
panel.prompt = String(localized: "Install Template")
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
installerViewModel.openLocalFile(url.path)
|
||||||
|
showingInstallSheet = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Project List
|
// MARK: - Project List
|
||||||
@@ -58,6 +279,32 @@ struct ProjectsView: View {
|
|||||||
Text(project.name)
|
Text(project.name)
|
||||||
}
|
}
|
||||||
.tag(project)
|
.tag(project)
|
||||||
|
.contextMenu {
|
||||||
|
if isConfigurable(project) {
|
||||||
|
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
||||||
|
configEditorProject = project
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uninstaller.isTemplateInstalled(project: project) {
|
||||||
|
// "Uninstall Template…" only appears for projects
|
||||||
|
// installed from a `.scarftemplate`. Trailing
|
||||||
|
// ellipsis signals a confirmation sheet follows
|
||||||
|
// (macOS HIG convention); the sheet itself lists
|
||||||
|
// every file/cron/skill that will be removed.
|
||||||
|
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||||
|
uninstallerViewModel.begin(project: project)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
|
|
||||||
@@ -69,10 +316,16 @@ struct ProjectsView: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let selected = viewModel.selectedProject {
|
if let selected = viewModel.selectedProject {
|
||||||
Button(action: { viewModel.removeProject(selected) }) {
|
// Route through the same confirmation dialog as the
|
||||||
|
// context-menu "Remove from List" entry. The minus
|
||||||
|
// icon is a drive-by click target right next to "+" —
|
||||||
|
// confirming before mutating the registry stops the
|
||||||
|
// "I clicked by accident and my project's gone" case.
|
||||||
|
Button(action: { pendingRemoveFromList = selected }) {
|
||||||
Image(systemName: "minus")
|
Image(systemName: "minus")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
|
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
@@ -150,7 +403,7 @@ struct ProjectsView: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(tab.rawValue)
|
Text(tab.displayName)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -209,6 +462,25 @@ struct ProjectsView: View {
|
|||||||
Image(systemName: "folder")
|
Image(systemName: "folder")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
|
if isConfigurable(project) {
|
||||||
|
Button {
|
||||||
|
configEditorProject = project
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Edit configuration")
|
||||||
|
}
|
||||||
|
if uninstaller.isTemplateInstalled(project: project) {
|
||||||
|
Button {
|
||||||
|
uninstallerViewModel.begin(project: project)
|
||||||
|
showingUninstallSheet = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "shippingbox.and.arrow.backward")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Uninstall template")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,16 @@ final class QuickCommandsViewModel {
|
|||||||
func load() {
|
func load() {
|
||||||
let ctx = context
|
let ctx = context
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
let yaml = ctx.readText(ctx.paths.configYAML)
|
let result = Self.loadQuickCommands(context: ctx)
|
||||||
let result: [HermesQuickCommand] = {
|
await MainActor.run { [weak self] in self?.commands = result }
|
||||||
guard let yaml else { return [] }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `quick_commands` from `config.yaml` on the given context. Safe to
|
||||||
|
/// call from any actor — performs synchronous file I/O, so dispatch from a
|
||||||
|
/// detached task when called from `@MainActor`.
|
||||||
|
nonisolated static func loadQuickCommands(context: ServerContext) -> [HermesQuickCommand] {
|
||||||
|
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
|
||||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||||
var byName: [String: (type: String, command: String)] = [:]
|
var byName: [String: (type: String, command: String)] = [:]
|
||||||
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
|
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
|
||||||
@@ -43,9 +50,6 @@ final class QuickCommandsViewModel {
|
|||||||
}
|
}
|
||||||
return byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) }
|
return byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) }
|
||||||
.sorted { $0.name < $1.name }
|
.sorted { $0.name < $1.name }
|
||||||
}()
|
|
||||||
await MainActor.run { [weak self] in self?.commands = result }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check for obviously destructive shell strings. Display-only; we do not block.
|
/// Check for obviously destructive shell strings. Display-only; we do not block.
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ private struct QuickCommandEditor: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text(initial == nil ? "Add Quick Command" : "Edit /\(initial!.name)")
|
(initial == nil ? Text("Add Quick Command") : Text("Edit /\(initial!.name)"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Name (no leading slash)")
|
Text("Name (no leading slash)")
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ final class AddServerViewModel {
|
|||||||
var testResult: TestResult?
|
var testResult: TestResult?
|
||||||
|
|
||||||
enum TestResult: Equatable {
|
enum TestResult: Equatable {
|
||||||
case success(hermesPath: String, dbFound: Bool)
|
/// `suggestedRemoteHome` is non-nil when the probe didn't find
|
||||||
|
/// state.db at the configured (or default) path but did find a
|
||||||
|
/// `state.db` at one of the well-known alternates (e.g. a systemd
|
||||||
|
/// install in `/var/lib/hermes/.hermes`). UI offers a one-click
|
||||||
|
/// fill so the user doesn't have to know the convention.
|
||||||
|
case success(hermesPath: String, dbFound: Bool, suggestedRemoteHome: String?)
|
||||||
/// `command` is the full ssh invocation we attempted (so the user can
|
/// `command` is the full ssh invocation we attempted (so the user can
|
||||||
/// paste it into Terminal to see what their shell does with it).
|
/// paste it into Terminal to see what their shell does with it).
|
||||||
/// `stderr` is whatever ssh / the remote shell wrote to stderr.
|
/// `stderr` is whatever ssh / the remote shell wrote to stderr.
|
||||||
@@ -95,7 +100,7 @@ final class AddServerViewModel {
|
|||||||
/// `hermesBinaryHint` so subsequent calls don't need to re-resolve it.
|
/// `hermesBinaryHint` so subsequent calls don't need to re-resolve it.
|
||||||
func configForSave() -> SSHConfig {
|
func configForSave() -> SSHConfig {
|
||||||
var cfg = draftConfig
|
var cfg = draftConfig
|
||||||
if case .success(let path, _) = testResult {
|
if case .success(let path, _, _) = testResult {
|
||||||
cfg.hermesBinaryHint = path
|
cfg.hermesBinaryHint = path
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ final class ConnectionStatusViewModel {
|
|||||||
private let logger = Logger(subsystem: "com.scarf", category: "ConnectionStatus")
|
private let logger = Logger(subsystem: "com.scarf", category: "ConnectionStatus")
|
||||||
|
|
||||||
enum Status: Equatable {
|
enum Status: Equatable {
|
||||||
/// Healthy: most recent probe succeeded.
|
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
||||||
case connected
|
case connected
|
||||||
|
/// SSH connects but the follow-up read-access probe failed. Data
|
||||||
|
/// views will be empty until this is resolved. `reason` is shown
|
||||||
|
/// in the pill tooltip; users click the pill to open diagnostics.
|
||||||
|
case degraded(reason: String)
|
||||||
/// No probe yet or the previous probe timed out but we haven't
|
/// No probe yet or the previous probe timed out but we haven't
|
||||||
/// confirmed failure. Shown as yellow to tell the user "checking…".
|
/// confirmed failure. Shown as yellow to tell the user "checking…".
|
||||||
case idle
|
case idle
|
||||||
@@ -72,20 +76,59 @@ final class ConnectionStatusViewModel {
|
|||||||
|
|
||||||
private func probeOnce() async {
|
private func probeOnce() async {
|
||||||
let snapshot = transport
|
let snapshot = transport
|
||||||
let result: Result<Void, TransportError>
|
let hermesHome = context.paths.home
|
||||||
// Transport IO on a detached task so we don't block MainActor.
|
// Two-tier probe in one SSH round-trip:
|
||||||
result = await Task.detached {
|
// tier 1: `true` — raw connectivity / auth / ControlMaster path
|
||||||
|
// tier 2: `test -r $HERMESHOME/config.yaml` — can we actually
|
||||||
|
// read the file Dashboard reads on every tick? Green pill
|
||||||
|
// only if both pass; yellow "degraded" if tier 1 passes
|
||||||
|
// but tier 2 fails (the exact symptom in issue #19).
|
||||||
|
// Script emits two lines: TIER1:<exitcode> and TIER2:<exitcode>.
|
||||||
|
let homeArg: String
|
||||||
|
if hermesHome.hasPrefix("~/") {
|
||||||
|
homeArg = "\"$HOME/\(hermesHome.dropFirst(2))\""
|
||||||
|
} else if hermesHome == "~" {
|
||||||
|
homeArg = "\"$HOME\""
|
||||||
|
} else {
|
||||||
|
homeArg = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
|
||||||
|
}
|
||||||
|
let script = """
|
||||||
|
echo TIER1:0
|
||||||
|
H=\(homeArg)
|
||||||
|
if [ -r "$H/config.yaml" ]; then echo TIER2:0; else echo TIER2:1; fi
|
||||||
|
"""
|
||||||
|
|
||||||
|
enum ProbeOutcome {
|
||||||
|
case connected
|
||||||
|
case degraded(reason: String)
|
||||||
|
case failure(TransportError)
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcome: ProbeOutcome = await Task.detached {
|
||||||
do {
|
do {
|
||||||
let probe = try snapshot.runProcess(
|
let probe = try snapshot.runProcess(
|
||||||
executable: "/bin/sh",
|
executable: "/bin/sh",
|
||||||
args: ["-c", "true"],
|
args: ["-c", script],
|
||||||
stdin: nil,
|
stdin: nil,
|
||||||
timeout: 10
|
timeout: 10
|
||||||
)
|
)
|
||||||
if probe.exitCode == 0 {
|
guard probe.exitCode == 0 else {
|
||||||
return .success(())
|
|
||||||
}
|
|
||||||
return .failure(.commandFailed(exitCode: probe.exitCode, stderr: probe.stderrString))
|
return .failure(.commandFailed(exitCode: probe.exitCode, stderr: probe.stderrString))
|
||||||
|
}
|
||||||
|
let out = probe.stdoutString
|
||||||
|
let tier1 = out.contains("TIER1:0")
|
||||||
|
let tier2 = out.contains("TIER2:0")
|
||||||
|
if !tier1 {
|
||||||
|
// The script itself didn't reach tier 1 — treat as connection failure.
|
||||||
|
return .failure(.commandFailed(exitCode: 1, stderr: out))
|
||||||
|
}
|
||||||
|
if tier2 {
|
||||||
|
return .connected
|
||||||
|
}
|
||||||
|
// Connected but can't read config.yaml — the core issue #19
|
||||||
|
// symptom. Give the pill a short reason; the full story goes
|
||||||
|
// into Remote Diagnostics.
|
||||||
|
return .degraded(reason: "can't read ~/.hermes/config.yaml")
|
||||||
} catch let e as TransportError {
|
} catch let e as TransportError {
|
||||||
return .failure(e)
|
return .failure(e)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -93,11 +136,15 @@ final class ConnectionStatusViewModel {
|
|||||||
}
|
}
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
switch result {
|
switch outcome {
|
||||||
case .success:
|
case .connected:
|
||||||
status = .connected
|
status = .connected
|
||||||
lastSuccess = Date()
|
lastSuccess = Date()
|
||||||
consecutiveFailures = 0
|
consecutiveFailures = 0
|
||||||
|
case .degraded(let reason):
|
||||||
|
status = .degraded(reason: reason)
|
||||||
|
lastSuccess = Date() // SSH itself is fine, reset failure count
|
||||||
|
consecutiveFailures = 0
|
||||||
case .failure(let err):
|
case .failure(let err):
|
||||||
consecutiveFailures += 1
|
consecutiveFailures += 1
|
||||||
// First failure → silent yellow "Reconnecting…" while we try
|
// First failure → silent yellow "Reconnecting…" while we try
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Runs a fixed check-list against a remote server and reports per-probe
|
||||||
|
/// pass/fail. Exists because `TestConnectionProbe` only verifies ssh
|
||||||
|
/// connectivity + hermes binary presence, and `ConnectionStatusViewModel`
|
||||||
|
/// only pings `/bin/sh -c true`. When users file "connection green but
|
||||||
|
/// everything empty" bug reports (issue #19), this is the diagnostic surface
|
||||||
|
/// that tells them (and us) exactly which read fails and why.
|
||||||
|
///
|
||||||
|
/// One shell invocation runs every check on the remote and emits a
|
||||||
|
/// line-delimited `KEY|STATUS|DETAIL` protocol that the view model parses.
|
||||||
|
/// Cheaper than one SSH round-trip per probe and gives a consistent shell
|
||||||
|
/// environment across all probes.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class RemoteDiagnosticsViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "RemoteDiagnostics")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
/// Probes in display order. The order matters: connectivity first, then
|
||||||
|
/// environment checks, then Hermes data-path checks. A failure early in
|
||||||
|
/// the list usually explains every subsequent failure.
|
||||||
|
enum ProbeID: String, CaseIterable, Identifiable {
|
||||||
|
case connectivity
|
||||||
|
case remoteUser
|
||||||
|
case remoteHome
|
||||||
|
case hermesHomeConfigured
|
||||||
|
case hermesDirExists
|
||||||
|
case hermesDirReadable
|
||||||
|
case configYAMLReadable
|
||||||
|
case configYAMLContents
|
||||||
|
case stateDBReadable
|
||||||
|
case sqlite3Installed
|
||||||
|
case sqlite3CanOpenStateDB
|
||||||
|
case hermesBinaryNonLogin
|
||||||
|
case hermesBinaryLogin
|
||||||
|
case pgrepAvailable
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// Human-readable title rendered in the diagnostics sheet.
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .connectivity: return "SSH connectivity"
|
||||||
|
case .remoteUser: return "Remote user identity"
|
||||||
|
case .remoteHome: return "Remote $HOME"
|
||||||
|
case .hermesHomeConfigured: return "Hermes home directory"
|
||||||
|
case .hermesDirExists: return "Hermes directory exists"
|
||||||
|
case .hermesDirReadable: return "Hermes directory readable"
|
||||||
|
case .configYAMLReadable: return "config.yaml readable"
|
||||||
|
case .configYAMLContents: return "config.yaml actually readable (content)"
|
||||||
|
case .stateDBReadable: return "state.db readable"
|
||||||
|
case .sqlite3Installed: return "sqlite3 binary installed on remote"
|
||||||
|
case .sqlite3CanOpenStateDB: return "sqlite3 can open state.db"
|
||||||
|
case .hermesBinaryNonLogin: return "hermes binary on non-login PATH"
|
||||||
|
case .hermesBinaryLogin: return "hermes binary on login PATH (via rc files)"
|
||||||
|
case .pgrepAvailable: return "pgrep available (for 'is Hermes running')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the check fails, show this hint alongside the stderr.
|
||||||
|
var failureHint: String? {
|
||||||
|
switch self {
|
||||||
|
case .connectivity:
|
||||||
|
return "SSH itself can't complete. Before re-testing in Scarf, confirm `ssh <host>` works in Terminal."
|
||||||
|
case .remoteUser, .remoteHome:
|
||||||
|
return nil
|
||||||
|
case .hermesHomeConfigured:
|
||||||
|
return nil
|
||||||
|
case .hermesDirExists:
|
||||||
|
return "Scarf is looking at the default `~/.hermes`. If Hermes is installed elsewhere (e.g. `/var/lib/hermes/.hermes` for systemd installs), set the Hermes home directory in Manage Servers → this server → Edit."
|
||||||
|
case .hermesDirReadable:
|
||||||
|
return "The SSH user can see `~/.hermes` but can't list it. Check permissions: `ls -ld ~/.hermes` on the remote — the SSH user needs at least `r-x`."
|
||||||
|
case .configYAMLReadable, .configYAMLContents:
|
||||||
|
return "Scarf can't read `config.yaml`. This usually means the SSH user is different from the user Hermes runs as. Either (a) run Hermes as the SSH user, (b) `chmod a+r ~/.hermes/config.yaml`, or (c) configure Scarf to SSH as the Hermes user."
|
||||||
|
case .stateDBReadable:
|
||||||
|
return "Scarf can't read `state.db` — Sessions, Activity, Dashboard stats all depend on this. Same fix pattern as config.yaml."
|
||||||
|
case .sqlite3Installed:
|
||||||
|
return "Scarf pulls a snapshot of state.db via `sqlite3 .backup`, so sqlite3 must be installed on the remote. Install: `sudo apt install sqlite3` (Ubuntu/Debian), `sudo yum install sqlite` (RHEL/Fedora), `apk add sqlite` (Alpine)."
|
||||||
|
case .sqlite3CanOpenStateDB:
|
||||||
|
return "sqlite3 exists but can't open state.db. Could be a permission issue, a corrupt DB, or a version skew."
|
||||||
|
case .hermesBinaryNonLogin:
|
||||||
|
return "Scarf's runtime calls use non-login SSH shells (no .bashrc). If `hermes` only appears here via the login path, runtime CLI calls will fail. Move your PATH export from `.bashrc` to `.zshenv` or `.profile`."
|
||||||
|
case .hermesBinaryLogin:
|
||||||
|
return "hermes couldn't be located even after sourcing login rc files. Install path is non-standard — set the hermes binary path manually in Manage Servers."
|
||||||
|
case .pgrepAvailable:
|
||||||
|
return "pgrep not found on remote. Dashboard can't determine whether Hermes is running. Install procps: `apt install procps` (most distros have it by default)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Probe: Identifiable, Sendable {
|
||||||
|
let id: ProbeID
|
||||||
|
let passed: Bool
|
||||||
|
let detail: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var probes: [Probe] = []
|
||||||
|
private(set) var isRunning: Bool = false
|
||||||
|
private(set) var startedAt: Date?
|
||||||
|
private(set) var finishedAt: Date?
|
||||||
|
/// Raw stdout/stderr from the most recent run, preserved so the UI can
|
||||||
|
/// surface them in a disclosure panel when things look wrong. This is
|
||||||
|
/// how we debug cases where the script ran but no probes were parsed
|
||||||
|
/// (e.g. transport-quoting bugs, dash-vs-bash incompatibilities).
|
||||||
|
private(set) var rawStdout: String = ""
|
||||||
|
private(set) var rawStderr: String = ""
|
||||||
|
private(set) var rawExitCode: Int32 = 0
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kick off the full check list. Safe to call again to re-run.
|
||||||
|
func run() async {
|
||||||
|
if isRunning { return }
|
||||||
|
isRunning = true
|
||||||
|
probes = []
|
||||||
|
startedAt = Date()
|
||||||
|
finishedAt = nil
|
||||||
|
|
||||||
|
let script = Self.buildScript(hermesHome: context.paths.home)
|
||||||
|
let captured = await Self.execute(script: script, context: context)
|
||||||
|
|
||||||
|
switch captured {
|
||||||
|
case .connectFailure(let msg):
|
||||||
|
rawStdout = ""
|
||||||
|
rawStderr = msg
|
||||||
|
rawExitCode = -1
|
||||||
|
probes = [
|
||||||
|
Probe(id: .connectivity, passed: false, detail: msg)
|
||||||
|
] + ProbeID.allCases
|
||||||
|
.filter { $0 != .connectivity }
|
||||||
|
.map { Probe(id: $0, passed: false, detail: "(skipped — SSH didn't connect)") }
|
||||||
|
case .completed(let stdout, let stderr, let exitCode):
|
||||||
|
rawStdout = stdout
|
||||||
|
rawStderr = stderr
|
||||||
|
rawExitCode = exitCode
|
||||||
|
probes = Self.parse(stdout: stdout, stderr: stderr, exitCode: exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
finishedAt = Date()
|
||||||
|
isRunning = false
|
||||||
|
Self.logger.info("Diagnostics for \(self.context.displayName, privacy: .public) finished — \(self.passingCount)/\(self.probes.count) passing")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quick summary string, e.g. "9/14 passing". Used in the header.
|
||||||
|
var summary: String {
|
||||||
|
guard !probes.isEmpty else { return "Not yet run." }
|
||||||
|
return "\(passingCount)/\(probes.count) checks passing"
|
||||||
|
}
|
||||||
|
|
||||||
|
var passingCount: Int {
|
||||||
|
probes.filter { $0.passed }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var allPassed: Bool {
|
||||||
|
!probes.isEmpty && passingCount == probes.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Script + parsing
|
||||||
|
|
||||||
|
/// Build the remote shell script. Uses a pipe-delimited protocol so the
|
||||||
|
/// Swift side can parse without regex surprises. Status is `PASS` or
|
||||||
|
/// `FAIL`; detail is a single line (can be blank). `__END__` at the
|
||||||
|
/// bottom lets us detect truncation.
|
||||||
|
private static func buildScript(hermesHome: String) -> String {
|
||||||
|
// Shell-quote the home path — user may have typed `~/.hermes` which
|
||||||
|
// we want the remote shell to expand, so we substitute `~/` with
|
||||||
|
// `$HOME/` like `SSHTransport.remotePathArg` does.
|
||||||
|
let expanded: String
|
||||||
|
if hermesHome.hasPrefix("~/") {
|
||||||
|
expanded = "\"$HOME/\(hermesHome.dropFirst(2))\""
|
||||||
|
} else if hermesHome == "~" {
|
||||||
|
expanded = "\"$HOME\""
|
||||||
|
} else {
|
||||||
|
// Absolute path — still quote in case of spaces.
|
||||||
|
expanded = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
|
||||||
|
}
|
||||||
|
|
||||||
|
return #"""
|
||||||
|
H=\#(expanded)
|
||||||
|
emit() { printf '%s|%s|%s\n' "$1" "$2" "$3"; }
|
||||||
|
|
||||||
|
emit connectivity PASS "(running in this shell)"
|
||||||
|
|
||||||
|
user=$(id -un 2>/dev/null || echo unknown)
|
||||||
|
emit remoteUser PASS "$user"
|
||||||
|
|
||||||
|
emit remoteHome PASS "$HOME"
|
||||||
|
|
||||||
|
emit hermesHomeConfigured PASS "$H"
|
||||||
|
|
||||||
|
if [ -d "$H" ]; then
|
||||||
|
emit hermesDirExists PASS "$H"
|
||||||
|
else
|
||||||
|
emit hermesDirExists FAIL "not a directory: $H"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r "$H" ] && [ -x "$H" ]; then
|
||||||
|
emit hermesDirReadable PASS ""
|
||||||
|
else
|
||||||
|
emit hermesDirReadable FAIL "cannot read/enter $H (check perms on the dir)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r "$H/config.yaml" ]; then
|
||||||
|
emit configYAMLReadable PASS ""
|
||||||
|
else
|
||||||
|
if [ -e "$H/config.yaml" ]; then
|
||||||
|
emit configYAMLReadable FAIL "exists but not readable by $user"
|
||||||
|
else
|
||||||
|
emit configYAMLReadable FAIL "file does not exist"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if head -c 1 "$H/config.yaml" > /dev/null 2>&1; then
|
||||||
|
size=$(wc -c < "$H/config.yaml" 2>/dev/null | tr -d ' ')
|
||||||
|
emit configYAMLContents PASS "${size} bytes"
|
||||||
|
else
|
||||||
|
emit configYAMLContents FAIL "cannot read file contents"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r "$H/state.db" ]; then
|
||||||
|
size=$(wc -c < "$H/state.db" 2>/dev/null | tr -d ' ')
|
||||||
|
emit stateDBReadable PASS "${size} bytes"
|
||||||
|
else
|
||||||
|
if [ -e "$H/state.db" ]; then
|
||||||
|
emit stateDBReadable FAIL "exists but not readable by $user"
|
||||||
|
else
|
||||||
|
emit stateDBReadable FAIL "file does not exist"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v sqlite3 > /dev/null 2>&1; then
|
||||||
|
sq=$(command -v sqlite3)
|
||||||
|
emit sqlite3Installed PASS "$sq"
|
||||||
|
else
|
||||||
|
emit sqlite3Installed FAIL "sqlite3 not on PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if sqlite3 "$H/state.db" 'SELECT 1' > /dev/null 2>&1; then
|
||||||
|
emit sqlite3CanOpenStateDB PASS ""
|
||||||
|
else
|
||||||
|
err=$(sqlite3 "$H/state.db" 'SELECT 1' 2>&1 | head -1)
|
||||||
|
emit sqlite3CanOpenStateDB FAIL "$err"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Non-login PATH: just ask the current shell.
|
||||||
|
hpath=$(command -v hermes 2>/dev/null)
|
||||||
|
if [ -n "$hpath" ]; then
|
||||||
|
emit hermesBinaryNonLogin PASS "$hpath"
|
||||||
|
else
|
||||||
|
emit hermesBinaryNonLogin FAIL "not on non-login PATH ($PATH)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Login PATH: source rc files (mirroring TestConnectionProbe) and re-probe.
|
||||||
|
for rc in "$HOME/.zshenv" "$HOME/.zprofile" "$HOME/.bash_profile" "$HOME/.profile"; do
|
||||||
|
[ -f "$rc" ] && . "$rc" 2>/dev/null
|
||||||
|
done
|
||||||
|
hpath2=$(command -v hermes 2>/dev/null)
|
||||||
|
if [ -z "$hpath2" ]; then
|
||||||
|
for cand in "$HOME/.local/bin/hermes" "/opt/homebrew/bin/hermes" "/usr/local/bin/hermes" "$HOME/.hermes/bin/hermes"; do
|
||||||
|
if [ -x "$cand" ]; then hpath2="$cand"; break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -n "$hpath2" ]; then
|
||||||
|
emit hermesBinaryLogin PASS "$hpath2"
|
||||||
|
else
|
||||||
|
emit hermesBinaryLogin FAIL "not found after sourcing rc files"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v pgrep > /dev/null 2>&1; then
|
||||||
|
emit pgrepAvailable PASS "$(command -v pgrep)"
|
||||||
|
else
|
||||||
|
emit pgrepAvailable FAIL "pgrep not on PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '__END__\n'
|
||||||
|
"""#
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Captured {
|
||||||
|
case connectFailure(String)
|
||||||
|
case completed(stdout: String, stderr: String, exitCode: Int32)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func execute(script: String, context: ServerContext) async -> Captured {
|
||||||
|
// Can't use `transport.runProcess(executable: "/bin/sh", args: ["-c", script])`
|
||||||
|
// here: SSHTransport.runProcess pipes every argument through
|
||||||
|
// `remotePathArg` (which double-quotes to rewrite `~/` → `$HOME/`),
|
||||||
|
// which mangles a multi-line shell script containing `"$1"`,
|
||||||
|
// nested quotes, and `printf` escape sequences. The result on the
|
||||||
|
// remote is a scrambled string and every probe fails to emit.
|
||||||
|
//
|
||||||
|
// Mirror TestConnectionProbe's approach: build the ssh argv
|
||||||
|
// directly so the script travels as a single opaque argv entry
|
||||||
|
// that ssh forwards to the remote shell unchanged.
|
||||||
|
switch context.kind {
|
||||||
|
case .local:
|
||||||
|
return await runLocally(script: script)
|
||||||
|
case .ssh(let config):
|
||||||
|
return await runOverSSH(script: script, config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct ssh invocation. Pipes the script into `sh` on stdin rather
|
||||||
|
/// than passing it as `sh -c <script>` argv — because ssh concatenates
|
||||||
|
/// argv with spaces and sends that as a single command string to the
|
||||||
|
/// remote's LOGIN shell, which then parses newlines as command
|
||||||
|
/// separators. A multi-line `sh -c <script>` would run only the first
|
||||||
|
/// line inside the `sh` subprocess (any variables set there die when
|
||||||
|
/// `sh` exits), and the rest would run in the login shell with no
|
||||||
|
/// access to those variables. Symptom: `$H=""` everywhere downstream.
|
||||||
|
///
|
||||||
|
/// Feeding the script via stdin avoids the split entirely — `sh -s`
|
||||||
|
/// consumes the whole stream in one process, so variable scope is
|
||||||
|
/// preserved and the script runs exactly the same way it would from
|
||||||
|
/// a local `cat script.sh | sh`.
|
||||||
|
private static func runOverSSH(script: String, config: SSHConfig) async -> Captured {
|
||||||
|
var sshArgv: [String] = [
|
||||||
|
"-o", "ControlMaster=auto",
|
||||||
|
"-o", "ControlPath=\(controlDirPath())/%C",
|
||||||
|
"-o", "ControlPersist=600",
|
||||||
|
"-o", "ServerAliveInterval=30",
|
||||||
|
"-o", "ConnectTimeout=10",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "LogLevel=QUIET",
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-T" // no pty — keep stdin/stdout a clean byte stream
|
||||||
|
]
|
||||||
|
if let port = config.port { sshArgv += ["-p", String(port)] }
|
||||||
|
if let id = config.identityFile, !id.isEmpty {
|
||||||
|
sshArgv += ["-i", id]
|
||||||
|
}
|
||||||
|
let hostSpec: String
|
||||||
|
if let user = config.user, !user.isEmpty { hostSpec = "\(user)@\(config.host)" }
|
||||||
|
else { hostSpec = config.host }
|
||||||
|
sshArgv.append(hostSpec)
|
||||||
|
sshArgv.append("--")
|
||||||
|
sshArgv.append("/bin/sh")
|
||||||
|
sshArgv.append("-s") // read script from stdin
|
||||||
|
|
||||||
|
return await Task.detached { () -> Captured in
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||||
|
proc.arguments = sshArgv
|
||||||
|
|
||||||
|
// Inherit the shell's SSH_AUTH_SOCK so ssh can reach the
|
||||||
|
// agent — same pattern as SSHTransport + TestConnectionProbe.
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
let shellEnv = HermesFileService.enrichedEnvironment()
|
||||||
|
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
|
||||||
|
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
|
||||||
|
env[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proc.environment = env
|
||||||
|
|
||||||
|
let stdinPipe = Pipe()
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
proc.standardInput = stdinPipe
|
||||||
|
proc.standardOutput = stdoutPipe
|
||||||
|
proc.standardError = stderrPipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
} catch {
|
||||||
|
return .connectFailure("Failed to launch ssh: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the script to ssh's stdin, then close the write end so
|
||||||
|
// remote sh sees EOF and exits after executing the whole script.
|
||||||
|
if let data = script.data(using: .utf8) {
|
||||||
|
try? stdinPipe.fileHandleForWriting.write(contentsOf: data)
|
||||||
|
}
|
||||||
|
try? stdinPipe.fileHandleForWriting.close()
|
||||||
|
|
||||||
|
let deadline = Date().addingTimeInterval(30)
|
||||||
|
while proc.isRunning && Date() < deadline {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
|
if proc.isRunning {
|
||||||
|
proc.terminate()
|
||||||
|
return .connectFailure("Diagnostics timed out after 30s")
|
||||||
|
}
|
||||||
|
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||||
|
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||||
|
return .completed(
|
||||||
|
stdout: String(data: out, encoding: .utf8) ?? "",
|
||||||
|
stderr: String(data: err, encoding: .utf8) ?? "",
|
||||||
|
exitCode: proc.terminationStatus
|
||||||
|
)
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local Shell invocation — runs the diagnostic script against the
|
||||||
|
/// user's own Mac. Less useful than the remote form (most checks will
|
||||||
|
/// trivially pass), but lets the same UI work for both contexts.
|
||||||
|
private static func runLocally(script: String) async -> Captured {
|
||||||
|
return await Task.detached { () -> Captured in
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||||
|
proc.arguments = ["-c", script]
|
||||||
|
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
proc.standardOutput = stdoutPipe
|
||||||
|
proc.standardError = stderrPipe
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
} catch {
|
||||||
|
return .connectFailure("Failed to launch /bin/sh: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while proc.isRunning && Date() < deadline {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
|
if proc.isRunning {
|
||||||
|
proc.terminate()
|
||||||
|
return .connectFailure("Local diagnostics timed out (should be <1s)")
|
||||||
|
}
|
||||||
|
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||||
|
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||||
|
return .completed(
|
||||||
|
stdout: String(data: out, encoding: .utf8) ?? "",
|
||||||
|
stderr: String(data: err, encoding: .utf8) ?? "",
|
||||||
|
exitCode: proc.terminationStatus
|
||||||
|
)
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same cache directory used by SSHTransport — shared so the diagnostic
|
||||||
|
/// probe reuses the connection's ControlMaster socket when it already
|
||||||
|
/// exists (no second TCP handshake, no second auth).
|
||||||
|
private static func controlDirPath() -> String {
|
||||||
|
SSHTransport.controlDirPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parse(stdout: String, stderr: String, exitCode: Int32) -> [Probe] {
|
||||||
|
var results: [ProbeID: Probe] = [:]
|
||||||
|
for line in stdout.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) {
|
||||||
|
let parts = line.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false)
|
||||||
|
guard parts.count == 3 else { continue }
|
||||||
|
let key = String(parts[0]).trimmingCharacters(in: .whitespaces)
|
||||||
|
let status = String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||||
|
let detail = String(parts[2]).trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let probe = ProbeID(rawValue: key) else { continue }
|
||||||
|
results[probe] = Probe(
|
||||||
|
id: probe,
|
||||||
|
passed: status == "PASS",
|
||||||
|
detail: detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the script didn't complete, fill in the missing probes so the UI
|
||||||
|
// still shows every expected row (rather than silently skipping).
|
||||||
|
let terminated = stdout.contains("__END__")
|
||||||
|
let fallbackDetail: String
|
||||||
|
if terminated {
|
||||||
|
fallbackDetail = "(no output)"
|
||||||
|
} else if exitCode != 0 {
|
||||||
|
fallbackDetail = "(script exited \(exitCode) before this check — stderr: \(stderr.prefix(200)))"
|
||||||
|
} else {
|
||||||
|
fallbackDetail = "(no output from script)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProbeID.allCases.map { id in
|
||||||
|
results[id] ?? Probe(id: id, passed: false, detail: fallbackDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,26 @@ struct TestConnectionProbe {
|
|||||||
// Scarf's local resolution.
|
// Scarf's local resolution.
|
||||||
// The matched absolute path is stored as `hermesBinaryHint` on the
|
// The matched absolute path is stored as `hermesBinaryHint` on the
|
||||||
// SSHConfig so subsequent CLI/ACP invocations don't have to re-probe.
|
// SSHConfig so subsequent CLI/ACP invocations don't have to re-probe.
|
||||||
|
// If the user already typed a remoteHome override, use it; otherwise
|
||||||
|
// default to $HOME/.hermes. Either way, the script also probes a
|
||||||
|
// short list of well-known alternates when the primary path doesn't
|
||||||
|
// have state.db — systemd/docker/VPS installs tend to live at
|
||||||
|
// /var/lib/hermes/.hermes or /home/hermes/.hermes, and SSHing in as
|
||||||
|
// a different user than the Hermes daemon is the leading cause of
|
||||||
|
// "connection green, data empty" bug reports (issue #19).
|
||||||
|
let primary: String
|
||||||
|
if let override = config.remoteHome, !override.isEmpty {
|
||||||
|
if override.hasPrefix("~/") {
|
||||||
|
primary = "$HOME/\(override.dropFirst(2))"
|
||||||
|
} else if override == "~" {
|
||||||
|
primary = "$HOME"
|
||||||
|
} else {
|
||||||
|
primary = override
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
primary = "$HOME/.hermes"
|
||||||
|
}
|
||||||
|
|
||||||
let script = #"""
|
let script = #"""
|
||||||
hpath=$(command -v hermes 2>/dev/null)
|
hpath=$(command -v hermes 2>/dev/null)
|
||||||
if [ -z "$hpath" ]; then
|
if [ -z "$hpath" ]; then
|
||||||
@@ -61,7 +81,21 @@ struct TestConnectionProbe {
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
echo "HERMES:$hpath"
|
echo "HERMES:$hpath"
|
||||||
if [ -e "$HOME/.hermes/state.db" ]; then echo DB:ok; else echo DB:missing; fi
|
PRIMARY="\#(primary)"
|
||||||
|
if [ -r "$PRIMARY/state.db" ]; then
|
||||||
|
echo "DB:ok"
|
||||||
|
echo "HOME_USED:$PRIMARY"
|
||||||
|
else
|
||||||
|
echo "DB:missing"
|
||||||
|
# Probe well-known alternates. Emit the first one that has a
|
||||||
|
# readable state.db so the UI can offer a one-click fill.
|
||||||
|
for alt in "/var/lib/hermes/.hermes" "/opt/hermes/.hermes" "/home/hermes/.hermes" "/root/.hermes"; do
|
||||||
|
if [ -r "$alt/state.db" ]; then
|
||||||
|
echo "SUGGEST:$alt"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
"""#
|
"""#
|
||||||
sshArgs.append("/bin/sh")
|
sshArgs.append("/bin/sh")
|
||||||
sshArgs.append("-c")
|
sshArgs.append("-c")
|
||||||
@@ -133,6 +167,8 @@ struct TestConnectionProbe {
|
|||||||
let hermesPath = lines.first(where: { $0.hasPrefix("HERMES:") })?
|
let hermesPath = lines.first(where: { $0.hasPrefix("HERMES:") })?
|
||||||
.dropFirst("HERMES:".count).trimmingCharacters(in: .whitespaces) ?? ""
|
.dropFirst("HERMES:".count).trimmingCharacters(in: .whitespaces) ?? ""
|
||||||
let dbFound = lines.contains(where: { $0 == "DB:ok" })
|
let dbFound = lines.contains(where: { $0 == "DB:ok" })
|
||||||
|
let suggestedHome = lines.first(where: { $0.hasPrefix("SUGGEST:") })
|
||||||
|
.map { String($0.dropFirst("SUGGEST:".count)).trimmingCharacters(in: .whitespaces) }
|
||||||
if hermesPath.isEmpty {
|
if hermesPath.isEmpty {
|
||||||
return .failure(
|
return .failure(
|
||||||
message: "hermes binary not found in remote $PATH",
|
message: "hermes binary not found in remote $PATH",
|
||||||
@@ -140,7 +176,7 @@ struct TestConnectionProbe {
|
|||||||
command: displayCommand
|
command: displayCommand
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return .success(hermesPath: String(hermesPath), dbFound: dbFound)
|
return .success(hermesPath: String(hermesPath), dbFound: dbFound, suggestedRemoteHome: suggestedHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify common failures by scanning the stderr trace.
|
// Classify common failures by scanning the stderr trace.
|
||||||
|
|||||||
@@ -81,11 +81,15 @@ struct AddServerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LabeledField("Remote ~/.hermes override") {
|
LabeledField("Hermes data directory") {
|
||||||
TextField("Leave blank for default", text: $viewModel.remoteHome)
|
TextField("Default: ~/.hermes", text: $viewModel.remoteHome)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
}
|
}
|
||||||
|
Text("Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
Text("Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.")
|
Text("Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -113,14 +117,43 @@ struct AddServerSheet: View {
|
|||||||
|
|
||||||
if let result = viewModel.testResult {
|
if let result = viewModel.testResult {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let path, let dbFound):
|
case .success(let path, let dbFound, let suggestedHome):
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Label("Connected", systemImage: "checkmark.circle.fill")
|
Label("Connected", systemImage: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
Text("hermes at \(path)").font(.caption).monospaced()
|
Text("hermes at \(path)").font(.caption).monospaced()
|
||||||
Text(dbFound ? "state.db found" : "state.db not found — Hermes may not have run yet on the remote")
|
if dbFound {
|
||||||
|
Text("state.db readable")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(dbFound ? Color.secondary : Color.orange)
|
.foregroundStyle(.secondary)
|
||||||
|
} else if let suggestion = suggestedHome {
|
||||||
|
// Scarf found Hermes data at one of the common
|
||||||
|
// alternate paths. One-click fill the
|
||||||
|
// remoteHome field so the user doesn't have to
|
||||||
|
// know this is a convention thing.
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("state.db not found at the default location, but Scarf found one at:")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
HStack {
|
||||||
|
Text(suggestion)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Spacer()
|
||||||
|
Button("Use this") {
|
||||||
|
viewModel.remoteHome = suggestion
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.yellow.opacity(0.12), in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("state.db not found at the configured path. Either Hermes hasn't run yet on this server, or it's installed at a non-default location — set the Hermes data directory field above.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .failure(let message, let stderr, let command):
|
case .failure(let message, let stderr, let command):
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|||||||
@@ -8,62 +8,89 @@ import SwiftUI
|
|||||||
struct ConnectionStatusPill: View {
|
struct ConnectionStatusPill: View {
|
||||||
let status: ConnectionStatusViewModel
|
let status: ConnectionStatusViewModel
|
||||||
@State private var showDetails = false
|
@State private var showDetails = false
|
||||||
|
@State private var showDiagnostics = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
switch status.status {
|
switch status.status {
|
||||||
case .error:
|
case .error:
|
||||||
showDetails = true
|
showDetails = true
|
||||||
|
case .degraded:
|
||||||
|
// Yellow "can't read" state — open the diagnostics sheet
|
||||||
|
// so the user can see exactly which files fail and why.
|
||||||
|
showDiagnostics = true
|
||||||
case .connected, .idle:
|
case .connected, .idle:
|
||||||
status.retry()
|
status.retry()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 4) {
|
// Leading SF Symbol does double duty: its color is the status
|
||||||
Circle()
|
// signal (green/orange/yellow/red), and its shape reads as a
|
||||||
.fill(color)
|
// clickable toolbar tool. No custom background — the toolbar's
|
||||||
.frame(width: 8, height: 8)
|
// `.principal` emphasis bezel is the frame.
|
||||||
Text(label)
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.symbolRenderingMode(.hierarchical)
|
||||||
|
labelText
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 4)
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Color.secondary.opacity(0.08), in: Capsule())
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help(tooltip)
|
.help(tooltipText)
|
||||||
.popover(isPresented: $showDetails, arrowEdge: .bottom) {
|
.popover(isPresented: $showDetails, arrowEdge: .bottom) {
|
||||||
errorDetails.frame(width: 400)
|
errorDetails.frame(width: 400)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showDiagnostics) {
|
||||||
|
RemoteDiagnosticsView(context: status.context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var color: Color {
|
private var color: Color {
|
||||||
switch status.status {
|
switch status.status {
|
||||||
case .connected: return .green
|
case .connected: return .green
|
||||||
|
case .degraded: return .orange
|
||||||
case .idle: return .yellow
|
case .idle: return .yellow
|
||||||
case .error: return .red
|
case .error: return .red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var label: String {
|
/// State-specific SF Symbol. The icon shape itself signals what the
|
||||||
|
/// click will do: checkmark for connected (click to re-probe),
|
||||||
|
/// stethoscope for degraded (click to run diagnostics), spinning
|
||||||
|
/// arrows for probing, triangle for error.
|
||||||
|
private var iconName: String {
|
||||||
switch status.status {
|
switch status.status {
|
||||||
case .connected: return "Connected"
|
case .connected: return "checkmark.circle.fill"
|
||||||
case .idle: return "Checking…"
|
case .degraded: return "stethoscope"
|
||||||
case .error(let message, _): return message
|
case .idle: return "arrow.triangle.2.circlepath"
|
||||||
|
case .error: return "exclamationmark.triangle.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tooltip: String {
|
private var labelText: Text {
|
||||||
|
switch status.status {
|
||||||
|
case .connected: return Text("Connected")
|
||||||
|
case .degraded: return Text("Connected — can't read Hermes state")
|
||||||
|
case .idle: return Text("Checking…")
|
||||||
|
case .error(let message, _): return Text(verbatim: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tooltipText: Text {
|
||||||
switch status.status {
|
switch status.status {
|
||||||
case .connected:
|
case .connected:
|
||||||
if let ts = status.lastSuccess {
|
if let ts = status.lastSuccess {
|
||||||
let fmt = RelativeDateTimeFormatter()
|
let fmt = RelativeDateTimeFormatter()
|
||||||
return "Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))"
|
return Text("Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))")
|
||||||
}
|
}
|
||||||
return "Connected"
|
return Text("Connected")
|
||||||
case .idle: return "Waiting for first probe"
|
case .degraded(let reason):
|
||||||
case .error(_, _): return "Click for details"
|
return Text("SSH works but \(reason). Click for diagnostics.")
|
||||||
|
case .idle: return Text("Waiting for first probe")
|
||||||
|
case .error: return Text("Click for details")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct ManageServersView: View {
|
|||||||
@Environment(ServerRegistry.self) private var registry
|
@Environment(ServerRegistry.self) private var registry
|
||||||
@State private var showAddSheet = false
|
@State private var showAddSheet = false
|
||||||
@State private var pendingRemoveID: ServerID?
|
@State private var pendingRemoveID: ServerID?
|
||||||
|
@State private var diagnosticsContext: ServerContext?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -17,12 +18,18 @@ struct ManageServersView: View {
|
|||||||
list
|
list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 380, height: 360)
|
.frame(width: 440, height: 380)
|
||||||
.sheet(isPresented: $showAddSheet) {
|
.sheet(isPresented: $showAddSheet) {
|
||||||
AddServerSheet { name, config in
|
AddServerSheet { name, config in
|
||||||
_ = registry.addServer(displayName: name, config: config)
|
_ = registry.addServer(displayName: name, config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(item: Binding(
|
||||||
|
get: { diagnosticsContext.map { IdentifiableContext(context: $0) } },
|
||||||
|
set: { diagnosticsContext = $0?.context }
|
||||||
|
)) { wrapper in
|
||||||
|
RemoteDiagnosticsView(context: wrapper.context)
|
||||||
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Remove this server?",
|
"Remove this server?",
|
||||||
isPresented: Binding(
|
isPresented: Binding(
|
||||||
@@ -42,6 +49,13 @@ struct ManageServersView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wrapper because `ServerContext` isn't `Identifiable` against the sheet
|
||||||
|
/// item API in a way that preserves display-ordering stability.
|
||||||
|
private struct IdentifiableContext: Identifiable {
|
||||||
|
var id: ServerID { context.id }
|
||||||
|
let context: ServerContext
|
||||||
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Servers").font(.headline)
|
Text("Servers").font(.headline)
|
||||||
@@ -73,13 +87,32 @@ struct ManageServersView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var list: some View {
|
private var list: some View {
|
||||||
List {
|
let defaultID = registry.defaultServerID
|
||||||
|
return List {
|
||||||
|
// Local sits at the top so users can mark it as the open-on-launch
|
||||||
|
// default alongside remote servers. It's synthesized (not in
|
||||||
|
// `registry.entries`), so render it explicitly.
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
defaultStar(for: ServerContext.local.id, currentDefault: defaultID)
|
||||||
|
Image(systemName: "laptopcomputer")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Local").font(.body)
|
||||||
|
Text("This Mac")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
ForEach(registry.entries) { entry in
|
ForEach(registry.entries) { entry in
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
|
defaultStar(for: entry.id, currentDefault: defaultID)
|
||||||
Image(systemName: "server.rack")
|
Image(systemName: "server.rack")
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(.blue)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(entry.displayName).font(.body)
|
Text(verbatim: entry.displayName).font(.body)
|
||||||
if case .ssh(let config) = entry.kind {
|
if case .ssh(let config) = entry.kind {
|
||||||
Text(summary(for: config))
|
Text(summary(for: config))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -87,6 +120,13 @@ struct ManageServersView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button {
|
||||||
|
diagnosticsContext = entry.context
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "stethoscope")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Run remote diagnostics — check exactly which files are readable on this server.")
|
||||||
Button {
|
Button {
|
||||||
pendingRemoveID = entry.id
|
pendingRemoveID = entry.id
|
||||||
} label: {
|
} label: {
|
||||||
@@ -94,6 +134,7 @@ struct ManageServersView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
|
.help("Remove this server from Scarf.")
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
@@ -101,6 +142,23 @@ struct ManageServersView: View {
|
|||||||
.listStyle(.inset)
|
.listStyle(.inset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A star button that marks the open-on-launch default. Filled + yellow
|
||||||
|
/// 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 {
|
||||||
|
if !isDefault { registry.setDefaultServer(id) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isDefault ? "star.fill" : "star")
|
||||||
|
.foregroundStyle(isDefault ? .yellow : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help(isDefault ? "Opens on launch" : "Set as default — open this server when Scarf launches.")
|
||||||
|
}
|
||||||
|
|
||||||
private func summary(for config: SSHConfig) -> String {
|
private func summary(for config: SSHConfig) -> String {
|
||||||
var s = ""
|
var s = ""
|
||||||
if let user = config.user, !user.isEmpty { s += "\(user)@" }
|
if let user = config.user, !user.isEmpty { s += "\(user)@" }
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Per-server diagnostics sheet. Shown from Manage Servers and from the
|
||||||
|
/// Dashboard "Run Diagnostics…" button when `lastReadError` is set. Gives
|
||||||
|
/// the user a specific list of what does/doesn't work over SSH, with
|
||||||
|
/// targeted remediation hints for each failure.
|
||||||
|
///
|
||||||
|
/// Design principle: a failing check always shows both the raw detail the
|
||||||
|
/// remote shell produced AND a human-written hint. The raw detail lets us
|
||||||
|
/// triage bug reports; the hint unblocks the user without a round trip.
|
||||||
|
struct RemoteDiagnosticsView: View {
|
||||||
|
let context: ServerContext
|
||||||
|
@State private var viewModel: RemoteDiagnosticsViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
self.context = context
|
||||||
|
_viewModel = State(initialValue: RemoteDiagnosticsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
probeList
|
||||||
|
Divider()
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.frame(minWidth: 640, minHeight: 520)
|
||||||
|
.task { await viewModel.run() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text("Remote Diagnostics — \(context.displayName)")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Spacer()
|
||||||
|
if viewModel.isRunning {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("Re-run") { Task { await viewModel.run() } }
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
if viewModel.isRunning {
|
||||||
|
Text("Running checks…")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Label(viewModel.summary, systemImage: viewModel.allPassed ? "checkmark.seal" : "info.circle")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(viewModel.allPassed ? .green : .orange)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if !viewModel.probes.isEmpty {
|
||||||
|
Button {
|
||||||
|
copyReportToClipboard()
|
||||||
|
} label: {
|
||||||
|
Label("Copy Full Report", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.help("Copy a plain-text summary of every check (passes and fails) — paste into GitHub issues so we can see everything at once.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var probeList: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if viewModel.probes.isEmpty && viewModel.isRunning {
|
||||||
|
Text("Running a single shell session on \(context.displayName) that exercises every path Scarf reads…")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
ForEach(viewModel.probes) { probe in
|
||||||
|
probeRow(probe)
|
||||||
|
if probe.id != viewModel.probes.last?.id {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func probeRow(_ probe: RemoteDiagnosticsViewModel.Probe) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: probe.passed ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||||
|
.foregroundStyle(probe.passed ? .green : .red)
|
||||||
|
.font(.title3)
|
||||||
|
.padding(.top, 2)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(probe.id.title)
|
||||||
|
.font(.body)
|
||||||
|
if !probe.detail.isEmpty {
|
||||||
|
Text(probe.detail)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
if !probe.passed, let hint = probe.id.failureHint {
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Image(systemName: "lightbulb")
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
.font(.caption)
|
||||||
|
Text(hint)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.yellow.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Raw-output disclosure. Shown whenever anything fails — we need
|
||||||
|
// this visible for partial failures too since the raw stdout is
|
||||||
|
// the only way to see WHY a check returned its detail. Hidden
|
||||||
|
// only when 14/14 pass (script worked, nothing to debug).
|
||||||
|
if !viewModel.probes.isEmpty, !viewModel.allPassed {
|
||||||
|
DisclosureGroup("Raw remote output (for debugging)") {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("exit code: \(viewModel.rawExitCode)")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
if !viewModel.rawStdout.isEmpty {
|
||||||
|
Text("stdout:").font(.caption).foregroundStyle(.secondary)
|
||||||
|
ScrollView {
|
||||||
|
Text(viewModel.rawStdout)
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 140)
|
||||||
|
}
|
||||||
|
if !viewModel.rawStderr.isEmpty {
|
||||||
|
Text("stderr:").font(.caption).foregroundStyle(.secondary)
|
||||||
|
ScrollView {
|
||||||
|
Text(viewModel.rawStderr)
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Scarf runs these over a single SSH session that mirrors the shell your dashboard reads from, so a green row here means Scarf can actually read that file at runtime.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer()
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyReportToClipboard() {
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.append("Scarf remote diagnostics — \(context.displayName)")
|
||||||
|
if case .ssh(let config) = context.kind {
|
||||||
|
lines.append("Host: \(config.host)" + (config.user.map { " (user: \($0))" } ?? ""))
|
||||||
|
if let rh = config.remoteHome { lines.append("Hermes home (override): \(rh)") }
|
||||||
|
}
|
||||||
|
lines.append("Ran at: \(viewModel.startedAt.map { ISO8601DateFormatter().string(from: $0) } ?? "?")")
|
||||||
|
lines.append("Result: \(viewModel.summary)")
|
||||||
|
lines.append("")
|
||||||
|
for probe in viewModel.probes {
|
||||||
|
let mark = probe.passed ? "PASS" : "FAIL"
|
||||||
|
lines.append("[\(mark)] \(probe.id.title)")
|
||||||
|
if !probe.detail.isEmpty { lines.append(" \(probe.detail)") }
|
||||||
|
if !probe.passed, let hint = probe.id.failureHint {
|
||||||
|
lines.append(" hint: \(hint)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = lines.joined(separator: "\n")
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
pb.clearContents()
|
||||||
|
pb.setString(text, forType: .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ struct ServerSwitcherToolbar: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(current.isRemote ? Color.blue : Color.green)
|
.fill(current.isRemote ? Color.blue : Color.green)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
Text(current.displayName)
|
Text(verbatim: current.displayName)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
|
|||||||
@@ -159,12 +159,7 @@ final class SessionsViewModel {
|
|||||||
let dbPath = context.paths.stateDB
|
let dbPath = context.paths.stateDB
|
||||||
let fileSize: String
|
let fileSize: String
|
||||||
if let stat = context.makeTransport().stat(dbPath) {
|
if let stat = context.makeTransport().stat(dbPath) {
|
||||||
let size = Double(stat.size)
|
fileSize = Int64(stat.size).formatted(.byteCount(style: .file))
|
||||||
if size >= FileSizeUnit.megabyte {
|
|
||||||
fileSize = String(format: "%.1f MB", size / FileSizeUnit.megabyte)
|
|
||||||
} else {
|
|
||||||
fileSize = String(format: "%.0f KB", size / FileSizeUnit.kilobyte)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
fileSize = "unknown"
|
fileSize = "unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ struct SessionDetailView: View {
|
|||||||
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
|
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
|
||||||
}
|
}
|
||||||
if let cost = session.displayCostUSD {
|
if let cost = session.displayCostUSD {
|
||||||
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
|
||||||
|
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
|
||||||
}
|
}
|
||||||
if let date = session.startedAt {
|
if let date = session.startedAt {
|
||||||
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ struct ModelPickerSheet: View {
|
|||||||
.font(.system(.body, design: .default, weight: .medium))
|
.font(.system(.body, design: .default, weight: .medium))
|
||||||
Spacer()
|
Spacer()
|
||||||
if let ctx = model.contextDisplay {
|
if let ctx = model.contextDisplay {
|
||||||
Text(ctx + " ctx")
|
Text("\(ctx) ctx")
|
||||||
.font(.caption2.monospaced())
|
.font(.caption2.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import AppKit
|
|||||||
/// on large view bodies (per project guidance in CLAUDE.md).
|
/// on large view bodies (per project guidance in CLAUDE.md).
|
||||||
|
|
||||||
struct SettingsSection<Content: View>: View {
|
struct SettingsSection<Content: View>: View {
|
||||||
let title: String
|
let title: LocalizedStringKey
|
||||||
let icon: String
|
let icon: String
|
||||||
@ViewBuilder let content: Content
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ struct DoubleStepperRow: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 160, alignment: .trailing)
|
.frame(width: 160, alignment: .trailing)
|
||||||
Text(String(format: "%.2f", value))
|
Text(value.formatted(.number.precision(.fractionLength(2))))
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.frame(width: 70, alignment: .leading)
|
.frame(width: 70, alignment: .leading)
|
||||||
Stepper("", value: Binding(
|
Stepper("", value: Binding(
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ struct SettingsView: View {
|
|||||||
case advanced = "Advanced"
|
case advanced = "Advanced"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .general: return "General"
|
||||||
|
case .display: return "Display"
|
||||||
|
case .agent: return "Agent"
|
||||||
|
case .terminal: return "Terminal"
|
||||||
|
case .browser: return "Browser"
|
||||||
|
case .voice: return "Voice"
|
||||||
|
case .memory: return "Memory"
|
||||||
|
case .auxiliary: return "Aux Models"
|
||||||
|
case .security: return "Security"
|
||||||
|
case .advanced: return "Advanced"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: return "gear"
|
case .general: return "gear"
|
||||||
@@ -56,7 +72,11 @@ struct SettingsView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label(tab.rawValue, systemImage: tab.icon)
|
Label {
|
||||||
|
Text(tab.displayName)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: tab.icon)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tag(tab)
|
.tag(tab)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ struct AuxiliaryTab: View {
|
|||||||
@Bindable var viewModel: SettingsViewModel
|
@Bindable var viewModel: SettingsViewModel
|
||||||
|
|
||||||
// Keyed by the config path name — matches `auxiliary.<task>.*` in config.yaml.
|
// Keyed by the config path name — matches `auxiliary.<task>.*` in config.yaml.
|
||||||
private let tasks: [(key: String, title: String, icon: String)] = [
|
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
|
||||||
("vision", "Vision", "eye"),
|
("vision", "Vision", "eye"),
|
||||||
("web_extract", "Web Extract", "doc.richtext"),
|
("web_extract", "Web Extract", "doc.richtext"),
|
||||||
("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"),
|
("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"),
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ struct SkillsView: View {
|
|||||||
case hub = "Browse Hub"
|
case hub = "Browse Hub"
|
||||||
case updates = "Updates"
|
case updates = "Updates"
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .installed: return "Installed"
|
||||||
|
case .hub: return "Browse Hub"
|
||||||
|
case .updates: return "Updates"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -34,7 +42,7 @@ struct SkillsView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Picker("", selection: $currentTab) {
|
Picker("", selection: $currentTab) {
|
||||||
ForEach(Tab.allCases) { tab in
|
ForEach(Tab.allCases) { tab in
|
||||||
Text(tab.rawValue).tag(tab)
|
Text(tab.displayName).tag(tab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the post-install "Configuration" button on the project
|
||||||
|
/// dashboard. Loads `<project>/.scarf/manifest.json` + `config.json`,
|
||||||
|
/// hands a `TemplateConfigViewModel` seeded with current values to the
|
||||||
|
/// sheet, then writes the edited values back on commit.
|
||||||
|
///
|
||||||
|
/// Smaller surface than `TemplateInstallerViewModel` — no unzipping,
|
||||||
|
/// no parent-dir picking, no cron CLI. Just: read → edit → save.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateConfigEditorViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigEditorViewModel")
|
||||||
|
|
||||||
|
enum Stage: Sendable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
/// Manifest + config loaded; the sheet is displaying the form.
|
||||||
|
case editing
|
||||||
|
case saving
|
||||||
|
case succeeded
|
||||||
|
case failed(String)
|
||||||
|
/// Project wasn't installed from a schemaful template — no
|
||||||
|
/// manifest cache on disk. The dashboard button is hidden in
|
||||||
|
/// this case so we shouldn't hit this stage normally.
|
||||||
|
case notConfigurable
|
||||||
|
}
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
let project: ProjectEntry
|
||||||
|
private let configService: ProjectConfigService
|
||||||
|
|
||||||
|
init(context: ServerContext, project: ProjectEntry) {
|
||||||
|
self.context = context
|
||||||
|
self.project = project
|
||||||
|
self.configService = ProjectConfigService(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stage: Stage = .idle
|
||||||
|
var manifest: ProjectTemplateManifest?
|
||||||
|
var currentValues: [String: TemplateConfigValue] = [:]
|
||||||
|
|
||||||
|
/// Non-nil while `.editing`; used to construct the sheet's VM.
|
||||||
|
var formViewModel: TemplateConfigViewModel?
|
||||||
|
|
||||||
|
/// Load the cached manifest + current config values, then move to
|
||||||
|
/// `.editing` so the sheet can render the form.
|
||||||
|
func begin() {
|
||||||
|
stage = .loading
|
||||||
|
let service = configService
|
||||||
|
let project = project
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
guard let cachedManifest = try service.loadCachedManifest(project: project),
|
||||||
|
let schema = cachedManifest.config,
|
||||||
|
!schema.isEmpty else {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .notConfigurable
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let configFile = try service.load(project: project)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.manifest = cachedManifest
|
||||||
|
self.currentValues = configFile?.values ?? [:]
|
||||||
|
self.formViewModel = TemplateConfigViewModel(
|
||||||
|
schema: schema,
|
||||||
|
templateId: cachedManifest.id,
|
||||||
|
templateSlug: cachedManifest.slug,
|
||||||
|
initialValues: self.currentValues,
|
||||||
|
mode: .edit(project: project)
|
||||||
|
)
|
||||||
|
self.stage = .editing
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("couldn't load config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the sheet's commit succeeded. Persists the edited
|
||||||
|
/// values to `<project>/.scarf/config.json`. Secrets are already
|
||||||
|
/// in the Keychain — the VM's commit step wrote them.
|
||||||
|
func save(values: [String: TemplateConfigValue]) {
|
||||||
|
guard let manifest else { return }
|
||||||
|
stage = .saving
|
||||||
|
let service = configService
|
||||||
|
let project = project
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
try service.save(
|
||||||
|
project: project,
|
||||||
|
templateId: manifest.id,
|
||||||
|
values: values
|
||||||
|
)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .succeeded
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("couldn't save config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
stage = .idle
|
||||||
|
formViewModel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the configure form for template install + post-install editing.
|
||||||
|
///
|
||||||
|
/// **Timing of secret storage.** The VM keeps freshly-entered secret bytes
|
||||||
|
/// in-memory (`pendingSecrets`) until the user clicks the commit button.
|
||||||
|
/// Only then does `commit()` push each secret through
|
||||||
|
/// `ProjectConfigService.storeSecret` and get back a `keychainRef` URI.
|
||||||
|
/// This means cancelling the sheet never leaves an orphan Keychain
|
||||||
|
/// entry behind — the form is transactional from the user's POV.
|
||||||
|
///
|
||||||
|
/// **Validation.** Runs via `ProjectConfigService.validateValues` every
|
||||||
|
/// time the user attempts to commit. Per-field errors are tracked in
|
||||||
|
/// `errors` so the sheet can surface them inline with the offending field.
|
||||||
|
/// No live validation on every keystroke — that creates a messy
|
||||||
|
/// "error appears the moment you start typing" UX.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateConfigViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigViewModel")
|
||||||
|
|
||||||
|
enum Mode: Sendable {
|
||||||
|
/// User is filling in values for the first time as part of the
|
||||||
|
/// install flow. Secrets will be written to the Keychain when
|
||||||
|
/// `commit` succeeds.
|
||||||
|
case install
|
||||||
|
/// User is editing values for an already-installed project.
|
||||||
|
/// Existing keychain refs are preserved for fields the user
|
||||||
|
/// doesn't touch; only secrets the user actually changes get
|
||||||
|
/// re-written to the Keychain.
|
||||||
|
case edit(project: ProjectEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
let schema: TemplateConfigSchema
|
||||||
|
let templateId: String
|
||||||
|
let templateSlug: String
|
||||||
|
let mode: Mode
|
||||||
|
private let configService: ProjectConfigService
|
||||||
|
|
||||||
|
/// Current form values, keyed by field key. Non-secret values live
|
||||||
|
/// here directly; secret fields either hold a `.keychainRef(...)`
|
||||||
|
/// (existing, untouched in edit mode) or nothing at all (user
|
||||||
|
/// hasn't entered a secret yet, or they just cleared it).
|
||||||
|
var values: [String: TemplateConfigValue] = [:]
|
||||||
|
|
||||||
|
/// Raw secret bytes waiting to be written to the Keychain on
|
||||||
|
/// `commit()`. Indexed by field key. `values[key]` stays as its
|
||||||
|
/// current `.keychainRef(...)` (for edit mode) or missing (for
|
||||||
|
/// install mode) until commit swaps it for the freshly-written
|
||||||
|
/// ref URI.
|
||||||
|
var pendingSecrets: [String: Data] = [:]
|
||||||
|
|
||||||
|
/// One error per field with a problem. Populated by `commit()` on
|
||||||
|
/// validation failure; the sheet surfaces the message inline below
|
||||||
|
/// the offending control.
|
||||||
|
var errors: [String: String] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
schema: TemplateConfigSchema,
|
||||||
|
templateId: String,
|
||||||
|
templateSlug: String,
|
||||||
|
initialValues: [String: TemplateConfigValue] = [:],
|
||||||
|
mode: Mode,
|
||||||
|
configService: ProjectConfigService = ProjectConfigService()
|
||||||
|
) {
|
||||||
|
self.schema = schema
|
||||||
|
self.templateId = templateId
|
||||||
|
self.templateSlug = templateSlug
|
||||||
|
self.mode = mode
|
||||||
|
self.configService = configService
|
||||||
|
self.values = Self.applyDefaults(schema: schema, initial: initialValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field setters (the sheet calls these as controls change)
|
||||||
|
|
||||||
|
func setString(_ key: String, _ value: String) {
|
||||||
|
values[key] = .string(value)
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNumber(_ key: String, _ value: Double) {
|
||||||
|
values[key] = .number(value)
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBool(_ key: String, _ value: Bool) {
|
||||||
|
values[key] = .bool(value)
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setList(_ key: String, _ items: [String]) {
|
||||||
|
values[key] = .list(items)
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage a new secret value. Doesn't hit the Keychain until
|
||||||
|
/// `commit()`. An empty `value` clears both the pending secret and
|
||||||
|
/// the field's stored keychainRef — only valid in edit mode, where
|
||||||
|
/// "empty" means "I want to remove this secret."
|
||||||
|
func setSecret(_ key: String, _ value: String) {
|
||||||
|
if value.isEmpty {
|
||||||
|
pendingSecrets.removeValue(forKey: key)
|
||||||
|
values.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
pendingSecrets[key] = Data(value.utf8)
|
||||||
|
// Keep any existing ref around; the sheet can display
|
||||||
|
// "(changed)" while the ref is still the old one. commit()
|
||||||
|
// overwrites on disk.
|
||||||
|
}
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Commit
|
||||||
|
|
||||||
|
/// Validate, persist secrets to the Keychain, and hand back the
|
||||||
|
/// final values dictionary. On validation failure, `errors` is
|
||||||
|
/// populated and the method returns `nil` without touching the
|
||||||
|
/// Keychain — the form is transactional.
|
||||||
|
///
|
||||||
|
/// In install mode, `project` is required (secrets need a path
|
||||||
|
/// hash for their Keychain account). In edit mode it falls out of
|
||||||
|
/// the `.edit(project:)` associated value.
|
||||||
|
func commit(project: ProjectEntry? = nil) -> [String: TemplateConfigValue]? {
|
||||||
|
// Build the value set we're about to validate. For secrets
|
||||||
|
// that have a pending update, we treat them as present (we'll
|
||||||
|
// write them in a moment); for secrets already stored as
|
||||||
|
// keychainRef, we treat them as present too. Only a completely
|
||||||
|
// empty secret field is "missing."
|
||||||
|
var candidate = values
|
||||||
|
for key in pendingSecrets.keys {
|
||||||
|
// The field is about to have a fresh keychainRef — for
|
||||||
|
// validation purposes, use a placeholder ref so the type
|
||||||
|
// check passes. The real ref replaces it below.
|
||||||
|
candidate[key] = .keychainRef("pending://\(key)")
|
||||||
|
}
|
||||||
|
let validationErrors = ProjectConfigService.validateValues(candidate, against: schema)
|
||||||
|
guard validationErrors.isEmpty else {
|
||||||
|
var byField: [String: String] = [:]
|
||||||
|
for err in validationErrors {
|
||||||
|
guard let key = err.fieldKey else { continue }
|
||||||
|
byField[key] = err.message
|
||||||
|
}
|
||||||
|
self.errors = byField
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation passed — write the pending secrets to the Keychain.
|
||||||
|
let targetProject: ProjectEntry
|
||||||
|
switch mode {
|
||||||
|
case .install:
|
||||||
|
guard let project else {
|
||||||
|
Self.logger.error("commit(project:) called in install mode without a project")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
targetProject = project
|
||||||
|
case .edit(let proj):
|
||||||
|
targetProject = proj
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, secret) in pendingSecrets {
|
||||||
|
do {
|
||||||
|
let ref = try configService.storeSecret(
|
||||||
|
templateSlug: templateSlug,
|
||||||
|
fieldKey: key,
|
||||||
|
project: targetProject,
|
||||||
|
secret: secret
|
||||||
|
)
|
||||||
|
values[key] = ref
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("failed to store secret for \(key, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
errors[key] = "Couldn't save secret to the Keychain: \(error.localizedDescription)"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingSecrets.removeAll()
|
||||||
|
errors.removeAll()
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Seed the form with any author-supplied defaults for fields that
|
||||||
|
/// don't already have an initial value (from a saved config.json).
|
||||||
|
nonisolated private static func applyDefaults(
|
||||||
|
schema: TemplateConfigSchema,
|
||||||
|
initial: [String: TemplateConfigValue]
|
||||||
|
) -> [String: TemplateConfigValue] {
|
||||||
|
var out = initial
|
||||||
|
for field in schema.fields where out[field.key] == nil {
|
||||||
|
if let def = field.defaultValue {
|
||||||
|
out[field.key] = def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the template export sheet. Holds form state for the author-facing
|
||||||
|
/// fields (id, name, version, description, …) and the selection of skills
|
||||||
|
/// and cron jobs to include, then builds and writes the `.scarftemplate` on
|
||||||
|
/// confirm.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateExporterViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateExporterViewModel")
|
||||||
|
|
||||||
|
enum Stage: Sendable {
|
||||||
|
case idle
|
||||||
|
case exporting
|
||||||
|
case succeeded(path: String)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
let project: ProjectEntry
|
||||||
|
private let exporter: ProjectTemplateExporter
|
||||||
|
|
||||||
|
init(context: ServerContext, project: ProjectEntry) {
|
||||||
|
self.context = context
|
||||||
|
self.project = project
|
||||||
|
self.exporter = ProjectTemplateExporter(context: context)
|
||||||
|
|
||||||
|
self.templateName = project.name
|
||||||
|
self.templateId = "you/\(ProjectTemplateExporter.slugify(project.name))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
var templateId: String
|
||||||
|
var templateName: String
|
||||||
|
var templateVersion: String = "1.0.0"
|
||||||
|
var templateDescription: String = ""
|
||||||
|
var authorName: String = ""
|
||||||
|
var authorURL: String = ""
|
||||||
|
var category: String = ""
|
||||||
|
var tags: String = ""
|
||||||
|
var includeSkillIds: Set<String> = []
|
||||||
|
var includeCronJobIds: Set<String> = []
|
||||||
|
var memoryAppendix: String = ""
|
||||||
|
|
||||||
|
// Derived: what the author can pick from
|
||||||
|
var availableSkills: [HermesSkill] = []
|
||||||
|
var availableCronJobs: [HermesCronJob] = []
|
||||||
|
|
||||||
|
var stage: Stage = .idle
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
let ctx = context
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let service = HermesFileService(context: ctx)
|
||||||
|
let skills = service.loadSkills().flatMap(\.skills)
|
||||||
|
let jobs = service.loadCronJobs()
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.availableSkills = skills
|
||||||
|
self?.availableCronJobs = jobs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewPlan() -> ProjectTemplateExporter.ExportPlan {
|
||||||
|
exporter.previewPlan(for: currentInputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kick off the export, writing to `outputPath`. The caller is
|
||||||
|
/// responsible for bouncing the user through an `NSSavePanel` to get
|
||||||
|
/// that path.
|
||||||
|
func export(to outputPath: String) {
|
||||||
|
stage = .exporting
|
||||||
|
let exporter = exporter
|
||||||
|
let inputs = currentInputs
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
try exporter.export(inputs: inputs, outputZipPath: outputPath)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .succeeded(path: outputPath)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private var currentInputs: ProjectTemplateExporter.ExportInputs {
|
||||||
|
let parsedTags = tags
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
let trimmedAppendix = memoryAppendix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return ProjectTemplateExporter.ExportInputs(
|
||||||
|
project: project,
|
||||||
|
templateId: templateId.trimmingCharacters(in: .whitespaces),
|
||||||
|
templateName: templateName.trimmingCharacters(in: .whitespaces),
|
||||||
|
templateVersion: templateVersion.trimmingCharacters(in: .whitespaces),
|
||||||
|
description: templateDescription.trimmingCharacters(in: .whitespaces),
|
||||||
|
authorName: authorName.isEmpty ? nil : authorName,
|
||||||
|
authorUrl: authorURL.isEmpty ? nil : authorURL,
|
||||||
|
category: category.isEmpty ? nil : category,
|
||||||
|
tags: parsedTags,
|
||||||
|
includeSkillIds: Array(includeSkillIds),
|
||||||
|
includeCronJobIds: Array(includeCronJobIds),
|
||||||
|
memoryAppendix: trimmedAppendix.isEmpty ? nil : trimmedAppendix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProjectTemplateExporter {
|
||||||
|
/// Lowercase-and-hyphenate a human name into something safe for a
|
||||||
|
/// template id suffix. Only used to seed the default id in the export
|
||||||
|
/// form — the author can overwrite it.
|
||||||
|
nonisolated static func slugify(_ raw: String) -> String {
|
||||||
|
let lower = raw.lowercased()
|
||||||
|
let mapped = lower.unicodeScalars.map { scalar -> Character in
|
||||||
|
let c = Character(scalar)
|
||||||
|
if c.isLetter || c.isNumber { return c }
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
let collapsed = String(mapped)
|
||||||
|
.split(separator: "-", omittingEmptySubsequences: true)
|
||||||
|
.joined(separator: "-")
|
||||||
|
return collapsed.isEmpty ? "template" : collapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the template install sheet. Handles three entry points:
|
||||||
|
/// 1. `openLocalFile(_:)` — user picked a `.scarftemplate` from disk.
|
||||||
|
/// 2. `openRemoteURL(_:)` — user pasted/deeplinked a https URL.
|
||||||
|
/// 3. `confirmInstall()` — user clicked "Install" in the preview sheet.
|
||||||
|
///
|
||||||
|
/// The view model owns one ephemeral temp dir at a time (the unpacked
|
||||||
|
/// bundle). `cancel()` or `confirmInstall()` removes it.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateInstallerViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateInstallerViewModel")
|
||||||
|
|
||||||
|
enum Stage: Sendable {
|
||||||
|
case idle
|
||||||
|
case fetching(sourceDescription: String)
|
||||||
|
case inspecting
|
||||||
|
case awaitingParentDirectory
|
||||||
|
/// Template declared a non-empty config schema; the sheet
|
||||||
|
/// presents `TemplateConfigSheet` before continuing to the
|
||||||
|
/// preview. Schema-less templates skip this stage entirely.
|
||||||
|
case awaitingConfig
|
||||||
|
case planned
|
||||||
|
case installing
|
||||||
|
case succeeded(installed: ProjectEntry)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
private let templateService: ProjectTemplateService
|
||||||
|
private let installer: ProjectTemplateInstaller
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
self.context = context
|
||||||
|
self.templateService = ProjectTemplateService(context: context)
|
||||||
|
self.installer = ProjectTemplateInstaller(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stage: Stage = .idle
|
||||||
|
var inspection: TemplateInspection?
|
||||||
|
var plan: TemplateInstallPlan?
|
||||||
|
var chosenParentDirectory: String?
|
||||||
|
/// README body preloaded off MainActor when inspection completes, so the
|
||||||
|
/// preview sheet can render it without hitting `String(contentsOf:)` from
|
||||||
|
/// inside a View body.
|
||||||
|
var readmeBody: String?
|
||||||
|
|
||||||
|
// MARK: - Entry points
|
||||||
|
|
||||||
|
/// Inspect a local `.scarftemplate` file. Moves stage to `.inspecting`
|
||||||
|
/// then either `.awaitingParentDirectory` or `.failed`. The unpacked
|
||||||
|
/// README body is read off MainActor here and stored on the VM so the
|
||||||
|
/// preview sheet doesn't do sync I/O during View body evaluation.
|
||||||
|
func openLocalFile(_ zipPath: String) {
|
||||||
|
resetTempState()
|
||||||
|
stage = .inspecting
|
||||||
|
let service = templateService
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
let inspection = try service.inspect(zipPath: zipPath)
|
||||||
|
let readme = Self.readReadme(unpackedDir: inspection.unpackedDir)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.inspection = inspection
|
||||||
|
self.readmeBody = readme
|
||||||
|
self.stage = .awaitingParentDirectory
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read README.md from an unpacked template dir. Nonisolated so the
|
||||||
|
/// inspect task can call it off MainActor. Returns `nil` on any I/O
|
||||||
|
/// failure — the preview sheet treats a nil README as "no section."
|
||||||
|
nonisolated private static func readReadme(unpackedDir: String) -> String? {
|
||||||
|
let path = unpackedDir + "/README.md"
|
||||||
|
do {
|
||||||
|
return try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
Logger(subsystem: "com.scarf", category: "TemplateInstallerViewModel")
|
||||||
|
.warning("couldn't read README at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a https `.scarftemplate` to a temp file, then hand off to
|
||||||
|
/// `openLocalFile`. The 50 MB cap matches the plan — templates shouldn't
|
||||||
|
/// be anywhere near that, and rejecting huge downloads is cheap defense.
|
||||||
|
///
|
||||||
|
/// Content-Length is checked first as an early-out, but chunked
|
||||||
|
/// transfer responses omit that header. The authoritative check is the
|
||||||
|
/// actual on-disk file size after the download completes — it runs
|
||||||
|
/// unconditionally and covers the chunked-transfer case.
|
||||||
|
func openRemoteURL(_ url: URL) {
|
||||||
|
resetTempState()
|
||||||
|
stage = .fetching(sourceDescription: url.host ?? url.absoluteString)
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let maxBytes: Int64 = 50 * 1024 * 1024
|
||||||
|
do {
|
||||||
|
let tempZip = NSTemporaryDirectory() + "scarf-template-download-" + UUID().uuidString + ".scarftemplate"
|
||||||
|
let (tempURL, response) = try await URLSession.shared.download(from: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: tempURL) }
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
throw ProjectTemplateError.unzipFailed("HTTP \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
if let length = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||||
|
let bytes = Int64(length), bytes > maxBytes {
|
||||||
|
throw ProjectTemplateError.unzipFailed("template exceeds 50 MB size cap (\(bytes) bytes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unconditional post-download size check — catches chunked
|
||||||
|
// responses that ship no Content-Length. The download already
|
||||||
|
// hit disk, but refusing to *process* it bounds the blast
|
||||||
|
// radius to one temp file that gets removed in the defer.
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: tempURL.path)
|
||||||
|
let actualSize = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
||||||
|
guard actualSize <= maxBytes else {
|
||||||
|
throw ProjectTemplateError.unzipFailed("template exceeds 50 MB size cap (\(actualSize) bytes)")
|
||||||
|
}
|
||||||
|
try FileManager.default.moveItem(atPath: tempURL.path, toPath: tempZip)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.openLocalFile(tempZip)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed("Couldn't fetch template: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planning + confirmation
|
||||||
|
|
||||||
|
/// Finalize the plan now that the user has picked a parent directory.
|
||||||
|
func pickParentDirectory(_ parentDir: String) {
|
||||||
|
guard let inspection else { return }
|
||||||
|
chosenParentDirectory = parentDir
|
||||||
|
let service = templateService
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.plan = plan
|
||||||
|
// If the template declares a non-empty config
|
||||||
|
// schema, insert the configure step before the
|
||||||
|
// preview sheet. Otherwise go straight to .planned.
|
||||||
|
if let schema = plan.configSchema, !schema.isEmpty {
|
||||||
|
self.stage = .awaitingConfig
|
||||||
|
} else {
|
||||||
|
self.stage = .planned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by `TemplateInstallSheet` once the user has filled in
|
||||||
|
/// the configure form and `TemplateConfigViewModel.commit()`
|
||||||
|
/// succeeded. Stashes the values in the plan and advances to the
|
||||||
|
/// preview stage (`.planned`). Secrets in `values` are already
|
||||||
|
/// `.keychainRef(...)` — the VM's commit step wrote them to the
|
||||||
|
/// Keychain.
|
||||||
|
func submitConfig(values: [String: TemplateConfigValue]) {
|
||||||
|
guard var plan else { return }
|
||||||
|
plan.configValues = values
|
||||||
|
self.plan = plan
|
||||||
|
stage = .planned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the user cancels out of the configure step without
|
||||||
|
/// committing. Returns to `.awaitingParentDirectory` so they can
|
||||||
|
/// try again (or dismiss the whole sheet).
|
||||||
|
func cancelConfig() {
|
||||||
|
stage = .awaitingParentDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmInstall() {
|
||||||
|
guard let plan else { return }
|
||||||
|
stage = .installing
|
||||||
|
let installer = installer
|
||||||
|
let service = templateService
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
let entry = try installer.install(plan: plan)
|
||||||
|
service.cleanupTempDir(plan.unpackedDir)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.stage = .succeeded(installed: entry)
|
||||||
|
self.inspection = nil
|
||||||
|
self.plan = nil
|
||||||
|
self.chosenParentDirectory = nil
|
||||||
|
self.readmeBody = nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
resetTempState()
|
||||||
|
stage = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetTempState() {
|
||||||
|
if let inspection {
|
||||||
|
templateService.cleanupTempDir(inspection.unpackedDir)
|
||||||
|
}
|
||||||
|
inspection = nil
|
||||||
|
plan = nil
|
||||||
|
chosenParentDirectory = nil
|
||||||
|
readmeBody = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the template-uninstall sheet. Mirrors the installer VM in
|
||||||
|
/// stage shape: open a plan (`begin`), preview it, confirm or cancel.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateUninstallerViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateUninstallerViewModel")
|
||||||
|
|
||||||
|
enum Stage: Sendable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case planned
|
||||||
|
case uninstalling
|
||||||
|
case succeeded(removed: ProjectEntry)
|
||||||
|
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
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
self.context = context
|
||||||
|
self.uninstaller = ProjectTemplateUninstaller(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let plan = try uninstaller.loadUninstallPlan(for: project)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.plan = plan
|
||||||
|
self.stage = .planned
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmUninstall() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
plan = nil
|
||||||
|
preservedOutcome = nil
|
||||||
|
stage = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Post-install configuration editor. Thin wrapper around the same
|
||||||
|
/// `TemplateConfigSheet` the install flow uses — owns a
|
||||||
|
/// `TemplateConfigEditorViewModel` that loads the cached manifest +
|
||||||
|
/// current values from `<project>/.scarf/`, feeds them to the form,
|
||||||
|
/// and writes the edited values back to `config.json` on commit.
|
||||||
|
///
|
||||||
|
/// Entry points: right-click on the project list (when the project has
|
||||||
|
/// a cached manifest) and a button on the dashboard header (shown
|
||||||
|
/// only when `isConfigurable` is true).
|
||||||
|
struct ConfigEditorSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var viewModel: TemplateConfigEditorViewModel
|
||||||
|
|
||||||
|
init(context: ServerContext, project: ProjectEntry) {
|
||||||
|
_viewModel = State(
|
||||||
|
initialValue: TemplateConfigEditorViewModel(
|
||||||
|
context: context,
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch viewModel.stage {
|
||||||
|
case .idle, .loading:
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Loading configuration…")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 320)
|
||||||
|
.padding()
|
||||||
|
case .editing:
|
||||||
|
if let form = viewModel.formViewModel,
|
||||||
|
let manifest = viewModel.manifest {
|
||||||
|
TemplateConfigSheet(
|
||||||
|
viewModel: form,
|
||||||
|
title: "Configure \(manifest.name)",
|
||||||
|
commitLabel: "Save",
|
||||||
|
project: nil, // edit mode; VM carries the project
|
||||||
|
onCommit: { values in
|
||||||
|
viewModel.save(values: values)
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
unexpectedState
|
||||||
|
}
|
||||||
|
case .saving:
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Saving…")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 320)
|
||||||
|
.padding()
|
||||||
|
case .succeeded:
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Configuration saved").font(.title2.bold())
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 280)
|
||||||
|
.padding()
|
||||||
|
case .failed(let message):
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Couldn't save").font(.title2.bold())
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 280)
|
||||||
|
.padding()
|
||||||
|
case .notConfigurable:
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No configuration")
|
||||||
|
.font(.title3.bold())
|
||||||
|
Text("This project wasn't installed from a schemaful template.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 280)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { viewModel.begin() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unexpectedState: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "questionmark.circle")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Internal state inconsistency — please close and re-open.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 280)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The configure form rendered for template install + post-install
|
||||||
|
/// editing. One row per schema field; controls dispatch by field type.
|
||||||
|
/// Commit button returns the finalized values via `onCommit` — in
|
||||||
|
/// install mode the caller stashes them in the install plan; in edit
|
||||||
|
/// mode the caller writes them straight to `<project>/.scarf/config.json`.
|
||||||
|
struct TemplateConfigSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State var viewModel: TemplateConfigViewModel
|
||||||
|
let title: LocalizedStringKey
|
||||||
|
let commitLabel: LocalizedStringKey
|
||||||
|
/// In install mode the caller passes the planned `ProjectEntry`
|
||||||
|
/// (project dir path is the unique key for the Keychain secret).
|
||||||
|
/// In edit mode the VM already holds the project; pass `nil` here.
|
||||||
|
let project: ProjectEntry?
|
||||||
|
let onCommit: ([String: TemplateConfigValue]) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
if viewModel.schema.fields.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No fields",
|
||||||
|
systemImage: "slider.horizontal.3",
|
||||||
|
description: Text("This template has no configuration fields.")
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 120)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.schema.fields) { field in
|
||||||
|
fieldRow(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let rec = viewModel.schema.modelRecommendation {
|
||||||
|
modelRecommendation(rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.frame(minWidth: 560, minHeight: 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header / footer
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).font(.title2.bold())
|
||||||
|
Text(viewModel.templateId)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var footer: some View {
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
// Caller owns dismissal — this view is used both as a
|
||||||
|
// standalone sheet (ConfigEditorSheet, where the caller
|
||||||
|
// wants dismissal) AND inlined inside the install sheet
|
||||||
|
// (TemplateInstallSheet.configureView, where calling
|
||||||
|
// .dismiss here would tear down the OUTER install sheet
|
||||||
|
// and abort the flow before .planned is reached).
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button(commitLabel) {
|
||||||
|
if let finalized = viewModel.commit(project: project) {
|
||||||
|
onCommit(finalized)
|
||||||
|
}
|
||||||
|
// Same dismissal-is-caller's-responsibility rule as
|
||||||
|
// Cancel — inside the install sheet, onCommit transitions
|
||||||
|
// stage to .planned and the outer view re-renders to
|
||||||
|
// show the preview. In the edit sheet, onCommit
|
||||||
|
// transitions the editor VM and its state machine
|
||||||
|
// handles dismissal via the success view's Done button.
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field rows
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func fieldRow(_ field: TemplateConfigField) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text(field.label).font(.headline)
|
||||||
|
if field.required {
|
||||||
|
Text("*")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(field.type.rawValue)
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let description = field.description, !description.isEmpty {
|
||||||
|
// Inline markdown so descriptions can include
|
||||||
|
// `[Create one](https://…)`-style links to token
|
||||||
|
// generation pages, **bold** emphasis on important
|
||||||
|
// prerequisites, etc.
|
||||||
|
TemplateMarkdown.inlineText(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
control(for: field)
|
||||||
|
if let err = viewModel.errors[field.key] {
|
||||||
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(.background.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func control(for field: TemplateConfigField) -> some View {
|
||||||
|
switch field.type {
|
||||||
|
case .string:
|
||||||
|
StringControl(
|
||||||
|
value: stringBinding(for: field),
|
||||||
|
placeholder: field.placeholder
|
||||||
|
)
|
||||||
|
case .text:
|
||||||
|
TextControl(value: stringBinding(for: field))
|
||||||
|
case .number:
|
||||||
|
NumberControl(value: numberBinding(for: field))
|
||||||
|
case .bool:
|
||||||
|
BoolControl(label: field.label, value: boolBinding(for: field))
|
||||||
|
case .enum:
|
||||||
|
EnumControl(
|
||||||
|
options: field.options ?? [],
|
||||||
|
value: stringBinding(for: field)
|
||||||
|
)
|
||||||
|
case .list:
|
||||||
|
ListControl(items: listBinding(for: field))
|
||||||
|
case .secret:
|
||||||
|
SecretControl(
|
||||||
|
fieldKey: field.key,
|
||||||
|
placeholder: field.placeholder,
|
||||||
|
viewModel: viewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Model recommendation panel
|
||||||
|
|
||||||
|
private func modelRecommendation(_ rec: TemplateModelRecommendation) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Label("Recommended model", systemImage: "lightbulb")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(rec.preferred).font(.body.monospaced())
|
||||||
|
if let rationale = rec.rationale, !rationale.isEmpty {
|
||||||
|
Text(rationale)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
if let alts = rec.alternatives, !alts.isEmpty {
|
||||||
|
Text("Also works: \(alts.joined(separator: ", "))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text("Scarf doesn't auto-switch your active model. Change it in Settings if you'd like.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.accentColor.opacity(0.08))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binding helpers (threading the VM through typed lenses)
|
||||||
|
|
||||||
|
private func stringBinding(for field: TemplateConfigField) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
if case .string(let s) = viewModel.values[field.key] { return s }
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
set: { viewModel.setString(field.key, $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func numberBinding(for field: TemplateConfigField) -> Binding<Double> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
if case .number(let n) = viewModel.values[field.key] { return n }
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
set: { viewModel.setNumber(field.key, $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func boolBinding(for field: TemplateConfigField) -> Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
if case .bool(let b) = viewModel.values[field.key] { return b }
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
set: { viewModel.setBool(field.key, $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listBinding(for field: TemplateConfigField) -> Binding<[String]> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
if case .list(let items) = viewModel.values[field.key] { return items }
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
set: { viewModel.setList(field.key, $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field controls
|
||||||
|
|
||||||
|
private struct StringControl: View {
|
||||||
|
@Binding var value: String
|
||||||
|
let placeholder: String?
|
||||||
|
var body: some View {
|
||||||
|
TextField(placeholder ?? "", text: $value)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TextControl: View {
|
||||||
|
@Binding var value: String
|
||||||
|
var body: some View {
|
||||||
|
TextEditor(text: $value)
|
||||||
|
.font(.body.monospaced())
|
||||||
|
.frame(minHeight: 80, maxHeight: 160)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(.secondary.opacity(0.3))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NumberControl: View {
|
||||||
|
@Binding var value: Double
|
||||||
|
var body: some View {
|
||||||
|
TextField("", value: $value, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BoolControl: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var value: Bool
|
||||||
|
var body: some View {
|
||||||
|
Toggle(isOn: $value) {
|
||||||
|
Text(value ? "Enabled" : "Disabled")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EnumControl: View {
|
||||||
|
let options: [TemplateConfigField.EnumOption]
|
||||||
|
@Binding var value: String
|
||||||
|
var body: some View {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variable-length list of string values. Each row is a text field
|
||||||
|
/// with an inline remove button; a + button adds a trailing row.
|
||||||
|
private struct ListControl: View {
|
||||||
|
@Binding var items: [String]
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(items.indices, id: \.self) { i in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
TextField("", text: Binding(
|
||||||
|
get: { i < items.count ? items[i] : "" },
|
||||||
|
set: { newValue in
|
||||||
|
guard i < items.count else { return }
|
||||||
|
items[i] = newValue
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button {
|
||||||
|
guard i < items.count else { return }
|
||||||
|
items.remove(at: i)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(items.count <= 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
items.append("")
|
||||||
|
} label: {
|
||||||
|
Label("Add", systemImage: "plus.circle")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secret fields never echo the previously-stored value back. Instead
|
||||||
|
/// we render "(unchanged)" when a Keychain ref already exists and let
|
||||||
|
/// the user type over it if they want to replace. Empty input in edit
|
||||||
|
/// mode signals "remove this secret entirely."
|
||||||
|
private struct SecretControl: View {
|
||||||
|
let fieldKey: String
|
||||||
|
let placeholder: String?
|
||||||
|
@Bindable var viewModel: TemplateConfigViewModel
|
||||||
|
|
||||||
|
@State private var typedValue: String = ""
|
||||||
|
@State private var isRevealed: Bool = false
|
||||||
|
|
||||||
|
private var hasStoredRef: Bool {
|
||||||
|
if case .keychainRef = viewModel.values[fieldKey] { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Group {
|
||||||
|
if isRevealed {
|
||||||
|
TextField(placeholder ?? "", text: $typedValue)
|
||||||
|
} else {
|
||||||
|
SecureField(placeholder ?? "", text: $typedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onChange(of: typedValue) { _, new in
|
||||||
|
viewModel.setSecret(fieldKey, new)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
isRevealed.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isRevealed ? "eye.slash" : "eye")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help(isRevealed ? "Hide" : "Show while typing")
|
||||||
|
}
|
||||||
|
if hasStoredRef && typedValue.isEmpty {
|
||||||
|
Text("Saved in Keychain — leave empty to keep the stored value.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if !typedValue.isEmpty {
|
||||||
|
Text("Will be saved to the Keychain on commit.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Author-facing sheet for exporting an existing project as a
|
||||||
|
/// `.scarftemplate`. Mirrors the profile-export flow: fill in a few fields,
|
||||||
|
/// pick which skills/cron jobs to include, save via NSSavePanel.
|
||||||
|
struct TemplateExportSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State var viewModel: TemplateExporterViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
switch viewModel.stage {
|
||||||
|
case .idle:
|
||||||
|
form
|
||||||
|
case .exporting:
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Building template…")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
case .succeeded(let path):
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Exported").font(.title2.bold())
|
||||||
|
Text(path)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
Button("Show in Finder") {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
|
||||||
|
}
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
case .failed(let message):
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Export Failed").font(.title2.bold())
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 620, minHeight: 560)
|
||||||
|
.padding()
|
||||||
|
.task { viewModel.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var form: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Export \"\(viewModel.project.name)\" as Template")
|
||||||
|
.font(.title2.bold())
|
||||||
|
metadataGroup
|
||||||
|
Divider()
|
||||||
|
requiredFilesGroup
|
||||||
|
Divider()
|
||||||
|
instructionsGroup
|
||||||
|
Divider()
|
||||||
|
skillsGroup
|
||||||
|
Divider()
|
||||||
|
cronGroup
|
||||||
|
Divider()
|
||||||
|
memoryGroup
|
||||||
|
}
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Export…") { runExport() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!canExport)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var metadataGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Metadata").font(.headline)
|
||||||
|
LabeledContent("Template ID") {
|
||||||
|
TextField("owner/name", text: $viewModel.templateId)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Display Name") {
|
||||||
|
TextField("", text: $viewModel.templateName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Version") {
|
||||||
|
TextField("1.0.0", text: $viewModel.templateVersion)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Description") {
|
||||||
|
TextField("One-line pitch", text: $viewModel.templateDescription, axis: .vertical)
|
||||||
|
.lineLimit(2...4)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Author") {
|
||||||
|
TextField("Your name", text: $viewModel.authorName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Author URL") {
|
||||||
|
TextField("https://…", text: $viewModel.authorURL)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Category") {
|
||||||
|
TextField("e.g. productivity", text: $viewModel.category)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Tags (comma-separated)") {
|
||||||
|
TextField("focus, timer", text: $viewModel.tags)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var requiredFilesGroup: some View {
|
||||||
|
let plan = viewModel.previewPlan()
|
||||||
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Required Files").font(.headline)
|
||||||
|
check(label: "dashboard.json (\(plan.projectDir)/.scarf/dashboard.json)", ok: plan.dashboardPresent)
|
||||||
|
check(label: "README.md (\(plan.projectDir)/README.md)", ok: plan.readmePresent)
|
||||||
|
check(label: "AGENTS.md (\(plan.projectDir)/AGENTS.md)", ok: plan.agentsMdPresent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var instructionsGroup: some View {
|
||||||
|
let plan = viewModel.previewPlan()
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Agent-specific instructions (optional)").font(.headline)
|
||||||
|
if plan.instructionFiles.isEmpty {
|
||||||
|
Text("No per-agent instruction files found in the project root.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(plan.instructionFiles, id: \.self) { file in
|
||||||
|
Label(file, systemImage: "doc.plaintext")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var skillsGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Include Skills").font(.headline)
|
||||||
|
if viewModel.availableSkills.isEmpty {
|
||||||
|
Text("No skills found.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.availableSkills) { skill in
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { viewModel.includeSkillIds.contains(skill.id) },
|
||||||
|
set: { on in
|
||||||
|
if on { viewModel.includeSkillIds.insert(skill.id) }
|
||||||
|
else { viewModel.includeSkillIds.remove(skill.id) }
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text(skill.id).font(.callout.monospaced())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cronGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Include Cron Jobs").font(.headline)
|
||||||
|
if viewModel.availableCronJobs.isEmpty {
|
||||||
|
Text("No cron jobs found.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.availableCronJobs) { job in
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { viewModel.includeCronJobIds.contains(job.id) },
|
||||||
|
set: { on in
|
||||||
|
if on { viewModel.includeCronJobIds.insert(job.id) }
|
||||||
|
else { viewModel.includeCronJobIds.remove(job.id) }
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(job.name).font(.callout)
|
||||||
|
Text(job.schedule.display ?? job.schedule.expression ?? job.schedule.kind)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var memoryGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Memory Appendix (optional)").font(.headline)
|
||||||
|
Text("Markdown that will be appended to the installer's MEMORY.md, wrapped in template-specific markers so it can be removed cleanly later.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextEditor(text: $viewModel.memoryAppendix)
|
||||||
|
.font(.callout.monospaced())
|
||||||
|
.frame(minHeight: 80, maxHeight: 160)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.stroke(.secondary.opacity(0.4))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func check(label: String, ok: Bool) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: ok ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||||
|
.foregroundStyle(ok ? .green : .red)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(ok ? .primary : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canExport: Bool {
|
||||||
|
let plan = viewModel.previewPlan()
|
||||||
|
return plan.dashboardPresent
|
||||||
|
&& plan.readmePresent
|
||||||
|
&& plan.agentsMdPresent
|
||||||
|
&& !viewModel.templateId.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !viewModel.templateName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !viewModel.templateVersion.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !viewModel.templateDescription.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runExport() {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.allowedContentTypes = [.zip]
|
||||||
|
panel.nameFieldStringValue = ProjectTemplateExporter.slugify(viewModel.templateName) + ".scarftemplate"
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
viewModel.export(to: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Preview-and-confirm sheet for installing a `.scarftemplate`. Honest
|
||||||
|
/// accounting: shows every file that will be written, every cron job that
|
||||||
|
/// will be registered, and the memory diff — nothing gets written until the
|
||||||
|
/// user clicks Install.
|
||||||
|
struct TemplateInstallSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State var viewModel: TemplateInstallerViewModel
|
||||||
|
let onCompleted: (ProjectEntry) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch viewModel.stage {
|
||||||
|
case .idle:
|
||||||
|
idleView
|
||||||
|
case .fetching(let src):
|
||||||
|
progress("Downloading from \(src)…")
|
||||||
|
case .inspecting:
|
||||||
|
progress("Inspecting template…")
|
||||||
|
case .awaitingParentDirectory:
|
||||||
|
pickParentView
|
||||||
|
case .awaitingConfig:
|
||||||
|
configureView
|
||||||
|
case .planned:
|
||||||
|
if let plan = viewModel.plan {
|
||||||
|
plannedView(plan: plan)
|
||||||
|
} else {
|
||||||
|
progress("Preparing…")
|
||||||
|
}
|
||||||
|
case .installing:
|
||||||
|
progress("Installing…")
|
||||||
|
case .succeeded(let entry):
|
||||||
|
successView(entry: entry)
|
||||||
|
case .failed(let message):
|
||||||
|
failureView(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 640, minHeight: 520)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stages
|
||||||
|
|
||||||
|
private var idleView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("No template loaded.")
|
||||||
|
.font(.headline)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func progress(_ label: LocalizedStringKey) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pickParentView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if let manifest = viewModel.inspection?.manifest {
|
||||||
|
manifestHeader(manifest)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
Text("Where should this project live?")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Scarf will create a new folder inside the directory you pick, named after the template id.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Choose Folder…") { chooseParentDirectory() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure step for schemaful templates. Inlines
|
||||||
|
/// `TemplateConfigSheet` into the install flow rather than pushing
|
||||||
|
/// a second sheet on top — keeps the user in one window. The
|
||||||
|
/// nested VM is created freshly each time `.awaitingConfig` is
|
||||||
|
/// entered so a Cancel + retry doesn't carry stale form state.
|
||||||
|
@ViewBuilder
|
||||||
|
private var configureView: some View {
|
||||||
|
if let plan = viewModel.plan,
|
||||||
|
let schema = plan.configSchema,
|
||||||
|
let manifest = viewModel.inspection?.manifest {
|
||||||
|
TemplateConfigSheet(
|
||||||
|
viewModel: TemplateConfigViewModel(
|
||||||
|
schema: schema,
|
||||||
|
templateId: manifest.id,
|
||||||
|
templateSlug: manifest.slug,
|
||||||
|
initialValues: plan.configValues,
|
||||||
|
mode: .install
|
||||||
|
),
|
||||||
|
title: "Configure \(manifest.name)",
|
||||||
|
commitLabel: "Continue",
|
||||||
|
project: ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir),
|
||||||
|
onCommit: { values in
|
||||||
|
viewModel.submitConfig(values: values)
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
viewModel.cancelConfig()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
progress("Preparing…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func plannedView(plan: TemplateInstallPlan) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
manifestHeader(plan.manifest)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
projectFilesSection(plan: plan)
|
||||||
|
if plan.skillsNamespaceDir != nil {
|
||||||
|
skillsSection(plan: plan)
|
||||||
|
}
|
||||||
|
if !plan.cronJobs.isEmpty {
|
||||||
|
cronSection(plan: plan)
|
||||||
|
}
|
||||||
|
if plan.memoryAppendix != nil {
|
||||||
|
memorySection(plan: plan)
|
||||||
|
}
|
||||||
|
if let schema = plan.configSchema, !schema.isEmpty {
|
||||||
|
configurationSection(plan: plan, schema: schema)
|
||||||
|
}
|
||||||
|
readmeSection
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Text("\(plan.totalWriteCount) changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Install") { viewModel.confirmInstall() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func manifestHeader(_ manifest: ProjectTemplateManifest) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(manifest.name).font(.title2.bold())
|
||||||
|
Text("v\(manifest.version)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(manifest.id)
|
||||||
|
.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)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let author = manifest.author {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "person.crop.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(author.name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let url = author.url, let parsed = URL(string: url) {
|
||||||
|
Link(parsed.host ?? url, destination: parsed)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func projectFilesSection(plan: TemplateInstallPlan) -> some View {
|
||||||
|
section(title: "New project directory", subtitle: plan.projectDir) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(plan.projectFiles, id: \.destinationPath) { copy in
|
||||||
|
fileRow(label: copy.destinationPath, systemImage: "doc.text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func skillsSection(plan: TemplateInstallPlan) -> some View {
|
||||||
|
section(
|
||||||
|
title: "Skills (namespaced, safe to remove later)",
|
||||||
|
subtitle: plan.skillsNamespaceDir
|
||||||
|
) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(plan.skillsFiles, id: \.destinationPath) { copy in
|
||||||
|
fileRow(label: copy.destinationPath, systemImage: "puzzlepiece")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(job.name).font(.callout.monospaced())
|
||||||
|
Text("schedule: \(job.schedule)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func memorySection(plan: TemplateInstallPlan) -> some View {
|
||||||
|
section(title: "Memory appendix", subtitle: plan.memoryPath) {
|
||||||
|
ScrollView {
|
||||||
|
Text(plan.memoryAppendix ?? "")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration values the user entered in the configure step.
|
||||||
|
/// Secrets display masked so the preview never echoes a freshly
|
||||||
|
/// typed API key back on screen.
|
||||||
|
private func configurationSection(plan: TemplateInstallPlan, schema: TemplateConfigSchema) -> some View {
|
||||||
|
section(title: "Configuration", subtitle: "written to \(plan.projectDir)/.scarf/config.json") {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(schema.fields) { field in
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(field.key)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(minWidth: 120, alignment: .leading)
|
||||||
|
Text(displayValue(for: field, in: plan.configValues))
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line display form for a value in the preview. Secrets are
|
||||||
|
/// always masked; lists show a count + first entry; strings are
|
||||||
|
/// truncated by `.lineLimit(1)` at the view level.
|
||||||
|
private func displayValue(
|
||||||
|
for field: TemplateConfigField,
|
||||||
|
in values: [String: TemplateConfigValue]
|
||||||
|
) -> String {
|
||||||
|
switch field.type {
|
||||||
|
case .secret:
|
||||||
|
return values[field.key] == nil ? "(not set)" : "••••••• (Keychain)"
|
||||||
|
case .list:
|
||||||
|
if case .list(let items) = values[field.key] {
|
||||||
|
if items.isEmpty { return "(none)" }
|
||||||
|
if items.count == 1 { return items[0] }
|
||||||
|
return "\(items[0]) + \(items.count - 1) more"
|
||||||
|
}
|
||||||
|
return "(none)"
|
||||||
|
default:
|
||||||
|
return values[field.key]?.displayString ?? "(not set)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var readmeSection: some View {
|
||||||
|
Group {
|
||||||
|
// The body is preloaded in the VM off MainActor when inspection
|
||||||
|
// completes — no sync file I/O during View body evaluation.
|
||||||
|
if let readme = viewModel.readmeBody {
|
||||||
|
section(title: "README", subtitle: nil) {
|
||||||
|
ScrollView {
|
||||||
|
TemplateMarkdown.render(readme)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 260)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func section<Content: View>(title: String, subtitle: String?, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(.headline)
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileRow(label: String, systemImage: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.head)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func successView(entry: ProjectEntry) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Installed \(entry.name)")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text(entry.path)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Open Project") {
|
||||||
|
onCompleted(entry)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failureView(message: String) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Install Failed").font(.title2.bold())
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func chooseParentDirectory() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.prompt = String(localized: "Choose Parent Folder")
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
viewModel.pickParentDirectory(url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Preview-and-confirm sheet for uninstalling a template-installed
|
||||||
|
/// project. Symmetric with the install sheet: lists every file, cron
|
||||||
|
/// job, and memory block that will be removed BEFORE anything happens.
|
||||||
|
struct TemplateUninstallSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State var viewModel: TemplateUninstallerViewModel
|
||||||
|
/// Called on success with the project that was removed. Parent uses
|
||||||
|
/// this to refresh its projects list and clear any selection.
|
||||||
|
let onCompleted: (ProjectEntry) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch viewModel.stage {
|
||||||
|
case .idle:
|
||||||
|
idleView
|
||||||
|
case .loading:
|
||||||
|
progress("Reading template.lock.json…")
|
||||||
|
case .planned:
|
||||||
|
if let plan = viewModel.plan {
|
||||||
|
plannedView(plan: plan)
|
||||||
|
} else {
|
||||||
|
progress("Preparing…")
|
||||||
|
}
|
||||||
|
case .uninstalling:
|
||||||
|
progress("Removing…")
|
||||||
|
case .succeeded(let removed):
|
||||||
|
successView(removed: removed)
|
||||||
|
case .failed(let message):
|
||||||
|
failureView(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 620, minHeight: 480)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stages
|
||||||
|
|
||||||
|
private var idleView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("No template loaded.")
|
||||||
|
.font(.headline)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func progress(_ label: LocalizedStringKey) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func plannedView(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header(plan: plan)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
projectFilesSection(plan: plan)
|
||||||
|
if plan.skillsNamespaceDir != nil {
|
||||||
|
skillsSection(plan: plan)
|
||||||
|
}
|
||||||
|
cronSection(plan: plan)
|
||||||
|
memorySection(plan: plan)
|
||||||
|
registrySection(plan: plan)
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Text("\(plan.totalRemoveCount) changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Remove") { viewModel.confirmUninstall() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text("Remove “\(plan.lock.templateName)”").font(.title2.bold())
|
||||||
|
Text("v\(plan.lock.templateVersion)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(plan.lock.templateId)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text("Installed \(plan.lock.installedAt)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func projectFilesSection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
section(title: "Project directory", subtitle: plan.project.path) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(plan.projectFilesToRemove, id: \.self) { path in
|
||||||
|
fileRow(
|
||||||
|
label: path,
|
||||||
|
systemImage: "minus.circle",
|
||||||
|
color: .red,
|
||||||
|
tag: "remove"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ForEach(plan.projectFilesAlreadyGone, id: \.self) { path in
|
||||||
|
fileRow(
|
||||||
|
label: path,
|
||||||
|
systemImage: "questionmark.circle",
|
||||||
|
color: .secondary,
|
||||||
|
tag: "already gone"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ForEach(plan.extraProjectEntries, id: \.self) { path in
|
||||||
|
fileRow(
|
||||||
|
label: path,
|
||||||
|
systemImage: "lock.shield",
|
||||||
|
color: .green,
|
||||||
|
tag: "keep (not installed by template)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if plan.projectDirBecomesEmpty {
|
||||||
|
Text("Project directory will also be removed (nothing user-owned left inside).")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
} else if !plan.extraProjectEntries.isEmpty {
|
||||||
|
Text("Project directory stays — it still holds files you created after install.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func skillsSection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
section(
|
||||||
|
title: "Skills",
|
||||||
|
subtitle: plan.skillsNamespaceDir
|
||||||
|
) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
Text("Remove the entire namespace dir recursively")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cronSection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
section(
|
||||||
|
title: "Cron jobs",
|
||||||
|
subtitle: plan.cronJobsToRemove.isEmpty && plan.cronJobsAlreadyGone.isEmpty
|
||||||
|
? "none"
|
||||||
|
: nil
|
||||||
|
) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(plan.cronJobsToRemove, id: \.id) { job in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(job.name).font(.callout.monospaced())
|
||||||
|
Text(job.id)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(plan.cronJobsAlreadyGone, id: \.self) { name in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "questionmark.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
Text("\(name) — already gone")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func memorySection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
if plan.memoryBlockPresent {
|
||||||
|
section(title: "Memory block", subtitle: plan.memoryPath) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
Text("Strip the template's begin/end block, preserve everything else in MEMORY.md")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if plan.lock.memoryBlockId != nil {
|
||||||
|
section(title: "Memory block", subtitle: nil) {
|
||||||
|
Text("A memory block was recorded in the lock but is no longer present in MEMORY.md — skipping.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registrySection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
section(title: "Projects registry", subtitle: nil) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
Text("Remove \"\(plan.project.name)\" from Scarf's project list")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func section<Content: View>(
|
||||||
|
title: LocalizedStringKey,
|
||||||
|
subtitle: String?,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(.headline)
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileRow(label: String, systemImage: String, color: Color, tag: LocalizedStringKey) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.font(.caption)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.head)
|
||||||
|
Spacer()
|
||||||
|
Text(tag)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func successView(removed: ProjectEntry) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.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 {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Uninstall Failed").font(.title2.bold())
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ struct ToolsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: KnownPlatforms.icon(for: viewModel.selectedPlatform.name))
|
Image(systemName: KnownPlatforms.icon(for: viewModel.selectedPlatform.name))
|
||||||
Text(viewModel.selectedPlatform.displayName)
|
Text(verbatim: viewModel.selectedPlatform.displayName)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
statusDot(for: viewModel.connectivity[viewModel.selectedPlatform.name] ?? .notConfigured)
|
statusDot(for: viewModel.connectivity[viewModel.selectedPlatform.name] ?? .notConfigured)
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
|
|||||||
@@ -38,5 +38,56 @@
|
|||||||
<integer>86400</integer>
|
<integer>86400</integer>
|
||||||
<key>SUEnableInstallerLauncherService</key>
|
<key>SUEnableInstallerLauncherService</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.scarf.url</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>scarf</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>UTExportedTypeDeclarations</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>com.scarf.template</string>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>Scarf Project Template</string>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.zip-archive</string>
|
||||||
|
<string>public.data</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeTagSpecification</key>
|
||||||
|
<dict>
|
||||||
|
<key>public.filename-extension</key>
|
||||||
|
<array>
|
||||||
|
<string>scarftemplate</string>
|
||||||
|
</array>
|
||||||
|
<key>public.mime-type</key>
|
||||||
|
<array>
|
||||||
|
<string>application/vnd.scarf.template+zip</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>Scarf Project Template</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Viewer</string>
|
||||||
|
<key>LSHandlerRank</key>
|
||||||
|
<string>Owner</string>
|
||||||
|
<key>LSItemContentTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>com.scarf.template</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage" : "en",
|
||||||
|
"strings" : {
|
||||||
|
"CFBundleDisplayName" : {
|
||||||
|
"comment" : "Bundle display name",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Scarf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CFBundleName" : {
|
||||||
|
"comment" : "Bundle name",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "scarf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSHumanReadableCopyright" : {
|
||||||
|
"comment" : "Copyright (human-readable)",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSMicrophoneUsageDescription" : {
|
||||||
|
"comment" : "Shown by macOS when Scarf first requests microphone access for Hermes voice chat.",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Scarf verwendet das Mikrofon für den Hermes-Sprach-Chat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Scarf uses the microphone for Hermes voice chat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Scarf usa el micrófono para el chat de voz de Hermes."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Scarf utilise le microphone pour le chat vocal de Hermes."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Scarf は Hermes の音声チャットのためにマイクを使用します。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "O Scarf usa o microfone para o chat de voz do Hermes."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Scarf 使用麦克风进行 Hermes 语音聊天。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Scarf Project Template" : {
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version" : "1.0"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,33 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .dashboard: return "Dashboard"
|
||||||
|
case .insights: return "Insights"
|
||||||
|
case .sessions: return "Sessions"
|
||||||
|
case .activity: return "Activity"
|
||||||
|
case .projects: return "Projects"
|
||||||
|
case .chat: return "Chat"
|
||||||
|
case .memory: return "Memory"
|
||||||
|
case .skills: return "Skills"
|
||||||
|
case .platforms: return "Platforms"
|
||||||
|
case .personalities: return "Personalities"
|
||||||
|
case .quickCommands: return "Quick Commands"
|
||||||
|
case .credentialPools: return "Credential Pools"
|
||||||
|
case .plugins: return "Plugins"
|
||||||
|
case .webhooks: return "Webhooks"
|
||||||
|
case .profiles: return "Profiles"
|
||||||
|
case .tools: return "Tools"
|
||||||
|
case .mcpServers: return "MCP Servers"
|
||||||
|
case .gateway: return "Gateway"
|
||||||
|
case .cron: return "Cron"
|
||||||
|
case .health: return "Health"
|
||||||
|
case .logs: return "Logs"
|
||||||
|
case .settings: return "Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .dashboard: return "gauge.with.dots.needle.33percent"
|
case .dashboard: return "gauge.with.dots.needle.33percent"
|
||||||
|
|||||||
@@ -8,36 +8,57 @@ struct SidebarView: View {
|
|||||||
List(selection: $coordinator.selectedSection) {
|
List(selection: $coordinator.selectedSection) {
|
||||||
Section("Monitor") {
|
Section("Monitor") {
|
||||||
ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in
|
ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label {
|
||||||
|
Text(section.displayName)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: section.icon)
|
||||||
|
}
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Projects") {
|
Section("Projects") {
|
||||||
ForEach([SidebarSection.projects]) { section in
|
ForEach([SidebarSection.projects]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label {
|
||||||
|
Text(section.displayName)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: section.icon)
|
||||||
|
}
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Interact") {
|
Section("Interact") {
|
||||||
ForEach([SidebarSection.chat, .memory, .skills]) { section in
|
ForEach([SidebarSection.chat, .memory, .skills]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label {
|
||||||
|
Text(section.displayName)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: section.icon)
|
||||||
|
}
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Configure") {
|
Section("Configure") {
|
||||||
ForEach([SidebarSection.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]) { section in
|
ForEach([SidebarSection.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label {
|
||||||
|
Text(section.displayName)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: section.icon)
|
||||||
|
}
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Manage") {
|
Section("Manage") {
|
||||||
ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in
|
ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label {
|
||||||
|
Text(section.displayName)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: section.icon)
|
||||||
|
}
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
.navigationTitle("Scarf")
|
.navigationTitle("Scarf")
|
||||||
|
.splitViewAutosaveName("ScarfMainSidebar")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Makes the enclosing `NSSplitView` remember its divider positions across
|
||||||
|
/// app launches. `NavigationSplitView` is backed by `NSSplitViewController`,
|
||||||
|
/// whose split view honours `autosaveName` — AppKit writes the divider
|
||||||
|
/// offsets to `UserDefaults` on drag and restores them on the next launch.
|
||||||
|
///
|
||||||
|
/// Usage: attach `.splitViewAutosaveName("…")` to a child of the split view
|
||||||
|
/// (the sidebar is a good choice). The modifier installs an invisible helper
|
||||||
|
/// that walks up the view hierarchy on first layout, finds the `NSSplitView`,
|
||||||
|
/// and assigns its autosave name. Subsequent launches restore the divider
|
||||||
|
/// positions before the window appears.
|
||||||
|
///
|
||||||
|
/// The name is also used to key the entry in `UserDefaults` (AppKit stores
|
||||||
|
/// it as `NSSplitView Subview Frames <name>`), so changing the name resets
|
||||||
|
/// the remembered width. Pick a stable string and leave it alone.
|
||||||
|
struct SplitViewAutosaveFinder: NSViewRepresentable {
|
||||||
|
let autosaveName: String
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView {
|
||||||
|
let view = NSView()
|
||||||
|
// Defer the hierarchy walk until after SwiftUI has attached this
|
||||||
|
// view to its host window — at makeNSView time the view has no
|
||||||
|
// superview yet, so we can't find the split view above us.
|
||||||
|
DispatchQueue.main.async { [weak view] in
|
||||||
|
guard let view else { return }
|
||||||
|
SplitViewAutosaveFinder.apply(autosaveName, startingFrom: view)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||||
|
|
||||||
|
private static func apply(_ name: String, startingFrom view: NSView) {
|
||||||
|
var current: NSView? = view
|
||||||
|
while let node = current {
|
||||||
|
if let split = node as? NSSplitView {
|
||||||
|
// Only set once — reassigning clobbers AppKit's restore path.
|
||||||
|
if split.autosaveName != NSSplitView.AutosaveName(name) {
|
||||||
|
split.autosaveName = NSSplitView.AutosaveName(name)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = node.superview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Persist the enclosing `NavigationSplitView` / `NSSplitView` divider
|
||||||
|
/// positions to `UserDefaults` under `autosaveName`. Attach to any child
|
||||||
|
/// of the split view (the sidebar works well).
|
||||||
|
func splitViewAutosaveName(_ autosaveName: String) -> some View {
|
||||||
|
background(SplitViewAutosaveFinder(autosaveName: autosaveName))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,13 +57,33 @@ struct ScarfApp: App {
|
|||||||
// covers the case where the user added a server in
|
// covers the case where the user added a server in
|
||||||
// another window since this one last opened.
|
// another window since this one last opened.
|
||||||
.onAppear { liveRegistry.rebuild() }
|
.onAppear { liveRegistry.rebuild() }
|
||||||
|
// scarf://install?url=… deep-link handler. Stages the
|
||||||
|
// URL on the process-wide router; ProjectsView picks it
|
||||||
|
// up and presents the install sheet. Activating the
|
||||||
|
// app here ensures a cold launch from a browser click
|
||||||
|
// surfaces the sheet without the user having to click
|
||||||
|
// into Scarf first.
|
||||||
|
.onOpenURL { url in
|
||||||
|
TemplateURLRouter.shared.handle(url)
|
||||||
|
NSApplication.shared.activate()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// MissingServerView is a dead-end "server was removed" pane
|
||||||
|
// with no ProjectsView — so no observer of the router's
|
||||||
|
// pendingInstallURL exists in this window. Routing a
|
||||||
|
// scarf://install URL here would silently drop it. Leave
|
||||||
|
// onOpenURL off this branch; ContextBoundRoot windows in
|
||||||
|
// the same app instance will still handle it.
|
||||||
MissingServerView(removedServerID: serverID)
|
MissingServerView(removedServerID: serverID)
|
||||||
.environment(registry)
|
.environment(registry)
|
||||||
.environment(updater)
|
.environment(updater)
|
||||||
}
|
}
|
||||||
} defaultValue: {
|
} defaultValue: {
|
||||||
ServerContext.local.id
|
// Honour the user's "open on launch" choice from the Manage
|
||||||
|
// Servers popover. Falls back to Local when no entry is flagged
|
||||||
|
// (the default behaviour for fresh installs) or when the
|
||||||
|
// flagged entry was removed while the app was closed.
|
||||||
|
registry.defaultServerID
|
||||||
}
|
}
|
||||||
.defaultSize(width: 1100, height: 700)
|
.defaultSize(width: 1100, height: 700)
|
||||||
.commands {
|
.commands {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import scarf
|
||||||
|
|
||||||
|
// MARK: - Schema validation
|
||||||
|
|
||||||
|
@Suite struct TemplateConfigSchemaValidationTests {
|
||||||
|
|
||||||
|
@Test func acceptsMinimalValidSchema() throws {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "name", type: .string, label: "Name",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsDuplicateKeys() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "same", type: .string, label: "A", description: nil,
|
||||||
|
required: false, placeholder: nil, defaultValue: nil,
|
||||||
|
options: nil, minLength: nil, maxLength: nil,
|
||||||
|
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||||
|
step: nil, itemType: nil, minItems: nil, maxItems: nil),
|
||||||
|
.init(key: "same", type: .bool, label: "B", description: nil,
|
||||||
|
required: false, placeholder: nil, defaultValue: nil,
|
||||||
|
options: nil, minLength: nil, maxLength: nil,
|
||||||
|
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||||
|
step: nil, itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsSecretWithDefault() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "api_key", type: .secret, label: "API Key",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: .string("leaked-by-accident"),
|
||||||
|
options: nil, minLength: nil, maxLength: nil,
|
||||||
|
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||||
|
step: nil, itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsEnumWithoutOptions() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "choice", type: .enum, label: "Choice",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: [],
|
||||||
|
minLength: nil, maxLength: nil, pattern: nil,
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsEnumWithDuplicateValues() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "choice", type: .enum, label: "Choice",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil,
|
||||||
|
options: [.init(value: "a", label: "A"),
|
||||||
|
.init(value: "a", label: "Another A")],
|
||||||
|
minLength: nil, maxLength: nil, pattern: nil,
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsUnsupportedListItemType() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "items", type: .list, label: "Items",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil,
|
||||||
|
minLength: nil, maxLength: nil, pattern: nil,
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: "number", minItems: 1, maxItems: 10)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsEmptyModelPreferred() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [],
|
||||||
|
modelRecommendation: .init(preferred: " ", rationale: nil, alternatives: nil)
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Value validation
|
||||||
|
|
||||||
|
@Suite struct TemplateConfigValueValidationTests {
|
||||||
|
|
||||||
|
@Test func requiredFieldRejectsEmptyString() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "name", type: .string, label: "Name",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let errors = ProjectConfigService.validateValues(
|
||||||
|
["name": .string("")], against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
#expect(errors.first?.fieldKey == "name")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func patternRejectsBadInput() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "email", type: .string, label: "Email",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: "^[^@]+@[^@]+$",
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let errors = ProjectConfigService.validateValues(
|
||||||
|
["email": .string("not-an-email")], against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func numberRangeEnforced() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "port", type: .number, label: "Port",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: 1024,
|
||||||
|
maxNumber: 65535, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let errors = ProjectConfigService.validateValues(
|
||||||
|
["port": .number(80)], against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func enumRejectsUnknownValue() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "mode", type: .enum, label: "Mode",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil,
|
||||||
|
options: [.init(value: "fast", label: "Fast"),
|
||||||
|
.init(value: "slow", label: "Slow")],
|
||||||
|
minLength: nil, maxLength: nil, pattern: nil,
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let errors = ProjectConfigService.validateValues(
|
||||||
|
["mode": .string("medium")], against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func listItemBoundsEnforced() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "urls", type: .list, label: "URLs",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: "string",
|
||||||
|
minItems: 1, maxItems: 3)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let tooFew = ProjectConfigService.validateValues(
|
||||||
|
["urls": .list([])], against: schema
|
||||||
|
)
|
||||||
|
let tooMany = ProjectConfigService.validateValues(
|
||||||
|
["urls": .list(["a", "b", "c", "d"])], against: schema
|
||||||
|
)
|
||||||
|
let justRight = ProjectConfigService.validateValues(
|
||||||
|
["urls": .list(["a", "b"])], against: schema
|
||||||
|
)
|
||||||
|
#expect(tooFew.count == 1)
|
||||||
|
#expect(tooMany.count == 1)
|
||||||
|
#expect(justRight.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func secretFieldAcceptsKeychainRef() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "tok", type: .secret, label: "Token",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let errors = ProjectConfigService.validateValues(
|
||||||
|
["tok": .keychainRef("keychain://test/tok:abc")],
|
||||||
|
against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain ref helpers
|
||||||
|
|
||||||
|
@Suite struct TemplateKeychainRefTests {
|
||||||
|
|
||||||
|
@Test func uriRoundTrips() {
|
||||||
|
let ref = TemplateKeychainRef(
|
||||||
|
service: "com.scarf.template.alice-foo",
|
||||||
|
account: "api_key:deadbeef"
|
||||||
|
)
|
||||||
|
#expect(ref.uri == "keychain://com.scarf.template.alice-foo/api_key:deadbeef")
|
||||||
|
let parsed = TemplateKeychainRef.parse(ref.uri)
|
||||||
|
#expect(parsed == ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseRejectsMalformedUris() {
|
||||||
|
#expect(TemplateKeychainRef.parse("") == nil)
|
||||||
|
#expect(TemplateKeychainRef.parse("keychain://") == nil)
|
||||||
|
#expect(TemplateKeychainRef.parse("keychain:///account-only") == nil)
|
||||||
|
#expect(TemplateKeychainRef.parse("keychain://service-only") == nil)
|
||||||
|
#expect(TemplateKeychainRef.parse("https://example.com/foo") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func hashDiffersByProjectPath() {
|
||||||
|
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||||
|
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p2")
|
||||||
|
#expect(a.service == b.service) // same template
|
||||||
|
#expect(a.account != b.account) // different project → different hash suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func hashStableForSamePath() {
|
||||||
|
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||||
|
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||||
|
#expect(a == b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - On-disk config round-trip
|
||||||
|
|
||||||
|
@Suite struct ProjectConfigFileTests {
|
||||||
|
|
||||||
|
@Test func roundTripsNonSecretValues() throws {
|
||||||
|
let file = ProjectConfigFile(
|
||||||
|
schemaVersion: 2,
|
||||||
|
templateId: "alice/example",
|
||||||
|
values: [
|
||||||
|
"name": .string("Alice"),
|
||||||
|
"enabled": .bool(true),
|
||||||
|
"count": .number(42),
|
||||||
|
"tags": .list(["a", "b", "c"]),
|
||||||
|
],
|
||||||
|
updatedAt: "2026-04-25T00:00:00Z"
|
||||||
|
)
|
||||||
|
let encoded = try JSONEncoder().encode(file)
|
||||||
|
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||||
|
#expect(decoded.schemaVersion == 2)
|
||||||
|
#expect(decoded.templateId == "alice/example")
|
||||||
|
#expect(decoded.values["name"] == .string("Alice"))
|
||||||
|
#expect(decoded.values["enabled"] == .bool(true))
|
||||||
|
#expect(decoded.values["count"] == .number(42))
|
||||||
|
#expect(decoded.values["tags"] == .list(["a", "b", "c"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func preservesKeychainRefsOnRoundTrip() throws {
|
||||||
|
let file = ProjectConfigFile(
|
||||||
|
schemaVersion: 2,
|
||||||
|
templateId: "alice/example",
|
||||||
|
values: ["tok": .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef")],
|
||||||
|
updatedAt: "2026-04-25T00:00:00Z"
|
||||||
|
)
|
||||||
|
let encoded = try JSONEncoder().encode(file)
|
||||||
|
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||||
|
// Keychain refs must NOT demote to plain strings on round-trip
|
||||||
|
// — otherwise a post-install editor would lose the secret
|
||||||
|
// binding when saving unchanged values.
|
||||||
|
#expect(decoded.values["tok"] == .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ProjectConfigService + Keychain integration
|
||||||
|
|
||||||
|
/// Exercises the full secret-storage path through a real macOS Keychain
|
||||||
|
/// with a test-only service suffix so nothing leaks into the user's
|
||||||
|
/// login Keychain. Every test sets + reads + deletes within a unique
|
||||||
|
/// service name so parallel runs don't collide.
|
||||||
|
@Suite struct ProjectConfigSecretsTests {
|
||||||
|
|
||||||
|
@Test func storeAndResolveSecret() throws {
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let service = ProjectConfigService(keychain: keychain)
|
||||||
|
let project = ProjectEntry(name: "Scratch", path: NSTemporaryDirectory() + UUID().uuidString)
|
||||||
|
|
||||||
|
let stored = try service.storeSecret(
|
||||||
|
templateSlug: "alice-example",
|
||||||
|
fieldKey: "api_key",
|
||||||
|
project: project,
|
||||||
|
secret: Data("hunter2".utf8)
|
||||||
|
)
|
||||||
|
|
||||||
|
// What goes into config.json is a keychainRef, not the bytes.
|
||||||
|
guard case .keychainRef(let uri) = stored else {
|
||||||
|
Issue.record("expected keychainRef, got \(stored)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(uri.hasPrefix("keychain://"))
|
||||||
|
|
||||||
|
// Resolve brings the bytes back.
|
||||||
|
let resolved = try service.resolveSecret(ref: stored)
|
||||||
|
#expect(resolved == Data("hunter2".utf8))
|
||||||
|
|
||||||
|
// Clean up so we don't leave a test item in the Keychain.
|
||||||
|
if let ref = TemplateKeychainRef.parse(uri) {
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
#expect((try keychain.get(ref: ref)) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func setOverwritesExistingSecret() throws {
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let ref = TemplateKeychainRef(service: "com.scarf.template.overwrite", account: "k:1")
|
||||||
|
try keychain.set(ref: ref, secret: Data("first".utf8))
|
||||||
|
try keychain.set(ref: ref, secret: Data("second".utf8))
|
||||||
|
#expect((try keychain.get(ref: ref)) == Data("second".utf8))
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deleteOfMissingItemSucceeds() throws {
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let ref = TemplateKeychainRef(service: "com.scarf.template.absent", account: "never:set")
|
||||||
|
// Deleting a non-existent item is a no-op — must not throw.
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deleteMultipleSecretsClearsAll() throws {
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let service = ProjectConfigService(keychain: keychain)
|
||||||
|
|
||||||
|
let refs = (0..<3).map { i in
|
||||||
|
TemplateKeychainRef(service: "com.scarf.template.bulk", account: "k:\(i)")
|
||||||
|
}
|
||||||
|
for ref in refs {
|
||||||
|
try keychain.set(ref: ref, secret: Data("v".utf8))
|
||||||
|
}
|
||||||
|
try service.deleteSecrets(refs: refs)
|
||||||
|
for ref in refs {
|
||||||
|
#expect((try keychain.get(ref: ref)) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user