mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Compare commits
75 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 | |||
| ba8bf14ff0 | |||
| 4212200dca | |||
| 5920923d92 | |||
| 00ca7229df | |||
| 679dedf132 | |||
| 12610faba0 | |||
| 73b44202ba | |||
| eed55cbb0f | |||
| 14c97bee62 | |||
| 8d3fe70e2c | |||
| da88c98c7a | |||
| b7ad01f9da | |||
| 868e61979e | |||
| 9bdd928469 | |||
| 75e489e39c | |||
| 41ea3aeb83 | |||
| eb39dcfa61 | |||
| 93ee194ba0 | |||
| b6d9113579 |
@@ -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,
|
||||
});
|
||||
+10
@@ -1,5 +1,7 @@
|
||||
# Xcode
|
||||
build/
|
||||
.gh-pages-worktree/
|
||||
.wiki-worktree/
|
||||
DerivedData/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
@@ -46,3 +48,11 @@ scarf/standards/backups/
|
||||
|
||||
# Scarf project dashboards (user-specific)
|
||||
.scarf/
|
||||
|
||||
# Release artifacts — GitHub Releases hosts the binaries; no need to bloat git
|
||||
# history. RELEASE_NOTES.md stays tracked (committed with the version bump).
|
||||
releases/v*/*.zip
|
||||
releases/v*/appcast-entry.xml
|
||||
|
||||
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
|
||||
scripts/wiki-blocklist.txt
|
||||
|
||||
@@ -39,6 +39,128 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
|
||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
Shipped via a single local script. **Never run manual `xcodebuild archive` / `notarytool` / `gh release create` steps — use the script so nothing is skipped or misordered.**
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <version> # full release: notarize → appcast → gh-pages → tag
|
||||
./scripts/release.sh <version> --draft # draft: everything builds + notarizes, but appcast/tag are skipped
|
||||
```
|
||||
|
||||
The script bumps version, archives Universal (arm64 + x86_64) + ARM64-only variants, signs with Developer ID, notarizes via `xcrun notarytool` (keychain profile `scarf-notary`), staples, EdDSA-signs the appcast entry with Sparkle's key, pushes the appcast to `gh-pages`, and creates a GitHub release with both zips attached. Draft mode stops after the release is uploaded so the current version stays "latest" until explicitly promoted.
|
||||
|
||||
**Release notes convention:** write them to `releases/v<version>/RELEASE_NOTES.md` BEFORE running the script — it's auto-included in the version-bump commit and used as the GitHub release body. If absent, a placeholder is used.
|
||||
|
||||
**Canonical prompts (any of these trigger the flow):**
|
||||
- "Release v1.6.2" — full release
|
||||
- "Release v1.6.2 as draft" — draft mode
|
||||
- "Prepare v1.6.2 release notes from recent commits, then release" — generate notes first, then run
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
- 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
|
||||
|
||||
Open an issue with:
|
||||
|
||||
@@ -13,26 +13,115 @@
|
||||
<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/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<em>Available in English, 简体中文, Deutsch, Français, Español, 日本語, and Português (Brasil).</em>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **Correctness pass** — Fixed remote WAL error spam, stale-snapshot session resume, auto-resume of dead cron sessions, 230+ Swift 6 concurrency warnings.
|
||||
|
||||
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
|
||||
|
||||
- **Platforms** — Native GUI setup for all 13 messaging platforms, no more hand-editing `.env`
|
||||
- **Credential Pools** — Fixed OAuth flow and API-key handling; pick providers from a catalog
|
||||
- **Model Picker** — Hierarchical browser backed by the 111-provider models.dev cache
|
||||
- **Settings tabs** — 10 organized tabs covering ~60 previously hidden config fields
|
||||
- **Configure sidebar** — Personalities, Quick Commands, Plugins, Webhooks, Profiles
|
||||
|
||||
See the [v1.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v1.6.0) for the full 1.6 series.
|
||||
|
||||
## Multi-server, one window per server
|
||||
|
||||
Scarf 2.0 is a multi-window app. Each window is bound to exactly one Hermes server — your local `~/.hermes/` is synthesized automatically, and you can add remotes via **File → Open Server…** → **Add Server** (host, user, port, optional identity file). Open a second window for a different server and the two run side-by-side with independent state.
|
||||
|
||||
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
|
||||
|
||||
Scarf mirrors Hermes's surface area through a sidebar-based UI. Sections below map 1:1 to the app's sidebar.
|
||||
|
||||
### Monitor
|
||||
|
||||
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
|
||||
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
||||
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
|
||||
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
|
||||
|
||||
### Interact
|
||||
|
||||
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
|
||||
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
|
||||
- **Skills Browser** — Browse and edit installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
|
||||
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, iMessage, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
|
||||
- **Skills Browser** — Browse installed skills by category with file content viewer and required config warnings. **New in 1.6:** Browse the Skills Hub, search by registry (official, skills.sh, well-known, GitHub, ClawHub, LobeHub), install, check for updates, and uninstall — all from the app
|
||||
|
||||
### Configure *(new in 1.6)*
|
||||
|
||||
- **Platforms** — Native GUI setup for all 13 messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost, Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write credentials to `~/.hermes/.env` and behavior toggles to `~/.hermes/config.yaml`. WhatsApp and Signal pairing use an inline SwiftTerm terminal for QR scan and signal-cli daemon management
|
||||
- **Personalities** — List defined personalities, pick the active one, and edit `SOUL.md` inline with markdown preview
|
||||
- **Quick Commands** — Editor for custom `/command_name` shell shortcuts with dangerous-pattern detection (`rm -rf`, `mkfs`, etc.)
|
||||
- **Credential Pools** — Per-provider credential rotation with a fixed OAuth flow (URL extraction + browser open + code paste) and proper `--type api-key` handling. API keys never stored in UI state — only last-4 preview. Strategy picker (fill_first / round_robin / least_used / random)
|
||||
- **Plugins** — Install via Git URL or `owner/repo`, update, remove, enable/disable. Reads `~/.hermes/plugins/` directly for reliable state
|
||||
- **Webhooks** — Create, list, test-fire, and remove webhook subscriptions. Detects the "platform not enabled" state and links to gateway setup
|
||||
- **Profiles** — Switch between multiple isolated Hermes instances. Create, rename, delete, export (zip), import. Safe-switch warning reminds users to restart Scarf after activating a different profile
|
||||
|
||||
### Manage
|
||||
|
||||
- **Tools** — Enable/disable toolsets per platform with a connectivity-aware platform menu (green/orange/grey/red dots for connected/configured/offline/error). **Fixed in 1.6:** all 13 platforms now appear (was previously stuck on CLI)
|
||||
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
|
||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
|
||||
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators. **New in 1.6:** full write support — create, edit, pause, resume, run-now, and delete jobs from the app
|
||||
- **Health** — Component-level status and diagnostics. **New in 1.6:** inline "Run Dump" and "Share Debug Report" buttons (the latter with an upload-confirmation dialog before sending to Nous support)
|
||||
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, and text search
|
||||
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Fast Mode service tier, interim assistant messages, gateway notify interval, force IPv4, context engine, Honcho eager init, Docker environment, command allowlist, credential management, and one-click **Backup & Restore** via `hermes backup` / `hermes import`
|
||||
- **Settings** — **Restructured in 1.6** into a 10-tab layout: General, Display, Agent, Terminal, Browser, Voice, Memory, Aux Models, Security, Advanced. Exposes ~60 previously hidden config fields including all 8 auxiliary model tasks, container limits, full TTS/STT provider settings, human-delay simulation, compression thresholds, logging rotation, checkpoints, website blocklist, Tirith sandbox, and delegation. One-click **Backup & Restore** via `hermes backup` / `hermes import`. Model picker replaces the old free-text model field, backed by the models.dev cache (111 providers, all major models) with a "Custom…" escape hatch
|
||||
|
||||
### Project Dashboards
|
||||
|
||||
Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically. See [Project Dashboards](#project-dashboards-1) below for the full schema.
|
||||
|
||||
### System
|
||||
|
||||
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
|
||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||
|
||||
@@ -40,7 +129,8 @@
|
||||
|
||||
- macOS 14.6+ (Sonoma)
|
||||
- Xcode 16.0+
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.9.0 recommended for full feature support)
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.9.0+ recommended for full feature support)
|
||||
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server.
|
||||
|
||||
### Compatibility
|
||||
|
||||
@@ -51,7 +141,10 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
|
||||
| v0.6.0 (2026-03-30) | Verified |
|
||||
| v0.7.0 (2026-04-03) | Verified |
|
||||
| v0.8.0 (2026-04-08) | Verified |
|
||||
| v0.9.0 (2026-04-13, latest) | Verified |
|
||||
| v0.9.0 (2026-04-13) | Verified |
|
||||
| v0.10.0 (2026-04-18) | Verified (recommended for full 2.0 feature support) |
|
||||
|
||||
Scarf 2.0 targets Hermes v0.10.0 for the ACP session/fork/list/resume capabilities used by remote chat. Earlier Hermes versions remain supported for monitoring, sessions, and file-based features; ACP-specific behavior may gracefully degrade on older agents.
|
||||
|
||||
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
||||
|
||||
@@ -62,10 +155,12 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
|
||||
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
|
||||
|
||||
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
|
||||
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller)
|
||||
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller download)
|
||||
|
||||
1. Unzip and drag **Scarf.app** to Applications
|
||||
2. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
||||
2. Launch normally — builds are Developer ID signed and notarized, so Gatekeeper accepts them on first launch
|
||||
|
||||
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
|
||||
|
||||
### Build from Source
|
||||
|
||||
@@ -139,6 +234,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
||||
| [Sparkle](https://github.com/sparkle-project/Sparkle) | Auto-updates from the GitHub-hosted appcast |
|
||||
|
||||
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
|
||||
|
||||
@@ -288,6 +384,30 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene
|
||||
|
||||
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
||||
|
||||
## Releases
|
||||
|
||||
Scarf ships through GitHub releases — the App Store is not supported because Scarf spawns the user-installed `hermes` binary and reads `~/.hermes/` directly, both of which App Sandbox forbids.
|
||||
|
||||
Each release goes through a single local script: [scripts/release.sh](scripts/release.sh). The script archives a universal binary, signs it with the Developer ID Application cert, submits to `notarytool`, staples the ticket, produces the distribution zip, signs an appcast entry with Sparkle's EdDSA key, pushes an updated `appcast.xml` to the `gh-pages` branch, creates the GitHub release, and tags `main`.
|
||||
|
||||
The Sparkle appcast is served from [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml).
|
||||
|
||||
Signing prerequisites (one-time):
|
||||
|
||||
- `Developer ID Application` certificate in the login Keychain
|
||||
- `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)
|
||||
|
||||
## 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
|
||||
|
||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||
@@ -298,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`)
|
||||
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
|
||||
|
||||
If you find Scarf useful, consider buying me a coffee.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
## What's New in 1.6.1
|
||||
|
||||
### Auto-updates
|
||||
|
||||
Scarf now ships with [Sparkle](https://sparkle-project.org). On launch (and daily thereafter) it checks an EdDSA-signed appcast at [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml). When a new version is available you'll get an in-app update prompt — no more manually downloading zips and dragging into Applications.
|
||||
|
||||
You can disable automatic checks or trigger a manual one from **Settings → General → Updates**, the menu bar icon, or the **Scarf → Check for Updates…** menu item.
|
||||
|
||||
### Notarized & Developer ID signed
|
||||
|
||||
This is the first release that's properly Developer ID signed and notarized by Apple. Gatekeeper accepts it on first launch — no more right-click → Open dance, no more "Scarf cannot be opened because the developer cannot be verified" warnings.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Chat works correctly when no terminal hermes session is running, and surfaces the real error when it can't reach the agent (b6df…)
|
||||
|
||||
### Under the hood
|
||||
|
||||
- Tracked `Info.plist` (replacing auto-generation) so signing-relevant keys live in version control
|
||||
- New `UpdaterService` wraps Sparkle and is injected via SwiftUI `.environment()`
|
||||
- One-command release pipeline at [scripts/release.sh](https://github.com/awizemann/scarf/blob/main/scripts/release.sh) handles archive → sign → notarize → staple → appcast → GitHub release → tag
|
||||
|
||||
---
|
||||
|
||||
**Migrating from 1.6.0:** unzip and replace your existing `Scarf.app` in `/Applications`. After this release, future updates install in-place via Sparkle.
|
||||
@@ -0,0 +1,13 @@
|
||||
## What's New in 1.6.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- **No more bogus "missing credentials" banner on Chat.** The orange "No AI provider credentials detected" warning was firing on the Chat tab whenever no session was selected, even for users whose credentials were configured and working. Root cause: the preflight check only inspected `~/.hermes/.env` and shell environment variables, missing the Credential Pools file at `~/.hermes/auth.json` (the in-app flow introduced in 1.6.0) and `api_key:` fields in `config.yaml`. The check now covers all four locations Hermes itself reads at runtime, so if you've added credentials via **Configure → Credential Pools**, the warning stays hidden.
|
||||
|
||||
### Polish
|
||||
|
||||
- Banner subtitle updated to point users at the in-app Credential Pools flow first, rather than prescribing `.env` edits.
|
||||
|
||||
---
|
||||
|
||||
**Upgrading from 1.6.1:** Sparkle will offer the update automatically. You can also trigger a check via **Scarf → Check for Updates…** or the menu bar icon.
|
||||
@@ -0,0 +1,58 @@
|
||||
## What's New in 2.0
|
||||
|
||||
Scarf now manages **multiple Hermes installations** — your local `~/.hermes/` plus any number of remote Hermes instances reached over SSH. Every feature that worked on your Mac now works against a Linux server, a Mac mini on the network, or whatever other host has Hermes installed.
|
||||
|
||||
This is a major version bump because the entire service layer was rewritten around a `ServerContext` + `ServerTransport` abstraction, and because the window model changed from single-window-single-server to multi-window-one-server-per-window.
|
||||
|
||||
### Multi-server
|
||||
|
||||
- **Manage Servers** sheet lets you add, rename, and remove remote servers. Each entry is an SSH target (`user@host`, port, optional identity file, optional `remoteHome` override if your install isn't at `~/.hermes/`).
|
||||
- Each window is bound to exactly one server. Open a second window via **File → Open Server** → pick a different server, and the two run side-by-side with independent state — chat, dashboards, activity, sessions, the lot.
|
||||
- The menu bar status icon shows a summary across all registered servers (green hare = any Hermes running anywhere).
|
||||
- Window-state restoration: quit + relaunch re-opens every window you had open, each reconnected to its bound server.
|
||||
|
||||
### Remote over SSH
|
||||
|
||||
- **ControlMaster connection pooling** — after the first auth, each remote primitive is a ~5ms tunnel call. Uses the system `ssh`, `scp`, `sftp` so your `~/.ssh/config`, ssh-agent, 1Password/Secretive SSH agents, and ProxyJump all work unchanged.
|
||||
- **DB access via atomic snapshots** — Scarf runs `sqlite3 .backup` on the remote (WAL-safe, won't corrupt), flips the snapshot out of WAL mode, and pulls it down with `scp`. Snapshots are cached under `~/Library/Caches/scarf/snapshots/<server-id>/` and re-pulled when the file watcher sees a change on the remote's `state.db`.
|
||||
- **ACP chat over SSH** — the Agent Client Protocol tunnel runs `ssh -T host -- hermes acp`. JSON-RPC over stdio travels end-to-end unmodified, so Rich Chat, streaming, tool calls, permission dialogs, and compression all work against the remote agent identically to local.
|
||||
- **File watcher** — local uses FSEvents (instant); remote polls `stat` mtime every 3s with ControlMaster keeping the cost bounded. Views auto-refresh on any tick.
|
||||
- **Cleanup on server-remove** — deleting a remote closes its ControlMaster socket (`ssh -O exit`), prunes its snapshot cache, and invalidates any process-wide caches keyed to its ID. App launch also sweeps orphaned snapshot dirs whose UUIDs are no longer in the registry.
|
||||
|
||||
### Chat UX overhaul
|
||||
|
||||
All of these were visible bugs during remote dogfooding and are now fixed on both local and remote:
|
||||
|
||||
- **No more white-screen flash** on the first message of a session. `RichChatView` used to swap `ContentUnavailableView` out for the message list, which tore down and recreated the entire ScrollView hierarchy. The empty state now lives inside the ScrollView itself.
|
||||
- **No more scroll-jumping to whitespace** at stream start/finish. Replaced six racing `onChange`-driven scroll calls with SwiftUI's built-in `.defaultScrollAnchor(.bottom)`, which is implemented inside the layout pass and doesn't overshoot LazyVStack content.
|
||||
- **Resuming a session on a remote now shows its full history.** The DB snapshot is refreshed on session-load — previously it was pulled once on first open and never again, so any messages the remote wrote since launch were invisible.
|
||||
- **"Continue from last session" surfaces errors** instead of silently doing nothing when SSH is down.
|
||||
- **Typing into a blank Chat always creates a new session.** Previously it auto-resumed the most recently active session in the DB, which often picked up a cron-spawned session that Hermes had already garbage-collected — producing a silent prompt failure.
|
||||
- **Failed prompts now explain themselves.** When the agent returns `stopReason: "refusal"`, `"error"`, or `"max_tokens"` with no assistant output, a system message appears under your prompt explaining what happened. No more spinning "Agent working…" forever.
|
||||
|
||||
### Correctness — remote SQLite
|
||||
|
||||
- The WAL-error spam (`cannot open file at line 51044 of [f0ca7bba1c] — os_unix.c:51044: (2) open(/Users/…/state.db-wal) - No such file or directory`) is gone. `sqlite3 .backup` preserves the source DB's journal mode; the scp'd copy used to try to open a WAL sidecar that doesn't exist. The snapshot script now runs `PRAGMA journal_mode=DELETE` after `.backup` on the remote, and Scarf opens remote snapshots with `file:…?immutable=1` as defense-in-depth.
|
||||
- **Concurrent snapshot dedupe** — a new `SnapshotCoordinator` actor makes sure that when Dashboard + Sessions + Activity all ask for a fresh snapshot at the same moment (e.g. on a file-watcher tick), only one SSH backup runs; the other callers await the in-flight pull and share the result.
|
||||
|
||||
### Under the hood
|
||||
|
||||
- New `ServerContext` value type flows through `.environment()` to every view and ViewModel. Every file and process operation routes through `context.makeTransport()` — `LocalTransport` (`FileManager`, `Process`, FSEvents) or `SSHTransport` (ssh, scp, sftp, mtime polling). The protocol is small enough that each transport is ~400 lines.
|
||||
- Swift 6 complete-concurrency sweep: ~230 warnings reduced to 1. `ServerContext`, `HermesPathSet`, `ServerTransport`, all service inits, and every value-type accessor are explicitly `nonisolated`. Hand-written `Codable` conformances for the nine types whose synthesized conformances were inferred `@MainActor` by Swift 6's default-isolation rule (`ACPRequest`, `ACPRawMessage`, `GatewayState`, `PlatformState`, `HermesCronJob`, `CronSchedule`, `CronJobsFile`, `AuthFile`, `AuthEntry`).
|
||||
- ACP cwd now comes from the *remote* `$HOME`, probed once on first connect and cached per server. Previously it passed your local Mac's home path to the ACP adapter, which only worked by coincidence when the remote username matched.
|
||||
|
||||
### Compatibility
|
||||
|
||||
Hermes v0.10.0 is now verified alongside v0.6–v0.9. Scarf builds its session/message `SELECT` columns based on an additive schema detection (`hasV07Schema`), so newer Hermes versions with extra columns don't break queries.
|
||||
|
||||
### Migration from 1.6.x
|
||||
|
||||
- Sparkle will offer the update automatically. Trigger manually via **Scarf → Check for Updates…** or the menu bar.
|
||||
- Your local server is synthesized automatically — existing 1.6.x users see "Local" in the server list with no setup needed.
|
||||
- `servers.json` is created on first add-remote. Location: `~/Library/Application Support/scarf/servers.json`.
|
||||
- Nothing you configured in 1.6.x (OAuth tokens, credential pools, cron jobs, MCP servers, platform setup) is touched. Those live in `~/.hermes/` and remain the source of truth.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Remote file watching is 3s mtime polling (vs. FSEvents on local). If you need sub-second updates on a remote, that's a followup.
|
||||
- The `session/load` ACP call against an already-deleted session returns success-with-no-body from the Hermes adapter — Scarf now detects the resulting `stopReason: "refusal"` and surfaces it, but the underlying Hermes behavior is an upstream-adapter bug that should also get a proper error response.
|
||||
@@ -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.
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
|
||||
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -33,9 +34,22 @@
|
||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
534959422F7B83B600BD31AD /* scarf */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */,
|
||||
);
|
||||
path = scarf;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -57,6 +71,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
||||
53SPARKLE00010 /* Sparkle in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -118,6 +133,7 @@
|
||||
name = scarf;
|
||||
packageProductDependencies = (
|
||||
53SWIFTTERM0001 /* SwiftTerm */,
|
||||
53SPARKLE00011 /* Sparkle */,
|
||||
);
|
||||
productName = scarf;
|
||||
productReference = 534959402F7B83B600BD31AD /* scarf.app */;
|
||||
@@ -198,11 +214,18 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
"zh-Hans",
|
||||
de,
|
||||
fr,
|
||||
es,
|
||||
ja,
|
||||
"pt-BR",
|
||||
);
|
||||
mainGroup = 534959372F7B83B600BD31AD;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
|
||||
@@ -283,6 +306,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -312,6 +336,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -337,6 +362,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -347,6 +373,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -376,6 +403,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
@@ -394,6 +422,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
@@ -407,23 +436,21 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.5.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -444,23 +471,21 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.5.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -477,11 +502,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -498,11 +524,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -518,10 +545,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -537,10 +565,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -594,6 +623,14 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.6.0;
|
||||
};
|
||||
};
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
|
||||
@@ -605,6 +642,11 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
53SPARKLE00011 /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
53SWIFTTERM0001 /* SwiftTerm */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||
|
||||
@@ -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>
|
||||
@@ -2,48 +2,79 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
/// Per-window connection status. Constructed from the window's
|
||||
/// `serverContext` once; lifetime matches the window.
|
||||
@State private var connectionStatus: ConnectionStatusViewModel
|
||||
|
||||
init() {
|
||||
_connectionStatus = State(initialValue: ConnectionStatusViewModel(context: .local))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
.navigationSplitViewColumnWidth(min: 180, ideal: 240, max: 360)
|
||||
} detail: {
|
||||
detailView
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
ServerSwitcherToolbar()
|
||||
}
|
||||
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) {
|
||||
ConnectionStatusPill(status: connectionStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// The actual context is injected via @Environment, which
|
||||
// isn't available in `init`. Rebuild the monitor here
|
||||
// the first time we know the real context. Safe to call
|
||||
// repeatedly; `startMonitoring()` cancels + restarts.
|
||||
if connectionStatus.context.id != serverContext.id {
|
||||
connectionStatus = ConnectionStatusViewModel(context: serverContext)
|
||||
}
|
||||
connectionStatus.startMonitoring()
|
||||
}
|
||||
.onDisappear { connectionStatus.stopMonitoring() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
// Each routed view receives the window's `serverContext` in its
|
||||
// init so its `@State` ViewModel is constructed bound to the right
|
||||
// server. This is what makes multi-window work — without it,
|
||||
// every window's VMs default-construct with `.local` even though
|
||||
// the surrounding env has the right context.
|
||||
switch coordinator.selectedSection {
|
||||
case .dashboard:
|
||||
DashboardView()
|
||||
case .insights:
|
||||
InsightsView()
|
||||
case .sessions:
|
||||
SessionsView()
|
||||
case .activity:
|
||||
ActivityView()
|
||||
case .projects:
|
||||
ProjectsView()
|
||||
case .chat:
|
||||
ChatView()
|
||||
case .memory:
|
||||
MemoryView()
|
||||
case .skills:
|
||||
SkillsView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .mcpServers:
|
||||
MCPServersView()
|
||||
case .gateway:
|
||||
GatewayView()
|
||||
case .cron:
|
||||
CronView()
|
||||
case .health:
|
||||
HealthView()
|
||||
case .logs:
|
||||
LogsView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .dashboard: DashboardView(context: serverContext)
|
||||
case .insights: InsightsView(context: serverContext)
|
||||
case .sessions: SessionsView(context: serverContext)
|
||||
case .activity: ActivityView(context: serverContext)
|
||||
case .projects: ProjectsView(context: serverContext)
|
||||
case .chat: ChatView()
|
||||
case .memory: MemoryView(context: serverContext)
|
||||
case .skills: SkillsView(context: serverContext)
|
||||
case .platforms: PlatformsView(context: serverContext)
|
||||
case .personalities: PersonalitiesView(context: serverContext)
|
||||
case .quickCommands: QuickCommandsView(context: serverContext)
|
||||
case .credentialPools: CredentialPoolsView(context: serverContext)
|
||||
case .plugins: PluginsView(context: serverContext)
|
||||
case .webhooks: WebhooksView(context: serverContext)
|
||||
case .profiles: ProfilesView(context: serverContext)
|
||||
case .tools: ToolsView(context: serverContext)
|
||||
case .mcpServers: MCPServersView(context: serverContext)
|
||||
case .gateway: GatewayView(context: serverContext)
|
||||
case .cron: CronView(context: serverContext)
|
||||
case .health: HealthView(context: serverContext)
|
||||
case .logs: LogsView(context: serverContext)
|
||||
case .settings: SettingsView(context: serverContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,39 +2,83 @@ import Foundation
|
||||
|
||||
// MARK: - JSON-RPC Transport
|
||||
|
||||
struct ACPRequest: Encodable {
|
||||
let jsonrpc = "2.0"
|
||||
let id: Int
|
||||
let method: String
|
||||
let params: [String: AnyCodable]
|
||||
// Hand-written `encode(to:)` / `init(from:)` with explicit `nonisolated` so
|
||||
// Swift 6's default-isolation doesn't synthesize a MainActor-isolated
|
||||
// conformance — which would prevent these payloads from being encoded or
|
||||
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
|
||||
// The member list must stay in sync with the stored properties above.
|
||||
|
||||
struct ACPRequest: Encodable, Sendable {
|
||||
nonisolated let jsonrpc = "2.0"
|
||||
nonisolated let id: Int
|
||||
nonisolated let method: String
|
||||
nonisolated let params: [String: AnyCodable]
|
||||
|
||||
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(jsonrpc, forKey: .jsonrpc)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encode(method, forKey: .method)
|
||||
try c.encode(params, forKey: .params)
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPRawMessage: Decodable {
|
||||
let jsonrpc: String?
|
||||
let id: Int?
|
||||
let method: String?
|
||||
let result: AnyCodable?
|
||||
let error: ACPError?
|
||||
let params: AnyCodable?
|
||||
struct ACPRawMessage: Decodable, Sendable {
|
||||
nonisolated let jsonrpc: String?
|
||||
nonisolated let id: Int?
|
||||
nonisolated let method: String?
|
||||
nonisolated let result: AnyCodable?
|
||||
nonisolated let error: ACPError?
|
||||
nonisolated let params: AnyCodable?
|
||||
|
||||
var isResponse: Bool { id != nil && method == nil }
|
||||
var isNotification: Bool { method != nil && id == nil }
|
||||
var isRequest: Bool { method != nil && id != nil }
|
||||
nonisolated var isResponse: Bool { id != nil && method == nil }
|
||||
nonisolated var isNotification: Bool { method != nil && id == nil }
|
||||
nonisolated var isRequest: Bool { method != nil && id != nil }
|
||||
|
||||
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
|
||||
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
|
||||
self.method = try c.decodeIfPresent(String.self, forKey: .method)
|
||||
self.result = try c.decodeIfPresent(AnyCodable.self, forKey: .result)
|
||||
self.error = try c.decodeIfPresent(ACPError.self, forKey: .error)
|
||||
self.params = try c.decodeIfPresent(AnyCodable.self, forKey: .params)
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPError: Decodable, Sendable {
|
||||
let code: Int
|
||||
let message: String
|
||||
nonisolated let code: Int
|
||||
nonisolated let message: String
|
||||
|
||||
enum CodingKeys: String, CodingKey { case code, message }
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.code = try c.decode(Int.self, forKey: .code)
|
||||
self.message = try c.decode(String.self, forKey: .message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AnyCodable (for dynamic JSON)
|
||||
|
||||
struct AnyCodable: Codable, Sendable {
|
||||
let value: Any
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
nonisolated let value: Any
|
||||
|
||||
init(_ value: Any) { self.value = value }
|
||||
nonisolated init(_ value: Any) { self.value = value }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
|
||||
// `let value: Any` stored property as MainActor-isolated even when the
|
||||
// property is declared nonisolated (Any can't be strictly Sendable, so
|
||||
// the compiler can't prove the write is safe off-main). Leaving the
|
||||
// init as default-isolated silences the mutation warnings; the Decodable
|
||||
// conformance is still usable from ACPClient's nonisolated read loop
|
||||
// because all callers are already @preconcurrency with respect to
|
||||
// `AnyCodable` (it's @unchecked Sendable).
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
value = NSNull()
|
||||
@@ -55,7 +99,7 @@ struct AnyCodable: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch value {
|
||||
case is NSNull:
|
||||
@@ -79,10 +123,10 @@ struct AnyCodable: Codable, Sendable {
|
||||
|
||||
// MARK: - Accessors
|
||||
|
||||
var stringValue: String? { value as? String }
|
||||
var intValue: Int? { value as? Int }
|
||||
var dictValue: [String: Any]? { value as? [String: Any] }
|
||||
var arrayValue: [Any]? { value as? [Any] }
|
||||
nonisolated var stringValue: String? { value as? String }
|
||||
nonisolated var intValue: Int? { value as? Int }
|
||||
nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
|
||||
nonisolated var arrayValue: [Any]? { value as? [Any] }
|
||||
}
|
||||
|
||||
// MARK: - ACP Events (parsed from session/update notifications)
|
||||
@@ -154,7 +198,7 @@ struct ACPPromptResult: Sendable {
|
||||
// MARK: - Event Parsing
|
||||
|
||||
enum ACPEventParser {
|
||||
static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
||||
nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
||||
guard notification.method == "session/update",
|
||||
let params = notification.params?.dictValue,
|
||||
let sessionId = params["sessionId"] as? String,
|
||||
@@ -202,7 +246,7 @@ enum ACPEventParser {
|
||||
}
|
||||
}
|
||||
|
||||
static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
|
||||
nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
|
||||
guard message.method == "session/request_permission",
|
||||
let params = message.params?.dictValue,
|
||||
let sessionId = params["sessionId"] as? String,
|
||||
@@ -226,7 +270,7 @@ enum ACPEventParser {
|
||||
|
||||
// MARK: - Content Extraction
|
||||
|
||||
private static func extractContentText(from update: [String: Any]) -> String {
|
||||
nonisolated private static func extractContentText(from update: [String: Any]) -> String {
|
||||
if let content = update["content"] as? [String: Any],
|
||||
let text = content["text"] as? String {
|
||||
return text
|
||||
@@ -234,7 +278,7 @@ enum ACPEventParser {
|
||||
return ""
|
||||
}
|
||||
|
||||
private static func extractContentArrayText(from update: [String: Any]) -> String {
|
||||
nonisolated private static func extractContentArrayText(from update: [String: Any]) -> String {
|
||||
if let contentArray = update["content"] as? [[String: Any]] {
|
||||
return contentArray.compactMap { item -> String? in
|
||||
guard let inner = item["content"] as? [String: Any] else { return nil }
|
||||
|
||||
@@ -1,6 +1,304 @@
|
||||
import Foundation
|
||||
|
||||
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
|
||||
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
|
||||
struct AuxiliaryModel: Sendable, Equatable {
|
||||
var provider: String
|
||||
var model: String
|
||||
var baseURL: String
|
||||
var apiKey: String
|
||||
var timeout: Int
|
||||
|
||||
nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
|
||||
}
|
||||
|
||||
/// Group of display-related settings mirroring the `display:` block in config.yaml.
|
||||
struct DisplaySettings: Sendable, Equatable {
|
||||
var skin: String
|
||||
var compact: Bool
|
||||
var resumeDisplay: String // "full" | "minimal"
|
||||
var bellOnComplete: Bool
|
||||
var inlineDiffs: Bool
|
||||
var toolProgressCommand: Bool
|
||||
var toolPreviewLength: Int
|
||||
var busyInputMode: String // e.g. "interrupt"
|
||||
|
||||
nonisolated static let empty = DisplaySettings(
|
||||
skin: "default",
|
||||
compact: false,
|
||||
resumeDisplay: "full",
|
||||
bellOnComplete: false,
|
||||
inlineDiffs: true,
|
||||
toolProgressCommand: false,
|
||||
toolPreviewLength: 0,
|
||||
busyInputMode: "interrupt"
|
||||
)
|
||||
}
|
||||
|
||||
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
|
||||
struct TerminalSettings: Sendable, Equatable {
|
||||
var cwd: String
|
||||
var timeout: Int
|
||||
var envPassthrough: [String]
|
||||
var persistentShell: Bool
|
||||
var dockerImage: String
|
||||
var dockerMountCwdToWorkspace: Bool
|
||||
var dockerForwardEnv: [String]
|
||||
var dockerVolumes: [String]
|
||||
var containerCPU: Int // 0 = unlimited
|
||||
var containerMemory: Int // MB, 0 = unlimited
|
||||
var containerDisk: Int // MB, 0 = unlimited
|
||||
var containerPersistent: Bool
|
||||
var modalImage: String
|
||||
var modalMode: String // "auto" | other
|
||||
var daytonaImage: String
|
||||
var singularityImage: String
|
||||
|
||||
nonisolated static let empty = TerminalSettings(
|
||||
cwd: ".",
|
||||
timeout: 180,
|
||||
envPassthrough: [],
|
||||
persistentShell: true,
|
||||
dockerImage: "",
|
||||
dockerMountCwdToWorkspace: false,
|
||||
dockerForwardEnv: [],
|
||||
dockerVolumes: [],
|
||||
containerCPU: 0,
|
||||
containerMemory: 0,
|
||||
containerDisk: 0,
|
||||
containerPersistent: false,
|
||||
modalImage: "",
|
||||
modalMode: "auto",
|
||||
daytonaImage: "",
|
||||
singularityImage: ""
|
||||
)
|
||||
}
|
||||
|
||||
/// Browser automation tuning (`browser.*`).
|
||||
struct BrowserSettings: Sendable, Equatable {
|
||||
var inactivityTimeout: Int
|
||||
var commandTimeout: Int
|
||||
var recordSessions: Bool
|
||||
var allowPrivateURLs: Bool
|
||||
var camofoxManagedPersistence: Bool
|
||||
|
||||
nonisolated static let empty = BrowserSettings(
|
||||
inactivityTimeout: 120,
|
||||
commandTimeout: 30,
|
||||
recordSessions: false,
|
||||
allowPrivateURLs: false,
|
||||
camofoxManagedPersistence: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Voice push-to-talk plus TTS/STT provider settings.
|
||||
struct VoiceSettings: Sendable, Equatable {
|
||||
var recordKey: String
|
||||
var maxRecordingSeconds: Int
|
||||
var silenceDuration: Double
|
||||
|
||||
// TTS
|
||||
var ttsProvider: String
|
||||
var ttsEdgeVoice: String
|
||||
var ttsElevenLabsVoiceID: String
|
||||
var ttsElevenLabsModelID: String
|
||||
var ttsOpenAIModel: String
|
||||
var ttsOpenAIVoice: String
|
||||
var ttsNeuTTSModel: String
|
||||
var ttsNeuTTSDevice: String
|
||||
|
||||
// STT
|
||||
var sttEnabled: Bool
|
||||
var sttProvider: String
|
||||
var sttLocalModel: String
|
||||
var sttLocalLanguage: String
|
||||
var sttOpenAIModel: String
|
||||
var sttMistralModel: String
|
||||
|
||||
nonisolated static let empty = VoiceSettings(
|
||||
recordKey: "ctrl+b",
|
||||
maxRecordingSeconds: 120,
|
||||
silenceDuration: 3.0,
|
||||
ttsProvider: "edge",
|
||||
ttsEdgeVoice: "en-US-AriaNeural",
|
||||
ttsElevenLabsVoiceID: "",
|
||||
ttsElevenLabsModelID: "eleven_multilingual_v2",
|
||||
ttsOpenAIModel: "gpt-4o-mini-tts",
|
||||
ttsOpenAIVoice: "alloy",
|
||||
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
|
||||
ttsNeuTTSDevice: "cpu",
|
||||
sttEnabled: true,
|
||||
sttProvider: "local",
|
||||
sttLocalModel: "base",
|
||||
sttLocalLanguage: "",
|
||||
sttOpenAIModel: "whisper-1",
|
||||
sttMistralModel: "voxtral-mini-latest"
|
||||
)
|
||||
}
|
||||
|
||||
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
|
||||
struct AuxiliarySettings: Sendable, Equatable {
|
||||
var vision: AuxiliaryModel
|
||||
var webExtract: AuxiliaryModel
|
||||
var compression: AuxiliaryModel
|
||||
var sessionSearch: AuxiliaryModel
|
||||
var skillsHub: AuxiliaryModel
|
||||
var approval: AuxiliaryModel
|
||||
var mcp: AuxiliaryModel
|
||||
var flushMemories: AuxiliaryModel
|
||||
|
||||
nonisolated static let empty = AuxiliarySettings(
|
||||
vision: .empty,
|
||||
webExtract: .empty,
|
||||
compression: .empty,
|
||||
sessionSearch: .empty,
|
||||
skillsHub: .empty,
|
||||
approval: .empty,
|
||||
mcp: .empty,
|
||||
flushMemories: .empty
|
||||
)
|
||||
}
|
||||
|
||||
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
|
||||
struct SecuritySettings: Sendable, Equatable {
|
||||
var redactSecrets: Bool
|
||||
var redactPII: Bool // from privacy.redact_pii
|
||||
var tirithEnabled: Bool
|
||||
var tirithPath: String
|
||||
var tirithTimeout: Int
|
||||
var tirithFailOpen: Bool
|
||||
var blocklistEnabled: Bool
|
||||
var blocklistDomains: [String]
|
||||
|
||||
nonisolated static let empty = SecuritySettings(
|
||||
redactSecrets: true,
|
||||
redactPII: false,
|
||||
tirithEnabled: true,
|
||||
tirithPath: "tirith",
|
||||
tirithTimeout: 5,
|
||||
tirithFailOpen: true,
|
||||
blocklistEnabled: false,
|
||||
blocklistDomains: []
|
||||
)
|
||||
}
|
||||
|
||||
/// Human-delay simulates realistic typing pace (`human_delay.*`).
|
||||
struct HumanDelaySettings: Sendable, Equatable {
|
||||
var mode: String // "off" | "natural" | "custom"
|
||||
var minMS: Int
|
||||
var maxMS: Int
|
||||
|
||||
nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
|
||||
}
|
||||
|
||||
/// Compression / context routing.
|
||||
struct CompressionSettings: Sendable, Equatable {
|
||||
var enabled: Bool
|
||||
var threshold: Double
|
||||
var targetRatio: Double
|
||||
var protectLastN: Int
|
||||
|
||||
nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
|
||||
}
|
||||
|
||||
struct CheckpointSettings: Sendable, Equatable {
|
||||
var enabled: Bool
|
||||
var maxSnapshots: Int
|
||||
|
||||
nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
|
||||
}
|
||||
|
||||
struct LoggingSettings: Sendable, Equatable {
|
||||
var level: String // DEBUG | INFO | WARNING | ERROR
|
||||
var maxSizeMB: Int
|
||||
var backupCount: Int
|
||||
|
||||
nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
|
||||
}
|
||||
|
||||
struct DelegationSettings: Sendable, Equatable {
|
||||
var model: String
|
||||
var provider: String
|
||||
var baseURL: String
|
||||
var apiKey: String
|
||||
var maxIterations: Int
|
||||
|
||||
nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
|
||||
}
|
||||
|
||||
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
|
||||
struct DiscordSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var freeResponseChannels: String
|
||||
var autoThread: Bool
|
||||
var reactions: Bool
|
||||
|
||||
nonisolated static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
|
||||
}
|
||||
|
||||
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
|
||||
/// done via environment variables (`TELEGRAM_*`) — this is the subset that lives
|
||||
/// in the YAML.
|
||||
struct TelegramSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var reactions: Bool
|
||||
|
||||
nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
|
||||
}
|
||||
|
||||
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
|
||||
struct SlackSettings: Sendable, Equatable {
|
||||
var replyToMode: String // "off" | "first" | "all"
|
||||
var requireMention: Bool
|
||||
var replyInThread: Bool
|
||||
var replyBroadcast: Bool
|
||||
|
||||
nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
|
||||
}
|
||||
|
||||
/// Matrix settings under `matrix.*`.
|
||||
struct MatrixSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var autoThread: Bool
|
||||
var dmMentionThreads: Bool
|
||||
|
||||
nonisolated static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
|
||||
}
|
||||
|
||||
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
|
||||
/// currently just exposes `group_sessions_per_user` at the top level, but we
|
||||
/// reserve this struct for future expansion so the form has a stable type.
|
||||
struct MattermostSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var replyMode: String // "thread" | "off"
|
||||
|
||||
nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
|
||||
}
|
||||
|
||||
/// WhatsApp settings under `whatsapp.*`.
|
||||
struct WhatsAppSettings: Sendable, Equatable {
|
||||
var unauthorizedDMBehavior: String // "pair" | "ignore"
|
||||
var replyPrefix: String
|
||||
|
||||
nonisolated static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
|
||||
}
|
||||
|
||||
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
|
||||
/// every state change by default; users must opt-in via at least one filter.
|
||||
struct HomeAssistantSettings: Sendable, Equatable {
|
||||
var watchDomains: [String]
|
||||
var watchEntities: [String]
|
||||
var watchAll: Bool
|
||||
var ignoreEntities: [String]
|
||||
var cooldownSeconds: Int
|
||||
|
||||
nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
|
||||
}
|
||||
|
||||
// MARK: - Root Config
|
||||
|
||||
struct HermesConfig: Sendable {
|
||||
// Original fields — preserved for zero breakage with existing call sites.
|
||||
var model: String
|
||||
var provider: String
|
||||
var maxTurns: Int
|
||||
@@ -30,7 +328,38 @@ struct HermesConfig: Sendable {
|
||||
var interimAssistantMessages: Bool
|
||||
var honchoInitOnSessionStart: Bool
|
||||
|
||||
static let empty = HermesConfig(
|
||||
// Phase 1 additions
|
||||
var timezone: String
|
||||
var userProfileEnabled: Bool
|
||||
var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
|
||||
var gatewayTimeout: Int
|
||||
var approvalTimeout: Int
|
||||
var fileReadMaxChars: Int
|
||||
var cronWrapResponse: Bool
|
||||
var prefillMessagesFile: String
|
||||
var skillsExternalDirs: [String]
|
||||
|
||||
// Grouped blocks
|
||||
var display: DisplaySettings
|
||||
var terminal: TerminalSettings
|
||||
var browser: BrowserSettings
|
||||
var voice: VoiceSettings
|
||||
var auxiliary: AuxiliarySettings
|
||||
var security: SecuritySettings
|
||||
var humanDelay: HumanDelaySettings
|
||||
var compression: CompressionSettings
|
||||
var checkpoints: CheckpointSettings
|
||||
var logging: LoggingSettings
|
||||
var delegation: DelegationSettings
|
||||
var discord: DiscordSettings
|
||||
var telegram: TelegramSettings
|
||||
var slack: SlackSettings
|
||||
var matrix: MatrixSettings
|
||||
var mattermost: MattermostSettings
|
||||
var whatsapp: WhatsAppSettings
|
||||
var homeAssistant: HomeAssistantSettings
|
||||
|
||||
nonisolated static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
provider: "unknown",
|
||||
maxTurns: 0,
|
||||
@@ -58,17 +387,47 @@ struct HermesConfig: Sendable {
|
||||
forceIPv4: false,
|
||||
contextEngine: "compressor",
|
||||
interimAssistantMessages: true,
|
||||
honchoInitOnSessionStart: false
|
||||
honchoInitOnSessionStart: false,
|
||||
timezone: "",
|
||||
userProfileEnabled: true,
|
||||
toolUseEnforcement: "auto",
|
||||
gatewayTimeout: 1800,
|
||||
approvalTimeout: 60,
|
||||
fileReadMaxChars: 100_000,
|
||||
cronWrapResponse: true,
|
||||
prefillMessagesFile: "",
|
||||
skillsExternalDirs: [],
|
||||
display: .empty,
|
||||
terminal: .empty,
|
||||
browser: .empty,
|
||||
voice: .empty,
|
||||
auxiliary: .empty,
|
||||
security: .empty,
|
||||
humanDelay: .empty,
|
||||
compression: .empty,
|
||||
checkpoints: .empty,
|
||||
logging: .empty,
|
||||
delegation: .empty,
|
||||
discord: .empty,
|
||||
telegram: .empty,
|
||||
slack: .empty,
|
||||
matrix: .empty,
|
||||
mattermost: .empty,
|
||||
whatsapp: .empty,
|
||||
homeAssistant: .empty
|
||||
)
|
||||
}
|
||||
|
||||
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a
|
||||
// MainActor-isolated Decodable conformance (which would fail to be used from
|
||||
// `HermesFileService.loadGatewayState()`, a nonisolated method).
|
||||
struct GatewayState: Sendable, Codable {
|
||||
let pid: Int?
|
||||
let kind: String?
|
||||
let gatewayState: String?
|
||||
let exitReason: String?
|
||||
let platforms: [String: PlatformState]?
|
||||
let updatedAt: String?
|
||||
nonisolated let pid: Int?
|
||||
nonisolated let kind: String?
|
||||
nonisolated let gatewayState: String?
|
||||
nonisolated let exitReason: String?
|
||||
nonisolated let platforms: [String: PlatformState]?
|
||||
nonisolated let updatedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pid, kind
|
||||
@@ -78,16 +437,50 @@ struct GatewayState: Sendable, Codable {
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
var isRunning: Bool {
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.pid = try c.decodeIfPresent(Int.self, forKey: .pid)
|
||||
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
|
||||
self.gatewayState = try c.decodeIfPresent(String.self, forKey: .gatewayState)
|
||||
self.exitReason = try c.decodeIfPresent(String.self, forKey: .exitReason)
|
||||
self.platforms = try c.decodeIfPresent([String: PlatformState].self, forKey: .platforms)
|
||||
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encodeIfPresent(pid, forKey: .pid)
|
||||
try c.encodeIfPresent(kind, forKey: .kind)
|
||||
try c.encodeIfPresent(gatewayState, forKey: .gatewayState)
|
||||
try c.encodeIfPresent(exitReason, forKey: .exitReason)
|
||||
try c.encodeIfPresent(platforms, forKey: .platforms)
|
||||
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
||||
}
|
||||
|
||||
nonisolated var isRunning: Bool {
|
||||
gatewayState == "running"
|
||||
}
|
||||
|
||||
var statusText: String {
|
||||
nonisolated var statusText: String {
|
||||
gatewayState ?? "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
struct PlatformState: Sendable, Codable {
|
||||
let connected: Bool?
|
||||
let error: String?
|
||||
nonisolated let connected: Bool?
|
||||
nonisolated let error: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey { case connected, error }
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.connected = try c.decodeIfPresent(Bool.self, forKey: .connected)
|
||||
self.error = try c.decodeIfPresent(String.self, forKey: .error)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encodeIfPresent(connected, forKey: .connected)
|
||||
try c.encodeIfPresent(error, forKey: .error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,70 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
/// Deprecated module-level path statics. Preserved as thin forwarders to
|
||||
/// `ServerContext.local.paths` so existing call sites continue to compile
|
||||
/// while Phase 1 migrates them to a per-server `ServerContext`.
|
||||
///
|
||||
/// New code should accept a `ServerContext` and read `context.paths.<field>`.
|
||||
enum HermesPaths: Sendable {
|
||||
private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"]
|
||||
?? NSHomeDirectory()
|
||||
@available(*, deprecated, message: "use ServerContext.paths.home")
|
||||
nonisolated static var home: String { ServerContext.local.paths.home }
|
||||
|
||||
nonisolated static let home: String = userHome + "/.hermes"
|
||||
nonisolated static let stateDB: String = home + "/state.db"
|
||||
nonisolated static let configYAML: String = home + "/config.yaml"
|
||||
nonisolated static let memoriesDir: String = home + "/memories"
|
||||
nonisolated static let memoryMD: String = memoriesDir + "/MEMORY.md"
|
||||
nonisolated static let userMD: String = memoriesDir + "/USER.md"
|
||||
nonisolated static let sessionsDir: String = home + "/sessions"
|
||||
nonisolated static let cronJobsJSON: String = home + "/cron/jobs.json"
|
||||
nonisolated static let cronOutputDir: String = home + "/cron/output"
|
||||
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json"
|
||||
nonisolated static let skillsDir: String = home + "/skills"
|
||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||
nonisolated static let agentLog: String = home + "/logs/agent.log"
|
||||
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
||||
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
||||
nonisolated static let scarfDir: String = home + "/scarf"
|
||||
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
|
||||
@available(*, deprecated, message: "use ServerContext.paths.stateDB")
|
||||
nonisolated static var stateDB: String { ServerContext.local.paths.stateDB }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.configYAML")
|
||||
nonisolated static var configYAML: String { ServerContext.local.paths.configYAML }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.memoriesDir")
|
||||
nonisolated static var memoriesDir: String { ServerContext.local.paths.memoriesDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.memoryMD")
|
||||
nonisolated static var memoryMD: String { ServerContext.local.paths.memoryMD }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.userMD")
|
||||
nonisolated static var userMD: String { ServerContext.local.paths.userMD }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.sessionsDir")
|
||||
nonisolated static var sessionsDir: String { ServerContext.local.paths.sessionsDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.cronJobsJSON")
|
||||
nonisolated static var cronJobsJSON: String { ServerContext.local.paths.cronJobsJSON }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.cronOutputDir")
|
||||
nonisolated static var cronOutputDir: String { ServerContext.local.paths.cronOutputDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.gatewayStateJSON")
|
||||
nonisolated static var gatewayStateJSON: String { ServerContext.local.paths.gatewayStateJSON }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.skillsDir")
|
||||
nonisolated static var skillsDir: String { ServerContext.local.paths.skillsDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.errorsLog")
|
||||
nonisolated static var errorsLog: String { ServerContext.local.paths.errorsLog }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.agentLog")
|
||||
nonisolated static var agentLog: String { ServerContext.local.paths.agentLog }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.gatewayLog")
|
||||
nonisolated static var gatewayLog: String { ServerContext.local.paths.gatewayLog }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.scarfDir")
|
||||
nonisolated static var scarfDir: String { ServerContext.local.paths.scarfDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.projectsRegistry")
|
||||
nonisolated static var projectsRegistry: String { ServerContext.local.paths.projectsRegistry }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.mcpTokensDir")
|
||||
nonisolated static var mcpTokensDir: String { ServerContext.local.paths.mcpTokensDir }
|
||||
|
||||
@available(*, deprecated, message: "use HermesPathSet.hermesBinaryCandidates")
|
||||
nonisolated static var hermesBinaryCandidates: [String] {
|
||||
HermesPathSet.hermesBinaryCandidates
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.hermesBinary")
|
||||
nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary }
|
||||
}
|
||||
|
||||
// MARK: - SQLite Constants
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let prompt: String
|
||||
let skills: [String]?
|
||||
let model: String?
|
||||
let schedule: CronSchedule
|
||||
let enabled: Bool
|
||||
let state: String
|
||||
let deliver: String?
|
||||
let nextRunAt: String?
|
||||
let lastRunAt: String?
|
||||
let lastError: String?
|
||||
let preRunScript: String?
|
||||
let deliveryFailures: Int?
|
||||
let lastDeliveryError: String?
|
||||
let timeoutType: String?
|
||||
let timeoutSeconds: Int?
|
||||
let silent: Bool?
|
||||
nonisolated let id: String
|
||||
nonisolated let name: String
|
||||
nonisolated let prompt: String
|
||||
nonisolated let skills: [String]?
|
||||
nonisolated let model: String?
|
||||
nonisolated let schedule: CronSchedule
|
||||
nonisolated let enabled: Bool
|
||||
nonisolated let state: String
|
||||
nonisolated let deliver: String?
|
||||
nonisolated let nextRunAt: String?
|
||||
nonisolated let lastRunAt: String?
|
||||
nonisolated let lastError: String?
|
||||
nonisolated let preRunScript: String?
|
||||
nonisolated let deliveryFailures: Int?
|
||||
nonisolated let lastDeliveryError: String?
|
||||
nonisolated let timeoutType: String?
|
||||
nonisolated let timeoutSeconds: Int?
|
||||
nonisolated let silent: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
||||
@@ -32,7 +32,51 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
case timeoutSeconds = "timeout_seconds"
|
||||
}
|
||||
|
||||
var stateIcon: String {
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try c.decode(String.self, forKey: .id)
|
||||
self.name = try c.decode(String.self, forKey: .name)
|
||||
self.prompt = try c.decode(String.self, forKey: .prompt)
|
||||
self.skills = try c.decodeIfPresent([String].self, forKey: .skills)
|
||||
self.model = try c.decodeIfPresent(String.self, forKey: .model)
|
||||
self.schedule = try c.decode(CronSchedule.self, forKey: .schedule)
|
||||
self.enabled = try c.decode(Bool.self, forKey: .enabled)
|
||||
self.state = try c.decode(String.self, forKey: .state)
|
||||
self.deliver = try c.decodeIfPresent(String.self, forKey: .deliver)
|
||||
self.nextRunAt = try c.decodeIfPresent(String.self, forKey: .nextRunAt)
|
||||
self.lastRunAt = try c.decodeIfPresent(String.self, forKey: .lastRunAt)
|
||||
self.lastError = try c.decodeIfPresent(String.self, forKey: .lastError)
|
||||
self.preRunScript = try c.decodeIfPresent(String.self, forKey: .preRunScript)
|
||||
self.deliveryFailures = try c.decodeIfPresent(Int.self, forKey: .deliveryFailures)
|
||||
self.lastDeliveryError = try c.decodeIfPresent(String.self, forKey: .lastDeliveryError)
|
||||
self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
|
||||
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
|
||||
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encode(name, forKey: .name)
|
||||
try c.encode(prompt, forKey: .prompt)
|
||||
try c.encodeIfPresent(skills, forKey: .skills)
|
||||
try c.encodeIfPresent(model, forKey: .model)
|
||||
try c.encode(schedule, forKey: .schedule)
|
||||
try c.encode(enabled, forKey: .enabled)
|
||||
try c.encode(state, forKey: .state)
|
||||
try c.encodeIfPresent(deliver, forKey: .deliver)
|
||||
try c.encodeIfPresent(nextRunAt, forKey: .nextRunAt)
|
||||
try c.encodeIfPresent(lastRunAt, forKey: .lastRunAt)
|
||||
try c.encodeIfPresent(lastError, forKey: .lastError)
|
||||
try c.encodeIfPresent(preRunScript, forKey: .preRunScript)
|
||||
try c.encodeIfPresent(deliveryFailures, forKey: .deliveryFailures)
|
||||
try c.encodeIfPresent(lastDeliveryError, forKey: .lastDeliveryError)
|
||||
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
|
||||
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||
try c.encodeIfPresent(silent, forKey: .silent)
|
||||
}
|
||||
|
||||
nonisolated var stateIcon: String {
|
||||
switch state {
|
||||
case "scheduled": return "clock"
|
||||
case "running": return "play.circle"
|
||||
@@ -42,7 +86,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
var deliveryDisplay: String? {
|
||||
nonisolated var deliveryDisplay: String? {
|
||||
guard let deliver, !deliver.isEmpty else { return nil }
|
||||
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
||||
if deliver.hasPrefix("discord:") {
|
||||
@@ -59,10 +103,10 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
}
|
||||
|
||||
struct CronSchedule: Sendable, Codable {
|
||||
let kind: String
|
||||
let runAt: String?
|
||||
let display: String?
|
||||
let expression: String?
|
||||
nonisolated let kind: String
|
||||
nonisolated let runAt: String?
|
||||
nonisolated let display: String?
|
||||
nonisolated let expression: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
@@ -70,14 +114,45 @@ struct CronSchedule: Sendable, Codable {
|
||||
case display
|
||||
case expression
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.kind = try c.decode(String.self, forKey: .kind)
|
||||
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
|
||||
self.display = try c.decodeIfPresent(String.self, forKey: .display)
|
||||
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(kind, forKey: .kind)
|
||||
try c.encodeIfPresent(runAt, forKey: .runAt)
|
||||
try c.encodeIfPresent(display, forKey: .display)
|
||||
try c.encodeIfPresent(expression, forKey: .expression)
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
|
||||
// MainActor-isolated Codable conformance — `HermesFileService.loadCronJobs`
|
||||
// is nonisolated and needs to decode this from a background task.
|
||||
struct CronJobsFile: Sendable, Codable {
|
||||
let jobs: [HermesCronJob]
|
||||
let updatedAt: String?
|
||||
nonisolated let jobs: [HermesCronJob]
|
||||
nonisolated let updatedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobs
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
||||
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(jobs, forKey: .jobs)
|
||||
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .stdio: return "Local (stdio)"
|
||||
case .http: return "Remote (HTTP)"
|
||||
|
||||
@@ -99,6 +99,17 @@ enum ToolKind: String, Sendable, CaseIterable {
|
||||
case browser
|
||||
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 {
|
||||
switch self {
|
||||
case .read: return "doc.text.magnifyingglass"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
|
||||
/// The filesystem layout of a Hermes installation, parameterized by the
|
||||
/// `home` directory. The same layout is used for local installations (where
|
||||
/// `home` is an absolute macOS path like `/Users/alan/.hermes`) and for
|
||||
/// remote installations reached over SSH (where `home` is a remote path like
|
||||
/// `/home/deploy/.hermes` or an unexpanded `~/.hermes` that the remote shell
|
||||
/// will resolve).
|
||||
///
|
||||
/// Every path that used to live as a module-level static on `HermesPaths` is
|
||||
/// an instance property here. `ServerContext.paths` is the canonical way to
|
||||
/// reach these values; the old `HermesPaths` statics are preserved as
|
||||
/// deprecated forwarders so Phase 1 can migrate call sites incrementally.
|
||||
struct HermesPathSet: Sendable, Hashable {
|
||||
let home: String
|
||||
/// `true` when this path set belongs to a remote installation. Affects
|
||||
/// only `hermesBinary` resolution — every other path is identical in
|
||||
/// shape between local and remote.
|
||||
let isRemote: Bool
|
||||
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
|
||||
/// Populated by `SSHTransport` once `command -v hermes` has run on the
|
||||
/// target host. Unused when `isRemote == false`.
|
||||
let binaryHint: String?
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
/// Absolute path to the local user's `~/.hermes` directory.
|
||||
nonisolated static let defaultLocalHome: String = {
|
||||
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||
return user + "/.hermes"
|
||||
}()
|
||||
|
||||
/// Default remote home when the user doesn't override it in `SSHConfig`.
|
||||
/// We leave `~` unexpanded on purpose — the remote shell resolves it.
|
||||
nonisolated static let defaultRemoteHome: String = "~/.hermes"
|
||||
|
||||
// MARK: - Paths (mirror of the old HermesPaths layout)
|
||||
|
||||
nonisolated var stateDB: String { home + "/state.db" }
|
||||
nonisolated var configYAML: String { home + "/config.yaml" }
|
||||
nonisolated var envFile: String { home + "/.env" }
|
||||
nonisolated var authJSON: String { home + "/auth.json" }
|
||||
nonisolated var soulMD: String { home + "/SOUL.md" }
|
||||
nonisolated var pluginsDir: String { home + "/plugins" }
|
||||
nonisolated var memoriesDir: String { home + "/memories" }
|
||||
nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
|
||||
nonisolated var userMD: String { memoriesDir + "/USER.md" }
|
||||
nonisolated var sessionsDir: String { home + "/sessions" }
|
||||
nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
|
||||
nonisolated var cronOutputDir: String { home + "/cron/output" }
|
||||
nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
|
||||
nonisolated var skillsDir: String { home + "/skills" }
|
||||
nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
||||
nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||
nonisolated var scarfDir: String { home + "/scarf" }
|
||||
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
/// Install locations we probe for the local `hermes` binary, in priority
|
||||
/// order. Checked on every access so a user installing via a different
|
||||
/// method doesn't need to relaunch Scarf.
|
||||
nonisolated static let hermesBinaryCandidates: [String] = {
|
||||
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||
return [
|
||||
user + "/.local/bin/hermes", // pipx / pip --user (default)
|
||||
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
|
||||
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
|
||||
user + "/.hermes/bin/hermes" // Some self-install layouts
|
||||
]
|
||||
}()
|
||||
|
||||
/// Resolved path to the `hermes` executable for this installation.
|
||||
///
|
||||
/// Local: returns the first executable candidate, falling back to the
|
||||
/// pipx default so error messages still make sense on a fresh machine.
|
||||
///
|
||||
/// Remote: returns `binaryHint` (populated at connect time) or bare
|
||||
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
|
||||
nonisolated var hermesBinary: String {
|
||||
if isRemote {
|
||||
return binaryHint ?? "hermes"
|
||||
}
|
||||
for path in Self.hermesBinaryCandidates
|
||||
where FileManager.default.isExecutableFile(atPath: path) {
|
||||
return path
|
||||
}
|
||||
return Self.hermesBinaryCandidates[0]
|
||||
}
|
||||
}
|
||||
@@ -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 .number(let n):
|
||||
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(Int(n))
|
||||
: String(format: "%.1f", n)
|
||||
? Int(n).formatted(.number)
|
||||
: 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,253 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Stable identifier for a server entry in the user's registry. Backed by
|
||||
/// `UUID` so it round-trips through `servers.json` and SwiftUI window-state
|
||||
/// restoration without collisions.
|
||||
typealias ServerID = UUID
|
||||
|
||||
/// Connection parameters for a remote Hermes installation reached over SSH.
|
||||
/// All fields are optional except `host` — unset values defer to the user's
|
||||
/// `~/.ssh/config` and the OpenSSH defaults.
|
||||
struct SSHConfig: Sendable, Hashable, Codable {
|
||||
/// Hostname or `~/.ssh/config` alias.
|
||||
var host: String
|
||||
/// Remote username. `nil` → defer to `~/.ssh/config` or the local user.
|
||||
var user: String?
|
||||
/// TCP port. `nil` → 22 (or whatever `~/.ssh/config` says).
|
||||
var port: Int?
|
||||
/// Absolute path to a private key. `nil` → defer to ssh-agent /
|
||||
/// `~/.ssh/config` identity files.
|
||||
var identityFile: String?
|
||||
/// Override for the remote `$HOME/.hermes` directory. `nil` uses
|
||||
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
|
||||
/// remote side).
|
||||
var remoteHome: String?
|
||||
/// Resolved remote path to the `hermes` binary. Populated by
|
||||
/// `SSHTransport` after the first `command -v hermes` probe; cached here
|
||||
/// so subsequent calls skip the round trip.
|
||||
var hermesBinaryHint: String?
|
||||
}
|
||||
|
||||
/// Distinguishes a local installation (the user's own `~/.hermes`) from a
|
||||
/// remote one reached over SSH. Service behavior is identical in shape but
|
||||
/// dispatches to different I/O primitives in Phase 2.
|
||||
enum ServerKind: Sendable, Hashable, Codable {
|
||||
case local
|
||||
case ssh(SSHConfig)
|
||||
}
|
||||
|
||||
/// The per-server value that flows through `.environment` and gets handed to
|
||||
/// every service and ViewModel in Phase 1. One `ServerContext` corresponds to
|
||||
/// one Hermes installation; multi-window scenes in Phase 3 will construct
|
||||
/// one per window.
|
||||
///
|
||||
/// **Why every member is `nonisolated`.** This file imports `AppKit`
|
||||
/// (`NSWorkspace.shared.open` in `openInLocalEditor`), which under Swift 6's
|
||||
/// upcoming default-isolation rules pulls the whole struct to `@MainActor`.
|
||||
/// `ServerContext` is a plain `Sendable` value — accessing `.local`, `.paths`,
|
||||
/// `.isRemote`, or `makeTransport()` from a background actor must not trap
|
||||
/// the caller into hopping MainActor. `nonisolated` on each member keeps
|
||||
/// them callable from any context; the one MainActor-dependent method
|
||||
/// (`openInLocalEditor`) lives in the extension below.
|
||||
struct ServerContext: Sendable, Hashable, Identifiable {
|
||||
let id: ServerID
|
||||
var displayName: String
|
||||
var kind: ServerKind
|
||||
|
||||
/// Path layout for this server. Cheap — all path components are computed
|
||||
/// on demand from `home`, no I/O.
|
||||
nonisolated var paths: HermesPathSet {
|
||||
switch kind {
|
||||
case .local:
|
||||
return HermesPathSet(
|
||||
home: HermesPathSet.defaultLocalHome,
|
||||
isRemote: false,
|
||||
binaryHint: nil
|
||||
)
|
||||
case .ssh(let config):
|
||||
return HermesPathSet(
|
||||
home: config.remoteHome ?? HermesPathSet.defaultRemoteHome,
|
||||
isRemote: true,
|
||||
binaryHint: config.hermesBinaryHint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated var isRemote: Bool {
|
||||
if case .ssh = kind { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Construct the `ServerTransport` for this context. Local contexts get
|
||||
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
|
||||
/// from `SSHConfig`. Each call returns a fresh value — transports are
|
||||
/// cheap and stateless beyond disk caches.
|
||||
nonisolated func makeTransport() -> any ServerTransport {
|
||||
switch kind {
|
||||
case .local:
|
||||
return LocalTransport(contextID: id)
|
||||
case .ssh(let config):
|
||||
return SSHTransport(contextID: id, config: config, displayName: displayName)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Well-known singletons
|
||||
|
||||
/// Stable UUID for the built-in "this machine" entry. Hard-coded so the
|
||||
/// local context has the same identity across launches, and so persisted
|
||||
/// window-state restorations that reference it continue to resolve even
|
||||
/// if `servers.json` hasn't been touched yet.
|
||||
nonisolated private static let localID = ServerID(uuidString: "00000000-0000-0000-0000-000000000001")!
|
||||
|
||||
/// The default "this machine" context. Used everywhere in Phase 0/1 and
|
||||
/// remains the fallback when no remote server is selected.
|
||||
nonisolated static let local = ServerContext(
|
||||
id: localID,
|
||||
displayName: "Local",
|
||||
kind: .local
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Remote user-home resolution
|
||||
|
||||
/// Process-wide cache of each server's resolved user `$HOME`. Probed once per
|
||||
/// `ServerID` via the transport, then memoized for the app's lifetime — home
|
||||
/// directories don't change under us, and the probe is a ~5ms SSH round-trip
|
||||
/// with ControlMaster. Used by anything that needs to hand a working
|
||||
/// directory to the ACP agent or the Hermes CLI on the correct host.
|
||||
private actor UserHomeCache {
|
||||
static let shared = UserHomeCache()
|
||||
private var cache: [ServerID: String] = [:]
|
||||
|
||||
func resolve(for context: ServerContext) async -> String {
|
||||
if let cached = cache[context.id] { return cached }
|
||||
let resolved = await probe(context: context)
|
||||
cache[context.id] = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
func invalidate(contextID: ServerID) {
|
||||
cache.removeValue(forKey: contextID)
|
||||
}
|
||||
|
||||
private func probe(context: ServerContext) async -> String {
|
||||
if !context.isRemote { return NSHomeDirectory() }
|
||||
let transport = context.makeTransport()
|
||||
let result = try? transport.runProcess(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", "echo $HOME"],
|
||||
stdin: nil,
|
||||
timeout: 10
|
||||
)
|
||||
let out = result?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
// Fall back to `~` (unexpanded) so ACP at least gets a plausible cwd
|
||||
// rather than a local Mac path. The remote side will expand it if
|
||||
// passed through a shell; if not, failures are surfaced by ACP itself.
|
||||
return out.isEmpty ? "~" : out
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerContext {
|
||||
/// Resolved absolute path to the user's home directory on the target host.
|
||||
/// Local: `NSHomeDirectory()`. Remote: probed `$HOME` over SSH, cached.
|
||||
/// Use this — not `NSHomeDirectory()` — whenever you're passing a `cwd`
|
||||
/// or user path to a process that runs on the target host.
|
||||
func resolvedUserHome() async -> String {
|
||||
await UserHomeCache.shared.resolve(for: self)
|
||||
}
|
||||
|
||||
/// Called when a server is removed from the registry, so the process-wide
|
||||
/// caches keyed by `ServerID` don't hold stale entries forever.
|
||||
static func invalidateCaches(for contextID: ServerID) async {
|
||||
await UserHomeCache.shared.invalidate(contextID: contextID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience file I/O via the right transport
|
||||
|
||||
/// Centralized file I/O entry points for VMs that don't own a service. Every
|
||||
/// call goes through the context's transport, so reads/writes hit the local
|
||||
/// disk for `.local` and ssh/scp for `.ssh` automatically.
|
||||
///
|
||||
/// **Always** prefer `context.readText(...)` over `String(contentsOfFile: ...)`
|
||||
/// when the path comes from `context.paths`. The 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.
|
||||
extension ServerContext {
|
||||
/// Read a UTF-8 text file. `nil` on any error (missing, transport down,
|
||||
/// invalid encoding).
|
||||
nonisolated func readText(_ path: String) -> String? {
|
||||
guard let data = try? makeTransport().readFile(path) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Read raw bytes. `nil` on any error.
|
||||
nonisolated func readData(_ path: String) -> Data? {
|
||||
try? makeTransport().readFile(path)
|
||||
}
|
||||
|
||||
/// Atomic write. Returns `true` on success, `false` on any error
|
||||
/// (caller is expected to surface failures via UI when relevant).
|
||||
@discardableResult
|
||||
nonisolated func writeText(_ path: String, content: String) -> Bool {
|
||||
guard let data = content.data(using: .utf8) else { return false }
|
||||
do {
|
||||
try makeTransport().writeFile(path, data: data)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Existence check. Local: `FileManager`. Remote: `ssh test -e`.
|
||||
nonisolated func fileExists(_ path: String) -> Bool {
|
||||
makeTransport().fileExists(path)
|
||||
}
|
||||
|
||||
/// File modification timestamp, or `nil` if the file doesn't exist.
|
||||
nonisolated func modificationDate(_ path: String) -> Date? {
|
||||
makeTransport().stat(path)?.mtime
|
||||
}
|
||||
|
||||
/// Invoke the `hermes` CLI on this server and return its combined output
|
||||
/// + exit code. Local: spawns the local binary via `Process`. Remote:
|
||||
/// rounds through `ssh host hermes …`. Use this from any VM that needs
|
||||
/// to fire off a CLI command — never spawn `hermes` via `Process()`
|
||||
/// directly, because that path bypasses the transport for remote.
|
||||
@discardableResult
|
||||
nonisolated func runHermes(_ args: [String], timeout: TimeInterval = 60, stdin: String? = nil) -> (output: String, exitCode: Int32) {
|
||||
let result = HermesFileService(context: self).runHermesCLI(args: args, timeout: timeout, stdinInput: stdin)
|
||||
return (result.output, result.exitCode)
|
||||
}
|
||||
|
||||
/// Reveal the file at `path` in the user's local editor (via
|
||||
/// `NSWorkspace.open`). For remote contexts this is a no-op — the
|
||||
/// file doesn't exist on this Mac, so opening it would fail silently
|
||||
/// or worse, open the wrong file from the local filesystem.
|
||||
/// Returns `true` if opened, `false` if the call was skipped.
|
||||
@discardableResult
|
||||
func openInLocalEditor(_ path: String) -> Bool {
|
||||
guard !isRemote else { return false }
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: path))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI environment plumbing
|
||||
|
||||
/// `ServerContext` is a value type, so SwiftUI's `.environment(_:)` (which
|
||||
/// requires an `@Observable` class) doesn't accept it directly. We expose it
|
||||
/// through a custom `EnvironmentKey` — views read it with
|
||||
/// `@Environment(\.serverContext) private var serverContext`.
|
||||
private struct ServerContextEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue: ServerContext = .local
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var serverContext: ServerContext {
|
||||
get { self[ServerContextEnvironmentKey.self] }
|
||||
set { self[ServerContextEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Persisted entry for a user-added server. `ServerContext` itself is a value
|
||||
/// type we rebuild from these fields at runtime — we persist the minimum that
|
||||
/// uniquely identifies a connection, not the whole context struct, so future
|
||||
/// fields we add to `ServerContext` don't force a migration.
|
||||
struct ServerEntry: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: ServerID
|
||||
var displayName: String
|
||||
var kind: ServerKind
|
||||
/// User preference: this server is the one Scarf opens into when a
|
||||
/// 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 context: ServerContext {
|
||||
ServerContext(id: id, displayName: displayName, kind: kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk envelope for `servers.json`. Schema-versioned so future changes
|
||||
/// can migrate without losing data.
|
||||
private struct RegistryFile: Codable {
|
||||
var schemaVersion: Int
|
||||
var entries: [ServerEntry]
|
||||
}
|
||||
|
||||
/// App-scoped store for user-added servers. `local` is synthesized (not
|
||||
/// persisted) and always appears first in `allContexts`. Remote entries are
|
||||
/// loaded from `~/Library/Application Support/scarf/servers.json`.
|
||||
///
|
||||
/// Observable so SwiftUI views binding to `entries` redraw when a server is
|
||||
/// added, renamed, or removed.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ServerRegistry {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ServerRegistry")
|
||||
private static let currentSchemaVersion = 1
|
||||
|
||||
/// Remote (user-added) entries. Observable: views redraw on mutation.
|
||||
private(set) var entries: [ServerEntry] = []
|
||||
|
||||
private let storeURL: URL
|
||||
|
||||
init() {
|
||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support")
|
||||
let dir = support.appendingPathComponent("scarf", isDirectory: true)
|
||||
self.storeURL = dir.appendingPathComponent("servers.json")
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Lookup
|
||||
|
||||
/// The implicit local server plus every persisted remote entry, in list
|
||||
/// order. Use this when populating UI like the toolbar switcher.
|
||||
var allContexts: [ServerContext] {
|
||||
[.local] + entries.map { $0.context }
|
||||
}
|
||||
|
||||
/// Resolve an ID to a context, or `nil` if the entry no longer exists.
|
||||
/// Used by the multi-window root to detect "this window points at a
|
||||
/// server you've since removed" and show a dedicated empty state.
|
||||
func context(for id: ServerID) -> ServerContext? {
|
||||
if id == ServerContext.local.id { return .local }
|
||||
if let entry = entries.first(where: { $0.id == id }) {
|
||||
return entry.context
|
||||
}
|
||||
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
|
||||
|
||||
/// Optional callback fired whenever `entries` changes. The app wires
|
||||
/// this to `ServerLiveStatusRegistry.rebuild()` so the menu-bar fanout
|
||||
/// stays in sync without polling the entries array.
|
||||
var onEntriesChanged: (() -> Void)?
|
||||
|
||||
@discardableResult
|
||||
func addServer(displayName: String, config: SSHConfig) -> ServerEntry {
|
||||
let entry = ServerEntry(
|
||||
id: ServerID(),
|
||||
displayName: displayName,
|
||||
kind: .ssh(config)
|
||||
)
|
||||
entries.append(entry)
|
||||
save()
|
||||
onEntriesChanged?()
|
||||
return entry
|
||||
}
|
||||
|
||||
func updateServer(_ id: ServerID, displayName: String?, config: SSHConfig?) {
|
||||
guard let idx = entries.firstIndex(where: { $0.id == id }) else { return }
|
||||
if let name = displayName { entries[idx].displayName = name }
|
||||
if let cfg = config { entries[idx].kind = .ssh(cfg) }
|
||||
save()
|
||||
onEntriesChanged?()
|
||||
}
|
||||
|
||||
func removeServer(_ id: ServerID) {
|
||||
// Grab the entry BEFORE removing it so we can tear down its transport
|
||||
// state. Without this the user would leak a ControlMaster socket
|
||||
// (~10min TTL) and a snapshot cache dir (indefinite) per removed
|
||||
// server — harmless individually, ugly at scale.
|
||||
let removed = entries.first { $0.id == id }
|
||||
entries.removeAll { $0.id == id }
|
||||
save()
|
||||
|
||||
if let removed, case .ssh(let config) = removed.kind {
|
||||
let transport = SSHTransport(contextID: id, config: config, displayName: removed.displayName)
|
||||
transport.closeControlMaster()
|
||||
}
|
||||
SSHTransport.pruneSnapshotCache(for: id)
|
||||
// Drop process-wide cache entries keyed on this ServerID so a future
|
||||
// re-add with a colliding ID (theoretical — UUIDs are random, but be
|
||||
// defensive) doesn't serve stale data.
|
||||
Task.detached { await ServerContext.invalidateCaches(for: id) }
|
||||
|
||||
onEntriesChanged?()
|
||||
}
|
||||
|
||||
// MARK: - App-launch sweep
|
||||
|
||||
/// Remove snapshot cache directories whose UUID isn't in the current
|
||||
/// registry. Handles the case where the user removed a server while the
|
||||
/// app was closed — we want the cache to converge to the registry's
|
||||
/// state at launch rather than carrying forever.
|
||||
func sweepOrphanCaches() {
|
||||
var keep: Set<ServerID> = [ServerContext.local.id]
|
||||
for entry in entries { keep.insert(entry.id) }
|
||||
SSHTransport.sweepOrphanSnapshots(keeping: keep)
|
||||
SSHTransport.sweepStaleControlSockets()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func load() {
|
||||
guard FileManager.default.fileExists(atPath: storeURL.path) else {
|
||||
entries = []
|
||||
return
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: storeURL)
|
||||
let file = try JSONDecoder().decode(RegistryFile.self, from: data)
|
||||
entries = file.entries
|
||||
} catch {
|
||||
Self.logger.error("Failed to load servers.json: \(error.localizedDescription)")
|
||||
entries = []
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: storeURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let file = RegistryFile(schemaVersion: Self.currentSchemaVersion, entries: entries)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
try data.write(to: storeURL, options: .atomic)
|
||||
} catch {
|
||||
Self.logger.error("Failed to save servers.json: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,35 @@ actor ACPClient {
|
||||
private(set) var currentSessionId: String?
|
||||
private(set) var statusMessage = ""
|
||||
|
||||
let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Ring buffer of recent stderr lines from `hermes acp` — used to attach
|
||||
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded
|
||||
/// growth when the subprocess logs heavily.
|
||||
private var stderrBuffer: [String] = []
|
||||
private static let stderrBufferMaxLines = 50
|
||||
|
||||
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the
|
||||
/// `hermes acp` subprocess, joined by newlines.
|
||||
var recentStderr: String {
|
||||
stderrBuffer.joined(separator: "\n")
|
||||
}
|
||||
|
||||
fileprivate func appendStderr(_ text: String) {
|
||||
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
stderrBuffer.append(String(line))
|
||||
}
|
||||
if stderrBuffer.count > Self.stderrBufferMaxLines {
|
||||
stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the underlying process is still alive and connected.
|
||||
var isHealthy: Bool {
|
||||
guard isConnected, let process else { return false }
|
||||
@@ -54,9 +83,15 @@ actor ACPClient {
|
||||
self._eventStream = stream
|
||||
self.eventContinuation = continuation
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
proc.arguments = ["acp"]
|
||||
// For local: Process is `hermes acp` directly.
|
||||
// For remote: the transport returns a Process configured as
|
||||
// `/usr/bin/ssh -T <opts> host -- <hermes> acp`. ACP's JSON-RPC
|
||||
// over stdio works identically because `-T` keeps the ssh channel
|
||||
// byte-clean and stdin/stdout travel end-to-end unmodified.
|
||||
let proc = transport.makeProcess(
|
||||
executable: context.paths.hermesBinary,
|
||||
args: ["acp"]
|
||||
)
|
||||
|
||||
let stdin = Pipe()
|
||||
let stdout = Pipe()
|
||||
@@ -67,11 +102,28 @@ actor ACPClient {
|
||||
proc.standardError = stderr
|
||||
|
||||
// ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution.
|
||||
// Use the enriched environment so any tools hermes spawns (MCP servers,
|
||||
// shell commands) can find brew/nvm/asdf binaries on PATH.
|
||||
var env = HermesFileService.enrichedEnvironment()
|
||||
env.removeValue(forKey: "TERM")
|
||||
proc.environment = env
|
||||
if context.isRemote {
|
||||
// Remote: this is the LOCAL ssh process spawning `ssh host …
|
||||
// hermes acp`. We don't forward our local PATH/credentials to
|
||||
// the remote (hermes runs under the remote user's login env),
|
||||
// but the ssh binary itself needs SSH_AUTH_SOCK to reach the
|
||||
// local ssh-agent for key-based auth.
|
||||
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
|
||||
}
|
||||
}
|
||||
env.removeValue(forKey: "TERM")
|
||||
proc.environment = env
|
||||
} else {
|
||||
// Local: enriched env so any tools hermes spawns (MCP servers,
|
||||
// shell commands) can find brew/nvm/asdf binaries on PATH.
|
||||
var env = HermesFileService.enrichedEnvironment()
|
||||
env.removeValue(forKey: "TERM")
|
||||
proc.environment = env
|
||||
}
|
||||
|
||||
proc.terminationHandler = { [weak self] proc in
|
||||
Task { await self?.handleTermination(exitCode: proc.terminationStatus) }
|
||||
@@ -384,21 +436,22 @@ actor ACPClient {
|
||||
guard !lineData.isEmpty else { continue }
|
||||
|
||||
if let lineStr = String(data: lineData, encoding: .utf8) {
|
||||
await self?.logger.debug("ACP recv: \(lineStr.prefix(200))")
|
||||
self?.logger.debug("ACP recv: \(lineStr.prefix(200))")
|
||||
}
|
||||
|
||||
do {
|
||||
let message = try JSONDecoder().decode(ACPRawMessage.self, from: lineData)
|
||||
await self?.handleMessage(message)
|
||||
} catch {
|
||||
await self?.logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
|
||||
self?.logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
await self?.handleReadLoopEnded()
|
||||
}
|
||||
|
||||
// Read stderr in background for diagnostic logging
|
||||
// Read stderr in background for diagnostic logging AND ring-buffer
|
||||
// capture so we can attach a tail to user-visible errors.
|
||||
stderrTask = Task.detached { [weak self] in
|
||||
let handle = stderr.fileHandleForReading
|
||||
while !Task.isCancelled {
|
||||
@@ -406,7 +459,8 @@ actor ACPClient {
|
||||
if data.isEmpty { break }
|
||||
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
await self?.logger.info("ACP stderr: \(text.prefix(500))")
|
||||
self?.logger.info("ACP stderr: \(text.prefix(500))")
|
||||
await self?.appendStderr(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,3 +570,35 @@ enum ACPClientError: Error, LocalizedError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a raw error message (RPC message or captured stderr) to a short
|
||||
/// human-readable hint for the chat UI. Pattern-matches the most common
|
||||
/// fresh-install failure modes. Returns nil when no known pattern matches.
|
||||
enum ACPErrorHint {
|
||||
static func classify(errorMessage: String, stderrTail: String) -> String? {
|
||||
let haystack = errorMessage + "\n" + stderrTail
|
||||
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
|
||||
options: .regularExpression) != nil
|
||||
|| haystack.contains("ANTHROPIC_API_KEY")
|
||||
|| haystack.contains("ANTHROPIC_TOKEN")
|
||||
|| haystack.contains("claude setup-token")
|
||||
|| haystack.contains("claude /login") {
|
||||
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
|
||||
}
|
||||
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
||||
options: .regularExpression) {
|
||||
let matched = String(haystack[match])
|
||||
if let nameStart = matched.range(of: "'"),
|
||||
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
|
||||
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
|
||||
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
|
||||
}
|
||||
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
|
||||
}
|
||||
if haystack.localizedCaseInsensitiveContains("rate limit")
|
||||
|| haystack.localizedCaseInsensitiveContains("429") {
|
||||
return "Your AI provider returned a rate-limit error. Try again in a moment."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,151 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
import os
|
||||
|
||||
/// Dedupes concurrent `snapshotSQLite` calls for the same server. When the
|
||||
/// file watcher ticks, Dashboard + Sessions + Activity (+ Chat's loadHistory)
|
||||
/// can all ask for a fresh snapshot within the same millisecond — without
|
||||
/// coordination they each spawn their own `ssh host sqlite3 .backup; scp`
|
||||
/// round-trip, three parallel backups of the same DB. Callers in flight for
|
||||
/// the same `ServerID` await the first caller's Task and share its result.
|
||||
actor SnapshotCoordinator {
|
||||
static let shared = SnapshotCoordinator()
|
||||
private var inFlight: [ServerID: Task<URL, Error>] = [:]
|
||||
|
||||
func snapshot(
|
||||
remotePath: String,
|
||||
contextID: ServerID,
|
||||
transport: any ServerTransport
|
||||
) async throws -> URL {
|
||||
if let existing = inFlight[contextID] {
|
||||
return try await existing.value
|
||||
}
|
||||
let task = Task<URL, Error> {
|
||||
try transport.snapshotSQLite(remotePath: remotePath)
|
||||
}
|
||||
inFlight[contextID] = task
|
||||
defer { inFlight[contextID] = nil }
|
||||
return try await task.value
|
||||
}
|
||||
}
|
||||
|
||||
actor HermesDataService {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "HermesDataService")
|
||||
|
||||
private var db: OpaquePointer?
|
||||
private var hasV07Schema = false
|
||||
/// Local filesystem path we last opened. For remote contexts this is
|
||||
/// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`.
|
||||
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?
|
||||
|
||||
func open() -> Bool {
|
||||
let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
func open() async -> Bool {
|
||||
if db != nil { return true }
|
||||
let path = HermesPaths.stateDB
|
||||
guard FileManager.default.fileExists(atPath: path) else { return false }
|
||||
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
||||
let result = sqlite3_open_v2(path, &db, flags, nil)
|
||||
let localPath: String
|
||||
if context.isRemote {
|
||||
// Pull a fresh snapshot from the remote host. Uses `sqlite3
|
||||
// .backup` on the remote, which is WAL-safe; a plain cp would
|
||||
// corrupt. Routed through SnapshotCoordinator so concurrent
|
||||
// view models don't each spawn a parallel SSH backup for the
|
||||
// same server.
|
||||
do {
|
||||
let url = try await SnapshotCoordinator.shared.snapshot(
|
||||
remotePath: context.paths.stateDB,
|
||||
contextID: context.id,
|
||||
transport: transport
|
||||
)
|
||||
localPath = url.path
|
||||
lastOpenError = nil
|
||||
} catch {
|
||||
lastOpenError = humanize(error)
|
||||
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
localPath = context.paths.stateDB
|
||||
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;
|
||||
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
|
||||
// locking entirely, which is both faster and avoids spurious
|
||||
// "unable to open database file" errors if the snapshot ever gets
|
||||
// pulled mid-checkpoint. Local points at the live Hermes DB where
|
||||
// the process already has WAL enabled in the header, so a plain
|
||||
// readonly open is the right thing.
|
||||
let flags: Int32
|
||||
let openPath: String
|
||||
if context.isRemote {
|
||||
openPath = "file:\(localPath)?immutable=1"
|
||||
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_URI
|
||||
} else {
|
||||
openPath = localPath
|
||||
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
||||
}
|
||||
let result = sqlite3_open_v2(openPath, &db, flags, nil)
|
||||
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
|
||||
return false
|
||||
}
|
||||
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
||||
openedAtPath = localPath
|
||||
lastOpenError = nil
|
||||
detectSchema()
|
||||
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
|
||||
/// any path that needs the UI to reflect writes Hermes just made.
|
||||
/// Without this, remote snapshots would be frozen at the first `open()`
|
||||
/// for the app's lifetime — new messages added to a resumed session
|
||||
/// would never appear because the snapshot was pulled before they were
|
||||
/// written. Local contexts pay essentially nothing: close+reopen on a
|
||||
/// live DB is a no-op.
|
||||
@discardableResult
|
||||
func refresh() async -> Bool {
|
||||
close()
|
||||
return await open()
|
||||
}
|
||||
|
||||
func close() {
|
||||
if let db {
|
||||
sqlite3_close(db)
|
||||
@@ -431,11 +557,10 @@ actor HermesDataService {
|
||||
}
|
||||
|
||||
func stateDBModificationDate() -> Date? {
|
||||
let walPath = HermesPaths.stateDB + "-wal"
|
||||
let dbPath = HermesPaths.stateDB
|
||||
let fm = FileManager.default
|
||||
let walDate = (try? fm.attributesOfItem(atPath: walPath))?[.modificationDate] as? Date
|
||||
let dbDate = (try? fm.attributesOfItem(atPath: dbPath))?[.modificationDate] as? Date
|
||||
// For remote contexts we stat the remote paths. For local it's the
|
||||
// same FileManager lookup as before, just via the transport.
|
||||
let walDate = transport.stat(context.paths.stateDB + "-wal")?.mtime
|
||||
let dbDate = transport.stat(context.paths.stateDB)?.mtime
|
||||
if let w = walDate, let d = dbDate {
|
||||
return max(w, d)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the
|
||||
/// ordering of keys we don't touch.
|
||||
///
|
||||
/// Hermes treats `.env` as a traditional dotenv file: `KEY=value`, `#` comments,
|
||||
/// and optional double-quoted values for strings with spaces or special chars.
|
||||
/// We do NOT attempt to implement full shell-style escaping; the fields we write
|
||||
/// from the GUI are bot tokens, user IDs, URLs, and on/off flags — none of which
|
||||
/// contain characters needing escaping beyond double-quoting.
|
||||
///
|
||||
/// Design choices:
|
||||
/// - **Non-destructive "unset"**: clearing a field comments the line out rather
|
||||
/// than deleting it, so users can restore a key by uncommenting without losing
|
||||
/// their value.
|
||||
/// - **Atomic write**: write to `.env.tmp`, then rename. Avoids a partially
|
||||
/// written file if Scarf crashes mid-write.
|
||||
/// - **Never logs values**: secrets flow through this service.
|
||||
struct HermesEnvService: Sendable {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "HermesEnvService")
|
||||
|
||||
/// Path to `~/.hermes/.env`. Kept configurable for tests.
|
||||
let path: String
|
||||
let transport: any ServerTransport
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.path = context.paths.envFile
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Escape hatch for tests that want to point at a fixture path directly.
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
self.transport = LocalTransport()
|
||||
}
|
||||
|
||||
/// Read the .env file into a `[key: value]` dict. Comments and commented-out
|
||||
/// assignments are ignored. Missing file returns an empty dict.
|
||||
func load() -> [String: String] {
|
||||
guard let data = try? transport.readFile(path),
|
||||
let content = String(data: data, encoding: .utf8) else {
|
||||
return [:]
|
||||
}
|
||||
var result: [String: String] = [:]
|
||||
for line in content.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
// Skip blanks and comments. A line beginning with `#` is either a pure
|
||||
// comment or a disabled assignment — both should be treated as "unset".
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
guard let eq = trimmed.firstIndex(of: "=") else { continue }
|
||||
let key = String(trimmed[trimmed.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
|
||||
let raw = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
|
||||
result[key] = Self.stripEnvQuotes(raw)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func get(_ key: String) -> String? {
|
||||
load()[key]
|
||||
}
|
||||
|
||||
/// Write/update a single key. Preserves the position of existing assignments
|
||||
/// (even if they were commented out — the new assignment replaces the comment
|
||||
/// line in place). New keys are appended at the end.
|
||||
@discardableResult
|
||||
func set(_ key: String, value: String) -> Bool {
|
||||
setMany([key: value])
|
||||
}
|
||||
|
||||
/// Update multiple keys in one atomic rewrite. Use this when a form saves
|
||||
/// several fields at once so the file doesn't get repeatedly rewritten.
|
||||
///
|
||||
/// Returns `true` on success, `false` if the atomic rewrite failed.
|
||||
@discardableResult
|
||||
func setMany(_ pairs: [String: String]) -> Bool {
|
||||
var remaining = pairs
|
||||
var lines: [String]
|
||||
|
||||
// Start from existing file contents, or a minimal header if creating new.
|
||||
if let data = try? transport.readFile(path),
|
||||
let content = String(data: data, encoding: .utf8) {
|
||||
lines = content.components(separatedBy: "\n")
|
||||
// Trim a single trailing empty line from splitting the final newline;
|
||||
// we'll re-add it on write.
|
||||
if lines.last == "" { lines.removeLast() }
|
||||
} else {
|
||||
lines = ["# Hermes Agent Environment Configuration"]
|
||||
}
|
||||
|
||||
// First pass: update in-place (handles both live and commented-out lines).
|
||||
for (idx, line) in lines.enumerated() {
|
||||
guard let match = Self.extractKey(fromLine: line) else { continue }
|
||||
if let newValue = remaining.removeValue(forKey: match.key) {
|
||||
// A commented-out `# KEY=...` becomes a live `KEY=...` with the new value.
|
||||
lines[idx] = Self.formatLine(key: match.key, value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: append any keys that didn't match an existing line.
|
||||
if !remaining.isEmpty {
|
||||
// Leave a blank line before appending new keys for visual separation.
|
||||
if let last = lines.last, !last.isEmpty {
|
||||
lines.append("")
|
||||
}
|
||||
for key in remaining.keys.sorted() {
|
||||
lines.append(Self.formatLine(key: key, value: remaining[key]!))
|
||||
}
|
||||
}
|
||||
|
||||
return atomicWrite(lines.joined(separator: "\n") + "\n")
|
||||
}
|
||||
|
||||
/// Comment out a key. The value is preserved so the user can restore by
|
||||
/// uncommenting. If the key doesn't exist, this is a no-op.
|
||||
@discardableResult
|
||||
func unset(_ key: String) -> Bool {
|
||||
guard let data = try? transport.readFile(path),
|
||||
let content = String(data: data, encoding: .utf8) else {
|
||||
return true
|
||||
}
|
||||
var lines = content.components(separatedBy: "\n")
|
||||
if lines.last == "" { lines.removeLast() }
|
||||
|
||||
var changed = false
|
||||
for (idx, line) in lines.enumerated() {
|
||||
guard let match = Self.extractKey(fromLine: line), match.key == key else { continue }
|
||||
// Skip lines that are already commented — nothing to do.
|
||||
if Self.isCommentedOutAssignment(line) { continue }
|
||||
lines[idx] = "# " + line
|
||||
changed = true
|
||||
}
|
||||
guard changed else { return true }
|
||||
return atomicWrite(lines.joined(separator: "\n") + "\n")
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Writes the entire file in one shot through the transport. For local
|
||||
/// contexts this ends up doing the same atomic-rename dance as before
|
||||
/// (via `LocalTransport.writeFile`). For remote contexts this goes
|
||||
/// through `scp` + remote `mv`, still atomic from Hermes's point of
|
||||
/// view.
|
||||
private func atomicWrite(_ content: String) -> Bool {
|
||||
guard let data = content.data(using: .utf8) else { return false }
|
||||
do {
|
||||
try transport.writeFile(path, data: data)
|
||||
return true
|
||||
} catch {
|
||||
logger.error("Failed to write .env: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a key name and whether the line was active or commented-out.
|
||||
/// Accepts both `KEY=value` and `# KEY=value` (any amount of whitespace after `#`).
|
||||
private static func extractKey(fromLine line: String) -> (key: String, active: Bool)? {
|
||||
var work = line.trimmingCharacters(in: .whitespaces)
|
||||
var active = true
|
||||
if work.hasPrefix("#") {
|
||||
active = false
|
||||
work = String(work.dropFirst()).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
guard let eq = work.firstIndex(of: "=") else { return nil }
|
||||
let key = String(work[work.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
|
||||
// Reject non-identifier looking keys to avoid matching prose in comments
|
||||
// (e.g. "# This is a note about something = nice").
|
||||
guard key.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else {
|
||||
return nil
|
||||
}
|
||||
return (key, active)
|
||||
}
|
||||
|
||||
private static func isCommentedOutAssignment(_ line: String) -> Bool {
|
||||
guard let match = extractKey(fromLine: line) else { return false }
|
||||
return !match.active
|
||||
}
|
||||
|
||||
/// Format a single `KEY=value` line. Values containing whitespace or shell
|
||||
/// metacharacters get double-quoted; simple tokens go in unquoted to match
|
||||
/// hermes's own output style.
|
||||
private static func formatLine(key: String, value: String) -> String {
|
||||
if Self.needsQuoting(value) {
|
||||
// Escape embedded backslashes and double quotes, then wrap.
|
||||
let escaped = value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\(key)=\"\(escaped)\""
|
||||
}
|
||||
return "\(key)=\(value)"
|
||||
}
|
||||
|
||||
private static func needsQuoting(_ value: String) -> Bool {
|
||||
if value.isEmpty { return false }
|
||||
// Whitespace, shell metacharacters, or quotes trigger quoting.
|
||||
let metacharacters: Set<Character> = [" ", "\t", "#", "$", "`", "\"", "'", "\\", "(", ")", "{", "}", "[", "]", "|", "&", ";", "<", ">", "*", "?"]
|
||||
return value.contains(where: { metacharacters.contains($0) })
|
||||
}
|
||||
|
||||
/// Strip one layer of matched double or single quotes from a loaded value.
|
||||
private static func stripEnvQuotes(_ s: String) -> String {
|
||||
guard s.count >= 2 else { return s }
|
||||
let first = s.first!
|
||||
let last = s.last!
|
||||
if (first == "\"" && last == "\"") || (first == "'" && last == "'") {
|
||||
var inner = String(s.dropFirst().dropLast())
|
||||
if first == "\"" {
|
||||
inner = inner
|
||||
.replacingOccurrences(of: "\\\"", with: "\"")
|
||||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||||
}
|
||||
return inner
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,32 +6,66 @@ final class HermesFileWatcher {
|
||||
private var coreSources: [DispatchSourceFileSystemObject] = []
|
||||
private var projectSources: [DispatchSourceFileSystemObject] = []
|
||||
private var timer: Timer?
|
||||
/// Remote polling task. Non-nil only when `context.isRemote`. Cancelled
|
||||
/// on `stopWatching()`.
|
||||
private var remotePollTask: Task<Void, Never>?
|
||||
|
||||
let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Canonical list of paths we observe. Used for both FSEvents (local)
|
||||
/// and mtime polling (remote).
|
||||
private var watchedCorePaths: [String] {
|
||||
let paths = context.paths
|
||||
return [
|
||||
paths.stateDB,
|
||||
paths.stateDB + "-wal",
|
||||
paths.configYAML,
|
||||
paths.home + "/.env",
|
||||
paths.memoryMD,
|
||||
paths.userMD,
|
||||
paths.cronJobsJSON,
|
||||
paths.gatewayStateJSON,
|
||||
paths.agentLog,
|
||||
paths.errorsLog,
|
||||
paths.gatewayLog,
|
||||
paths.projectsRegistry,
|
||||
paths.mcpTokensDir
|
||||
]
|
||||
}
|
||||
|
||||
func startWatching() {
|
||||
let paths = [
|
||||
HermesPaths.stateDB,
|
||||
HermesPaths.stateDB + "-wal",
|
||||
HermesPaths.configYAML,
|
||||
HermesPaths.memoryMD,
|
||||
HermesPaths.userMD,
|
||||
HermesPaths.cronJobsJSON,
|
||||
HermesPaths.gatewayStateJSON,
|
||||
HermesPaths.agentLog,
|
||||
HermesPaths.errorsLog,
|
||||
HermesPaths.gatewayLog,
|
||||
HermesPaths.projectsRegistry,
|
||||
HermesPaths.mcpTokensDir
|
||||
]
|
||||
if context.isRemote {
|
||||
// FSEvents doesn't reach across SSH. Drive lastChangeDate off
|
||||
// the transport's AsyncStream, which polls stat mtime on a
|
||||
// shared ControlMaster channel (~5ms per tick).
|
||||
let stream = transport.watchPaths(watchedCorePaths)
|
||||
remotePollTask = Task { [weak self] in
|
||||
for await _ in stream {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.lastChangeDate = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for path in paths {
|
||||
for path in watchedCorePaths {
|
||||
if let source = makeSource(for: path) {
|
||||
coreSources.append(source)
|
||||
}
|
||||
}
|
||||
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||
self?.lastChangeDate = Date()
|
||||
}
|
||||
// No heartbeat timer: every observing view runs its `.onChange`
|
||||
// refresh whenever `lastChangeDate` ticks, so a 5s unconditional
|
||||
// tick was triggering wasted reloads across many subscribers
|
||||
// (Dashboard, Memory, Cron, Gateway, Platforms, Projects, Chat).
|
||||
// FSEvents reliably fires on real changes; menu-bar Start/Stop
|
||||
// touches `gateway_state.json` which the watcher catches.
|
||||
}
|
||||
|
||||
func stopWatching() {
|
||||
@@ -42,9 +76,15 @@ final class HermesFileWatcher {
|
||||
projectSources.removeAll()
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
remotePollTask?.cancel()
|
||||
remotePollTask = nil
|
||||
}
|
||||
|
||||
func updateProjectWatches(_ dashboardPaths: [String]) {
|
||||
// Remote contexts don't support per-project FSEvents watches today —
|
||||
// the shared mtime poll covers the core set. Adding per-project
|
||||
// polling is a Phase 4 polish item.
|
||||
guard !context.isRemote else { return }
|
||||
for source in projectSources {
|
||||
source.cancel()
|
||||
}
|
||||
|
||||
@@ -33,10 +33,46 @@ actor HermesLogService {
|
||||
private var currentPath: String?
|
||||
private var entryCounter = 0
|
||||
|
||||
/// Remote tailing state. When set, we're reading from `ssh host tail -F`
|
||||
/// instead of a local file. Process stdout pipe drives `readNewLines()`;
|
||||
/// process lifecycle is the actor's responsibility.
|
||||
private var remoteTailProcess: Process?
|
||||
private var remoteTailBuffer: String = ""
|
||||
|
||||
let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
func openLog(path: String) {
|
||||
closeLog()
|
||||
currentPath = path
|
||||
fileHandle = FileHandle(forReadingAtPath: path)
|
||||
if context.isRemote {
|
||||
// Spawn `ssh host tail -F` and pipe stdout into our buffer. `-F`
|
||||
// follows the file through rotations — important for remote
|
||||
// log rotation setups (logrotate).
|
||||
let proc = transport.makeProcess(
|
||||
executable: "/usr/bin/tail",
|
||||
args: ["-n", String(QueryDefaults.logLineLimit), "-F", path]
|
||||
)
|
||||
let outPipe = Pipe()
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
remoteTailProcess = proc
|
||||
fileHandle = outPipe.fileHandleForReading
|
||||
} catch {
|
||||
print("[Scarf] Failed to start remote tail: \(error.localizedDescription)")
|
||||
remoteTailProcess = nil
|
||||
fileHandle = nil
|
||||
}
|
||||
} else {
|
||||
fileHandle = FileHandle(forReadingAtPath: path)
|
||||
}
|
||||
}
|
||||
|
||||
func closeLog() {
|
||||
@@ -47,11 +83,29 @@ actor HermesLogService {
|
||||
}
|
||||
fileHandle = nil
|
||||
currentPath = nil
|
||||
if let proc = remoteTailProcess, proc.isRunning {
|
||||
proc.terminate()
|
||||
}
|
||||
remoteTailProcess = nil
|
||||
remoteTailBuffer = ""
|
||||
}
|
||||
|
||||
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||
guard let path = currentPath,
|
||||
let data = FileManager.default.contents(atPath: path) else { return [] }
|
||||
guard let path = currentPath else { return [] }
|
||||
if context.isRemote {
|
||||
// For the initial load we bypass the streaming tail and run a
|
||||
// one-shot `tail -n <count>` for a clean bounded read.
|
||||
let result = try? transport.runProcess(
|
||||
executable: "/usr/bin/tail",
|
||||
args: ["-n", String(count), path],
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
let content = result?.stdoutString ?? ""
|
||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.map { parseLine($0) }
|
||||
}
|
||||
guard let data = FileManager.default.contents(atPath: path) else { return [] }
|
||||
let content = String(data: data, encoding: .utf8) ?? ""
|
||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let lastLines = Array(lines.suffix(count))
|
||||
@@ -62,13 +116,29 @@ actor HermesLogService {
|
||||
guard let handle = fileHandle else { return [] }
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return [] }
|
||||
let content = String(data: data, encoding: .utf8) ?? ""
|
||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let chunk = String(data: data, encoding: .utf8) ?? ""
|
||||
if context.isRemote {
|
||||
// Remote tail emits bytes as they arrive — not line-aligned.
|
||||
// Buffer partials across reads so we don't split a line mid-way.
|
||||
remoteTailBuffer += chunk
|
||||
guard let lastNewline = remoteTailBuffer.lastIndex(of: "\n") else {
|
||||
return []
|
||||
}
|
||||
let complete = String(remoteTailBuffer[..<lastNewline])
|
||||
remoteTailBuffer = String(remoteTailBuffer[remoteTailBuffer.index(after: lastNewline)...])
|
||||
let lines = complete.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.map { parseLine($0) }
|
||||
}
|
||||
let lines = chunk.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.map { parseLine($0) }
|
||||
}
|
||||
|
||||
func seekToEnd() {
|
||||
fileHandle?.seekToEndOfFile()
|
||||
// Only meaningful for local FileHandles — remote tail starts at the
|
||||
// end implicitly after `readLastLines` drained the initial load.
|
||||
if !context.isRemote {
|
||||
fileHandle?.seekToEndOfFile()
|
||||
}
|
||||
}
|
||||
|
||||
private func parseLine(_ line: String) -> LogEntry {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// A single model from the models.dev catalog shipped with hermes.
|
||||
struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
||||
var id: String { providerID + ":" + modelID }
|
||||
|
||||
let providerID: String
|
||||
let providerName: String
|
||||
let modelID: String
|
||||
let modelName: String
|
||||
let contextWindow: Int?
|
||||
let maxOutput: Int?
|
||||
let costInput: Double? // USD per 1M input tokens
|
||||
let costOutput: Double? // USD per 1M output tokens
|
||||
let reasoning: Bool
|
||||
let toolCall: Bool
|
||||
let releaseDate: String?
|
||||
|
||||
/// Display-friendly cost string, or nil if cost is unknown.
|
||||
var costDisplay: String? {
|
||||
guard let input = costInput, let output = costOutput else { return nil }
|
||||
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.).
|
||||
var contextDisplay: String? {
|
||||
guard let ctx = contextWindow else { return nil }
|
||||
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
|
||||
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
|
||||
return "\(ctx)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider summary — one row in the left column of the picker.
|
||||
struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
||||
var id: String { providerID }
|
||||
|
||||
let providerID: String
|
||||
let providerName: String
|
||||
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
||||
let docURL: String?
|
||||
let modelCount: Int
|
||||
}
|
||||
|
||||
/// Reads the models.dev catalog that hermes caches at
|
||||
/// `~/.hermes/models_dev_cache.json`. Offline-capable, fast enough to read per
|
||||
/// call (~1500 models across ~110 providers).
|
||||
///
|
||||
/// We decode a trimmed subset so unknown fields don't break loading. Every
|
||||
/// field we care about is optional on disk — providers may omit cost, context
|
||||
/// limits, etc.
|
||||
struct ModelCatalogService: Sendable {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
|
||||
let path: String
|
||||
let transport: any ServerTransport
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.path = context.paths.home + "/models_dev_cache.json"
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Escape hatch for tests.
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
self.transport = LocalTransport()
|
||||
}
|
||||
|
||||
/// All providers, sorted by display name.
|
||||
func loadProviders() -> [HermesProviderInfo] {
|
||||
guard let catalog = loadCatalog() else { return [] }
|
||||
return catalog
|
||||
.map { (id, p) in
|
||||
HermesProviderInfo(
|
||||
providerID: id,
|
||||
providerName: p.name ?? id,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Models for one provider, sorted by release date (newest first), then name.
|
||||
func loadModels(for providerID: String) -> [HermesModelInfo] {
|
||||
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
||||
let providerName = provider.name ?? providerID
|
||||
let models = (provider.models ?? [:]).map { (id, m) in
|
||||
HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: providerName,
|
||||
modelID: id,
|
||||
modelName: m.name ?? id,
|
||||
contextWindow: m.limit?.context,
|
||||
maxOutput: m.limit?.output,
|
||||
costInput: m.cost?.input,
|
||||
costOutput: m.cost?.output,
|
||||
reasoning: m.reasoning ?? false,
|
||||
toolCall: m.tool_call ?? false,
|
||||
releaseDate: m.release_date
|
||||
)
|
||||
}
|
||||
return models.sorted { lhs, rhs in
|
||||
// Newest-first by release date if both are known; otherwise fall
|
||||
// back to alphabetical on display name.
|
||||
if let lDate = lhs.releaseDate, let rDate = rhs.releaseDate, lDate != rDate {
|
||||
return lDate > rDate
|
||||
}
|
||||
return lhs.modelName.localizedCaseInsensitiveCompare(rhs.modelName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the provider that ships a given model ID. Useful for auto-syncing
|
||||
/// provider when the user picks a model from a flat list or types one in.
|
||||
func provider(for modelID: String) -> HermesProviderInfo? {
|
||||
guard let catalog = loadCatalog() else { return nil }
|
||||
for (providerID, p) in catalog {
|
||||
if p.models?[modelID] != nil {
|
||||
return HermesProviderInfo(
|
||||
providerID: providerID,
|
||||
providerName: p.name ?? providerID,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
// Handle provider-prefixed IDs like "openai/gpt-4o" — look up the
|
||||
// prefix before the slash.
|
||||
if let slash = modelID.firstIndex(of: "/") {
|
||||
let prefix = String(modelID[modelID.startIndex..<slash])
|
||||
if let p = catalog[prefix] {
|
||||
return HermesProviderInfo(
|
||||
providerID: prefix,
|
||||
providerName: p.name ?? prefix,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Look up a specific model by provider + ID. Returns nil if not in the
|
||||
/// catalog (e.g., free-typed custom model).
|
||||
func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
||||
guard let catalog = loadCatalog(),
|
||||
let provider = catalog[providerID],
|
||||
let raw = provider.models?[modelID] else { return nil }
|
||||
return HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: provider.name ?? providerID,
|
||||
modelID: modelID,
|
||||
modelName: raw.name ?? modelID,
|
||||
contextWindow: raw.limit?.context,
|
||||
maxOutput: raw.limit?.output,
|
||||
costInput: raw.cost?.input,
|
||||
costOutput: raw.cost?.output,
|
||||
reasoning: raw.reasoning ?? false,
|
||||
toolCall: raw.tool_call ?? false,
|
||||
releaseDate: raw.release_date
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
private func loadCatalog() -> [String: ProviderEntry]? {
|
||||
guard let data = try? transport.readFile(path) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
|
||||
} catch {
|
||||
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Trimmed representations — we decode a subset of fields and tolerate
|
||||
// anything new hermes adds later. `snake_case` field names match the file.
|
||||
private struct ProviderEntry: Decodable {
|
||||
let id: String?
|
||||
let name: String?
|
||||
let env: [String]?
|
||||
let doc: String?
|
||||
let models: [String: ModelEntry]?
|
||||
}
|
||||
|
||||
private struct ModelEntry: Decodable {
|
||||
let name: String?
|
||||
let reasoning: Bool?
|
||||
let tool_call: Bool?
|
||||
let release_date: String?
|
||||
let cost: CostEntry?
|
||||
let limit: LimitEntry?
|
||||
}
|
||||
|
||||
private struct CostEntry: Decodable {
|
||||
let input: Double?
|
||||
let output: Double?
|
||||
}
|
||||
|
||||
private struct LimitEntry: Decodable {
|
||||
let context: Int?
|
||||
let output: Int?
|
||||
}
|
||||
}
|
||||
@@ -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,45 +1,62 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct ProjectDashboardService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
||||
|
||||
let context: ServerContext
|
||||
let transport: any ServerTransport
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
// MARK: - Registry
|
||||
|
||||
func loadRegistry() -> ProjectRegistry {
|
||||
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
|
||||
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||
} 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: [])
|
||||
}
|
||||
}
|
||||
|
||||
func saveRegistry(_ registry: ProjectRegistry) {
|
||||
let dir = HermesPaths.scarfDir
|
||||
if !FileManager.default.fileExists(atPath: dir) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
/// 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
|
||||
if !transport.fileExists(dir) {
|
||||
try transport.createDirectory(dir)
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(registry) else { return }
|
||||
// Pretty-print for readability (agents may read this file)
|
||||
let data = try JSONEncoder().encode(registry)
|
||||
// Pretty-print for readability (agents may read this file).
|
||||
let writeData: Data
|
||||
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted)
|
||||
writeData = formatted
|
||||
} else {
|
||||
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data)
|
||||
writeData = data
|
||||
}
|
||||
try transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
|
||||
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
|
||||
guard let data = try? transport.readFile(project.dashboardPath) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
@@ -51,13 +68,10 @@ struct ProjectDashboardService: Sendable {
|
||||
}
|
||||
|
||||
func dashboardExists(for project: ProjectEntry) -> Bool {
|
||||
FileManager.default.fileExists(atPath: project.dashboardPath)
|
||||
transport.fileExists(project.dashboardPath)
|
||||
}
|
||||
|
||||
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else {
|
||||
return nil
|
||||
}
|
||||
return attrs[.modificationDate] as? Date
|
||||
transport.stat(project.dashboardPath)?.mtime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import Sparkle
|
||||
|
||||
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
|
||||
///
|
||||
/// Sparkle reads `SUFeedURL`, `SUPublicEDKey`, and check-interval defaults from Info.plist.
|
||||
/// This service exposes the bits the UI needs: a "check now" trigger, a toggle for automatic
|
||||
/// checks, and observable state for the Settings screen.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class UpdaterService: NSObject {
|
||||
private let controller: SPUStandardUpdaterController
|
||||
|
||||
/// User-facing toggle. Mirrors `updater.automaticallyChecksForUpdates`.
|
||||
var automaticallyChecksForUpdates: Bool {
|
||||
get { controller.updater.automaticallyChecksForUpdates }
|
||||
set { controller.updater.automaticallyChecksForUpdates = newValue }
|
||||
}
|
||||
|
||||
/// Last time Sparkle checked the appcast (nil before the first check).
|
||||
var lastUpdateCheckDate: Date? {
|
||||
controller.updater.lastUpdateCheckDate
|
||||
}
|
||||
|
||||
override init() {
|
||||
// startingUpdater: true → Sparkle scans for updates on launch per Info.plist schedule.
|
||||
// Default delegates are sufficient for a non-sandboxed app.
|
||||
self.controller = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Triggers a user-initiated update check. Sparkle handles the UI (alert, progress, install).
|
||||
func checkForUpdates() {
|
||||
controller.checkForUpdates(nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// `ServerTransport` over the local filesystem. Thin wrapper around
|
||||
/// `FileManager`, `Process`, and `DispatchSourceFileSystemObject` — the APIs
|
||||
/// services were already using before Phase 2.
|
||||
struct LocalTransport: ServerTransport {
|
||||
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "LocalTransport")
|
||||
|
||||
let contextID: ServerID
|
||||
let isRemote: Bool = false
|
||||
|
||||
nonisolated init(contextID: ServerID = ServerContext.local.id) {
|
||||
self.contextID = contextID
|
||||
}
|
||||
|
||||
// MARK: - Files
|
||||
|
||||
func readFile(_ path: String) throws -> Data {
|
||||
do {
|
||||
return try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
} catch {
|
||||
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(_ path: String, data: Data) throws {
|
||||
let tmp = path + ".scarf.tmp"
|
||||
do {
|
||||
try data.write(to: URL(fileURLWithPath: tmp))
|
||||
// Preserve `0600` for dotfiles holding secrets (.env, .auth, ...).
|
||||
// The existing files already use 0600 via HermesEnvService; we
|
||||
// mirror that here so a brand-new file created via this write
|
||||
// also starts with safe permissions.
|
||||
if Self.shouldEnforcePrivateMode(for: path) {
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
|
||||
}
|
||||
// Atomic swap onto the final path.
|
||||
let destURL = URL(fileURLWithPath: path)
|
||||
let tmpURL = URL(fileURLWithPath: tmp)
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
|
||||
} else {
|
||||
// Ensure parent exists.
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) {
|
||||
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||
}
|
||||
try FileManager.default.moveItem(at: tmpURL, to: destURL)
|
||||
}
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(atPath: tmp)
|
||||
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(_ path: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
func stat(_ path: String) -> FileStat? {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else {
|
||||
return nil
|
||||
}
|
||||
let size = (attrs[.size] as? Int64) ?? Int64((attrs[.size] as? Int) ?? 0)
|
||||
let mtime = (attrs[.modificationDate] as? Date) ?? Date(timeIntervalSince1970: 0)
|
||||
let isDir = (attrs[.type] as? FileAttributeType) == .typeDirectory
|
||||
return FileStat(size: size, mtime: mtime, isDirectory: isDir)
|
||||
}
|
||||
|
||||
func listDirectory(_ path: String) throws -> [String] {
|
||||
do {
|
||||
return try FileManager.default.contentsOfDirectory(atPath: path)
|
||||
} catch {
|
||||
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func createDirectory(_ path: String) throws {
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func removeFile(_ path: String) throws {
|
||||
guard FileManager.default.fileExists(atPath: path) else { return }
|
||||
do {
|
||||
try FileManager.default.removeItem(atPath: path)
|
||||
} catch {
|
||||
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Processes
|
||||
|
||||
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
let stdinPipe = Pipe()
|
||||
proc.standardOutput = stdoutPipe
|
||||
proc.standardError = stderrPipe
|
||||
if stdin != nil { proc.standardInput = stdinPipe }
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
|
||||
}
|
||||
if let stdin {
|
||||
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
}
|
||||
// Timeout handling: poll every 100ms up to timeout, kill on overrun.
|
||||
if let timeout {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while proc.isRunning && Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
throw TransportError.timeout(seconds: timeout, partialStdout: partial)
|
||||
}
|
||||
} else {
|
||||
proc.waitUntilExit()
|
||||
}
|
||||
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
|
||||
}
|
||||
|
||||
func makeProcess(executable: String, args: [String]) -> Process {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
return proc
|
||||
}
|
||||
|
||||
// MARK: - SQLite
|
||||
|
||||
func snapshotSQLite(remotePath: String) throws -> URL {
|
||||
// Local case: no copy needed. Services open the path directly.
|
||||
URL(fileURLWithPath: remotePath)
|
||||
}
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
AsyncStream { continuation in
|
||||
// Build the source list immutably, then hand a value-typed copy
|
||||
// to onTermination. Swift 6's concurrent-capture rule rejects a
|
||||
// `var sources` shared between the outer builder and the inner
|
||||
// termination closure.
|
||||
let sources: [DispatchSourceFileSystemObject] = paths.compactMap { path in
|
||||
let fd = Darwin.open(path, O_EVTONLY)
|
||||
guard fd >= 0 else { return nil }
|
||||
let src = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
eventMask: [.write, .extend, .rename],
|
||||
queue: .global()
|
||||
)
|
||||
src.setEventHandler { continuation.yield(.anyChanged) }
|
||||
src.setCancelHandler { Darwin.close(fd) }
|
||||
src.resume()
|
||||
return src
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
for s in sources { s.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Heuristic: files that conventionally hold secrets should be created
|
||||
/// with restrictive permissions so a future `scp` or editor doesn't end
|
||||
/// up exposing them.
|
||||
private static func shouldEnforcePrivateMode(for path: String) -> Bool {
|
||||
let name = (path as NSString).lastPathComponent
|
||||
return name == ".env" || name == "auth.json" || name.hasSuffix("-tokens.json")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// `ServerTransport` that reaches a remote Hermes installation through the
|
||||
/// system `ssh`, `scp`, and `sftp` binaries.
|
||||
///
|
||||
/// Why system ssh (not a native library): the user's `~/.ssh/config`,
|
||||
/// ssh-agent, 1Password/Secretive agents, ProxyJump, and ControlMaster
|
||||
/// multiplexing all work for free. OpenSSH also owns crypto — a smaller
|
||||
/// audit surface than dragging libssh2 along.
|
||||
///
|
||||
/// **ControlMaster matters.** Without it, every remote primitive (stat, cat,
|
||||
/// cp) authenticates from scratch — 500ms-2s per call. With ControlMaster
|
||||
/// `auto` + `ControlPersist 600`, the first call authenticates, subsequent
|
||||
/// calls reuse the same TCP/crypto session at ~5ms each. We point the
|
||||
/// control socket at `~/Library/Caches/scarf/ssh/%C` so multiple Scarf
|
||||
/// windows pointed at the same host share one session cleanly.
|
||||
struct SSHTransport: ServerTransport {
|
||||
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "SSHTransport")
|
||||
|
||||
let contextID: ServerID
|
||||
let isRemote: Bool = true
|
||||
|
||||
let config: SSHConfig
|
||||
let displayName: String
|
||||
|
||||
nonisolated init(contextID: ServerID, config: SSHConfig, displayName: String) {
|
||||
self.contextID = contextID
|
||||
self.config = config
|
||||
self.displayName = displayName
|
||||
}
|
||||
|
||||
// MARK: - ssh/scp binary discovery
|
||||
|
||||
nonisolated private var sshBinary: String { "/usr/bin/ssh" }
|
||||
nonisolated private var scpBinary: String { "/usr/bin/scp" }
|
||||
|
||||
/// The fully-qualified `user@host` spec (or just `host` if no user set).
|
||||
nonisolated private var hostSpec: String {
|
||||
if let user = config.user, !user.isEmpty { return "\(user)@\(config.host)" }
|
||||
return config.host
|
||||
}
|
||||
|
||||
/// Absolute path to this server's ControlMaster socket directory. One
|
||||
/// socket per server, lives under the app's Caches so macOS can sweep it.
|
||||
nonisolated private var controlDir: String { Self.controlDirPath() }
|
||||
|
||||
/// Per-server snapshot cache directory (for SQLite `.backup` drops).
|
||||
nonisolated private var snapshotDir: String { Self.snapshotDirPath(for: contextID) }
|
||||
|
||||
/// Shared control-master socket directory (one dir, sockets within it are
|
||||
/// per-host via OpenSSH's `%C` token). Exposed as a static so
|
||||
/// cleanup paths (`ServerRegistry.removeServer`, app-launch sweep) can
|
||||
/// 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 {
|
||||
return "/tmp/scarf-ssh-\(getuid())"
|
||||
}
|
||||
|
||||
/// Snapshot cache directory for a given server. Stable per-ID so repeated
|
||||
/// connections to the same server share the cache, and so cleanup can
|
||||
/// find it from the ID alone.
|
||||
nonisolated static func snapshotDirPath(for contextID: ServerID) -> String {
|
||||
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
|
||||
?? NSHomeDirectory() + "/Library/Caches"
|
||||
return base + "/scarf/snapshots/\(contextID.uuidString)"
|
||||
}
|
||||
|
||||
/// Root of the snapshot cache (all servers). Used by the app-launch sweep
|
||||
/// that prunes dirs whose UUID no longer appears in the registry.
|
||||
nonisolated static func snapshotRootPath() -> String {
|
||||
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
|
||||
?? NSHomeDirectory() + "/Library/Caches"
|
||||
return base + "/scarf/snapshots"
|
||||
}
|
||||
|
||||
/// Remove the snapshot directory for a server (no-op if absent). Called
|
||||
/// on `removeServer` and on app-launch for orphaned dirs.
|
||||
static func pruneSnapshotCache(for contextID: ServerID) {
|
||||
let dir = snapshotDirPath(for: contextID)
|
||||
try? FileManager.default.removeItem(atPath: dir)
|
||||
}
|
||||
|
||||
/// Walk the snapshot root and delete any directory whose UUID isn't in
|
||||
/// `keep`. Called once at app launch so snapshots from servers the user
|
||||
/// removed while the app was closed don't linger.
|
||||
static func sweepOrphanSnapshots(keeping keep: Set<ServerID>) {
|
||||
let root = snapshotRootPath()
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
|
||||
for name in entries {
|
||||
if let id = ServerID(uuidString: name), keep.contains(id) { continue }
|
||||
try? FileManager.default.removeItem(atPath: root + "/" + name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// the exit code because the desired end state (no master) is reached
|
||||
/// either way.
|
||||
func closeControlMaster() {
|
||||
ensureControlDir()
|
||||
let args = sshArgs(extra: ["-O", "exit", hostSpec])
|
||||
_ = try? runLocal(executable: sshBinary, args: args, stdin: nil, timeout: 10)
|
||||
}
|
||||
|
||||
/// Common ssh options used by every invocation. Keep every `-o` flag
|
||||
/// here so we never drift between calls.
|
||||
///
|
||||
/// - `ControlMaster=auto` + `ControlPersist=600` gives us free connection
|
||||
/// pooling for the bursty stat/cat/cp traffic the services produce.
|
||||
/// - `StrictHostKeyChecking=accept-new` writes new hosts to
|
||||
/// `known_hosts` silently the first time but blocks on key mismatch —
|
||||
/// the UX surfaced by `TransportError.hostKeyMismatch`.
|
||||
/// - `ServerAliveInterval=30` makes dropped connections surface as a
|
||||
/// process exit rather than a hang.
|
||||
/// - `LogLevel=QUIET` suppresses the login banner so ACP's line-delimited
|
||||
/// JSON stays binary-clean.
|
||||
nonisolated private func sshArgs(extra: [String] = []) -> [String] {
|
||||
var args: [String] = [
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=\(controlDir)/%C",
|
||||
"-o", "ControlPersist=600",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "LogLevel=QUIET",
|
||||
"-o", "BatchMode=yes" // Never prompt for passphrases; ssh-agent only.
|
||||
]
|
||||
if let port = config.port { args += ["-p", String(port)] }
|
||||
if let id = config.identityFile, !id.isEmpty {
|
||||
args += ["-i", id]
|
||||
}
|
||||
args += extra
|
||||
return args
|
||||
}
|
||||
|
||||
/// Ensure the ControlMaster socket directory exists, is a real directory
|
||||
/// (not a symlink), is owned by us, and has mode 0700. Called before every
|
||||
/// 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() {
|
||||
let path = 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
|
||||
/// receives our argv joined with spaces, so anything containing
|
||||
/// whitespace/metacharacters must be quoted to survive that flattening.
|
||||
nonisolated private static func shellQuote(_ s: String) -> String {
|
||||
if s.isEmpty { return "''" }
|
||||
// Safe subset: alphanumerics + a few shell-inert characters.
|
||||
let safe = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@%+=:,./-_")
|
||||
if s.unicodeScalars.allSatisfy({ safe.contains($0) }) { return s }
|
||||
// Wrap in single quotes; close/reopen around any embedded single quote.
|
||||
return "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
|
||||
/// Format a path for inclusion in a remote `sh -c` command. **Critical**
|
||||
/// for any path containing `~/`: bash/zsh do NOT expand `~` inside
|
||||
/// quotes (single OR double), so a single-quoted `'~/.hermes/foo'` is
|
||||
/// passed to commands as the literal seven-character string
|
||||
/// `~/.hermes/foo` and lookups fail. We rewrite the leading `~/` to
|
||||
/// `$HOME/` (which DOES expand inside double quotes) and emit the path
|
||||
/// double-quoted so embedded spaces / metacharacters are still safe.
|
||||
///
|
||||
/// Why not single-quote: that would make `$HOME` literal too. We
|
||||
/// specifically need partial-expansion semantics, which is what double
|
||||
/// quotes give us.
|
||||
nonisolated private static func remotePathArg(_ path: String) -> String {
|
||||
var p = path
|
||||
if p.hasPrefix("~/") {
|
||||
p = "$HOME/" + p.dropFirst(2)
|
||||
} else if p == "~" {
|
||||
p = "$HOME"
|
||||
}
|
||||
let escaped = p
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}
|
||||
|
||||
/// Run a remote shell command. Wraps in `sh -c '<command>'` and uses
|
||||
/// the standard ssh-after-host placement (no `--` separator — that
|
||||
/// would be sent to the remote shell as a literal first token, which
|
||||
/// most shells reject as "command not found"). The `command` is
|
||||
/// single-quoted via `shellQuote` so ssh's argv-join-by-space doesn't
|
||||
/// split it across multiple shell tokens on the remote side.
|
||||
@discardableResult
|
||||
nonisolated private func runRemoteShell(_ command: String, timeout: TimeInterval? = 60) throws -> ProcessResult {
|
||||
var args = sshArgs()
|
||||
args.append(hostSpec)
|
||||
args.append("sh")
|
||||
args.append("-c")
|
||||
args.append(Self.shellQuote(command))
|
||||
return try runLocal(executable: sshBinary, args: args, stdin: nil, timeout: timeout)
|
||||
}
|
||||
|
||||
// MARK: - Files
|
||||
|
||||
func readFile(_ path: String) throws -> Data {
|
||||
// `cat` is the simplest portable "give me file bytes" command; we
|
||||
// don't need scp's progress machinery for typical config/memory
|
||||
// files (<1 MB each).
|
||||
let result = try runRemoteShell("cat \(Self.remotePathArg(path))")
|
||||
if result.exitCode != 0 {
|
||||
let errText = result.stderrString
|
||||
// Missing file looks like exit 1 + "No such file" — surface as a
|
||||
// typed fileIO error so callers that treat missing == "empty"
|
||||
// behave the same as they do locally.
|
||||
if errText.contains("No such file") {
|
||||
throw TransportError.fileIO(path: path, underlying: "No such file or directory")
|
||||
}
|
||||
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: errText)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
func writeFile(_ path: String, data: Data) throws {
|
||||
// Atomic pattern:
|
||||
// 1. scp to `<path>.scarf.tmp` on the remote
|
||||
// 2. ssh `mv <tmp> <path>` — atomic on POSIX within the same FS
|
||||
// Hermes never sees a partial write.
|
||||
let tmp = path + ".scarf.tmp"
|
||||
|
||||
// scp from a local temp file (scp reads from disk, not stdin).
|
||||
let localTmpURL = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"scarf-scp-\(UUID().uuidString).tmp"
|
||||
)
|
||||
do {
|
||||
try data.write(to: localTmpURL)
|
||||
} catch {
|
||||
throw TransportError.fileIO(path: path, underlying: "local temp write: \(error.localizedDescription)")
|
||||
}
|
||||
defer { try? FileManager.default.removeItem(at: localTmpURL) }
|
||||
|
||||
ensureControlDir()
|
||||
var scpArgs: [String] = [
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=\(controlDir)/%C",
|
||||
"-o", "ControlPersist=600",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "LogLevel=QUIET",
|
||||
"-o", "BatchMode=yes"
|
||||
]
|
||||
if let port = config.port { scpArgs += ["-P", String(port)] }
|
||||
if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] }
|
||||
scpArgs.append(localTmpURL.path)
|
||||
scpArgs.append("\(hostSpec):\(tmp)")
|
||||
|
||||
let scpResult = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 60)
|
||||
if scpResult.exitCode != 0 {
|
||||
throw TransportError.classifySSHFailure(host: config.host, exitCode: scpResult.exitCode, stderr: scpResult.stderrString)
|
||||
}
|
||||
|
||||
// Now atomic mv on the remote. Note: scp/sftp DOES expand `~` (it
|
||||
// goes through the SSH file transfer protocol, not a remote shell),
|
||||
// so the upload landed at the resolved $HOME path. The mv is a
|
||||
// shell command and needs the $HOME-rewritten path to find it.
|
||||
let mvResult = try runRemoteShell("mv \(Self.remotePathArg(tmp)) \(Self.remotePathArg(path))")
|
||||
if mvResult.exitCode != 0 {
|
||||
// Best-effort cleanup of the orphan tmp.
|
||||
_ = try? runRemoteShell("rm -f \(Self.remotePathArg(tmp))")
|
||||
throw TransportError.classifySSHFailure(host: config.host, exitCode: mvResult.exitCode, stderr: mvResult.stderrString)
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(_ path: String) -> Bool {
|
||||
guard let result = try? runRemoteShell("test -e \(Self.remotePathArg(path))") else {
|
||||
return false
|
||||
}
|
||||
return result.exitCode == 0
|
||||
}
|
||||
|
||||
func stat(_ path: String) -> FileStat? {
|
||||
// macOS and Linux `stat` differ in flags. `stat -f` is macOS's BSD
|
||||
// form; `stat -c` is GNU/Linux. We try the GNU form first (typical
|
||||
// remote target) and fall back to BSD. The format strings use
|
||||
// double quotes — safe inside our outer single-quoted sh -c.
|
||||
let linux = try? runRemoteShell(#"stat -c "%s %Y %F" \#(Self.remotePathArg(path))"#)
|
||||
if let result = linux, result.exitCode == 0 {
|
||||
return Self.parseStatOutput(result.stdoutString)
|
||||
}
|
||||
let bsd = try? runRemoteShell(#"stat -f "%z %m %HT" \#(Self.remotePathArg(path))"#)
|
||||
if let result = bsd, result.exitCode == 0 {
|
||||
return Self.parseStatOutput(result.stdoutString)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseStatOutput(_ s: String) -> FileStat? {
|
||||
// Expected: "<bytes> <unix-epoch-secs> <type>" where <type> is either
|
||||
// a GNU word ("regular file", "directory") or a BSD word ("Regular
|
||||
// File", "Directory"). Only the first word of <type> matters for
|
||||
// isDirectory.
|
||||
let parts = s.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ", maxSplits: 2)
|
||||
guard parts.count >= 2 else { return nil }
|
||||
let size = Int64(parts[0]) ?? 0
|
||||
let mtimeSecs = TimeInterval(parts[1]) ?? 0
|
||||
let typeStr = parts.count == 3 ? parts[2].lowercased() : ""
|
||||
let isDir = typeStr.contains("directory")
|
||||
return FileStat(size: size, mtime: Date(timeIntervalSince1970: mtimeSecs), isDirectory: isDir)
|
||||
}
|
||||
|
||||
func listDirectory(_ path: String) throws -> [String] {
|
||||
// `ls -A` lists all entries (incl. dotfiles) except `.`/`..`, one per
|
||||
// line. Sort order matches local FileManager.contentsOfDirectory.
|
||||
let result = try runRemoteShell("ls -A \(Self.remotePathArg(path))")
|
||||
if result.exitCode != 0 {
|
||||
if result.stderrString.contains("No such file") {
|
||||
throw TransportError.fileIO(path: path, underlying: "No such file or directory")
|
||||
}
|
||||
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
|
||||
}
|
||||
return result.stdoutString
|
||||
.split(separator: "\n", omittingEmptySubsequences: true)
|
||||
.map(String.init)
|
||||
}
|
||||
|
||||
func createDirectory(_ path: String) throws {
|
||||
let result = try runRemoteShell("mkdir -p \(Self.remotePathArg(path))")
|
||||
if result.exitCode != 0 {
|
||||
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
|
||||
}
|
||||
}
|
||||
|
||||
func removeFile(_ path: String) throws {
|
||||
let result = try runRemoteShell("rm -f \(Self.remotePathArg(path))")
|
||||
if result.exitCode != 0 {
|
||||
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Processes
|
||||
|
||||
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
|
||||
// Wrap in `sh -c '<exe> <arg> <arg>'` with `~/`-rewritten paths so
|
||||
// home-relative args expand on the remote. The executable might be
|
||||
// `~/.local/bin/hermes` or just `hermes`; either survives.
|
||||
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
|
||||
var sshArgv = sshArgs()
|
||||
sshArgv.append(hostSpec)
|
||||
sshArgv.append("sh")
|
||||
sshArgv.append("-c")
|
||||
sshArgv.append(Self.shellQuote(cmd))
|
||||
return try runLocal(executable: sshBinary, args: sshArgv, stdin: stdin, timeout: timeout)
|
||||
}
|
||||
|
||||
func makeProcess(executable: String, args: [String]) -> Process {
|
||||
ensureControlDir()
|
||||
// `-T` disables pty allocation — critical for binary-clean stdin/stdout
|
||||
// (ACP JSON-RPC, log tail bytes). Same sh -c wrapping as runProcess
|
||||
// so home-relative paths in `executable`/`args` actually expand.
|
||||
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
|
||||
var sshArgv = sshArgs()
|
||||
sshArgv.insert("-T", at: 0)
|
||||
sshArgv.append(hostSpec)
|
||||
sshArgv.append("sh")
|
||||
sshArgv.append("-c")
|
||||
sshArgv.append(Self.shellQuote(cmd))
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: sshBinary)
|
||||
proc.arguments = sshArgv
|
||||
proc.environment = Self.sshSubprocessEnvironment()
|
||||
return proc
|
||||
}
|
||||
|
||||
/// Environment for an ssh/scp subprocess: process env merged with
|
||||
/// SSH_AUTH_SOCK / SSH_AGENT_PID harvested from the user's login shell.
|
||||
/// Without this, GUI-launched Scarf can't reach 1Password / Secretive /
|
||||
/// `ssh-add`'d keys that the user's terminal sees fine.
|
||||
nonisolated private static func sshSubprocessEnvironment() -> [String: String] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
let shellEnv = HermesFileService.enrichedEnvironment()
|
||||
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
|
||||
if env[key] == nil, let value = shellEnv[key], !value.isEmpty {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// MARK: - SQLite snapshot
|
||||
|
||||
func snapshotSQLite(remotePath: String) throws -> URL {
|
||||
try? FileManager.default.createDirectory(atPath: snapshotDir, withIntermediateDirectories: true)
|
||||
let localPath = snapshotDir + "/state.db"
|
||||
// `.backup` is WAL-safe: sqlite takes a consistent snapshot without
|
||||
// blocking writers. A plain `cp` of a WAL-mode DB could corrupt.
|
||||
let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db"
|
||||
// sqlite3's `.backup` is a dot-command, not a CLI arg. The whole
|
||||
// dot-command must be one shell argument (double-quoted) so sqlite3
|
||||
// receives it as a single command; the backup path inside it is
|
||||
// single-quoted so sqlite3 parses it correctly. The DB path is a
|
||||
// separate shell argument and goes through `remotePathArg`
|
||||
// (double-quoted, $HOME-aware) so `~/.hermes/state.db` actually
|
||||
// resolves on the remote.
|
||||
//
|
||||
// The second sqlite3 invocation flips the snapshot out of WAL mode
|
||||
// so the scp'd file is self-contained: `.backup` preserves the
|
||||
// source's journal_mode in the destination header, so without this
|
||||
// step the client would need the `-wal`/`-shm` sidecars too, and
|
||||
// every read would fail with "unable to open database file".
|
||||
//
|
||||
// Final shell command on the remote:
|
||||
// sqlite3 "$HOME/.hermes/state.db" ".backup '/tmp/scarf-snapshot-XYZ.db'" \
|
||||
// && sqlite3 '/tmp/scarf-snapshot-XYZ.db' "PRAGMA journal_mode=DELETE;"
|
||||
let backupScript = #"sqlite3 \#(Self.remotePathArg(remotePath)) ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
|
||||
let backup = try runRemoteShell(backupScript)
|
||||
if backup.exitCode != 0 {
|
||||
throw TransportError.classifySSHFailure(host: config.host, exitCode: backup.exitCode, stderr: backup.stderrString)
|
||||
}
|
||||
// scp the backup down. scp/sftp expands `~` natively (it goes
|
||||
// through the SSH file-transfer protocol, not a remote shell), so
|
||||
// remoteTmp's `/tmp/...` absolute path round-trips as-is.
|
||||
ensureControlDir()
|
||||
var scpArgs: [String] = [
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=\(controlDir)/%C",
|
||||
"-o", "ControlPersist=600",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "LogLevel=QUIET",
|
||||
"-o", "BatchMode=yes"
|
||||
]
|
||||
if let port = config.port { scpArgs += ["-P", String(port)] }
|
||||
if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] }
|
||||
scpArgs.append("\(hostSpec):\(remoteTmp)")
|
||||
scpArgs.append(localPath)
|
||||
let pull = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 120)
|
||||
// Regardless of pull outcome, try to clean up the remote tmp.
|
||||
_ = try? runRemoteShell("rm -f \(Self.remotePathArg(remoteTmp))")
|
||||
if pull.exitCode != 0 {
|
||||
throw TransportError.classifySSHFailure(host: config.host, exitCode: pull.exitCode, stderr: pull.stderrString)
|
||||
}
|
||||
return URL(fileURLWithPath: localPath)
|
||||
}
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
// Polling: call `stat -c %Y` on all paths every 3s and yield a single
|
||||
// `.anyChanged` when any mtime changed vs. the prior tick. ControlMaster
|
||||
// makes each stat ~5ms so the cost is bounded.
|
||||
AsyncStream { continuation in
|
||||
let task = Task.detached { [self] in
|
||||
var lastSignature: String = ""
|
||||
while !Task.isCancelled {
|
||||
// Build one shell command that stats all paths in one
|
||||
// ssh round-trip. Missing paths print "0" which still
|
||||
// participates correctly in change detection. Paths
|
||||
// get the `~`→`$HOME` rewrite via remotePathArg.
|
||||
let argList = paths.map { Self.remotePathArg($0) }.joined(separator: " ")
|
||||
let cmd = "for p in \(argList); do stat -c %Y \"$p\" 2>/dev/null || stat -f %m \"$p\" 2>/dev/null || echo 0; done"
|
||||
do {
|
||||
let result = try runRemoteShell(cmd, timeout: 30)
|
||||
let signature = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !signature.isEmpty && signature != lastSignature {
|
||||
if !lastSignature.isEmpty {
|
||||
continuation.yield(.anyChanged)
|
||||
}
|
||||
lastSignature = signature
|
||||
}
|
||||
} catch {
|
||||
// Transient failure (connection drop) — skip this tick.
|
||||
Self.logger.debug("watchPaths poll failed: \(String(describing: error))")
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
/// Spawn a local process (ssh/scp/etc.) and collect its result. Mirrors
|
||||
/// `LocalTransport.runProcess` — duplicated rather than shared because
|
||||
/// SSH-specific code paths live on this type and we want all Process
|
||||
/// lifecycle in one place per transport.
|
||||
nonisolated private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
|
||||
ensureControlDir()
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
// Inherit the user's shell environment so ssh can reach the
|
||||
// ssh-agent socket. GUI-launched apps don't see SSH_AUTH_SOCK by
|
||||
// default — without this, terminal ssh works (because the user's
|
||||
// shell exports it) but Scarf-launched ssh fails auth with exit 255.
|
||||
proc.environment = Self.sshSubprocessEnvironment()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
let stdinPipe = Pipe()
|
||||
proc.standardOutput = stdoutPipe
|
||||
proc.standardError = stderrPipe
|
||||
if stdin != nil { proc.standardInput = stdinPipe }
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
|
||||
}
|
||||
if let stdin {
|
||||
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
}
|
||||
if let timeout {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while proc.isRunning && Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
throw TransportError.timeout(seconds: timeout, partialStdout: partial)
|
||||
}
|
||||
} else {
|
||||
proc.waitUntilExit()
|
||||
}
|
||||
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
/// Unified I/O surface shared by local and remote Hermes installations.
|
||||
///
|
||||
/// **Design rationale.** The services that read Hermes state (`~/.hermes/…`)
|
||||
/// and spawn the `hermes` CLI all boil down to a handful of primitives:
|
||||
/// read/write/list files, stat file attributes, run a process to completion,
|
||||
/// spawn a long-running stdio process for streaming, take a consistent DB
|
||||
/// snapshot, observe file changes. `ServerTransport` exposes exactly those
|
||||
/// primitives so the same service code works against either a local
|
||||
/// filesystem or a remote host reached over SSH.
|
||||
///
|
||||
/// The primitives are deliberately **synchronous where possible** (file I/O,
|
||||
/// process `run` + wait) so services don't need to become `async` end-to-end.
|
||||
/// The two naturally-streaming cases — log tail and ACP stdio — use
|
||||
/// `makeProcess` which returns a configured `Process`; services own the
|
||||
/// stdio pipes and lifecycle exactly as they do today.
|
||||
protocol ServerTransport: Sendable {
|
||||
/// Identifies the context this transport serves. Used for cache
|
||||
/// namespacing (e.g. per-server SQLite snapshot directories).
|
||||
nonisolated var contextID: ServerID { get }
|
||||
|
||||
/// `true` if this transport talks to a remote host over SSH.
|
||||
nonisolated var isRemote: Bool { get }
|
||||
|
||||
// MARK: - Files
|
||||
|
||||
nonisolated func readFile(_ path: String) throws -> Data
|
||||
/// Atomic write: the file at `path` is either the previous contents or
|
||||
/// the new contents, never a partial write. Preserves `0600` mode for
|
||||
/// paths that match `.env` conventions so secrets stay owner-only.
|
||||
nonisolated func writeFile(_ path: String, data: Data) throws
|
||||
nonisolated func fileExists(_ path: String) -> Bool
|
||||
nonisolated func stat(_ path: String) -> FileStat?
|
||||
nonisolated func listDirectory(_ path: String) throws -> [String]
|
||||
/// Create directories including intermediates. No-op if already present.
|
||||
nonisolated func createDirectory(_ path: String) throws
|
||||
/// Delete a file. No-op if absent.
|
||||
nonisolated func removeFile(_ path: String) throws
|
||||
|
||||
// MARK: - Processes
|
||||
|
||||
/// Run a process to completion and capture its stdout/stderr. For remote
|
||||
/// transports this actually invokes `ssh host -- executable args…` under
|
||||
/// the hood; for local it spawns `executable` directly.
|
||||
nonisolated func runProcess(
|
||||
executable: String,
|
||||
args: [String],
|
||||
stdin: Data?,
|
||||
timeout: TimeInterval?
|
||||
) throws -> ProcessResult
|
||||
|
||||
/// Return a `Process` configured for the target — already pointed at the
|
||||
/// right executable with the right arguments, but **not yet started**.
|
||||
/// Callers attach their own `Pipe`s and call `run()`. Used by ACPClient
|
||||
/// (JSON-RPC over stdio) and by `HermesLogService`'s streaming tail.
|
||||
///
|
||||
/// Local: `executable` + `args` verbatim.
|
||||
/// Remote: `/usr/bin/ssh` + connection flags + `[host, "--", executable, args…]`.
|
||||
nonisolated func makeProcess(executable: String, args: [String]) -> Process
|
||||
|
||||
// MARK: - SQLite
|
||||
|
||||
/// Return a local filesystem URL pointing at a fresh, consistent copy of
|
||||
/// the SQLite database at `remotePath`. For local transports this is
|
||||
/// just the remote path unchanged. For SSH transports this performs
|
||||
/// `sqlite3 .backup` on the remote side and scp's the backup into
|
||||
/// `~/Library/Caches/scarf/<serverID>/state.db`, returning that URL.
|
||||
nonisolated func snapshotSQLite(remotePath: String) throws -> URL
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
/// Observe changes to a set of paths and yield events when any of them
|
||||
/// change. Local: FSEvents. Remote: polls `stat` mtime every 3s.
|
||||
nonisolated func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent>
|
||||
}
|
||||
|
||||
/// Stat-style file metadata. `nil` (return value) means the file does not
|
||||
/// exist or couldn't be queried.
|
||||
struct FileStat: Sendable, Hashable {
|
||||
let size: Int64
|
||||
let mtime: Date
|
||||
let isDirectory: Bool
|
||||
}
|
||||
|
||||
/// Result of a one-shot process invocation.
|
||||
struct ProcessResult: Sendable {
|
||||
let exitCode: Int32
|
||||
let stdout: Data
|
||||
let stderr: Data
|
||||
|
||||
nonisolated var stdoutString: String { String(data: stdout, encoding: .utf8) ?? "" }
|
||||
nonisolated var stderrString: String { String(data: stderr, encoding: .utf8) ?? "" }
|
||||
}
|
||||
|
||||
enum WatchEvent: Sendable {
|
||||
/// Any path in the watched set changed; implementations may coalesce
|
||||
/// rapid changes into one event. Consumers should treat this as "refresh
|
||||
/// whatever you were displaying" rather than expecting fine-grained
|
||||
/// per-path signals.
|
||||
case anyChanged
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
/// Typed errors surfaced by `ServerTransport` implementations. The UI
|
||||
/// distinguishes these so user-visible messages can be specific
|
||||
/// ("authentication failed" vs. "command failed") without having to grep
|
||||
/// stderr strings.
|
||||
enum TransportError: LocalizedError {
|
||||
/// `ssh`/`scp` could not reach the host or hit a protocol-level issue
|
||||
/// (name resolution, connection refused, route error).
|
||||
case hostUnreachable(host: String, stderr: String)
|
||||
/// Remote rejected our credentials. Typically means no ssh-agent key is
|
||||
/// loaded, or the loaded keys don't match any `authorized_keys` entry.
|
||||
case authenticationFailed(host: String, stderr: String)
|
||||
/// Remote `~/.ssh/known_hosts` fingerprint no longer matches. Blocking —
|
||||
/// we never auto-accept on mismatch.
|
||||
case hostKeyMismatch(host: String, stderr: String)
|
||||
/// The command ran on the remote but exited non-zero.
|
||||
case commandFailed(exitCode: Int32, stderr: String)
|
||||
/// Local filesystem operation failed (read/write/stat) with the OS error
|
||||
/// message attached.
|
||||
case fileIO(path: String, underlying: String)
|
||||
/// Timed out waiting for a process to finish. `partialStdout` carries
|
||||
/// whatever output was captured before the timer fired.
|
||||
case timeout(seconds: TimeInterval, partialStdout: Data)
|
||||
/// Something we didn't plan for. Fall-through bucket with enough context
|
||||
/// for a bug report.
|
||||
case other(message: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .hostUnreachable(let host, _):
|
||||
return "Can't reach \(host). Check the hostname, network, and SSH config."
|
||||
case .authenticationFailed(let host, _):
|
||||
return "SSH authentication to \(host) failed. Ensure your key is loaded in ssh-agent."
|
||||
case .hostKeyMismatch(let host, _):
|
||||
return "Host key for \(host) has changed. Inspect ~/.ssh/known_hosts before continuing."
|
||||
case .commandFailed(let code, let stderr):
|
||||
// Trim stderr to a single line for the summary; full text is in
|
||||
// the associated value for disclosure views.
|
||||
let firstLine = stderr.split(separator: "\n").first.map(String.init) ?? ""
|
||||
return "Remote command exited \(code). \(firstLine)"
|
||||
case .fileIO(let path, let msg):
|
||||
return "File I/O failed at \(path): \(msg)"
|
||||
case .timeout(let secs, _):
|
||||
return "Command timed out after \(Int(secs))s."
|
||||
case .other(let msg):
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
/// Full stderr (if any) for display in a disclosure view. Empty string
|
||||
/// when there's no additional detail worth showing.
|
||||
var diagnosticStderr: String {
|
||||
switch self {
|
||||
case .hostUnreachable(_, let s),
|
||||
.authenticationFailed(_, let s),
|
||||
.hostKeyMismatch(_, let s),
|
||||
.commandFailed(_, let s):
|
||||
return s
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic classifier: convert the ssh/scp stderr of a failed command
|
||||
/// into a specific `TransportError`. Used by `SSHTransport` after a
|
||||
/// non-zero exit. Defaults to `.commandFailed` when no known marker
|
||||
/// matches.
|
||||
static func classifySSHFailure(host: String, exitCode: Int32, stderr: String) -> TransportError {
|
||||
let s = stderr.lowercased()
|
||||
if s.contains("permission denied") || s.contains("authentication failed")
|
||||
|| s.contains("publickey") && s.contains("denied") {
|
||||
return .authenticationFailed(host: host, stderr: stderr)
|
||||
}
|
||||
if s.contains("host key verification failed")
|
||||
|| s.contains("remote host identification has changed") {
|
||||
return .hostKeyMismatch(host: host, stderr: stderr)
|
||||
}
|
||||
if s.contains("no route to host") || s.contains("connection refused")
|
||||
|| s.contains("connection timed out") || s.contains("could not resolve hostname")
|
||||
|| s.contains("connection closed by") && s.contains("port 22") {
|
||||
return .hostUnreachable(host: host, stderr: stderr)
|
||||
}
|
||||
return .commandFailed(exitCode: exitCode, stderr: stderr)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,14 @@ import Foundation
|
||||
|
||||
@Observable
|
||||
final class ActivityViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var toolMessages: [HermesMessage] = []
|
||||
var filterKind: ToolKind?
|
||||
@@ -45,7 +52,12 @@ final class ActivityViewModel {
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
let opened = await dataService.open()
|
||||
// refresh() = close + reopen, which forces a fresh snapshot pull on
|
||||
// remote contexts. Using open() here would short-circuit after the
|
||||
// first load and show stale data for the view's lifetime. The DB
|
||||
// stays open after load() returns so selectEntry() can read tool
|
||||
// results without re-opening — cleanup() closes on disappear.
|
||||
let opened = await dataService.refresh()
|
||||
guard opened else {
|
||||
isLoading = false
|
||||
return
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ActivityView: View {
|
||||
@State private var viewModel = ActivityViewModel()
|
||||
@State private var viewModel: ActivityViewModel
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: ActivityViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -17,6 +23,9 @@ struct ActivityView: View {
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.task { await viewModel.load() }
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.onDisappear { Task { await viewModel.cleanup() } }
|
||||
}
|
||||
|
||||
@@ -105,7 +114,7 @@ struct ActivityView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.toolName)
|
||||
.font(.title3.bold().monospaced())
|
||||
Text(entry.kind.rawValue.capitalized)
|
||||
Text(entry.kind.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,29 @@ import os
|
||||
@Observable
|
||||
final class ChatViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ChatViewModel")
|
||||
private let dataService = HermesDataService()
|
||||
private let fileService = HermesFileService()
|
||||
let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
private let fileService: HermesFileService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
self.fileService = HermesFileService(context: context)
|
||||
self.richChatViewModel = RichChatViewModel(context: context)
|
||||
// Probe hermes binary existence once off-main, then cache. Doing
|
||||
// this synchronously inside `hermesBinaryExists`'s getter would
|
||||
// block main on every chat-body re-evaluation — for a remote
|
||||
// context that's a SSH `test -e` round-trip on every streaming
|
||||
// chunk, which manifests as the chat screen flashing or going
|
||||
// blank during prompts.
|
||||
Task.detached(priority: .userInitiated) { [context] in
|
||||
let exists = context.fileExists(context.paths.hermesBinary)
|
||||
await MainActor.run { [weak self] in
|
||||
self?.hermesBinaryExists = exists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var recentSessions: [HermesSession] = []
|
||||
var sessionPreviews: [String: String] = [:]
|
||||
@@ -17,7 +38,7 @@ final class ChatViewModel {
|
||||
var ttsEnabled = false
|
||||
var isRecording = false
|
||||
var displayMode: ChatDisplayMode = .richChat
|
||||
let richChatViewModel = RichChatViewModel()
|
||||
let richChatViewModel: RichChatViewModel
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
// ACP state
|
||||
@@ -29,14 +50,70 @@ final class ChatViewModel {
|
||||
private var isHandlingDisconnect = false
|
||||
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
||||
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?
|
||||
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
|
||||
/// Shown above the raw error in the UI when present.
|
||||
var acpErrorHint: String?
|
||||
/// Tail of stderr captured from `hermes acp` at the time of the last
|
||||
/// failure — shown in a collapsible details section so users can copy/paste.
|
||||
var acpErrorDetails: String?
|
||||
/// True when `hasAnyAICredential()` returned false at last preflight.
|
||||
var missingCredentials: Bool = false
|
||||
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
|
||||
|
||||
var hermesBinaryExists: Bool {
|
||||
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
||||
/// Cached result of probing for `hermes` on the target server. Updated
|
||||
/// once at init by a detached task; defaults to `true` so the chat
|
||||
/// view doesn't briefly flash "Hermes not found" while the async
|
||||
/// probe runs. Set to `false` only after the probe confirms the
|
||||
/// binary really isn't there.
|
||||
var hermesBinaryExists: Bool = true
|
||||
|
||||
/// Re-checks env + `~/.hermes/.env` for AI-provider credentials and
|
||||
/// updates `missingCredentials`. Cheap — safe to call from view `.task`.
|
||||
func refreshCredentialPreflight() {
|
||||
missingCredentials = !fileService.hasAnyAICredential()
|
||||
}
|
||||
|
||||
/// Clears the error/hint/details triplet so future failures overwrite
|
||||
/// cleanly instead of stacking on top of stale state.
|
||||
private func clearACPErrorState() {
|
||||
acpError = nil
|
||||
acpErrorHint = nil
|
||||
acpErrorDetails = nil
|
||||
}
|
||||
|
||||
/// Populates acpError, acpErrorHint, acpErrorDetails from an error + the
|
||||
/// stderr tail the ACP client captured, and logs the failure with a
|
||||
/// site-specific context label. Call on any failure path.
|
||||
@MainActor
|
||||
private func recordACPFailure(_ error: Error, client: ACPClient?, context: String) async {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("\(context): \(msg)")
|
||||
let stderrTail = await client?.recentStderr ?? ""
|
||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
acpError = msg
|
||||
acpErrorHint = hint
|
||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||
}
|
||||
|
||||
// MARK: - Session Lifecycle
|
||||
@@ -78,7 +155,14 @@ final class ChatViewModel {
|
||||
// Find most recent session and resume via ACP
|
||||
Task { @MainActor in
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
if !opened {
|
||||
acpError = context.isRemote
|
||||
? "Couldn't reach \(context.displayName). Check the SSH connection and try again."
|
||||
: "Couldn't open the Hermes state database."
|
||||
acpErrorHint = nil
|
||||
acpErrorDetails = nil
|
||||
return
|
||||
}
|
||||
let sessionId = await dataService.fetchMostRecentlyActiveSessionId()
|
||||
await dataService.close()
|
||||
if let sessionId {
|
||||
@@ -107,23 +191,22 @@ final class ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start ACP for the current or most recent session, then send the queued prompt.
|
||||
/// Start ACP for the current session (or create a new one), then send the
|
||||
/// queued prompt. Typing into a blank Chat screen ALWAYS creates a new
|
||||
/// session — the "Continue from Last Session" button is the explicit path
|
||||
/// for resuming. The previous behavior (falling back to the most recently
|
||||
/// active session in the DB) would pick up cron/background sessions the
|
||||
/// user never interacted with; those can be garbage-collected by Hermes
|
||||
/// between the DB read and ACP `session/load`, producing a silent prompt
|
||||
/// failure with no UI feedback.
|
||||
private func autoStartACPAndSend(text: String) {
|
||||
// Show the user message immediately
|
||||
richChatViewModel.addUserMessage(text: text)
|
||||
|
||||
Task { @MainActor in
|
||||
// Find a session to resume: prefer current sessionId, then most recent
|
||||
var sessionToResume = richChatViewModel.sessionId
|
||||
if sessionToResume == nil {
|
||||
let opened = await dataService.open()
|
||||
if opened {
|
||||
sessionToResume = await dataService.fetchMostRecentlyActiveSessionId()
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
let sessionToResume = richChatViewModel.sessionId
|
||||
|
||||
let client = ACPClient()
|
||||
let client = ACPClient(context: context)
|
||||
self.acpClient = client
|
||||
|
||||
do {
|
||||
@@ -132,7 +215,7 @@ final class ChatViewModel {
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
let cwd = NSHomeDirectory()
|
||||
let cwd = await context.resolvedUserHome()
|
||||
|
||||
hasActiveProcess = true
|
||||
|
||||
@@ -157,10 +240,8 @@ final class ChatViewModel {
|
||||
// Now send the queued prompt
|
||||
sendViaACP(client: client, text: text)
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("Auto-start ACP failed: \(msg)")
|
||||
acpStatus = "Failed"
|
||||
acpError = msg
|
||||
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
@@ -169,6 +250,7 @@ final class ChatViewModel {
|
||||
|
||||
private func sendViaACP(client: ACPClient, text: String) {
|
||||
guard let sessionId = richChatViewModel.sessionId else {
|
||||
clearACPErrorState()
|
||||
acpError = "No session ID — cannot send"
|
||||
return
|
||||
}
|
||||
@@ -192,10 +274,8 @@ final class ChatViewModel {
|
||||
} catch is CancellationError {
|
||||
acpStatus = "Cancelled"
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("ACP prompt failed: \(msg)")
|
||||
acpStatus = "Error"
|
||||
acpError = msg
|
||||
await recordACPFailure(error, client: client, context: "ACP prompt failed")
|
||||
richChatViewModel.handleACPEvent(
|
||||
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
|
||||
stopReason: "error",
|
||||
@@ -211,10 +291,10 @@ final class ChatViewModel {
|
||||
|
||||
private func startACPSession(resume sessionId: String?) {
|
||||
stopACP()
|
||||
acpError = nil
|
||||
clearACPErrorState()
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient()
|
||||
let client = ACPClient(context: context)
|
||||
self.acpClient = client
|
||||
|
||||
Task { @MainActor in
|
||||
@@ -225,7 +305,7 @@ final class ChatViewModel {
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
let cwd = NSHomeDirectory()
|
||||
let cwd = await context.resolvedUserHome()
|
||||
|
||||
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
|
||||
// and doesn't wipe messages with a DB refresh
|
||||
@@ -259,10 +339,8 @@ final class ChatViewModel {
|
||||
|
||||
logger.info("ACP session ready: \(resolvedSessionId)")
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("Failed to start ACP session: \(msg)")
|
||||
acpStatus = "Failed"
|
||||
acpError = msg
|
||||
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
@@ -333,7 +411,7 @@ final class ChatViewModel {
|
||||
|
||||
private func attemptReconnect(sessionId: String) {
|
||||
reconnectTask?.cancel()
|
||||
acpError = nil
|
||||
clearACPErrorState()
|
||||
|
||||
reconnectTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -354,11 +432,11 @@ final class ChatViewModel {
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
|
||||
let client = ACPClient()
|
||||
let client = ACPClient(context: context)
|
||||
do {
|
||||
try await client.start()
|
||||
|
||||
let cwd = NSHomeDirectory()
|
||||
let cwd = await context.resolvedUserHome()
|
||||
let resolvedSessionId: String
|
||||
|
||||
// Try resumeSession first (designed for reconnection), then loadSession.
|
||||
@@ -379,7 +457,7 @@ final class ChatViewModel {
|
||||
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
|
||||
|
||||
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
|
||||
acpError = nil
|
||||
clearACPErrorState()
|
||||
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
@@ -404,6 +482,7 @@ final class ChatViewModel {
|
||||
private func showConnectionFailure() {
|
||||
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
|
||||
acpStatus = "Connection lost"
|
||||
clearACPErrorState()
|
||||
acpError = "Connection lost. Use the Session menu to reconnect."
|
||||
}
|
||||
|
||||
@@ -510,11 +589,44 @@ final class ChatViewModel {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["TERM"] = "xterm-256color"
|
||||
env["COLORTERM"] = "truecolor"
|
||||
// Inherit ssh-agent socket for remote so password-less auth works.
|
||||
if context.isRemote {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
let envArray = env.map { "\($0.key)=\($0.value)" }
|
||||
|
||||
// For remote: wrap the invocation in `ssh -t host -- hermes <args>`
|
||||
// so the embedded terminal opens a pty against the remote and the
|
||||
// hermes TUI gets the bytes it expects. `-t` requests a pty (the
|
||||
// SwiftTerm view is one).
|
||||
let exe: String
|
||||
let argv: [String]
|
||||
if context.isRemote, case .ssh(let cfg) = context.kind {
|
||||
let host = cfg.user.map { "\($0)@\(cfg.host)" } ?? cfg.host
|
||||
exe = "/usr/bin/ssh"
|
||||
var sshArgs: [String] = ["-t"]
|
||||
if let port = cfg.port { sshArgs += ["-p", String(port)] }
|
||||
if let id = cfg.identityFile, !id.isEmpty { sshArgs += ["-i", id] }
|
||||
sshArgs += ["-o", "StrictHostKeyChecking=accept-new"]
|
||||
sshArgs += ["-o", "BatchMode=yes"]
|
||||
sshArgs.append(host)
|
||||
sshArgs.append("--")
|
||||
sshArgs.append(context.paths.hermesBinary)
|
||||
sshArgs.append(contentsOf: arguments)
|
||||
argv = sshArgs
|
||||
} else {
|
||||
exe = context.paths.hermesBinary
|
||||
argv = arguments
|
||||
}
|
||||
|
||||
terminal.startProcess(
|
||||
executable: HermesPaths.hermesBinary,
|
||||
args: arguments,
|
||||
executable: exe,
|
||||
args: argv,
|
||||
environment: envArray,
|
||||
execName: nil
|
||||
)
|
||||
|
||||
@@ -25,7 +25,15 @@ struct MessageGroup: Identifiable {
|
||||
|
||||
@Observable
|
||||
final class RichChatViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
loadQuickCommands()
|
||||
}
|
||||
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
var currentSession: HermesSession?
|
||||
@@ -42,9 +50,21 @@ final class RichChatViewModel {
|
||||
private(set) var acpCachedReadTokens = 0
|
||||
|
||||
/// 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 }
|
||||
|
||||
@@ -98,8 +118,9 @@ final class RichChatViewModel {
|
||||
acpOutputTokens = 0
|
||||
acpThoughtTokens = 0
|
||||
acpCachedReadTokens = 0
|
||||
availableCommandNames = []
|
||||
acpCommands = []
|
||||
pendingPermission = nil
|
||||
loadQuickCommands()
|
||||
}
|
||||
|
||||
func setSessionId(_ id: String?) {
|
||||
@@ -149,6 +170,11 @@ final class RichChatViewModel {
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
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.
|
||||
@@ -174,19 +200,59 @@ final class RichChatViewModel {
|
||||
case .connectionLost(let reason):
|
||||
handleConnectionLost(reason: reason)
|
||||
case .availableCommands(_, let commands):
|
||||
var names: Set<String> = []
|
||||
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
|
||||
acpCommands = parseACPCommands(commands)
|
||||
case .unknown:
|
||||
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) {
|
||||
streamingAssistantText += text
|
||||
upsertStreamingMessage()
|
||||
@@ -231,7 +297,44 @@ final class RichChatViewModel {
|
||||
}
|
||||
|
||||
private func handlePromptComplete(response: ACPPromptResult) {
|
||||
// Detect a failed prompt that produced no assistant output — e.g.
|
||||
// Hermes returning `stopReason: "refusal"` when the session was
|
||||
// silently garbage-collected, or `"error"` when the ACP call itself
|
||||
// threw. Without surfacing this, the user sees their prompt sitting
|
||||
// alone under "Agent working…" that never completes with any text.
|
||||
let hadAssistantOutput = streamingAssistantText.isEmpty == false
|
||||
|| messages.last?.isAssistant == true
|
||||
finalizeStreamingMessage()
|
||||
|
||||
if !hadAssistantOutput, response.stopReason != "end_turn" {
|
||||
let reason: String
|
||||
switch response.stopReason {
|
||||
case "refusal":
|
||||
reason = "The agent refused to respond (the session may have been cleared on the server). Try starting a new session from the Session menu."
|
||||
case "error":
|
||||
reason = "The prompt failed — check the ACP error banner above for details."
|
||||
case "max_tokens":
|
||||
reason = "The response was cut off before the agent could produce any output (max_tokens reached before any tokens were emitted)."
|
||||
default:
|
||||
reason = "The prompt ended without a response (stopReason: \(response.stopReason))."
|
||||
}
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages.append(HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "system",
|
||||
content: reason,
|
||||
toolCallId: nil,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: response.stopReason,
|
||||
reasoning: nil
|
||||
))
|
||||
}
|
||||
|
||||
// Accumulate token usage from this prompt
|
||||
acpInputTokens += response.inputTokens
|
||||
acpOutputTokens += response.outputTokens
|
||||
@@ -239,6 +342,10 @@ final class RichChatViewModel {
|
||||
acpCachedReadTokens += response.cachedReadTokens
|
||||
isAgentWorking = false
|
||||
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) {
|
||||
@@ -391,7 +498,11 @@ final class RichChatViewModel {
|
||||
/// (e.g., CLI session) with the current ACP session.
|
||||
func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
||||
self.sessionId = sessionId
|
||||
let opened = await dataService.open()
|
||||
// Force a fresh snapshot pull on remote contexts. An earlier open()
|
||||
// would have cached a stale copy — on resume we need whatever
|
||||
// Hermes has actually persisted since then, or the resumed session
|
||||
// will show only history up to the moment the snapshot was taken.
|
||||
let opened = await dataService.refresh()
|
||||
guard opened else { return }
|
||||
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
@@ -434,7 +545,10 @@ final class RichChatViewModel {
|
||||
}
|
||||
|
||||
func refreshMessages() async {
|
||||
let opened = await dataService.open()
|
||||
// Polling tick (terminal mode): pull a fresh snapshot so remote
|
||||
// reflects Hermes writes since the last tick. On local this is a
|
||||
// cheap reopen of the live DB.
|
||||
let opened = await dataService.refresh()
|
||||
guard opened else { return }
|
||||
|
||||
if sessionId == nil {
|
||||
|
||||
@@ -3,18 +3,113 @@ import SwiftUI
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
errorBanner
|
||||
chatArea
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.task { await viewModel.loadRecentSessions() }
|
||||
.task {
|
||||
await viewModel.loadRecentSessions()
|
||||
viewModel.refreshCredentialPreflight()
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel.loadRecentSessions() }
|
||||
viewModel.refreshCredentialPreflight()
|
||||
}
|
||||
}
|
||||
|
||||
/// Banner rendered between the toolbar and the chat area when either
|
||||
/// (a) a preflight credential check failed, or (b) the ACP subprocess
|
||||
/// returned an error we captured. Shows a short hint + expandable raw
|
||||
/// details (stderr tail) that the user can copy to the clipboard.
|
||||
@ViewBuilder
|
||||
private var errorBanner: some View {
|
||||
if let err = viewModel.acpError {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let hint = viewModel.acpErrorHint {
|
||||
Text(hint)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
Text(err)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(showErrorDetails ? nil : 2)
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.acpErrorDetails != nil {
|
||||
Button(showErrorDetails ? "Hide details" : "Show details") {
|
||||
showErrorDetails.toggle()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button {
|
||||
let payload = [viewModel.acpErrorHint, err, viewModel.acpErrorDetails]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: "\n\n")
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(payload, forType: .string)
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Copy error details")
|
||||
}
|
||||
if showErrorDetails, let details = viewModel.acpErrorDetails {
|
||||
ScrollView {
|
||||
Text(details)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 160)
|
||||
.padding(8)
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.orange.opacity(0.08))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(Color.orange.opacity(0.25))
|
||||
.frame(height: 1),
|
||||
alignment: .bottom
|
||||
)
|
||||
} else if viewModel.missingCredentials && !viewModel.hasActiveProcess {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "key.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("No AI provider credentials detected")
|
||||
.font(.callout)
|
||||
Text("Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.orange.opacity(0.08))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(Color.orange.opacity(0.25))
|
||||
.frame(height: 1),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +122,7 @@ struct ChatView: View {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(viewModel.acpStatus.isEmpty ? "Active" : viewModel.acpStatus)
|
||||
(viewModel.acpStatus.isEmpty ? Text("Active") : Text(viewModel.acpStatus))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -143,7 +238,7 @@ struct ChatView: View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash")
|
||||
.foregroundStyle(viewModel.voiceEnabled ? .green : .secondary)
|
||||
Text(viewModel.voiceEnabled ? "Voice On" : "Voice Off")
|
||||
(viewModel.voiceEnabled ? Text("Voice On") : Text("Voice Off"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary)
|
||||
}
|
||||
@@ -158,7 +253,7 @@ struct ChatView: View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
|
||||
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
|
||||
Text(viewModel.ttsEnabled ? "TTS On" : "TTS Off")
|
||||
(viewModel.ttsEnabled ? Text("TTS On") : Text("TTS Off"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
|
||||
}
|
||||
@@ -173,7 +268,7 @@ struct ChatView: View {
|
||||
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
|
||||
.foregroundStyle(viewModel.isRecording ? .red : Color.accentColor)
|
||||
.symbolEffect(.pulse, isActive: viewModel.isRecording)
|
||||
Text(viewModel.isRecording ? "Recording..." : "Push to Talk")
|
||||
(viewModel.isRecording ? Text("Recording…") : Text("Push to Talk"))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
@@ -209,7 +304,7 @@ struct ChatView: View {
|
||||
ContentUnavailableView(
|
||||
"Hermes Not Found",
|
||||
systemImage: "terminal",
|
||||
description: Text("Expected at \(HermesPaths.hermesBinary)")
|
||||
description: Text("Expected at \(viewModel.context.paths.hermesBinary)")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
@@ -236,7 +331,7 @@ struct ChatView: View {
|
||||
ContentUnavailableView(
|
||||
"Hermes Not Found",
|
||||
systemImage: "terminal",
|
||||
description: Text("Expected at \(HermesPaths.hermesBinary)")
|
||||
description: Text("Expected at \(viewModel.context.paths.hermesBinary)")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
@@ -264,7 +359,7 @@ struct ChatView: View {
|
||||
|
||||
// MARK: - Permission Approval View
|
||||
|
||||
extension RichChatViewModel.PendingPermission: @retroactive Identifiable {
|
||||
extension RichChatViewModel.PendingPermission: Identifiable {
|
||||
var id: Int { requestId }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,70 +3,127 @@ import SwiftUI
|
||||
struct RichChatInputBar: View {
|
||||
let onSend: (String) -> Void
|
||||
let isEnabled: Bool
|
||||
var supportsCompress: Bool = false
|
||||
var commands: [HermesSlashCommand] = []
|
||||
var showCompressButton: Bool = false
|
||||
|
||||
@State private var text = ""
|
||||
@State private var showCompressSheet = false
|
||||
@State private var compressFocus = ""
|
||||
@State private var showMenu = false
|
||||
@State private var selectedIndex = 0
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if supportsCompress {
|
||||
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) {
|
||||
if showCompressButton {
|
||||
Button {
|
||||
compressFocus = ""
|
||||
showCompressSheet = true
|
||||
} label: {
|
||||
Image(systemName: "rectangle.compress.vertical")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEnabled)
|
||||
.help("Compress conversation (/compress)")
|
||||
}
|
||||
|
||||
TextEditor(text: $text)
|
||||
.font(.body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.focused($isFocused)
|
||||
.frame(minHeight: 28, maxHeight: 120)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(alignment: .topLeading) {
|
||||
if text.isEmpty {
|
||||
Text("Message Hermes...")
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.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
|
||||
if press.modifiers.contains(.shift) {
|
||||
return .ignored
|
||||
}
|
||||
if showMenu, let command = filteredCommands[safe: selectedIndex] {
|
||||
insertCommand(command)
|
||||
return .handled
|
||||
}
|
||||
send()
|
||||
return .handled
|
||||
}
|
||||
|
||||
Button {
|
||||
compressFocus = ""
|
||||
showCompressSheet = true
|
||||
send()
|
||||
} label: {
|
||||
Image(systemName: "rectangle.compress.vertical")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(canSend ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEnabled)
|
||||
.help("Compress conversation (/compress)")
|
||||
.disabled(!canSend)
|
||||
.help("Send message (Enter)")
|
||||
}
|
||||
|
||||
TextEditor(text: $text)
|
||||
.font(.body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.focused($isFocused)
|
||||
.frame(minHeight: 28, maxHeight: 120)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(alignment: .topLeading) {
|
||||
if text.isEmpty {
|
||||
Text("Message Hermes...")
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.onKeyPress(.return, phases: .down) { press in
|
||||
if press.modifiers.contains(.shift) {
|
||||
return .ignored
|
||||
}
|
||||
send()
|
||||
return .handled
|
||||
}
|
||||
|
||||
Button {
|
||||
send()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(canSend ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSend)
|
||||
.help("Send message (Enter)")
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
.onChange(of: text) { _, _ in
|
||||
updateMenuState()
|
||||
}
|
||||
.onChange(of: commands.map(\.id)) { _, _ in
|
||||
updateMenuState()
|
||||
}
|
||||
.sheet(isPresented: $showCompressSheet) {
|
||||
compressSheet
|
||||
}
|
||||
@@ -101,10 +158,61 @@ struct RichChatInputBar: View {
|
||||
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() {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, isEnabled else { return }
|
||||
onSend(trimmed)
|
||||
text = ""
|
||||
showMenu = false
|
||||
selectedIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,54 @@ import SwiftUI
|
||||
struct RichChatMessageList: View {
|
||||
let groups: [MessageGroup]
|
||||
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").
|
||||
var scrollTrigger: UUID = UUID()
|
||||
|
||||
/// Track the last group's assistant content length to detect streaming updates.
|
||||
private var scrollAnchor: String {
|
||||
if isWorking { return "typing-indicator" }
|
||||
if let last = groups.last { return "group-\(last.id)" }
|
||||
return "scroll-top"
|
||||
}
|
||||
|
||||
/// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus
|
||||
/// `.defaultScrollAnchor(.bottom)`.
|
||||
///
|
||||
/// `LazyVStack` was causing the classic "loaded session shows whitespace
|
||||
/// and the chat is above" bug: lazy rows return estimated heights before
|
||||
/// 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.
|
||||
///
|
||||
/// Switching to `VStack` materializes every row immediately, so
|
||||
/// `.defaultScrollAnchor(.bottom)` has real heights to work with and
|
||||
/// can't overshoot. For typical Hermes sessions (<500 messages) the
|
||||
/// first-render cost is acceptable. If ever needed for huge sessions
|
||||
/// we can reintroduce lazy with a preference-key-based height
|
||||
/// measurement, but that's a much larger change.
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
Spacer(minLength: 0)
|
||||
.id("scroll-top")
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
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
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.containerRelativeFrame(.vertical)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
ForEach(groups) { group in
|
||||
MessageGroupView(group: group)
|
||||
.id("group-\(group.id)")
|
||||
@@ -30,51 +62,50 @@ struct RichChatMessageList: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.animation(.easeInOut(duration: 0.15), value: isLoadingSession)
|
||||
.animation(.easeInOut(duration: 0.15), value: groups.isEmpty)
|
||||
}
|
||||
.defaultScrollAnchor(.bottom)
|
||||
// Scroll to bottom when view first appears with content
|
||||
.onAppear {
|
||||
if !groups.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scroll on new groups
|
||||
.onChange(of: groups.count) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll when agent starts/stops working
|
||||
.onChange(of: isWorking) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll on streaming content updates (group content changes)
|
||||
.onChange(of: scrollAnchor) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll on last message content change (streaming text)
|
||||
.onChange(of: groups.last?.assistantMessages.last?.content ?? "") {
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
// Scroll on tool call count change
|
||||
.onChange(of: groups.last?.toolCallCount ?? 0) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll on external trigger (e.g., "Return to Active Session" button)
|
||||
.onChange(of: scrollTrigger) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
let target = lastAnchorID
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = true) {
|
||||
let target = scrollAnchor
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
} else {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
/// Anchor ID used by the explicit scrollTrigger path. Prefers the typing
|
||||
/// indicator when visible (so we scroll to the very bottom of the
|
||||
/// current turn), otherwise the last group.
|
||||
private var lastAnchorID: String {
|
||||
if isWorking { return "typing-indicator" }
|
||||
if let last = groups.last { return "group-\(last.id)" }
|
||||
return "group-0"
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "bubble.left.and.text.bubble.right")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("Chat Messages")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
Text("Messages will appear here as the conversation progresses.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingState: some View {
|
||||
VStack(spacing: 14) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
Text("Loading session…")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +139,17 @@ struct MessageGroupView: View {
|
||||
RichMessageBubble(message: user, toolResults: [:])
|
||||
}
|
||||
|
||||
ForEach(group.assistantMessages.filter(\.isAssistant)) { message in
|
||||
// Identify by array offset rather than `message.id`. The
|
||||
// streaming assistant message starts with id=0 and gets a
|
||||
// new negative id when finalized — using `\.id` would make
|
||||
// SwiftUI think the bubble disappeared and a new one appeared
|
||||
// (destroying + recreating the view, which manifests as the
|
||||
// chat flashing or jumping right when the prompt completes).
|
||||
// Within a single group the assistant messages are
|
||||
// append-only, so offset is a stable identity for the
|
||||
// group's lifetime.
|
||||
let assistantMessages = group.assistantMessages.filter(\.isAssistant)
|
||||
ForEach(Array(assistantMessages.enumerated()), id: \.offset) { _, message in
|
||||
RichMessageBubble(message: message, toolResults: group.toolResults)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,20 +21,16 @@ struct RichChatView: View {
|
||||
)
|
||||
Divider()
|
||||
|
||||
if richChat.messageGroups.isEmpty && !richChat.isAgentWorking {
|
||||
ContentUnavailableView(
|
||||
"Chat Messages",
|
||||
systemImage: "bubble.left.and.text.bubble.right",
|
||||
description: Text("Messages will appear here as the conversation progresses.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
RichChatMessageList(
|
||||
groups: richChat.messageGroups,
|
||||
isWorking: richChat.isAgentWorking,
|
||||
scrollTrigger: richChat.scrollTrigger
|
||||
)
|
||||
}
|
||||
// Always mount RichChatMessageList; empty state lives inside it.
|
||||
// Swapping between a ContentUnavailableView and the ScrollView
|
||||
// hierarchy on first message caused a full view tree rebuild,
|
||||
// which manifests as a white flash.
|
||||
RichChatMessageList(
|
||||
groups: richChat.messageGroups,
|
||||
isWorking: richChat.isAgentWorking,
|
||||
isLoadingSession: chatViewModel.isPreparingSession,
|
||||
scrollTrigger: richChat.scrollTrigger
|
||||
)
|
||||
|
||||
Divider()
|
||||
RichChatInputBar(
|
||||
@@ -42,7 +38,8 @@ struct RichChatView: View {
|
||||
onSend(text)
|
||||
},
|
||||
isEnabled: isEnabled,
|
||||
supportsCompress: richChat.supportsCompress
|
||||
commands: richChat.availableCommands,
|
||||
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
|
||||
)
|
||||
}
|
||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||
|
||||
@@ -45,7 +45,8 @@ struct SessionInfoBar: View {
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -75,11 +76,6 @@ struct SessionInfoBar: View {
|
||||
}
|
||||
|
||||
private func formatTokens(_ count: Int) -> String {
|
||||
if count >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||
} else if count >= 1_000 {
|
||||
return String(format: "%.1fK", Double(count) / 1_000)
|
||||
}
|
||||
return "\(count)"
|
||||
count.formatted(.number.notation(.compactName).precision(.fractionLength(0...1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Translucent loading overlay used by feature views while their VM's
|
||||
/// `load()` runs in the background. Shows a centered ProgressView with
|
||||
/// optional label; the underlying content stays visible (just dimmed)
|
||||
/// when it's already populated, or the overlay fully covers an empty
|
||||
/// section so the user sees activity instead of "nothing here yet".
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// SomeContent()
|
||||
/// .loadingOverlay(viewModel.isLoading, label: "Loading credentials…", isEmpty: viewModel.pools.isEmpty)
|
||||
/// ```
|
||||
///
|
||||
/// The `isEmpty` flag controls whether the overlay covers the full view
|
||||
/// (when there's no stale content to show under it) or just dims it
|
||||
/// (when refreshing existing data).
|
||||
struct LoadingOverlay: ViewModifier {
|
||||
let isLoading: Bool
|
||||
let label: String
|
||||
let isEmpty: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
if isLoading {
|
||||
if isEmpty {
|
||||
// Full cover: empty state. User has no data to look at,
|
||||
// so own the whole pane with the spinner.
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
Text(label)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
} else {
|
||||
// Stale-content refresh: top-trailing pill so the
|
||||
// user sees data is being refreshed without losing
|
||||
// their place.
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(.thinMaterial, in: Capsule())
|
||||
.padding(8)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Show a loading indicator while `isLoading` is true. If `isEmpty` is
|
||||
/// also true, the indicator covers the full view; otherwise it shows
|
||||
/// as a small refresh pill in the top-trailing corner so existing
|
||||
/// content stays visible.
|
||||
func loadingOverlay(_ isLoading: Bool, label: String = "Loading…", isEmpty: Bool = false) -> some View {
|
||||
modifier(LoadingOverlay(isLoading: isLoading, label: label, isEmpty: isEmpty))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A single pooled credential for a provider (rotation entry).
|
||||
struct HermesCredential: Identifiable, Sendable, Equatable {
|
||||
var id: String { "\(provider):\(index):\(internalID)" }
|
||||
let internalID: String // Stable id from auth.json (e.g. "9f8d9b")
|
||||
let provider: String
|
||||
let index: Int // 0-based index in the provider's pool
|
||||
let label: String // Human label ("OPENROUTER_API_KEY")
|
||||
let authType: String // "api_key" | "oauth"
|
||||
let source: String // "env:OPENROUTER_API_KEY" | "gh_cli" | "file:..."
|
||||
let tokenTail: String // Last 4 chars of the token — NEVER store full token in UI state
|
||||
let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
|
||||
let requestCount: Int
|
||||
}
|
||||
|
||||
/// Summary of one provider's pool with its rotation strategy.
|
||||
struct HermesCredentialPool: Identifiable, Sendable {
|
||||
var id: String { provider }
|
||||
let provider: String
|
||||
let strategy: String // "fill_first" | "round_robin" | "least_used" | "random"
|
||||
let credentials: [HermesCredential]
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CredentialPoolsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CredentialPoolsViewModel")
|
||||
let context: ServerContext
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.oauthFlow = OAuthFlowController(context: context)
|
||||
}
|
||||
|
||||
var pools: [HermesCredentialPool] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
/// Driver for the OAuth flow. Uses Process + pipes (not SwiftTerm) so we
|
||||
/// can extract the authorization URL, pop it open with an explicit button,
|
||||
/// and feed the code back via stdin. See OAuthFlowController for why we
|
||||
/// moved off the embedded-terminal approach.
|
||||
let oauthFlow: OAuthFlowController
|
||||
var oauthProvider: String = ""
|
||||
/// Convenience — the sheet keys a lot of UI off "is the flow running?".
|
||||
var oauthInProgress: Bool { oauthFlow.isRunning }
|
||||
|
||||
let strategyOptions = ["fill_first", "round_robin", "least_used", "random"]
|
||||
|
||||
/// Source of truth is `~/.hermes/auth.json`. Parsing box-drawn `hermes auth list`
|
||||
/// output is fragile — the JSON file is structured, stable, and already stores
|
||||
/// exactly the pool data the UI needs. We never display full tokens.
|
||||
///
|
||||
/// Runs the file reads on a detached task so the synchronous SSH calls
|
||||
/// (which can block for hundreds of milliseconds even with ControlMaster
|
||||
/// multiplexing) don't freeze the main thread / spin the beach ball.
|
||||
func load() {
|
||||
isLoading = true
|
||||
let ctx = context
|
||||
Task.detached { [weak self] in
|
||||
let authData = ctx.readData(ctx.paths.authJSON)
|
||||
let yaml = ctx.readText(ctx.paths.configYAML) ?? ""
|
||||
let strategies = Self.parseStrategies(from: yaml)
|
||||
|
||||
let decodedPools: [HermesCredentialPool]
|
||||
if let data = authData,
|
||||
let decoded = try? JSONDecoder().decode(AuthFile.self, from: data) {
|
||||
decodedPools = Self.buildPools(from: decoded, strategies: strategies)
|
||||
} else {
|
||||
decodedPools = []
|
||||
}
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
self?.pools = decodedPools
|
||||
self?.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
|
||||
/// Pure-function form so it's safe to call from the detached load task.
|
||||
nonisolated private static func parseStrategies(from yaml: String) -> [String: String] {
|
||||
guard !yaml.isEmpty else { return [:] }
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
return parsed.maps["credential_pool_strategies"] ?? [:]
|
||||
}
|
||||
|
||||
nonisolated private static func buildPools(from auth: AuthFile, strategies: [String: String]) -> [HermesCredentialPool] {
|
||||
auth.credential_pool.keys.sorted().map { provider in
|
||||
let entries = auth.credential_pool[provider] ?? []
|
||||
let creds = entries.enumerated().map { index, entry in
|
||||
HermesCredential(
|
||||
internalID: entry.id ?? "",
|
||||
provider: provider,
|
||||
index: index,
|
||||
label: entry.label ?? entry.source ?? "",
|
||||
authType: entry.auth_type ?? "",
|
||||
source: entry.source ?? "",
|
||||
tokenTail: Self.tail(of: entry.access_token ?? ""),
|
||||
lastStatus: entry.last_status ?? "",
|
||||
requestCount: entry.request_count ?? 0
|
||||
)
|
||||
}
|
||||
return HermesCredentialPool(
|
||||
provider: provider,
|
||||
strategy: strategies[provider] ?? "fill_first",
|
||||
credentials: creds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return last 4 chars prefixed with "…", or "" if the token is too short.
|
||||
/// Callers MUST NOT pass the full token anywhere user-visible beyond this.
|
||||
nonisolated private static func tail(of token: String) -> String {
|
||||
guard token.count >= 4 else { return "" }
|
||||
return "…" + String(token.suffix(4))
|
||||
}
|
||||
|
||||
// MARK: - Mutations (all routed through the hermes CLI so hermes stays authoritative)
|
||||
|
||||
func setStrategy(_ strategy: String, for provider: String) {
|
||||
let result = runHermes(["config", "set", "credential_pool_strategies.\(provider)", strategy])
|
||||
if result.exitCode == 0 {
|
||||
message = "Strategy updated for \(provider)"
|
||||
load()
|
||||
} else {
|
||||
message = "Failed to update strategy"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an API-key credential to a provider's pool. Runs non-interactively.
|
||||
///
|
||||
/// **Critical:** we must pass `--type api-key` in addition to `--api-key`.
|
||||
/// Without `--type`, hermes falls back to the provider's default (OAuth for
|
||||
/// Anthropic, etc.) and launches the browser flow even though the user
|
||||
/// just gave us a key.
|
||||
func addAPIKey(provider: String, apiKey: String, label: String) {
|
||||
var args = ["auth", "add", provider, "--type", "api-key", "--api-key", apiKey]
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedLabel.isEmpty {
|
||||
args += ["--label", trimmedLabel]
|
||||
}
|
||||
let result = runHermes(args)
|
||||
if result.exitCode == 0 {
|
||||
message = "Credential added"
|
||||
load()
|
||||
} else {
|
||||
logger.warning("Add credential failed: \(result.output)")
|
||||
message = "Add failed: \(result.output.prefix(160))"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off the OAuth flow. Uses OAuthFlowController (Process + pipes) so
|
||||
/// we can detect the authorization URL from hermes's output, open the
|
||||
/// browser ourselves, and feed the code back via stdin — avoiding the
|
||||
/// subprocess-can't-open-browser problem SwiftTerm had.
|
||||
func startOAuth(provider: String, label: String) {
|
||||
guard !provider.isEmpty else { return }
|
||||
oauthProvider = provider
|
||||
|
||||
oauthFlow.onExit = { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.message = self.oauthFlow.succeeded
|
||||
? "OAuth login succeeded"
|
||||
: (self.oauthFlow.errorMessage ?? "OAuth login failed or cancelled")
|
||||
// Reload regardless — hermes may have written a partial credential
|
||||
// even on a soft failure, and we want the list to reflect truth.
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
oauthFlow.start(provider: provider, label: label)
|
||||
}
|
||||
|
||||
/// Submit the authorization code the user pasted into the form's text
|
||||
/// field. Writes it to hermes's stdin.
|
||||
func submitOAuthCode(_ code: String) {
|
||||
oauthFlow.submitCode(code)
|
||||
}
|
||||
|
||||
/// Cancel an in-progress OAuth attempt (e.g., user closed the sheet).
|
||||
func cancelOAuth() {
|
||||
oauthFlow.stop()
|
||||
}
|
||||
|
||||
func removeCredential(provider: String, index: Int) {
|
||||
// The CLI uses 1-based indexing ("#1", "#2" in `hermes auth list`); our
|
||||
// stored `index` is 0-based, so add 1 when handing to the CLI.
|
||||
let result = runHermes(["auth", "remove", provider, String(index + 1)])
|
||||
if result.exitCode == 0 {
|
||||
message = "Credential removed"
|
||||
load()
|
||||
} else {
|
||||
message = "Remove failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func resetProvider(_ provider: String) {
|
||||
let result = runHermes(["auth", "reset", provider])
|
||||
message = result.exitCode == 0 ? "Cooldowns cleared for \(provider)" : "Reset failed"
|
||||
load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
context.runHermes(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - auth.json decoding
|
||||
// Shape verified against a real `~/.hermes/auth.json` — see sample in plan notes.
|
||||
// All fields are optional because the format evolves and we want decoding to
|
||||
// succeed even if hermes adds new keys or omits some for certain auth types.
|
||||
|
||||
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a MainActor-
|
||||
// isolated conformance — auth.json decode runs in `load()`'s detached task.
|
||||
private struct AuthFile: Decodable, Sendable {
|
||||
nonisolated let credential_pool: [String: [AuthEntry]]
|
||||
|
||||
enum CodingKeys: String, CodingKey { case credential_pool }
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.credential_pool = try c.decode([String: [AuthEntry]].self, forKey: .credential_pool)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AuthEntry: Decodable, Sendable {
|
||||
nonisolated let id: String?
|
||||
nonisolated let label: String?
|
||||
nonisolated let auth_type: String?
|
||||
nonisolated let source: String?
|
||||
nonisolated let access_token: String?
|
||||
nonisolated let last_status: String?
|
||||
nonisolated let request_count: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, label, auth_type, source, access_token, last_status, request_count
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try c.decodeIfPresent(String.self, forKey: .id)
|
||||
self.label = try c.decodeIfPresent(String.self, forKey: .label)
|
||||
self.auth_type = try c.decodeIfPresent(String.self, forKey: .auth_type)
|
||||
self.source = try c.decodeIfPresent(String.self, forKey: .source)
|
||||
self.access_token = try c.decodeIfPresent(String.self, forKey: .access_token)
|
||||
self.last_status = try c.decodeIfPresent(String.self, forKey: .last_status)
|
||||
self.request_count = try c.decodeIfPresent(Int.self, forKey: .request_count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// Drives the `hermes auth add <provider> --type oauth` flow via `Process` +
|
||||
/// pipes instead of SwiftTerm. The embedded terminal approach turned out to
|
||||
/// have two problems:
|
||||
///
|
||||
/// 1. Python's `webbrowser.open` called from a subprocess doesn't reliably
|
||||
/// open the user's browser — the macOS `open` command can fail silently
|
||||
/// depending on how the parent app was launched.
|
||||
/// 2. Even when it works, users can't easily copy the URL from a terminal
|
||||
/// emulator to click or share.
|
||||
///
|
||||
/// This controller runs hermes with `--no-browser`, captures stdout/stderr,
|
||||
/// regex-extracts the authorization URL, and exposes it to the UI as a plain
|
||||
/// string. The UI shows a real "Open in Browser" button (via NSWorkspace) and
|
||||
/// a code input text field. Submitting writes the code + newline to hermes's
|
||||
/// stdin pipe, which Python's `input()` reads normally — verified in shell
|
||||
/// testing that hermes accepts piped stdin when a TTY isn't available.
|
||||
///
|
||||
/// Hermes exits 0 even on "login did not return credentials" failures, so we
|
||||
/// detect success by scanning output for failure markers AND by letting the
|
||||
/// calling VM reload `auth.json` to see whether a new credential actually
|
||||
/// landed.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class OAuthFlowController {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "OAuthFlowController")
|
||||
let context: ServerContext
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Observable state
|
||||
|
||||
/// Accumulated terminal output for display. Grows monotonically during
|
||||
/// the flow; cleared on `start(...)`.
|
||||
var output: String = ""
|
||||
|
||||
/// Authorization URL extracted from hermes's output. Shown as a prominent
|
||||
/// "Open in Browser" button once detected.
|
||||
var authorizationURL: String?
|
||||
|
||||
/// True once hermes has printed the "Authorization code:" prompt. Gates
|
||||
/// the code submit button so users can't submit too early.
|
||||
var awaitingCode: Bool = false
|
||||
|
||||
/// True between `start(...)` and process termination.
|
||||
var isRunning: Bool = false
|
||||
|
||||
/// Set when the process exits with a success signal (both zero exit AND
|
||||
/// no failure marker in output). The VM checks this + reloads auth.json.
|
||||
var succeeded: Bool = false
|
||||
|
||||
/// Human-readable error message if start/submit failed mid-flow.
|
||||
var errorMessage: String?
|
||||
|
||||
/// Fired when the process exits, with the raw exit code. Use this to
|
||||
/// trigger a UI reload or close the sheet.
|
||||
var onExit: ((Int32) -> Void)?
|
||||
|
||||
// MARK: - Private state
|
||||
|
||||
private var process: Process?
|
||||
private var stdinPipe: Pipe?
|
||||
private var stdoutPipe: Pipe?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Start the OAuth flow. Any prior in-flight flow is terminated first.
|
||||
func start(provider: String, label: String) {
|
||||
stop()
|
||||
|
||||
output = ""
|
||||
authorizationURL = nil
|
||||
awaitingCode = false
|
||||
succeeded = false
|
||||
errorMessage = nil
|
||||
|
||||
// Pass --no-browser so hermes doesn't try (and potentially fail) to
|
||||
// launch the browser itself — we do it explicitly with the button.
|
||||
var args = ["auth", "add", provider, "--type", "oauth", "--no-browser"]
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedLabel.isEmpty {
|
||||
args += ["--label", trimmedLabel]
|
||||
}
|
||||
|
||||
// Use the transport so OAuth works against remote contexts too:
|
||||
// local spawns hermes directly, remote rounds through ssh -T while
|
||||
// preserving stdin (for the auth-code prompt) and stdout (for the
|
||||
// URL parser).
|
||||
let proc = context.makeTransport().makeProcess(
|
||||
executable: context.paths.hermesBinary,
|
||||
args: args
|
||||
)
|
||||
if !context.isRemote {
|
||||
// Only enrich env locally — the remote ssh process gets the
|
||||
// remote login env naturally, and exporting our local API keys
|
||||
// into it would be wrong.
|
||||
proc.environment = HermesFileService.enrichedEnvironment()
|
||||
}
|
||||
|
||||
let outPipe = Pipe()
|
||||
let inPipe = Pipe()
|
||||
// Merge stderr into stdout: hermes prints the URL + prompt to stdout,
|
||||
// but diagnostic messages can land on stderr; we want both interleaved
|
||||
// in display order.
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = outPipe
|
||||
proc.standardInput = inPipe
|
||||
|
||||
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
// EOF — the peer closed its write end. Drop the handler so
|
||||
// Foundation doesn't keep calling us with empty reads.
|
||||
handle.readabilityHandler = nil
|
||||
return
|
||||
}
|
||||
let chunk = String(data: data, encoding: .utf8) ?? ""
|
||||
// Hop onto the main actor to mutate observable state.
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleOutputChunk(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
proc.terminationHandler = { [weak self] p in
|
||||
let code = p.terminationStatus
|
||||
Task { @MainActor [weak self] in
|
||||
outPipe.fileHandleForReading.readabilityHandler = nil
|
||||
self?.handleTermination(exitCode: code)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
process = proc
|
||||
stdinPipe = inPipe
|
||||
stdoutPipe = outPipe
|
||||
isRunning = true
|
||||
} catch {
|
||||
errorMessage = "Failed to start hermes: \(error.localizedDescription)"
|
||||
logger.error("Failed to start hermes: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate the in-flight process (if any). Safe to call when nothing is running.
|
||||
func stop() {
|
||||
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
process?.terminate()
|
||||
process = nil
|
||||
stdinPipe = nil
|
||||
stdoutPipe = nil
|
||||
isRunning = false
|
||||
awaitingCode = false
|
||||
}
|
||||
|
||||
/// Send the authorization code to hermes's stdin. Called when the user
|
||||
/// taps "Submit" in the sheet's code input field.
|
||||
func submitCode(_ code: String) {
|
||||
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "Authorization code is empty"
|
||||
return
|
||||
}
|
||||
guard let stdinPipe else {
|
||||
errorMessage = "Process is no longer accepting input"
|
||||
return
|
||||
}
|
||||
let payload = trimmed + "\n"
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
errorMessage = "Could not encode code"
|
||||
return
|
||||
}
|
||||
do {
|
||||
try stdinPipe.fileHandleForWriting.write(contentsOf: data)
|
||||
// After writing, we don't close stdin — hermes might prompt again
|
||||
// on failure. Instead we flip `awaitingCode` off so the UI can
|
||||
// dim the submit button until another prompt appears.
|
||||
awaitingCode = false
|
||||
} catch {
|
||||
errorMessage = "Failed to send code: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly open the detected authorization URL in the default browser.
|
||||
/// Does nothing if no URL has been detected yet.
|
||||
func openURLInBrowser() {
|
||||
guard let url = authorizationURL, let parsed = URL(string: url) else { return }
|
||||
NSWorkspace.shared.open(parsed)
|
||||
}
|
||||
|
||||
// MARK: - Output handling
|
||||
|
||||
private func handleOutputChunk(_ chunk: String) {
|
||||
output += chunk
|
||||
|
||||
if authorizationURL == nil, let url = Self.extractAuthURL(from: output) {
|
||||
authorizationURL = url
|
||||
// Auto-open the browser on first detection, since that's what a
|
||||
// well-behaved hermes would have done. We keep the manual button
|
||||
// available for retries / copy-paste.
|
||||
if let parsed = URL(string: url) {
|
||||
NSWorkspace.shared.open(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// The prompt may arrive in the same chunk as the URL. Checking
|
||||
// cumulative output (rather than just this chunk) is safer.
|
||||
if !awaitingCode, output.contains("Authorization code:") {
|
||||
awaitingCode = true
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTermination(exitCode: Int32) {
|
||||
isRunning = false
|
||||
// Hermes exits 0 even on "login did not return credentials" — detect
|
||||
// that failure marker explicitly so we don't report false success.
|
||||
let failureMarkers = [
|
||||
"did not return credentials",
|
||||
"Token exchange failed",
|
||||
"OAuth login failed",
|
||||
"HTTP Error"
|
||||
]
|
||||
let outputFailed = failureMarkers.contains { output.localizedCaseInsensitiveContains($0) }
|
||||
succeeded = exitCode == 0 && !outputFailed
|
||||
if !succeeded, errorMessage == nil {
|
||||
if outputFailed {
|
||||
errorMessage = "OAuth did not complete — check the output above for details"
|
||||
} else if exitCode != 0 {
|
||||
errorMessage = "hermes exited with code \(exitCode)"
|
||||
}
|
||||
}
|
||||
onExit?(exitCode)
|
||||
}
|
||||
|
||||
// MARK: - URL extraction
|
||||
|
||||
/// Extract the OAuth authorization URL from hermes's output. Hermes prints
|
||||
/// it on its own line in a Rich-rendered box; we want a plain https URL
|
||||
/// that looks like a provider OAuth endpoint.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. URLs containing `client_id=` — real OAuth auth URLs always have this.
|
||||
/// 2. URLs containing `/authorize` — fallback for providers that don't
|
||||
/// include client_id in the query (unusual but possible).
|
||||
/// 3. URLs containing `/oauth/` — last resort.
|
||||
///
|
||||
/// Docs URLs and generic callback URLs are filtered out by these checks.
|
||||
nonisolated static func extractAuthURL(from text: String) -> String? {
|
||||
let pattern = #"https://[^\s\)\]\"'`<>]+"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
let urls: [String] = regex.matches(in: text, range: range).compactMap { match in
|
||||
Range(match.range, in: text).map { String(text[$0]) }
|
||||
}
|
||||
// Prefer the strongest signal so we don't accidentally surface the
|
||||
// redirect callback URL when both appear unencoded in output.
|
||||
if let url = urls.first(where: { $0.contains("client_id=") }) { return url }
|
||||
if let url = urls.first(where: { $0.contains("/authorize") }) { return url }
|
||||
if let url = urls.first(where: { $0.contains("/oauth/") }) { return url }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CredentialPoolsView: View {
|
||||
@State private var viewModel: CredentialPoolsViewModel
|
||||
@State private var showAddSheet = false
|
||||
@State private var pendingRemove: HermesCredential?
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: CredentialPoolsViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
safetyNotice
|
||||
if viewModel.isLoading {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.pools.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(viewModel.pools) { pool in
|
||||
poolSection(pool)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Credential Pools")
|
||||
.loadingOverlay(
|
||||
viewModel.isLoading,
|
||||
label: "Loading credentials…",
|
||||
isEmpty: viewModel.pools.isEmpty
|
||||
)
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddCredentialSheet(viewModel: viewModel) {
|
||||
showAddSheet = false
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingRemove.map { "Remove credential for \($0.provider)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
|
||||
) {
|
||||
Button("Remove", role: .destructive) {
|
||||
if let target = pendingRemove {
|
||||
viewModel.removeCredential(provider: target.provider, index: target.index)
|
||||
}
|
||||
pendingRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRemove = nil }
|
||||
} message: {
|
||||
Text("This removes the credential from hermes. The upstream provider key is not revoked.")
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Label("Add Credential", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var safetyNotice: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "lock.shield")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "key.horizontal")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No credential pools configured")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Add rotation credentials so hermes can failover between keys when one hits rate limits.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
||||
SettingsSection(title: LocalizedStringKey(pool.provider), icon: "key.horizontal") {
|
||||
PickerRow(label: "Rotation", selection: pool.strategy, options: viewModel.strategyOptions) { strategy in
|
||||
viewModel.setStrategy(strategy, for: pool.provider)
|
||||
}
|
||||
ForEach(pool.credentials) { cred in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: cred.authType == "oauth" ? "person.badge.key" : "key.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("#\(cred.index + 1)")
|
||||
.font(.system(.caption, design: .monospaced, weight: .bold))
|
||||
if !cred.label.isEmpty {
|
||||
Text(cred.label).font(.caption)
|
||||
}
|
||||
if !cred.authType.isEmpty {
|
||||
Text(cred.authType)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if !cred.lastStatus.isEmpty {
|
||||
Text(cred.lastStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(statusColor(cred.lastStatus))
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(cred.tokenTail.isEmpty ? "—" : cred.tokenTail)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if !cred.source.isEmpty {
|
||||
Text(cred.source)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if cred.requestCount > 0 {
|
||||
Text("\(cred.requestCount) req")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Remove", role: .destructive) { pendingRemove = cred }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Cooldowns") { viewModel.resetProvider(pool.provider) }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: String) -> Color {
|
||||
switch status {
|
||||
case "ok", "active": return .green
|
||||
case "cooldown": return .orange
|
||||
case "exhausted": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-step sheet for adding a credential:
|
||||
/// 1. Provider picker (populated from the models catalog, falls back to free text)
|
||||
/// + type selector (API Key vs OAuth) + optional label
|
||||
/// 2. Either an immediate save (API key) or an embedded terminal running the
|
||||
/// OAuth flow so the user can paste the authorization code back.
|
||||
private struct AddCredentialSheet: View {
|
||||
@Bindable var viewModel: CredentialPoolsViewModel
|
||||
let onDismiss: () -> Void
|
||||
|
||||
enum AuthType: String, CaseIterable, Identifiable {
|
||||
case apiKey = "API Key"
|
||||
case oauth = "OAuth"
|
||||
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 authType: AuthType = .apiKey
|
||||
@State private var apiKey: String = ""
|
||||
@State private var label: String = ""
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
@State private var oauthStarted: Bool = false
|
||||
@State private var authCode: String = ""
|
||||
|
||||
private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Add Credential")
|
||||
.font(.headline)
|
||||
if !oauthStarted {
|
||||
configSection
|
||||
} else {
|
||||
oauthSection
|
||||
}
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 600, minHeight: 460)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
}
|
||||
// Auto-close the sheet once a credential is actually saved. We key
|
||||
// off `succeeded` which the controller sets only when hermes exited
|
||||
// zero AND the output has no failure markers. The 0.8s delay lets the
|
||||
// user see the success banner before the sheet disappears.
|
||||
.onChange(of: viewModel.oauthFlow.succeeded) { _, newValue in
|
||||
guard newValue else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 1: provider + type + label + optional API key
|
||||
|
||||
private var configSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Provider").font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
// Free-text first so providers missing from the catalog
|
||||
// (e.g. "nous") are still addable.
|
||||
TextField("e.g. anthropic", text: $providerID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Menu("Browse") {
|
||||
ForEach(providers) { provider in
|
||||
Button(provider.providerName + " (\(provider.providerID))") {
|
||||
providerID = provider.providerID
|
||||
}
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Credential Type").font(.caption).foregroundStyle(.secondary)
|
||||
Picker("", selection: $authType) {
|
||||
ForEach(AuthType.allCases) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Label (optional)").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. team-prod", text: $label)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
if authType == .apiKey {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("API Key").font(.caption).foregroundStyle(.secondary)
|
||||
SecureField("sk-…", text: $apiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
} else {
|
||||
oauthPreamble
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brief explanation shown before the user clicks "Start OAuth". Sets
|
||||
/// expectations about the embedded-terminal flow so the browser window
|
||||
/// and code-paste step aren't surprises.
|
||||
private var oauthPreamble: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
// MARK: - Step 2: OAuth — URL button, code field, live output log
|
||||
|
||||
private var oauthSection: some View {
|
||||
// Pull the observable controller into a local so the view redraws
|
||||
// when its @Observable properties change.
|
||||
let flow = viewModel.oauthFlow
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
oauthHeader(flow: flow)
|
||||
urlBlock(flow: flow)
|
||||
codeEntryBlock(flow: flow)
|
||||
outputLogBlock(flow: flow)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func oauthHeader(flow: OAuthFlowController) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.badge.key")
|
||||
Text("OAuth login for \(viewModel.oauthProvider)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if flow.isRunning {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if flow.succeeded {
|
||||
Label("Succeeded", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else if let err = flow.errorMessage {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization URL block. Hermes prints the URL on startup; we detect
|
||||
/// it via regex and expose a prominent Open + Copy pair. The URL keeps
|
||||
/// showing even after the browser is opened so users can paste it into
|
||||
/// a different browser profile if needed.
|
||||
@ViewBuilder
|
||||
private func urlBlock(flow: OAuthFlowController) -> some View {
|
||||
if let url = flow.authorizationURL {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Authorization URL", systemImage: "link")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Text(url)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button {
|
||||
flow.openURLInBrowser()
|
||||
} label: {
|
||||
Label("Open in Browser", systemImage: "safari")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(.blue.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else if flow.isRunning {
|
||||
// Still waiting for hermes to print the URL — usually <1s.
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for authorization URL…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization code input. Only active once hermes has printed its
|
||||
/// "Authorization code:" prompt so users can't submit before hermes is
|
||||
/// ready to receive input.
|
||||
@ViewBuilder
|
||||
private func codeEntryBlock(flow: OAuthFlowController) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Authorization Code", systemImage: "keyboard")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text("After approving in your browser, the provider shows a code. Paste it below and submit.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
TextField("Paste code here…", text: $authCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.disabled(!flow.awaitingCode)
|
||||
.onSubmit { submitCode(flow: flow) }
|
||||
Button("Submit") { submitCode(flow: flow) }
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!flow.awaitingCode || authCode.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
if !flow.awaitingCode && flow.isRunning {
|
||||
Text("Waiting for hermes to prompt for the code…")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live output log — useful for diagnostics if the flow stalls or errors.
|
||||
@ViewBuilder
|
||||
private func outputLogBlock(flow: OAuthFlowController) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Output", systemImage: "text.alignleft")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
Text(flow.output.isEmpty ? "(no output yet)" : flow.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(minHeight: 120, maxHeight: 200)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
|
||||
private func submitCode(flow: OAuthFlowController) {
|
||||
let trimmed = authCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
viewModel.submitOAuthCode(trimmed)
|
||||
authCode = ""
|
||||
}
|
||||
|
||||
// MARK: - Footer (buttons)
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
if oauthStarted {
|
||||
Button("Close") {
|
||||
// Closing mid-flow terminates hermes so we don't leave a
|
||||
// zombie process waiting for stdin forever.
|
||||
viewModel.cancelOAuth()
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
Button("Cancel") { onDismiss() }
|
||||
if authType == .apiKey {
|
||||
Button("Add") {
|
||||
viewModel.addAPIKey(provider: providerID, apiKey: apiKey, label: label)
|
||||
onDismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
} else {
|
||||
Button("Start OAuth") {
|
||||
viewModel.startOAuth(provider: providerID, label: label)
|
||||
oauthStarted = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,180 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class CronViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CronViewModel")
|
||||
let context: ServerContext
|
||||
private let fileService: HermesFileService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.fileService = HermesFileService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var jobs: [HermesCronJob] = []
|
||||
var selectedJob: HermesCronJob?
|
||||
var jobOutput: String?
|
||||
var availableSkills: [String] = []
|
||||
var message: String?
|
||||
var showCreateSheet = false
|
||||
var editingJob: HermesCronJob?
|
||||
var isLoading = false
|
||||
|
||||
func load() {
|
||||
jobs = fileService.loadCronJobs()
|
||||
isLoading = true
|
||||
let svc = fileService
|
||||
let selectedID = selectedJob?.id
|
||||
Task.detached { [weak self] in
|
||||
// Three sync transport ops on remote — keep them off main.
|
||||
let jobs = svc.loadCronJobs()
|
||||
let skills = svc.loadSkills().flatMap { $0.skills.map(\.id) }.sorted()
|
||||
let refreshed = selectedID.flatMap { id in jobs.first(where: { $0.id == id }) }
|
||||
let output = refreshed.flatMap { svc.loadCronOutput(jobId: $0.id) }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.jobs = jobs
|
||||
self.availableSkills = skills
|
||||
if let refreshed { self.selectedJob = refreshed }
|
||||
if output != nil { self.jobOutput = output }
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectJob(_ job: HermesCronJob) {
|
||||
selectedJob = job
|
||||
jobOutput = fileService.loadCronOutput(jobId: job.id)
|
||||
let svc = fileService
|
||||
let jobID = job.id
|
||||
Task.detached { [weak self] in
|
||||
let output = svc.loadCronOutput(jobId: jobID)
|
||||
await MainActor.run { [weak self] in self?.jobOutput = output }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CLI wrappers
|
||||
|
||||
func pauseJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "pause", job.id], success: "Paused")
|
||||
}
|
||||
|
||||
func resumeJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "resume", job.id], success: "Resumed")
|
||||
}
|
||||
|
||||
func runNow(_ job: HermesCronJob) {
|
||||
// `hermes cron run <id>` only marks the job as due on the next
|
||||
// scheduler tick — it doesn't actually execute. If the Hermes
|
||||
// gateway's scheduler isn't running (common during dev + right
|
||||
// after install), the user's "Run now" click results in zero
|
||||
// visible effect because the tick never comes. We follow up
|
||||
// with `hermes cron tick` which runs all due jobs once and
|
||||
// exits. Redundant-but-harmless when the gateway is running;
|
||||
// the actual trigger when it isn't.
|
||||
//
|
||||
// Feedback model: show a "Agent started" toast as soon as
|
||||
// `cron run` succeeds, WITHOUT waiting for `cron tick` to
|
||||
// return. Agent jobs routinely run past a minute (network IO +
|
||||
// an LLM call + a file rewrite), and earlier versions with a
|
||||
// 60s tick timeout surfaced a misleading "Run failed" toast
|
||||
// every time while the job kept running in the background.
|
||||
// The app's HermesFileWatcher picks up the dashboard.json
|
||||
// rewrite that the agent lands at the end — that's what the
|
||||
// user actually watches for, not this toast.
|
||||
let svc = fileService
|
||||
let jobID = job.id
|
||||
Task.detached { [weak self] in
|
||||
let runResult = svc.runHermesCLI(args: ["cron", "run", jobID], timeout: 30)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if runResult.exitCode != 0 {
|
||||
self.message = "Run failed to queue: \(runResult.output.prefix(200))"
|
||||
self.logger.warning("cron run failed: \(runResult.output)")
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
self.message = "Agent started — dashboard will update when it finishes"
|
||||
self.load()
|
||||
}
|
||||
// `cron run` is queued; now force the tick. The 300s
|
||||
// timeout catches truly stuck processes without killing
|
||||
// the long-but-valid agent case that blew up the 60s
|
||||
// version. A timeout here is survivable — the Hermes
|
||||
// scheduler re-runs due jobs on its own cadence — so we
|
||||
// log but don't surface it as a failure toast.
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
let tickResult = svc.runHermesCLI(args: ["cron", "tick"], timeout: 300)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if tickResult.exitCode != 0 {
|
||||
self.logger.warning("cron tick exited non-zero (job may still complete via scheduler): \(tickResult.output)")
|
||||
}
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "remove", job.id], success: "Removed")
|
||||
if selectedJob?.id == job.id {
|
||||
selectedJob = nil
|
||||
jobOutput = nil
|
||||
}
|
||||
}
|
||||
|
||||
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) {
|
||||
var args = ["cron", "create"]
|
||||
if !name.isEmpty { args += ["--name", name] }
|
||||
if !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
if !script.isEmpty { args += ["--script", script] }
|
||||
args.append(schedule)
|
||||
if !prompt.isEmpty { args.append(prompt) }
|
||||
runAndReload(args, success: "Job created")
|
||||
}
|
||||
|
||||
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?) {
|
||||
var args = ["cron", "edit", id]
|
||||
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
|
||||
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
|
||||
if let name, !name.isEmpty { args += ["--name", name] }
|
||||
if let deliver { args += ["--deliver", deliver] }
|
||||
if let repeatCount, !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||
if clearSkills {
|
||||
args.append("--clear-skills")
|
||||
} else if let newSkills {
|
||||
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
}
|
||||
if let script { args += ["--script", script] }
|
||||
runAndReload(args, success: "Updated")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func runAndReload(_ arguments: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: arguments, timeout: 60)
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.message = success
|
||||
} else {
|
||||
self.message = "Failed: \(result.output.prefix(200))"
|
||||
self.logger.warning("cron command failed: args=\(arguments) output=\(result.output)")
|
||||
}
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,148 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CronView: View {
|
||||
@State private var viewModel = CronViewModel()
|
||||
@State private var viewModel: CronViewModel
|
||||
@State private var pendingDelete: HermesCronJob?
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: CronViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
jobsList
|
||||
.frame(minWidth: 300, idealWidth: 350)
|
||||
.frame(minWidth: 320, idealWidth: 360)
|
||||
jobDetail
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Cron Jobs")
|
||||
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
|
||||
viewModel.createJob(
|
||||
schedule: form.schedule,
|
||||
prompt: form.prompt,
|
||||
name: form.name,
|
||||
deliver: form.deliver,
|
||||
skills: form.skills,
|
||||
script: form.script,
|
||||
repeatCount: form.repeatCount
|
||||
)
|
||||
viewModel.showCreateSheet = false
|
||||
} onCancel: {
|
||||
viewModel.showCreateSheet = false
|
||||
}
|
||||
}
|
||||
.sheet(item: $viewModel.editingJob) { job in
|
||||
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills) { form in
|
||||
viewModel.updateJob(
|
||||
id: job.id,
|
||||
schedule: form.schedule,
|
||||
prompt: form.prompt,
|
||||
name: form.name,
|
||||
deliver: form.deliver,
|
||||
repeatCount: form.repeatCount,
|
||||
newSkills: form.skills,
|
||||
clearSkills: form.clearSkills,
|
||||
script: form.script
|
||||
)
|
||||
viewModel.editingJob = nil
|
||||
} onCancel: {
|
||||
viewModel.editingJob = nil
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDelete.map { "Delete \($0.name)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let job = pendingDelete { viewModel.deleteJob(job) }
|
||||
pendingDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
Text("This removes the scheduled job permanently.")
|
||||
}
|
||||
}
|
||||
|
||||
private var jobsList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedJob?.id },
|
||||
set: { id in
|
||||
if let id, let job = viewModel.jobs.first(where: { $0.id == id }) {
|
||||
viewModel.selectJob(job)
|
||||
} else {
|
||||
viewModel.selectedJob = nil
|
||||
viewModel.jobOutput = nil
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.showCreateSheet = true
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
HStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(job.enabled ? .primary : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.lineLimit(1)
|
||||
Text(job.schedule.display ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if job.silent == true {
|
||||
Text("SILENT")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
Divider()
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedJob?.id },
|
||||
set: { id in
|
||||
if let id, let job = viewModel.jobs.first(where: { $0.id == id }) {
|
||||
viewModel.selectJob(job)
|
||||
} else {
|
||||
viewModel.selectedJob = nil
|
||||
viewModel.jobOutput = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
HStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(job.enabled ? .primary : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.lineLimit(1)
|
||||
Text(job.schedule.display ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if job.silent == true {
|
||||
Text("SILENT")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tag(job.id)
|
||||
.contextMenu {
|
||||
Button(job.enabled ? "Pause" : "Resume") {
|
||||
if job.enabled {
|
||||
viewModel.pauseJob(job)
|
||||
} else {
|
||||
viewModel.resumeJob(job)
|
||||
}
|
||||
}
|
||||
Button("Run Now") { viewModel.runNow(job) }
|
||||
Button("Edit") { viewModel.editingJob = job }
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) { pendingDelete = job }
|
||||
}
|
||||
}
|
||||
.tag(job.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.jobs.isEmpty {
|
||||
ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured"))
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.jobs.isEmpty {
|
||||
ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,108 +152,10 @@ struct CronView: View {
|
||||
if let job = viewModel.selectedJob {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(job.name)
|
||||
.font(.title2.bold())
|
||||
HStack(spacing: 16) {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliveryDisplay {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
detailHeader(job)
|
||||
actionBar(job)
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(job.prompt)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if let script = job.preRunScript, !script.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pre-Run Script")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(script)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(skills, id: \.self) { skill in
|
||||
Text(skill)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nextRun = job.nextRunAt {
|
||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastRun = job.lastRunAt {
|
||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = job.lastError {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let timeout = job.timeoutSeconds {
|
||||
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let failures = job.deliveryFailures, failures > 0 {
|
||||
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let deliveryError = job.lastDeliveryError {
|
||||
Label(deliveryError, systemImage: "paperplane.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let output = viewModel.jobOutput {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
detailBody(job)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
@@ -176,4 +165,257 @@ struct CronView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailHeader(_ job: HermesCronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(job.name)
|
||||
.font(.title2.bold())
|
||||
HStack(spacing: 16) {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliveryDisplay {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func actionBar(_ job: HermesCronJob) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
if job.enabled { viewModel.pauseJob(job) } else { viewModel.resumeJob(job) }
|
||||
} label: {
|
||||
Label(job.enabled ? "Pause" : "Resume", systemImage: job.enabled ? "pause" : "play")
|
||||
}
|
||||
Button {
|
||||
viewModel.runNow(job)
|
||||
} label: {
|
||||
Label("Run Now", systemImage: "bolt")
|
||||
}
|
||||
Button {
|
||||
viewModel.editingJob = job
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
pendingDelete = job
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailBody(_ job: HermesCronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(job.prompt)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if let script = job.preRunScript, !script.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pre-Run Script")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(script)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(skills, id: \.self) { skill in
|
||||
Text(skill)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nextRun = job.nextRunAt {
|
||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastRun = job.lastRunAt {
|
||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = job.lastError {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let timeout = job.timeoutSeconds {
|
||||
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let failures = job.deliveryFailures, failures > 0 {
|
||||
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let deliveryError = job.lastDeliveryError {
|
||||
Label(deliveryError, systemImage: "paperplane.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let output = viewModel.jobOutput {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create/edit sheet. Form fields mirror `hermes cron create|edit` flags.
|
||||
struct CronJobEditor: View {
|
||||
enum Mode {
|
||||
case create
|
||||
case edit(HermesCronJob)
|
||||
}
|
||||
|
||||
struct FormState {
|
||||
var name: String = ""
|
||||
var schedule: String = ""
|
||||
var prompt: String = ""
|
||||
var deliver: String = ""
|
||||
var repeatCount: String = ""
|
||||
var skills: [String] = []
|
||||
var clearSkills: Bool = false
|
||||
var script: String = ""
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let availableSkills: [String]
|
||||
let onSave: (FormState) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var form = FormState()
|
||||
@State private var isEditMode = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(headerText)
|
||||
.font(.headline)
|
||||
formField("Name", text: $form.name, placeholder: "Friendly label")
|
||||
formField("Schedule", text: $form.schedule, placeholder: "0 9 * * * or 30m or every 2h", mono: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
TextEditor(text: $form.prompt)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
|
||||
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
|
||||
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
|
||||
if !availableSkills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(availableSkills, id: \.self) { skill in
|
||||
Toggle(skill, isOn: Binding(
|
||||
get: { form.skills.contains(skill) },
|
||||
set: { on in
|
||||
if on {
|
||||
form.skills.append(skill)
|
||||
} else {
|
||||
form.skills.removeAll { $0 == skill }
|
||||
}
|
||||
}
|
||||
))
|
||||
.font(.caption.monospaced())
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.padding(6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
if isEditMode {
|
||||
Toggle("Clear all skills on save", isOn: $form.clearSkills)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
Button("Save") { onSave(form) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(form.schedule.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 560, minHeight: 560)
|
||||
.onAppear {
|
||||
if case .edit(let job) = mode {
|
||||
isEditMode = true
|
||||
form.name = job.name
|
||||
form.schedule = job.schedule.expression ?? job.schedule.display ?? ""
|
||||
form.prompt = job.prompt
|
||||
form.deliver = job.deliver ?? ""
|
||||
form.skills = job.skills ?? []
|
||||
form.script = job.preRunScript ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerText: String {
|
||||
switch mode {
|
||||
case .create: return "Create Cron Job"
|
||||
case .edit(let job): return "Edit \(job.name)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formField(_ label: String, text: Binding<String>, placeholder: String, mono: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(mono ? .system(.caption, design: .monospaced) : .caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,16 @@ import Foundation
|
||||
|
||||
@Observable
|
||||
final class DashboardViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
private let fileService = HermesFileService()
|
||||
let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
private let fileService: HermesFileService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
self.fileService = HermesFileService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var stats = HermesDataService.SessionStats.empty
|
||||
var recentSessions: [HermesSession] = []
|
||||
@@ -13,18 +21,73 @@ final class DashboardViewModel {
|
||||
var hermesRunning = false
|
||||
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 {
|
||||
isLoading = true
|
||||
let opened = await dataService.open()
|
||||
// refresh() = close + reopen, forces a fresh remote snapshot. Cheap
|
||||
// on local (live DB reopen).
|
||||
let opened = await dataService.refresh()
|
||||
var collectedErrors: [String] = []
|
||||
if opened {
|
||||
stats = await dataService.fetchStats()
|
||||
recentSessions = await dataService.fetchSessions(limit: 5)
|
||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 5)
|
||||
await dataService.close()
|
||||
} else if let msg = await dataService.lastOpenError {
|
||||
collectedErrors.append(msg)
|
||||
}
|
||||
// The fileService methods are synchronous and route through the
|
||||
// transport. For remote contexts each call is a blocking ssh
|
||||
// round-trip — do them off the main thread to avoid spinning the
|
||||
// beach ball during the load.
|
||||
let svc = fileService
|
||||
struct LoadResults: Sendable {
|
||||
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
|
||||
|
||||
switch results.cfg {
|
||||
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
|
||||
}
|
||||
config = fileService.loadConfig()
|
||||
gatewayState = fileService.loadGatewayState()
|
||||
hermesRunning = fileService.isHermesRunning()
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import SwiftUI
|
||||
|
||||
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(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: DashboardViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if let err = viewModel.lastReadError {
|
||||
readErrorBanner(err)
|
||||
}
|
||||
statusSection
|
||||
statsSection
|
||||
recentSessionsSection
|
||||
@@ -16,10 +25,53 @@ struct DashboardView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Dashboard")
|
||||
.loadingOverlay(
|
||||
viewModel.isLoading,
|
||||
label: "Loading dashboard…",
|
||||
isEmpty: viewModel.recentSessions.isEmpty
|
||||
)
|
||||
.task { await viewModel.load() }
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
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 {
|
||||
@@ -62,7 +114,7 @@ struct DashboardView: View {
|
||||
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
||||
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
|
||||
if cost > 0 {
|
||||
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
|
||||
StatCard(label: "Cost", value: cost.formatted(.currency(code: "USD").precision(.fractionLength(2))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,7 +217,7 @@ struct SessionRow: View {
|
||||
Label("\(session.messageCount)", systemImage: "bubble.left")
|
||||
Label("\(session.toolCallCount)", systemImage: "wrench")
|
||||
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)
|
||||
|
||||
@@ -37,6 +37,12 @@ struct PendingPairing: Identifiable {
|
||||
|
||||
@Observable
|
||||
final class GatewayViewModel {
|
||||
let context: ServerContext
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false)
|
||||
var approvedUsers: [PairedUser] = []
|
||||
var pendingPairings: [PendingPairing] = []
|
||||
@@ -45,52 +51,26 @@ final class GatewayViewModel {
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
loadGatewayStatus()
|
||||
loadPairing()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func startGateway() {
|
||||
runHermes(["gateway", "start"])
|
||||
actionMessage = "Gateway start requested"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.loadGatewayStatus()
|
||||
self?.actionMessage = nil
|
||||
let ctx = context
|
||||
Task.detached { [weak self] in
|
||||
// Two sync transport calls + two CLI invocations — substantial
|
||||
// remote latency. Detach the whole load and commit at the end.
|
||||
let status = Self.fetchGatewayStatus(context: ctx)
|
||||
let pairing = Self.fetchPairing(context: ctx)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.gateway = status
|
||||
self.approvedUsers = pairing.approved
|
||||
self.pendingPairings = pairing.pending
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopGateway() {
|
||||
runHermes(["gateway", "stop"])
|
||||
actionMessage = "Gateway stop requested"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.loadGatewayStatus()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func restartGateway() {
|
||||
runHermes(["gateway", "restart"])
|
||||
actionMessage = "Gateway restart requested"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.loadGatewayStatus()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func approvePairing(platform: String, code: String) {
|
||||
runHermes(["pairing", "approve", platform, code])
|
||||
loadPairing()
|
||||
}
|
||||
|
||||
func revokeUser(_ user: PairedUser) {
|
||||
runHermes(["pairing", "revoke", user.platform, user.userId])
|
||||
approvedUsers.removeAll { $0.id == user.id }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadGatewayStatus() {
|
||||
let stateJSON = FileManager.default.contents(atPath: HermesPaths.gatewayStateJSON)
|
||||
/// Static form of the gateway-status walk so the detached load can call
|
||||
/// it without bouncing back to MainActor.
|
||||
nonisolated private static func fetchGatewayStatus(context: ServerContext) -> GatewayInfo {
|
||||
let stateJSON = context.readData(context.paths.gatewayStateJSON)
|
||||
var pid: Int?
|
||||
var state = "unknown"
|
||||
var exitReason: String?
|
||||
@@ -117,21 +97,21 @@ final class GatewayViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
let statusOutput = runHermes(["gateway", "status"]).output
|
||||
let statusOutput = context.runHermes(["gateway", "status"]).output
|
||||
let isLoaded = statusOutput.contains("service is loaded")
|
||||
let isStale = statusOutput.contains("stale")
|
||||
|
||||
gateway = GatewayInfo(
|
||||
return GatewayInfo(
|
||||
pid: pid, state: state, exitReason: exitReason,
|
||||
startTime: startTime, updatedAt: updatedAt,
|
||||
platforms: platforms, isLoaded: isLoaded, isStale: isStale
|
||||
)
|
||||
}
|
||||
|
||||
private func loadPairing() {
|
||||
let output = runHermes(["pairing", "list"]).output
|
||||
approvedUsers = []
|
||||
pendingPairings = []
|
||||
nonisolated private static func fetchPairing(context: ServerContext) -> (approved: [PairedUser], pending: [PendingPairing]) {
|
||||
let output = context.runHermes(["pairing", "list"]).output
|
||||
var approved: [PairedUser] = []
|
||||
var pending: [PendingPairing] = []
|
||||
|
||||
var inApproved = false
|
||||
var inPending = false
|
||||
@@ -147,31 +127,59 @@ final class GatewayViewModel {
|
||||
let platform = String(parts[0])
|
||||
let userId = String(parts[1])
|
||||
let name = parts[2...].joined(separator: " ")
|
||||
approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name))
|
||||
}
|
||||
if inPending && parts.count >= 2 {
|
||||
approved.append(PairedUser(platform: platform, userId: userId, name: name))
|
||||
} else if inPending && parts.count >= 2 {
|
||||
let platform = String(parts[0])
|
||||
let code = String(parts[1])
|
||||
pendingPairings.append(PendingPairing(platform: platform, code: code))
|
||||
pending.append(PendingPairing(platform: platform, code: code))
|
||||
}
|
||||
}
|
||||
return (approved, pending)
|
||||
}
|
||||
|
||||
func startGateway() {
|
||||
runHermes(["gateway", "start"])
|
||||
actionMessage = "Gateway start requested"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.load()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
func stopGateway() {
|
||||
runHermes(["gateway", "stop"])
|
||||
actionMessage = "Gateway stop requested"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.load()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func restartGateway() {
|
||||
runHermes(["gateway", "restart"])
|
||||
actionMessage = "Gateway restart requested"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.load()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func approvePairing(platform: String, code: String) {
|
||||
runHermes(["pairing", "approve", platform, code])
|
||||
load()
|
||||
}
|
||||
|
||||
func revokeUser(_ user: PairedUser) {
|
||||
runHermes(["pairing", "revoke", user.platform, user.userId])
|
||||
approvedUsers.removeAll { $0.id == user.id }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
// (loadGatewayStatus / loadPairing were moved to static helpers above
|
||||
// so the detached load() can run them without touching MainActor state.)
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
context.runHermes(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayView: View {
|
||||
@State private var viewModel = GatewayViewModel()
|
||||
@State private var viewModel: GatewayViewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: GatewayViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
@@ -97,7 +102,7 @@ struct GatewayView: View {
|
||||
Image(systemName: platform.icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
|
||||
Text(platform.name.capitalized)
|
||||
Text(verbatim: platform.name.capitalized)
|
||||
.font(.caption.bold())
|
||||
StatusBadge(
|
||||
label: platform.state,
|
||||
|
||||
@@ -22,7 +22,14 @@ struct HealthSection: Identifiable {
|
||||
|
||||
@Observable
|
||||
final class HealthViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
let context: ServerContext
|
||||
private let fileService: HermesFileService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.fileService = HermesFileService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var version = ""
|
||||
var updateInfo = ""
|
||||
@@ -37,21 +44,56 @@ final class HealthViewModel {
|
||||
var hermesPID: pid_t?
|
||||
var actionMessage: String?
|
||||
|
||||
/// Text output from `hermes dump` / `hermes debug share`. Shown in an expandable panel.
|
||||
var diagnosticsOutput: String = ""
|
||||
var isSharingDebug = false
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
refreshProcessStatus()
|
||||
loadVersion()
|
||||
let statusOutput = runHermes(["status"]).output
|
||||
statusSections = parseOutput(statusOutput)
|
||||
let doctorOutput = runHermes(["doctor"]).output
|
||||
doctorSections = parseOutput(doctorOutput)
|
||||
computeCounts()
|
||||
isLoading = false
|
||||
let ctx = context
|
||||
let svc = fileService
|
||||
// Health runs four sync transport-mediated commands plus a process
|
||||
// probe — that's 4-5 ssh round-trips on remote, easily 1-2s. Detach
|
||||
// the whole load.
|
||||
Task.detached { [weak self] in
|
||||
let pid = svc.hermesPID()
|
||||
let versionOutput = ctx.runHermes(["version"]).output
|
||||
let statusOutput = ctx.runHermes(["status"]).output
|
||||
let doctorOutput = ctx.runHermes(["doctor"]).output
|
||||
|
||||
let lines = versionOutput.components(separatedBy: "\n")
|
||||
let version = lines.first ?? ""
|
||||
let updateLine = lines.first(where: { $0.contains("commits behind") })
|
||||
let hasUpdate = updateLine != nil
|
||||
let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
|
||||
let statusSections = Self.parseOutputStatic(statusOutput)
|
||||
let doctorSections = Self.parseOutputStatic(doctorOutput)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.hermesPID = pid
|
||||
self.hermesRunning = pid != nil
|
||||
self.version = version
|
||||
self.updateInfo = updateInfo
|
||||
self.hasUpdate = hasUpdate
|
||||
self.statusSections = statusSections
|
||||
self.doctorSections = doctorSections
|
||||
self.computeCounts()
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshProcessStatus() {
|
||||
hermesPID = fileService.hermesPID()
|
||||
hermesRunning = hermesPID != nil
|
||||
let svc = fileService
|
||||
Task.detached { [weak self] in
|
||||
let pid = svc.hermesPID()
|
||||
await MainActor.run { [weak self] in
|
||||
self?.hermesPID = pid
|
||||
self?.hermesRunning = pid != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopHermes() {
|
||||
@@ -97,6 +139,96 @@ final class HealthViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static-callable form for the detached load() task. The instance
|
||||
/// `parseOutput` below delegates here so existing call sites still work.
|
||||
nonisolated static func parseOutputStatic(_ output: String) -> [HealthSection] {
|
||||
var sections: [HealthSection] = []
|
||||
var currentTitle = ""
|
||||
var currentChecks: [HealthCheck] = []
|
||||
|
||||
for line in output.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed.hasPrefix("◆ ") {
|
||||
if !currentTitle.isEmpty {
|
||||
sections.append(HealthSection(
|
||||
title: currentTitle,
|
||||
icon: iconForSectionStatic(currentTitle),
|
||||
checks: currentChecks
|
||||
))
|
||||
}
|
||||
currentTitle = String(trimmed.dropFirst(2))
|
||||
currentChecks = []
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.hasPrefix("✓ ") {
|
||||
let text = String(trimmed.dropFirst(2))
|
||||
let (label, detail) = splitCheckStatic(text)
|
||||
currentChecks.append(HealthCheck(label: label, status: .ok, detail: detail))
|
||||
} else if trimmed.hasPrefix("⚠ ") || trimmed.hasPrefix("⚠") {
|
||||
let text = trimmed.replacingOccurrences(of: "⚠ ", with: "").replacingOccurrences(of: "⚠", with: "")
|
||||
let (label, detail) = splitCheckStatic(text)
|
||||
currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail))
|
||||
} else if trimmed.hasPrefix("✗ ") {
|
||||
let text = String(trimmed.dropFirst(2))
|
||||
let (label, detail) = splitCheckStatic(text)
|
||||
currentChecks.append(HealthCheck(label: label, status: .error, detail: detail))
|
||||
} else if trimmed.hasPrefix("→ ") || trimmed.hasPrefix("Error:") {
|
||||
if !currentChecks.isEmpty {
|
||||
let last = currentChecks.removeLast()
|
||||
let extra = trimmed.replacingOccurrences(of: "→ ", with: "").replacingOccurrences(of: "Error:", with: "").trimmingCharacters(in: .whitespaces)
|
||||
let combined = [last.detail, extra].compactMap { $0 }.joined(separator: " ")
|
||||
currentChecks.append(HealthCheck(label: last.label, status: last.status, detail: combined))
|
||||
}
|
||||
} else if !trimmed.isEmpty && trimmed.contains(":") && !trimmed.hasPrefix("┌") && !trimmed.hasPrefix("│") && !trimmed.hasPrefix("└") && !trimmed.hasPrefix("─") && !trimmed.hasPrefix("Run ") && !trimmed.hasPrefix("Found ") && !trimmed.hasPrefix("Tip:") {
|
||||
let parts = trimmed.split(separator: ":", maxSplits: 1)
|
||||
if parts.count == 2 {
|
||||
let key = parts[0].trimmingCharacters(in: .whitespaces)
|
||||
let val = parts[1].trimmingCharacters(in: .whitespaces)
|
||||
if !key.isEmpty && key.count < 30 {
|
||||
currentChecks.append(HealthCheck(label: key, status: .ok, detail: val))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !currentTitle.isEmpty {
|
||||
sections.append(HealthSection(
|
||||
title: currentTitle,
|
||||
icon: iconForSectionStatic(currentTitle),
|
||||
checks: currentChecks
|
||||
))
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
nonisolated private static func splitCheckStatic(_ text: String) -> (String, String?) {
|
||||
if let range = text.range(of: ":") {
|
||||
let label = String(text[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||
let detail = String(text[range.upperBound...]).trimmingCharacters(in: .whitespaces)
|
||||
return (label, detail.isEmpty ? nil : detail)
|
||||
}
|
||||
return (text, nil)
|
||||
}
|
||||
|
||||
nonisolated private static func iconForSectionStatic(_ title: String) -> String {
|
||||
let lower = title.lowercased()
|
||||
if lower.contains("system") || lower.contains("environment") { return "desktopcomputer" }
|
||||
if lower.contains("config") { return "doc.text" }
|
||||
if lower.contains("model") || lower.contains("provider") { return "brain" }
|
||||
if lower.contains("memory") { return "memorychip" }
|
||||
if lower.contains("session") { return "list.bullet" }
|
||||
if lower.contains("gateway") || lower.contains("platform") { return "antenna.radiowaves.left.and.right" }
|
||||
if lower.contains("skill") { return "wrench.and.screwdriver" }
|
||||
if lower.contains("mcp") { return "cube.box" }
|
||||
if lower.contains("plugin") { return "puzzlepiece" }
|
||||
if lower.contains("auth") || lower.contains("credential") { return "key" }
|
||||
if lower.contains("disk") || lower.contains("storage") { return "internaldrive" }
|
||||
if lower.contains("update") { return "arrow.triangle.2.circlepath" }
|
||||
return "circle"
|
||||
}
|
||||
|
||||
private func parseOutput(_ output: String) -> [HealthSection] {
|
||||
var sections: [HealthSection] = []
|
||||
var currentTitle = ""
|
||||
@@ -201,20 +333,38 @@ final class HealthViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
/// Capture `hermes dump` output — a setup summary used for debugging / support.
|
||||
/// Does NOT upload anything.
|
||||
func runDump() {
|
||||
actionMessage = "Running dump…"
|
||||
let result = runHermes(["dump"])
|
||||
diagnosticsOutput = result.output
|
||||
actionMessage = result.exitCode == 0 ? "Dump captured" : "Dump failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a debug report via `hermes debug share`. THIS UPLOADS DATA to Nous
|
||||
/// Research support infrastructure — caller must confirm with the user first.
|
||||
func runDebugShare() {
|
||||
isSharingDebug = true
|
||||
actionMessage = "Uploading debug report…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["debug", "share"], timeout: 120)
|
||||
await MainActor.run {
|
||||
self.isSharingDebug = false
|
||||
self.diagnosticsOutput = result.output
|
||||
self.actionMessage = result.exitCode == 0 ? "Upload complete" : "Upload failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
context.runHermes(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HealthView: View {
|
||||
@State private var viewModel = HealthViewModel()
|
||||
@State private var viewModel: HealthViewModel
|
||||
@State private var expandedSection: UUID?
|
||||
@State private var selectedTab = 0
|
||||
@State private var showShareConfirm = false
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: HealthViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerBar
|
||||
Divider()
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Status").tag(0)
|
||||
Text("Diagnostics").tag(1)
|
||||
HStack {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Status").tag(0)
|
||||
Text("Diagnostics").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
Spacer()
|
||||
Button("Run Dump") {
|
||||
viewModel.runDump()
|
||||
showDiagnostics = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Share Debug Report…") {
|
||||
showShareConfirm = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.isSharingDebug)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal)
|
||||
if showDiagnostics && !viewModel.diagnosticsOutput.isEmpty {
|
||||
Divider()
|
||||
diagnosticsPanel
|
||||
}
|
||||
Divider()
|
||||
ScrollView {
|
||||
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
|
||||
@@ -23,7 +48,46 @@ struct HealthView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Health")
|
||||
.loadingOverlay(
|
||||
viewModel.isLoading,
|
||||
label: "Running health checks…",
|
||||
isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty
|
||||
)
|
||||
.onAppear { viewModel.load() }
|
||||
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
|
||||
Button("Upload", role: .destructive) {
|
||||
viewModel.runDebugShare()
|
||||
showDiagnostics = true
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.")
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Diagnostic Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Hide") { showDiagnostics = false }
|
||||
.controlSize(.mini)
|
||||
}
|
||||
ScrollView {
|
||||
Text(viewModel.diagnosticsOutput)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 240)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -68,7 +132,7 @@ struct HealthView: View {
|
||||
Circle()
|
||||
.fill(viewModel.hermesRunning ? .green : .red)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(viewModel.hermesRunning ? "Hermes Running" : "Hermes Stopped")
|
||||
(viewModel.hermesRunning ? Text("Hermes Running") : Text("Hermes Stopped"))
|
||||
.font(.caption.bold())
|
||||
if let pid = viewModel.hermesPID {
|
||||
Text("PID \(pid)")
|
||||
|
||||
@@ -8,6 +8,15 @@ enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||
|
||||
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 {
|
||||
let calendar = Calendar.current
|
||||
switch self {
|
||||
@@ -56,7 +65,14 @@ struct NotableSession: Identifiable {
|
||||
|
||||
@Observable
|
||||
final class InsightsViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var period: InsightsPeriod = .month
|
||||
var isLoading = true
|
||||
@@ -85,7 +101,9 @@ final class InsightsViewModel {
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
let opened = await dataService.open()
|
||||
// refresh() forces a fresh remote snapshot each load. On local it's
|
||||
// a cheap reopen of the live DB.
|
||||
let opened = await dataService.refresh()
|
||||
guard opened else {
|
||||
isLoading = false
|
||||
return
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InsightsView: View {
|
||||
@State private var viewModel = InsightsViewModel()
|
||||
@State private var viewModel: InsightsViewModel
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: InsightsViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -23,12 +29,15 @@ struct InsightsView: View {
|
||||
.onChange(of: viewModel.period) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
|
||||
private var periodPicker: some View {
|
||||
Picker("Period", selection: $viewModel.period) {
|
||||
ForEach(InsightsPeriod.allCases) { period in
|
||||
Text(period.rawValue).tag(period)
|
||||
Text(period.displayName).tag(period)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
@@ -52,10 +61,10 @@ struct InsightsView: View {
|
||||
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
||||
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
|
||||
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: "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))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,7 +90,7 @@ struct InsightsView: View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(model.sessions) sessions")
|
||||
.font(.caption)
|
||||
Text(formatTokens(model.totalTokens) + " tokens")
|
||||
Text("\(formatTokens(model.totalTokens)) tokens")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -155,7 +164,7 @@ struct InsightsView: View {
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40, alignment: .trailing)
|
||||
Text(String(format: "%.1f%%", tool.percentage))
|
||||
Text((tool.percentage / 100).formatted(.percent.precision(.fractionLength(1))))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
@@ -184,12 +193,12 @@ struct InsightsView: View {
|
||||
Text("By Day")
|
||||
.font(.caption.bold())
|
||||
.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)
|
||||
ForEach(0..<7, id: \.self) { day in
|
||||
let count = viewModel.dailyActivity[day] ?? 0
|
||||
HStack(spacing: 6) {
|
||||
Text(dayNames[day])
|
||||
Text(verbatim: dayNames[(day + 1) % 7])
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 30, alignment: .trailing)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
|
||||
@@ -2,7 +2,13 @@ import Foundation
|
||||
|
||||
@Observable
|
||||
final class LogsViewModel {
|
||||
private let logService = HermesLogService()
|
||||
let context: ServerContext
|
||||
private let logService: HermesLogService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.logService = HermesLogService(context: context)
|
||||
}
|
||||
|
||||
var entries: [LogEntry] = []
|
||||
var selectedLogFile: LogFile = .agent
|
||||
@@ -18,15 +24,23 @@ final class LogsViewModel {
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var path: String {
|
||||
var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .agent: return HermesPaths.agentLog
|
||||
case .errors: return HermesPaths.errorsLog
|
||||
case .gateway: return HermesPaths.gatewayLog
|
||||
case .agent: return "Agent"
|
||||
case .errors: return "Errors"
|
||||
case .gateway: return "Gateway"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func path(for file: LogFile) -> String {
|
||||
switch file {
|
||||
case .agent: return context.paths.agentLog
|
||||
case .errors: return context.paths.errorsLog
|
||||
case .gateway: return context.paths.gatewayLog
|
||||
}
|
||||
}
|
||||
|
||||
enum LogComponent: String, CaseIterable, Identifiable {
|
||||
case all = "All"
|
||||
case gateway = "Gateway"
|
||||
@@ -37,6 +51,17 @@ final class LogsViewModel {
|
||||
|
||||
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? {
|
||||
switch self {
|
||||
case .all: return nil
|
||||
@@ -62,7 +87,7 @@ final class LogsViewModel {
|
||||
}
|
||||
|
||||
func load() async {
|
||||
await logService.openLog(path: selectedLogFile.path)
|
||||
await logService.openLog(path: path(for: selectedLogFile))
|
||||
entries = await logService.readLastLines(count: 500)
|
||||
await logService.seekToEnd()
|
||||
startPolling()
|
||||
@@ -71,7 +96,7 @@ final class LogsViewModel {
|
||||
func switchLogFile(_ file: LogFile) async {
|
||||
selectedLogFile = file
|
||||
entries = []
|
||||
await logService.openLog(path: file.path)
|
||||
await logService.openLog(path: path(for: file))
|
||||
entries = await logService.readLastLines(count: 500)
|
||||
await logService.seekToEnd()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LogsView: View {
|
||||
@State private var viewModel = LogsViewModel()
|
||||
@State private var viewModel: LogsViewModel
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: LogsViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -22,7 +27,7 @@ struct LogsView: View {
|
||||
set: { file in Task { await viewModel.switchLogFile(file) } }
|
||||
)) {
|
||||
ForEach(LogsViewModel.LogFile.allCases) { file in
|
||||
Text(file.rawValue).tag(file)
|
||||
Text(file.displayName).tag(file)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
@@ -30,7 +35,7 @@ struct LogsView: View {
|
||||
|
||||
Picker("Component", selection: $viewModel.selectedComponent) {
|
||||
ForEach(LogsViewModel.LogComponent.allCases) { component in
|
||||
Text(component.rawValue).tag(component)
|
||||
Text(component.displayName).tag(component)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 140)
|
||||
@@ -40,7 +45,7 @@ struct LogsView: View {
|
||||
Picker("Level", selection: $viewModel.filterLevel) {
|
||||
Text("All Levels").tag(LogEntry.LogLevel?.none)
|
||||
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)
|
||||
@@ -61,7 +66,7 @@ struct LogsView: View {
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 140, alignment: .leading)
|
||||
Text(entry.level.rawValue)
|
||||
Text(verbatim: entry.level.rawValue)
|
||||
.font(.caption.monospaced().bold())
|
||||
.foregroundStyle(colorForLevel(entry.level))
|
||||
.frame(width: 60, alignment: .leading)
|
||||
|
||||
@@ -8,7 +8,8 @@ final class MCPServerEditorViewModel {
|
||||
var value: String
|
||||
}
|
||||
|
||||
private let fileService = HermesFileService()
|
||||
let context: ServerContext
|
||||
private let fileService: HermesFileService
|
||||
let server: HermesMCPServer
|
||||
|
||||
var envDraft: [KeyValueRow]
|
||||
@@ -23,8 +24,10 @@ final class MCPServerEditorViewModel {
|
||||
var isSaving: Bool = false
|
||||
var saveError: String?
|
||||
|
||||
init(server: HermesMCPServer) {
|
||||
init(server: HermesMCPServer, context: ServerContext = .local) {
|
||||
self.server = server
|
||||
self.context = context
|
||||
self.fileService = HermesFileService(context: context)
|
||||
self.envDraft = server.env.keys.sorted().map { KeyValueRow(key: $0, value: server.env[$0] ?? "") }
|
||||
self.headersDraft = server.headers.keys.sorted().map { KeyValueRow(key: $0, value: server.headers[$0] ?? "") }
|
||||
self.includeDraft = server.toolsInclude.joined(separator: ", ")
|
||||
@@ -73,27 +76,33 @@ final class MCPServerEditorViewModel {
|
||||
let prompts = promptsEnabled
|
||||
|
||||
Task.detached {
|
||||
var success = true
|
||||
switch transport {
|
||||
case .stdio:
|
||||
if !service.setMCPServerEnv(name: name, env: envMap) { success = false }
|
||||
case .http:
|
||||
if !service.setMCPServerHeaders(name: name, headers: headerMap) { success = false }
|
||||
}
|
||||
if !service.updateMCPToolFilters(
|
||||
name: name,
|
||||
include: include,
|
||||
exclude: exclude,
|
||||
resources: resources,
|
||||
prompts: prompts
|
||||
) { success = false }
|
||||
if !service.setMCPServerTimeouts(name: name, timeout: timeoutValue, connectTimeout: connectValue) {
|
||||
success = false
|
||||
}
|
||||
// Compute success as an immutable so the MainActor.run closure
|
||||
// captures a value, not a mutable var. Swift 6 rejects
|
||||
// var-captures across concurrent closures as data races.
|
||||
let success: Bool = {
|
||||
var ok = true
|
||||
switch transport {
|
||||
case .stdio:
|
||||
if !service.setMCPServerEnv(name: name, env: envMap) { ok = false }
|
||||
case .http:
|
||||
if !service.setMCPServerHeaders(name: name, headers: headerMap) { ok = false }
|
||||
}
|
||||
if !service.updateMCPToolFilters(
|
||||
name: name,
|
||||
include: include,
|
||||
exclude: exclude,
|
||||
resources: resources,
|
||||
prompts: prompts
|
||||
) { ok = false }
|
||||
if !service.setMCPServerTimeouts(name: name, timeout: timeoutValue, connectTimeout: connectValue) {
|
||||
ok = false
|
||||
}
|
||||
return ok
|
||||
}()
|
||||
await MainActor.run {
|
||||
self.isSaving = false
|
||||
if !success {
|
||||
self.saveError = "One or more fields could not be written. Check \(HermesPaths.configYAML)."
|
||||
self.saveError = "One or more fields could not be written. Check \(self.context.paths.configYAML)."
|
||||
}
|
||||
completion(success)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ import Foundation
|
||||
|
||||
@Observable
|
||||
final class MCPServersViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
let context: ServerContext
|
||||
private let fileService: HermesFileService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.fileService = HermesFileService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var servers: [HermesMCPServer] = []
|
||||
var selectedServerName: String?
|
||||
@@ -41,10 +48,19 @@ final class MCPServersViewModel {
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
servers = fileService.loadMCPServers()
|
||||
isLoading = false
|
||||
if let name = selectedServerName, !servers.contains(where: { $0.name == name }) {
|
||||
selectedServerName = nil
|
||||
let svc = fileService
|
||||
Task.detached { [weak self] in
|
||||
// loadMCPServers reads config.yaml + lists mcp-tokens — both
|
||||
// are sync transport calls that block on remote ssh round-trips.
|
||||
let result = svc.loadMCPServers()
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.servers = result
|
||||
self.isLoading = false
|
||||
if let name = self.selectedServerName, !result.contains(where: { $0.name == name }) {
|
||||
self.selectedServerName = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ struct MCPServerDetailView: View {
|
||||
Text(key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Text(String(repeating: "•", count: 10))
|
||||
Text("••••••••••")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -182,7 +182,7 @@ struct MCPServerDetailView: View {
|
||||
Text(key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Text(String(repeating: "•", count: 10))
|
||||
Text("••••••••••")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ struct MCPServerPresetPickerView: View {
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(selectedPreset?.displayName ?? "Add from Preset")
|
||||
(selectedPreset.map { Text(verbatim: $0.displayName) } ?? Text("Add from Preset"))
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -83,14 +83,14 @@ struct MCPServerPresetPickerView: View {
|
||||
Image(systemName: preset.iconSystemName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text(preset.displayName)
|
||||
Text(verbatim: preset.displayName)
|
||||
.font(.body.bold())
|
||||
Spacer()
|
||||
Image(systemName: preset.transport == .http ? "network" : "terminal")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(preset.description)
|
||||
Text(verbatim: preset.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
|
||||
@@ -10,9 +10,9 @@ struct MCPServerTestResultView: View {
|
||||
Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill")
|
||||
.foregroundStyle(result.succeeded ? .green : .red)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(result.succeeded ? "Test passed" : "Test failed")
|
||||
(result.succeeded ? Text("Test passed") : Text("Test failed"))
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -20,8 +20,12 @@ struct MCPServerTestResultView: View {
|
||||
Button {
|
||||
showOutput.toggle()
|
||||
} label: {
|
||||
Label(showOutput ? "Hide Output" : "Show Output", systemImage: showOutput ? "chevron.up" : "chevron.down")
|
||||
.font(.caption)
|
||||
Label {
|
||||
showOutput ? Text("Hide Output") : Text("Show Output")
|
||||
} icon: {
|
||||
Image(systemName: showOutput ? "chevron.up" : "chevron.down")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServersView: View {
|
||||
@State private var viewModel = MCPServersViewModel()
|
||||
@State private var viewModel: MCPServersViewModel
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: MCPServersViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
@@ -11,6 +16,11 @@ struct MCPServersView: View {
|
||||
.frame(minWidth: 500)
|
||||
}
|
||||
.navigationTitle("MCP Servers (\(viewModel.servers.count))")
|
||||
.loadingOverlay(
|
||||
viewModel.isLoading,
|
||||
label: "Loading MCP servers…",
|
||||
isEmpty: viewModel.servers.isEmpty
|
||||
)
|
||||
.searchable(text: $viewModel.searchText, prompt: "Filter servers...")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
@@ -118,7 +128,7 @@ struct MCPServersView: View {
|
||||
} else if let result = viewModel.testResults[server.name] {
|
||||
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ import Foundation
|
||||
|
||||
@Observable
|
||||
final class MemoryViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
let context: ServerContext
|
||||
private let fileService: HermesFileService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.fileService = HermesFileService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var memoryContent = ""
|
||||
var userContent = ""
|
||||
@@ -12,6 +19,7 @@ final class MemoryViewModel {
|
||||
var editText = ""
|
||||
var profiles: [String] = []
|
||||
var activeProfile = ""
|
||||
var isLoading = false
|
||||
|
||||
enum EditTarget {
|
||||
case memory, user
|
||||
@@ -30,20 +38,40 @@ final class MemoryViewModel {
|
||||
var hasMultipleProfiles: Bool { !profiles.isEmpty }
|
||||
|
||||
func load() {
|
||||
let config = fileService.loadConfig()
|
||||
memoryProvider = config.memoryProvider
|
||||
profiles = fileService.loadMemoryProfiles()
|
||||
if activeProfile.isEmpty {
|
||||
activeProfile = config.memoryProfile
|
||||
isLoading = true
|
||||
let svc = fileService
|
||||
let currentProfile = activeProfile
|
||||
// Sync transport calls would beach-ball the UI on remote — dispatch
|
||||
// off main, then commit results back on MainActor.
|
||||
Task.detached { [weak self] in
|
||||
let config = svc.loadConfig()
|
||||
let profiles = svc.loadMemoryProfiles()
|
||||
let profile = currentProfile.isEmpty ? config.memoryProfile : currentProfile
|
||||
let memory = svc.loadMemory(profile: profile)
|
||||
let user = svc.loadUserProfile(profile: profile)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.memoryProvider = config.memoryProvider
|
||||
self.profiles = profiles
|
||||
self.activeProfile = profile
|
||||
self.memoryContent = memory
|
||||
self.userContent = user
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
memoryContent = fileService.loadMemory(profile: activeProfile)
|
||||
userContent = fileService.loadUserProfile(profile: activeProfile)
|
||||
}
|
||||
|
||||
func switchProfile(_ profile: String) {
|
||||
activeProfile = profile
|
||||
memoryContent = fileService.loadMemory(profile: profile)
|
||||
userContent = fileService.loadUserProfile(profile: profile)
|
||||
let svc = fileService
|
||||
Task.detached { [weak self] in
|
||||
let memory = svc.loadMemory(profile: profile)
|
||||
let user = svc.loadUserProfile(profile: profile)
|
||||
await MainActor.run { [weak self] in
|
||||
self?.memoryContent = memory
|
||||
self?.userContent = user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startEditing(_ target: EditTarget) {
|
||||
@@ -53,15 +81,24 @@ final class MemoryViewModel {
|
||||
}
|
||||
|
||||
func save() {
|
||||
switch editingFile {
|
||||
case .memory:
|
||||
fileService.saveMemory(editText, profile: activeProfile)
|
||||
memoryContent = editText
|
||||
case .user:
|
||||
fileService.saveUserProfile(editText, profile: activeProfile)
|
||||
userContent = editText
|
||||
let svc = fileService
|
||||
let target = editingFile
|
||||
let text = editText
|
||||
let profile = activeProfile
|
||||
Task.detached { [weak self] in
|
||||
switch target {
|
||||
case .memory: svc.saveMemory(text, profile: profile)
|
||||
case .user: svc.saveUserProfile(text, profile: profile)
|
||||
}
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
switch target {
|
||||
case .memory: self.memoryContent = text
|
||||
case .user: self.userContent = text
|
||||
}
|
||||
self.isEditing = false
|
||||
}
|
||||
}
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MemoryView: View {
|
||||
@State private var viewModel = MemoryViewModel()
|
||||
@State private var viewModel: MemoryViewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: MemoryViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
@@ -43,6 +48,11 @@ struct MemoryView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Memory")
|
||||
.loadingOverlay(
|
||||
viewModel.isLoading,
|
||||
label: "Loading memory…",
|
||||
isEmpty: viewModel.memoryContent.isEmpty && viewModel.userContent.isEmpty
|
||||
)
|
||||
.onAppear { viewModel.load() }
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
viewModel.load()
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A personality defined under the `personalities:` block in config.yaml.
|
||||
/// Each entry may have a free-form `prompt` string plus arbitrary extra fields.
|
||||
struct HermesPersonality: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class PersonalitiesViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "PersonalitiesViewModel")
|
||||
let context: ServerContext
|
||||
private let fileService: HermesFileService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.fileService = HermesFileService(context: context)
|
||||
}
|
||||
|
||||
var personalities: [HermesPersonality] = []
|
||||
var activeName: String = ""
|
||||
var soulMarkdown: String = ""
|
||||
var soulPath: String { context.paths.soulMD }
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let svc = fileService
|
||||
let ctx = context
|
||||
let path = soulPath
|
||||
Task.detached { [weak self] in
|
||||
let config = svc.loadConfig()
|
||||
let parsed = Self.parsePersonalitiesBlock(yaml: ctx.readText(ctx.paths.configYAML) ?? "")
|
||||
let soul = ctx.readText(path) ?? ""
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeName = config.personality
|
||||
self.personalities = parsed
|
||||
self.soulMarkdown = soul
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Static form so the detached load can call into it without touching
|
||||
/// MainActor-isolated state. The instance form below remains for any
|
||||
/// other callers that need it.
|
||||
nonisolated private static func parsePersonalitiesBlock(yaml: String) -> [HermesPersonality] {
|
||||
guard !yaml.isEmpty else { return [] }
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
var nameSet: Set<String> = []
|
||||
for key in parsed.values.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
for key in parsed.lists.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
return nameSet.sorted().map { name in
|
||||
let prompt = parsed.values["personalities.\(name).prompt"] ?? ""
|
||||
return HermesPersonality(name: name, prompt: HermesFileService.stripYAMLQuotes(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `personalities:` section of config.yaml using the nested parser.
|
||||
/// Each personality is a top-level key under `personalities`, optionally with
|
||||
/// a `prompt:` child.
|
||||
private func parsePersonalitiesBlock() -> [HermesPersonality] {
|
||||
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
// Find all keys "personalities.<name>[.subkey]"
|
||||
var nameSet: Set<String> = []
|
||||
for key in parsed.values.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
for key in parsed.lists.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
return nameSet.sorted().map { name in
|
||||
let prompt = parsed.values["personalities.\(name).prompt"] ?? ""
|
||||
return HermesPersonality(name: name, prompt: HermesFileService.stripYAMLQuotes(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ name: String) {
|
||||
let result = runHermes(["config", "set", "display.personality", name])
|
||||
if result.exitCode == 0 {
|
||||
activeName = name
|
||||
message = "Active personality set to \(name)"
|
||||
} else {
|
||||
logger.warning("Failed to set personality: \(result.output)")
|
||||
message = "Failed to set personality"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func saveSOUL(_ content: String) {
|
||||
if context.writeText(soulPath, content: content) {
|
||||
soulMarkdown = content
|
||||
message = "SOUL.md saved"
|
||||
} else {
|
||||
logger.error("Failed to write SOUL.md to \(self.context.displayName)")
|
||||
message = "Save failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openConfigInEditor() {
|
||||
context.openInLocalEditor(context.paths.configYAML)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
context.runHermes(arguments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PersonalitiesView: View {
|
||||
@State private var viewModel: PersonalitiesViewModel
|
||||
@State private var soulDraft = ""
|
||||
@State private var editingSOUL = false
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: PersonalitiesViewModel(context: context))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
activeSection
|
||||
listSection
|
||||
soulSection
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Personalities")
|
||||
.onAppear {
|
||||
viewModel.load()
|
||||
soulDraft = viewModel.soulMarkdown
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Edit config.yaml") { viewModel.openConfigInEditor() }
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load(); soulDraft = viewModel.soulMarkdown }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSection: some View {
|
||||
SettingsSection(title: "Active Personality", icon: "theatermasks.fill") {
|
||||
if viewModel.personalities.isEmpty {
|
||||
ReadOnlyRow(label: "Current", value: viewModel.activeName.isEmpty ? "default" : viewModel.activeName)
|
||||
ReadOnlyRow(label: "Defined", value: "None in config.yaml — add under `personalities:` to customize.")
|
||||
} else {
|
||||
PickerRow(label: "Active", selection: viewModel.activeName, options: viewModel.personalities.map(\.name)) { viewModel.setActive($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var listSection: some View {
|
||||
if !viewModel.personalities.isEmpty {
|
||||
SettingsSection(title: "Defined Personalities", icon: "list.bullet") {
|
||||
ForEach(viewModel.personalities) { personality in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(personality.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if personality.name == viewModel.activeName {
|
||||
Text("active")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.green)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.green.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
if !personality.prompt.isEmpty {
|
||||
Text(personality.prompt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(6)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var soulSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Label("SOUL.md", systemImage: "sparkles")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if editingSOUL {
|
||||
Button("Cancel") {
|
||||
editingSOUL = false
|
||||
soulDraft = viewModel.soulMarkdown
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Save") {
|
||||
viewModel.saveSOUL(soulDraft)
|
||||
editingSOUL = false
|
||||
}
|
||||
.controlSize(.small)
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
} else {
|
||||
Button("Edit") { editingSOUL = true }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Text("SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if editingSOUL {
|
||||
TextEditor(text: $soulDraft)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 220)
|
||||
.padding(6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
Text(viewModel.soulMarkdown.isEmpty ? "(empty)" : viewModel.soulMarkdown)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(viewModel.soulMarkdown.isEmpty ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord
|
||||
@Observable
|
||||
@MainActor
|
||||
final class DiscordSetupViewModel {
|
||||
let context: ServerContext
|
||||
init(context: ServerContext = .local) { self.context = context }
|
||||
|
||||
var botToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var homeChannelName: String = ""
|
||||
var allowBots: String = "none" // "none" | "mentions" | "all"
|
||||
var replyToMode: String = "first" // "off" | "first" | "all"
|
||||
|
||||
// config.yaml — these mirror the existing `HermesConfig.discord` block so we
|
||||
// stay consistent with whatever the Settings UI shows.
|
||||
var requireMention: Bool = true
|
||||
var freeResponseChannels: String = ""
|
||||
var autoThread: Bool = true
|
||||
var reactions: Bool = true
|
||||
|
||||
var message: String?
|
||||
|
||||
let allowBotsOptions = ["none", "mentions", "all"]
|
||||
let replyToModeOptions = ["off", "first", "all"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
botToken = env["DISCORD_BOT_TOKEN"] ?? ""
|
||||
allowedUsers = env["DISCORD_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["DISCORD_HOME_CHANNEL"] ?? ""
|
||||
homeChannelName = env["DISCORD_HOME_CHANNEL_NAME"] ?? ""
|
||||
allowBots = env["DISCORD_ALLOW_BOTS"] ?? "none"
|
||||
replyToMode = env["DISCORD_REPLY_TO_MODE"] ?? "first"
|
||||
|
||||
let cfg = HermesFileService(context: context).loadConfig().discord
|
||||
requireMention = cfg.requireMention
|
||||
freeResponseChannels = cfg.freeResponseChannels
|
||||
autoThread = cfg.autoThread
|
||||
reactions = cfg.reactions
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"DISCORD_BOT_TOKEN": botToken,
|
||||
"DISCORD_ALLOWED_USERS": allowedUsers,
|
||||
"DISCORD_HOME_CHANNEL": homeChannel,
|
||||
"DISCORD_HOME_CHANNEL_NAME": homeChannelName,
|
||||
"DISCORD_ALLOW_BOTS": allowBots == "none" ? "" : allowBots, // default is "none", don't persist
|
||||
"DISCORD_REPLY_TO_MODE": replyToMode == "first" ? "" : replyToMode
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"discord.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"discord.free_response_channels": freeResponseChannels,
|
||||
"discord.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||
"discord.reactions": PlatformSetupHelpers.envBool(reactions)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
/// Email setup. IMAP/SMTP with app passwords — no OAuth.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email
|
||||
@Observable
|
||||
@MainActor
|
||||
final class EmailSetupViewModel {
|
||||
let context: ServerContext
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
var address: String = ""
|
||||
var password: String = ""
|
||||
var imapHost: String = ""
|
||||
var smtpHost: String = ""
|
||||
var imapPort: String = "993"
|
||||
var smtpPort: String = "587"
|
||||
var pollInterval: String = "15"
|
||||
var allowedUsers: String = ""
|
||||
var homeAddress: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
var skipAttachments: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
/// Common provider presets so users don't have to look up IMAP/SMTP servers.
|
||||
struct Preset {
|
||||
let name: String
|
||||
let imap: String
|
||||
let smtp: String
|
||||
}
|
||||
let presets: [Preset] = [
|
||||
Preset(name: "Gmail", imap: "imap.gmail.com", smtp: "smtp.gmail.com"),
|
||||
Preset(name: "Outlook", imap: "outlook.office365.com", smtp: "smtp.office365.com"),
|
||||
Preset(name: "iCloud", imap: "imap.mail.me.com", smtp: "smtp.mail.me.com"),
|
||||
Preset(name: "Fastmail", imap: "imap.fastmail.com", smtp: "smtp.fastmail.com"),
|
||||
Preset(name: "Yahoo", imap: "imap.mail.yahoo.com", smtp: "smtp.mail.yahoo.com")
|
||||
]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
address = env["EMAIL_ADDRESS"] ?? ""
|
||||
password = env["EMAIL_PASSWORD"] ?? ""
|
||||
imapHost = env["EMAIL_IMAP_HOST"] ?? ""
|
||||
smtpHost = env["EMAIL_SMTP_HOST"] ?? ""
|
||||
imapPort = env["EMAIL_IMAP_PORT"] ?? "993"
|
||||
smtpPort = env["EMAIL_SMTP_PORT"] ?? "587"
|
||||
pollInterval = env["EMAIL_POLL_INTERVAL"] ?? "15"
|
||||
allowedUsers = env["EMAIL_ALLOWED_USERS"] ?? ""
|
||||
homeAddress = env["EMAIL_HOME_ADDRESS"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["EMAIL_ALLOW_ALL_USERS"])
|
||||
// skip_attachments lives in config.yaml.
|
||||
let yaml = context.readText(context.paths.configYAML) ?? ""
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
skipAttachments = (parsed.values["platforms.email.skip_attachments"] ?? "false") == "true"
|
||||
}
|
||||
|
||||
func applyPreset(_ preset: Preset) {
|
||||
imapHost = preset.imap
|
||||
smtpHost = preset.smtp
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"EMAIL_ADDRESS": address,
|
||||
"EMAIL_PASSWORD": password,
|
||||
"EMAIL_IMAP_HOST": imapHost,
|
||||
"EMAIL_SMTP_HOST": smtpHost,
|
||||
"EMAIL_IMAP_PORT": imapPort,
|
||||
"EMAIL_SMTP_PORT": smtpPort,
|
||||
"EMAIL_POLL_INTERVAL": pollInterval,
|
||||
"EMAIL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"EMAIL_HOME_ADDRESS": homeAddress,
|
||||
"EMAIL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"platforms.email.skip_attachments": PlatformSetupHelpers.envBool(skipAttachments)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// Feishu/Lark setup. Choose domain (feishu = China, lark = international).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu
|
||||
@Observable
|
||||
@MainActor
|
||||
final class FeishuSetupViewModel {
|
||||
let context: ServerContext
|
||||
init(context: ServerContext = .local) { self.context = context }
|
||||
|
||||
var appID: String = ""
|
||||
var appSecret: String = ""
|
||||
var domain: String = "lark"
|
||||
var encryptKey: String = ""
|
||||
var verificationToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var connectionMode: String = "websocket" // "websocket" | "webhook"
|
||||
|
||||
var message: String?
|
||||
|
||||
let domainOptions = ["feishu", "lark"]
|
||||
let connectionOptions = ["websocket", "webhook"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
appID = env["FEISHU_APP_ID"] ?? ""
|
||||
appSecret = env["FEISHU_APP_SECRET"] ?? ""
|
||||
domain = env["FEISHU_DOMAIN"] ?? "lark"
|
||||
encryptKey = env["FEISHU_ENCRYPT_KEY"] ?? ""
|
||||
verificationToken = env["FEISHU_VERIFICATION_TOKEN"] ?? ""
|
||||
allowedUsers = env["FEISHU_ALLOWED_USERS"] ?? ""
|
||||
connectionMode = env["FEISHU_CONNECTION_MODE"] ?? "websocket"
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"FEISHU_APP_ID": appID,
|
||||
"FEISHU_APP_SECRET": appSecret,
|
||||
"FEISHU_DOMAIN": domain,
|
||||
"FEISHU_ENCRYPT_KEY": encryptKey,
|
||||
"FEISHU_VERIFICATION_TOKEN": verificationToken,
|
||||
"FEISHU_ALLOWED_USERS": allowedUsers,
|
||||
"FEISHU_CONNECTION_MODE": connectionMode == "websocket" ? "" : connectionMode
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Home Assistant setup. Long-lived access token in `.env`, scalar filters via
|
||||
/// `hermes config set` under `platforms.homeassistant.extra.*`.
|
||||
///
|
||||
/// **List fields** (`watch_domains`, `watch_entities`, `ignore_entities`) are
|
||||
/// NOT editable in the form. `hermes config set` stores array arguments as
|
||||
/// quoted strings instead of YAML lists, which hermes would then reject as
|
||||
/// invalid. Users edit these directly in config.yaml — the view shows the
|
||||
/// current values (read-only) and an "Edit in config.yaml" button that opens
|
||||
/// the file.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant
|
||||
@Observable
|
||||
@MainActor
|
||||
final class HomeAssistantSetupViewModel {
|
||||
let context: ServerContext
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
var url: String = "http://homeassistant.local:8123"
|
||||
var token: String = ""
|
||||
|
||||
// Scalar filters — writable via hermes config set.
|
||||
var watchAll: Bool = false
|
||||
var cooldownSeconds: Int = 30
|
||||
|
||||
// List filters — read-only; user must edit config.yaml manually.
|
||||
var watchDomains: [String] = []
|
||||
var watchEntities: [String] = []
|
||||
var ignoreEntities: [String] = []
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
url = env["HASS_URL"] ?? "http://homeassistant.local:8123"
|
||||
token = env["HASS_TOKEN"] ?? ""
|
||||
|
||||
let cfg = HermesFileService(context: context).loadConfig().homeAssistant
|
||||
watchAll = cfg.watchAll
|
||||
cooldownSeconds = cfg.cooldownSeconds
|
||||
watchDomains = cfg.watchDomains
|
||||
watchEntities = cfg.watchEntities
|
||||
ignoreEntities = cfg.ignoreEntities
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"HASS_URL": url,
|
||||
"HASS_TOKEN": token
|
||||
]
|
||||
// Only scalar config values — lists are skipped intentionally; see
|
||||
// file header comment for rationale.
|
||||
let configKV: [String: String] = [
|
||||
"platforms.homeassistant.extra.watch_all": PlatformSetupHelpers.envBool(watchAll),
|
||||
"platforms.homeassistant.extra.cooldown_seconds": String(cooldownSeconds)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Open config.yaml in the user's default editor so they can manually edit
|
||||
/// the list-valued filter fields.
|
||||
func openConfigForLists() {
|
||||
context.openInLocalEditor(context.paths.configYAML)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac
|
||||
/// that's always on, with an Apple ID signed into Messages.app.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles
|
||||
@Observable
|
||||
@MainActor
|
||||
final class IMessageSetupViewModel {
|
||||
let context: ServerContext
|
||||
init(context: ServerContext = .local) { self.context = context }
|
||||
|
||||
var serverURL: String = ""
|
||||
var password: String = ""
|
||||
var webhookHost: String = "127.0.0.1"
|
||||
var webhookPort: String = "8645"
|
||||
var webhookPath: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
var sendReadReceipts: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
serverURL = env["BLUEBUBBLES_SERVER_URL"] ?? ""
|
||||
password = env["BLUEBUBBLES_PASSWORD"] ?? ""
|
||||
webhookHost = env["BLUEBUBBLES_WEBHOOK_HOST"] ?? "127.0.0.1"
|
||||
webhookPort = env["BLUEBUBBLES_WEBHOOK_PORT"] ?? "8645"
|
||||
webhookPath = env["BLUEBUBBLES_WEBHOOK_PATH"] ?? ""
|
||||
allowedUsers = env["BLUEBUBBLES_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["BLUEBUBBLES_HOME_CHANNEL"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_ALLOW_ALL_USERS"])
|
||||
sendReadReceipts = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_SEND_READ_RECEIPTS"])
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"BLUEBUBBLES_SERVER_URL": serverURL,
|
||||
"BLUEBUBBLES_PASSWORD": password,
|
||||
"BLUEBUBBLES_WEBHOOK_HOST": webhookHost,
|
||||
"BLUEBUBBLES_WEBHOOK_PORT": webhookPort,
|
||||
"BLUEBUBBLES_WEBHOOK_PATH": webhookPath,
|
||||
"BLUEBUBBLES_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"BLUEBUBBLES_HOME_CHANNEL": homeChannel,
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS": allowAllUsers ? "true" : "",
|
||||
"BLUEBUBBLES_SEND_READ_RECEIPTS": sendReadReceipts ? "true" : ""
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Matrix setup. Supports both access-token and password auth. No SSO.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix
|
||||
@Observable
|
||||
@MainActor
|
||||
final class MatrixSetupViewModel {
|
||||
let context: ServerContext
|
||||
init(context: ServerContext = .local) { self.context = context }
|
||||
|
||||
var homeserver: String = ""
|
||||
var accessToken: String = "" // preferred
|
||||
var userID: String = ""
|
||||
var password: String = "" // alternative to accessToken
|
||||
var allowedUsers: String = ""
|
||||
var homeRoom: String = ""
|
||||
var recoveryKey: String = ""
|
||||
var encryption: Bool = false
|
||||
|
||||
// config.yaml
|
||||
var requireMention: Bool = true
|
||||
var autoThread: Bool = true
|
||||
var dmMentionThreads: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
homeserver = env["MATRIX_HOMESERVER"] ?? ""
|
||||
accessToken = env["MATRIX_ACCESS_TOKEN"] ?? ""
|
||||
userID = env["MATRIX_USER_ID"] ?? ""
|
||||
password = env["MATRIX_PASSWORD"] ?? ""
|
||||
allowedUsers = env["MATRIX_ALLOWED_USERS"] ?? ""
|
||||
homeRoom = env["MATRIX_HOME_ROOM"] ?? ""
|
||||
recoveryKey = env["MATRIX_RECOVERY_KEY"] ?? ""
|
||||
encryption = PlatformSetupHelpers.parseEnvBool(env["MATRIX_ENCRYPTION"])
|
||||
|
||||
let cfg = HermesFileService(context: context).loadConfig().matrix
|
||||
requireMention = cfg.requireMention
|
||||
autoThread = cfg.autoThread
|
||||
dmMentionThreads = cfg.dmMentionThreads
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"MATRIX_HOMESERVER": homeserver,
|
||||
"MATRIX_ACCESS_TOKEN": accessToken,
|
||||
"MATRIX_USER_ID": userID,
|
||||
"MATRIX_PASSWORD": password,
|
||||
"MATRIX_ALLOWED_USERS": allowedUsers,
|
||||
"MATRIX_HOME_ROOM": homeRoom,
|
||||
"MATRIX_RECOVERY_KEY": recoveryKey,
|
||||
"MATRIX_ENCRYPTION": encryption ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"matrix.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"matrix.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||
"matrix.dm_mention_threads": PlatformSetupHelpers.envBool(dmMentionThreads)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// Mattermost setup. Server URL + personal access token (or bot token).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost
|
||||
@Observable
|
||||
@MainActor
|
||||
final class MattermostSetupViewModel {
|
||||
let context: ServerContext
|
||||
init(context: ServerContext = .local) { self.context = context }
|
||||
|
||||
var serverURL: String = ""
|
||||
var token: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var freeResponseChannels: String = ""
|
||||
|
||||
var replyMode: String = "off"
|
||||
var requireMention: Bool = true
|
||||
|
||||
var message: String?
|
||||
let replyModeOptions = ["off", "thread"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
serverURL = env["MATTERMOST_URL"] ?? ""
|
||||
token = env["MATTERMOST_TOKEN"] ?? ""
|
||||
allowedUsers = env["MATTERMOST_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["MATTERMOST_HOME_CHANNEL"] ?? ""
|
||||
freeResponseChannels = env["MATTERMOST_FREE_RESPONSE_CHANNELS"] ?? ""
|
||||
replyMode = env["MATTERMOST_REPLY_MODE"] ?? "off"
|
||||
|
||||
let cfg = HermesFileService(context: context).loadConfig().mattermost
|
||||
requireMention = cfg.requireMention
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"MATTERMOST_URL": serverURL,
|
||||
"MATTERMOST_TOKEN": token,
|
||||
"MATTERMOST_ALLOWED_USERS": allowedUsers,
|
||||
"MATTERMOST_HOME_CHANNEL": homeChannel,
|
||||
"MATTERMOST_FREE_RESPONSE_CHANNELS": freeResponseChannels,
|
||||
"MATTERMOST_REPLY_MODE": replyMode == "off" ? "" : replyMode,
|
||||
"MATTERMOST_REQUIRE_MENTION": PlatformSetupHelpers.envBool(requireMention)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// Shared helpers used by every per-platform setup view model.
|
||||
///
|
||||
/// Each platform form follows the same pattern:
|
||||
/// 1. Load current values from `.env` + config.yaml into local `@Observable` state.
|
||||
/// 2. Present them in a form where changes happen in-memory.
|
||||
/// 3. On save, write env vars via `HermesEnvService.setMany` and config.yaml keys
|
||||
/// via `hermes config set`, then surface a success/error toast.
|
||||
///
|
||||
/// Putting the save logic here keeps each per-platform VM focused on its own
|
||||
/// field set without re-implementing the write plumbing 12 times.
|
||||
@MainActor
|
||||
enum PlatformSetupHelpers {
|
||||
static let logger = Logger(subsystem: "com.scarf", category: "PlatformSetup")
|
||||
|
||||
/// Apply a form save in one atomic batch against a specific server.
|
||||
///
|
||||
/// - `context`: the server whose `.env` and `config.yaml` we're writing.
|
||||
/// Local goes through `LocalTransport`; remote rounds through ssh+scp.
|
||||
/// - `envPairs`: values to write into `.env`. Empty strings trigger `unset()`
|
||||
/// (commenting the line out) rather than storing a literal empty value.
|
||||
/// - `configKV`: scalar config.yaml paths to set via `hermes config set`.
|
||||
/// Empty strings still produce a `config set <key> ""` call because
|
||||
/// some fields accept an explicit empty string (e.g., `display.skin: ""`).
|
||||
///
|
||||
/// Returns a user-facing summary message.
|
||||
@discardableResult
|
||||
static func saveForm(context: ServerContext, envPairs: [String: String], configKV: [String: String]) -> String {
|
||||
let envService = HermesEnvService(context: context)
|
||||
|
||||
// Split env pairs into set vs. unset.
|
||||
var toSet: [String: String] = [:]
|
||||
var toUnset: [String] = []
|
||||
for (k, v) in envPairs {
|
||||
if v.isEmpty {
|
||||
toUnset.append(k)
|
||||
} else {
|
||||
toSet[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
var envOK = true
|
||||
if !toSet.isEmpty {
|
||||
envOK = envService.setMany(toSet)
|
||||
}
|
||||
for key in toUnset {
|
||||
_ = envService.unset(key)
|
||||
}
|
||||
|
||||
var configFailures: [String] = []
|
||||
for (key, value) in configKV {
|
||||
let result = runHermesCLI(context: context, args: ["config", "set", key, value])
|
||||
if result.exitCode != 0 {
|
||||
configFailures.append(key)
|
||||
logger.warning("hermes config set \(key) failed: \(result.output)")
|
||||
}
|
||||
}
|
||||
|
||||
if !envOK { return "Failed to write .env" }
|
||||
if !configFailures.isEmpty { return "Saved, but failed to update: \(configFailures.joined(separator: ", "))" }
|
||||
return "Saved — restart gateway to apply"
|
||||
}
|
||||
|
||||
/// Synchronous hermes CLI invocation against the given server. Use only
|
||||
/// for fast commands like `config set`; longer commands should use
|
||||
/// `HermesFileService.runHermesCLI` from a `Task.detached`.
|
||||
static func runHermesCLI(context: ServerContext, args: [String], timeout: TimeInterval = 15) -> (exitCode: Int32, output: String) {
|
||||
HermesFileService(context: context).runHermesCLI(args: args, timeout: timeout)
|
||||
}
|
||||
|
||||
/// Ask the user's default browser to open a URL (typically a hermes doc page
|
||||
/// or a platform developer portal).
|
||||
static func openURL(_ string: String) {
|
||||
guard let url = URL(string: string) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
/// Bool <-> "true"/"false" round-trip for env vars. Hermes accepts both
|
||||
/// "true"/"false" and "1"/"0"; we emit the string form for readability.
|
||||
static func envBool(_ on: Bool) -> String { on ? "true" : "false" }
|
||||
|
||||
/// Parse an env string as a bool. Treats missing/empty as `false`.
|
||||
/// "true", "1", "yes", "on" (case-insensitive) are true.
|
||||
static func parseEnvBool(_ s: String?) -> Bool {
|
||||
guard let s else { return false }
|
||||
switch s.lowercased() {
|
||||
case "true", "1", "yes", "on": return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
|
||||
/// Signal setup. Users must install `signal-cli` externally (needs Java), link
|
||||
/// their account via `signal-cli link -n ...`, and run a daemon on an HTTP port
|
||||
/// that hermes talks to. We expose an embedded terminal for both the link and
|
||||
/// daemon commands.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/signal
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SignalSetupViewModel {
|
||||
let context: ServerContext
|
||||
init(context: ServerContext = .local) { self.context = context }
|
||||
|
||||
var httpURL: String = "http://127.0.0.1:8080"
|
||||
var account: String = "" // E.164 phone, e.g. +15551234567
|
||||
var allowedUsers: String = ""
|
||||
var groupAllowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
let terminalController = EmbeddedSetupTerminalController()
|
||||
var signalCLIInstalled: Bool = false
|
||||
var activeTask: SignalTerminalTask = .none
|
||||
|
||||
enum SignalTerminalTask: Equatable {
|
||||
case none
|
||||
case link
|
||||
case daemon
|
||||
}
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
httpURL = env["SIGNAL_HTTP_URL"] ?? "http://127.0.0.1:8080"
|
||||
account = env["SIGNAL_ACCOUNT"] ?? ""
|
||||
allowedUsers = env["SIGNAL_ALLOWED_USERS"] ?? ""
|
||||
groupAllowedUsers = env["SIGNAL_GROUP_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["SIGNAL_HOME_CHANNEL"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["SIGNAL_ALLOW_ALL_USERS"])
|
||||
signalCLIInstalled = Self.detectSignalCLI()
|
||||
}
|
||||
|
||||
/// Best-effort `signal-cli` binary lookup on the login-shell PATH.
|
||||
private static func detectSignalCLI() -> Bool {
|
||||
let env = HermesFileService.enrichedEnvironment()
|
||||
let paths = env["PATH"]?.split(separator: ":").map(String.init) ?? []
|
||||
for dir in paths {
|
||||
if FileManager.default.isExecutableFile(atPath: dir + "/signal-cli") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"SIGNAL_HTTP_URL": httpURL,
|
||||
"SIGNAL_ACCOUNT": account,
|
||||
"SIGNAL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"SIGNAL_GROUP_ALLOWED_USERS": groupAllowedUsers,
|
||||
"SIGNAL_HOME_CHANNEL": homeChannel,
|
||||
"SIGNAL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:])
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
/// Run `signal-cli link -n HermesAgent` to generate a QR code.
|
||||
func startLink() {
|
||||
guard signalCLIInstalled else {
|
||||
message = "signal-cli not found on PATH — install it first"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
activeTask = .link
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.activeTask = .none
|
||||
self?.message = "Link step exited — save credentials and start the daemon next"
|
||||
self?.clearMessageAfterDelay()
|
||||
}
|
||||
terminalController.start(executable: "/usr/bin/env", arguments: ["signal-cli", "link", "-n", "HermesAgent"])
|
||||
}
|
||||
|
||||
/// Run the signal-cli daemon. Users can stop it by closing the panel.
|
||||
func startDaemon() {
|
||||
guard !account.isEmpty else {
|
||||
message = "Enter your Signal account (E.164 format) first"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
guard signalCLIInstalled else {
|
||||
message = "signal-cli not found on PATH"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
activeTask = .daemon
|
||||
let bind = httpURL.replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "")
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.activeTask = .none
|
||||
}
|
||||
terminalController.start(
|
||||
executable: "/usr/bin/env",
|
||||
arguments: ["signal-cli", "--account", account, "daemon", "--http", bind]
|
||||
)
|
||||
}
|
||||
|
||||
func stopTerminal() {
|
||||
terminalController.stop()
|
||||
activeTask = .none
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Slack setup. Requires two tokens (bot + app-level for Socket Mode).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SlackSetupViewModel {
|
||||
let context: ServerContext
|
||||
init(context: ServerContext = .local) { self.context = context }
|
||||
|
||||
var botToken: String = "" // xoxb-...
|
||||
var appToken: String = "" // xapp-...
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var homeChannelName: String = ""
|
||||
|
||||
var replyToMode: String = "first"
|
||||
var requireMention: Bool = true
|
||||
var replyInThread: Bool = true
|
||||
var replyBroadcast: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
let replyToModeOptions = ["off", "first", "all"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
botToken = env["SLACK_BOT_TOKEN"] ?? ""
|
||||
appToken = env["SLACK_APP_TOKEN"] ?? ""
|
||||
allowedUsers = env["SLACK_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["SLACK_HOME_CHANNEL"] ?? ""
|
||||
homeChannelName = env["SLACK_HOME_CHANNEL_NAME"] ?? ""
|
||||
|
||||
let cfg = HermesFileService(context: context).loadConfig().slack
|
||||
replyToMode = cfg.replyToMode
|
||||
requireMention = cfg.requireMention
|
||||
replyInThread = cfg.replyInThread
|
||||
replyBroadcast = cfg.replyBroadcast
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"SLACK_BOT_TOKEN": botToken,
|
||||
"SLACK_APP_TOKEN": appToken,
|
||||
"SLACK_ALLOWED_USERS": allowedUsers,
|
||||
"SLACK_HOME_CHANNEL": homeChannel,
|
||||
"SLACK_HOME_CHANNEL_NAME": homeChannelName
|
||||
]
|
||||
// Slack uses the modern `platforms.slack.*` schema.
|
||||
let configKV: [String: String] = [
|
||||
"platforms.slack.reply_to_mode": replyToMode,
|
||||
"platforms.slack.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"platforms.slack.extra.reply_in_thread": PlatformSetupHelpers.envBool(replyInThread),
|
||||
"platforms.slack.extra.reply_broadcast": PlatformSetupHelpers.envBool(replyBroadcast)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Telegram platform setup. Credentials live in `.env` (`TELEGRAM_*`); mention /
|
||||
/// reactions toggles live in `config.yaml` under `telegram.*`.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TelegramSetupViewModel {
|
||||
let context: ServerContext
|
||||
init(context: ServerContext = .local) { self.context = context }
|
||||
|
||||
// Required
|
||||
var botToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
// Optional
|
||||
var homeChannel: String = ""
|
||||
var webhookURL: String = ""
|
||||
var webhookPort: String = ""
|
||||
var webhookSecret: String = ""
|
||||
// Config.yaml toggles
|
||||
var requireMention: Bool = true
|
||||
var reactions: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
botToken = env["TELEGRAM_BOT_TOKEN"] ?? ""
|
||||
allowedUsers = env["TELEGRAM_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["TELEGRAM_HOME_CHANNEL"] ?? ""
|
||||
webhookURL = env["TELEGRAM_WEBHOOK_URL"] ?? ""
|
||||
webhookPort = env["TELEGRAM_WEBHOOK_PORT"] ?? ""
|
||||
webhookSecret = env["TELEGRAM_WEBHOOK_SECRET"] ?? ""
|
||||
|
||||
let cfg = HermesFileService(context: context).loadConfig()
|
||||
requireMention = cfg.telegram.requireMention
|
||||
reactions = cfg.telegram.reactions
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"TELEGRAM_BOT_TOKEN": botToken,
|
||||
"TELEGRAM_ALLOWED_USERS": allowedUsers,
|
||||
"TELEGRAM_HOME_CHANNEL": homeChannel,
|
||||
"TELEGRAM_WEBHOOK_URL": webhookURL,
|
||||
"TELEGRAM_WEBHOOK_PORT": webhookPort,
|
||||
"TELEGRAM_WEBHOOK_SECRET": webhookSecret
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"telegram.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"telegram.reactions": PlatformSetupHelpers.envBool(reactions)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Webhook platform setup. Just the global enable/port/secret — per-subscription
|
||||
/// routes live in the Webhooks sidebar feature.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WebhookSetupViewModel {
|
||||
let context: ServerContext
|
||||
init(context: ServerContext = .local) { self.context = context }
|
||||
|
||||
var enabled: Bool = false
|
||||
var port: String = "8644"
|
||||
var secret: String = ""
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService(context: context).load()
|
||||
enabled = PlatformSetupHelpers.parseEnvBool(env["WEBHOOK_ENABLED"])
|
||||
port = env["WEBHOOK_PORT"] ?? "8644"
|
||||
secret = env["WEBHOOK_SECRET"] ?? ""
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"WEBHOOK_ENABLED": enabled ? "true" : "",
|
||||
"WEBHOOK_PORT": port,
|
||||
"WEBHOOK_SECRET": secret
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user