Compare commits

...

11 Commits

Author SHA1 Message Date
Alan Wizemann 12610faba0 chore: Bump version to 1.6.2 2026-04-17 17:18:33 -07:00
Alan Wizemann 73b44202ba fix: release script preflight allows pre-written RELEASE_NOTES.md
CLAUDE.md's release-notes convention says "write them to
releases/v<version>/RELEASE_NOTES.md BEFORE running the script" — but
the script's git-clean preflight rejected any working-tree state
including that exact file as untracked. Chicken-and-egg: you couldn't
follow the documented flow.

Preflight now whitelists releases/v<VERSION>/RELEASE_NOTES.md as the one
allowed untracked path. Everything else still fails the check.

Caught while running v1.6.2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:18:23 -07:00
Alan Wizemann eed55cbb0f chore: Ignore release-artifact binaries
Stops the release script's git-clean preflight from tripping on the
zips + appcast-entry.xml that every release run produces under
releases/v<VERSION>/. GitHub Releases hosts the actual downloads; there's
no reason to commit ~30 MB of binaries per release into git history.

RELEASE_NOTES.md stays tracked — it's committed as part of the version
bump by the release script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:16:10 -07:00
Alan Wizemann 14c97bee62 docs: CLAUDE.md — document the release flow + canonical prompts
Adds a Releases section so future Claude sessions (and teammates) don't
have to rediscover the release workflow. Documents:

- The single entry point: `./scripts/release.sh <ver> [--draft]`
- What the script does end-to-end
- The release notes convention (write them before running)
- A handful of canonical prompts the user can type
- Pointers to deeper prerequisite docs (README, script header)

Deliberately brief — detail lives in README and the personal auto-memory
at reference_release_process.md. CLAUDE.md's job here is just to make
the entry point discoverable on session start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:13:15 -07:00
Alan Wizemann 8d3fe70e2c fix: Chat tab false-positive "no credentials" warning before session pick
The orange "No AI provider credentials detected" banner was firing on the
Chat tab whenever no session was selected, even for users whose
credentials were configured and working. The banner only disappeared
when a session started — not because credentials were actually found,
but because the banner's `!hasActiveProcess` gate flipped to false once
ACP launched.

Root cause: `HermesFileService.hasAnyAICredential()` inspected only the
shell environment and `~/.hermes/.env`, while Hermes itself resolves
credentials from two additional places Scarf had never learned about:

  - `~/.hermes/auth.json` — the Credential Pools file written by the
    Configure → Credential Pools UI (the blessed v1.6 flow)
  - `~/.hermes/config.yaml` — embedded `api_key:` under auxiliary.<task>
    and delegation

The preflight now checks all four locations. For auth.json we parse the
JSON and look for any `credential_pool.<provider>[*].access_token` that
is non-empty. For config.yaml we line-scan for `api_key:` leaves with a
non-empty value, matching the defensive style of the existing .env
scanner (no YAML parser needed in a nonisolated function).

Also updated the banner subtitle to point users at Credential Pools
before .env, since the former is the blessed in-app flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:10:51 -07:00
Alan Wizemann da88c98c7a feat: release script builds Universal + ARM64 variants
Each release now produces two distribution zips:
- Scarf-vX.X.X-Universal.zip  (arm64 + x86_64, recommended)
- Scarf-vX.X.X-ARM64.zip      (arm64 only, ~14% smaller)

Both are independently archived, exported with Developer ID, notarized,
and stapled via a new build_variant helper. The appcast still points at
the Universal zip since it works on all supported macs; ARM64 is an
alternative manual download for Apple Silicon users who want the smaller
file.

README updated to list both variants.

Prompted by the v1.6.1 release shipping only Universal; the ARM64 zip
for v1.6.1 was produced ad-hoc and uploaded to the existing release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:10:17 -07:00
Alan Wizemann b7ad01f9da chore: Bump version to 1.6.1 2026-04-16 19:04:48 -07:00
Alan Wizemann 868e61979e chore: release script supports --draft + RELEASE_NOTES.md
Drafts skip the appcast push and main tag, so a draft release won't
show up in users' Sparkle update feed and v1.6.0 stays "latest" until
explicitly promoted. The signed appcast entry is saved to the release
dir for later manual promotion.

Also adds release notes file convention: releases/v<VERSION>/RELEASE_NOTES.md
is auto-included in the version-bump commit and used as the GitHub
release body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:04:27 -07:00
Alan Wizemann 9bdd928469 fix: release script — rename scarf.app → Scarf.app after export
Xcode exports the bundle as scarf.app because PRODUCT_NAME = $TARGET_NAME
and the target is lowercase "scarf". Users expect Scarf.app in their
/Applications folder. Renaming the bundle wrapper preserves the
signature (codesign signs contents, not the wrapper directory name).

Caught during a build+sign+verify dry run before the first notarized
release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:59:41 -07:00
Alan Wizemann 75e489e39c fix: chat works without a terminal hermes session; surface the real error when it doesn't
A fresh-install user reported Scarf chat only worked while `hermes chat`
was also running in Terminal. ACP connected successfully but sending a
message errored. `~/.hermes/logs/errors.log` showed the real cause:

  RuntimeError: No Anthropic credentials found. Set ANTHROPIC_TOKEN or
  ANTHROPIC_API_KEY, run 'claude setup-token', or authenticate with
  'claude /login'.

The terminal workaround masked the bug because the terminal-launched
`hermes` inherits the user's shell env (ANTHROPIC_* exports, Keychain
session) while a Finder/Dock-launched Scarf subprocess does not.
Scarf's previous PATH-only enrichment (commit b2a29ab) fixed binary
discovery but not credential propagation.

Five changes:

1. Propagate credential env vars from the login shell.
   HermesFileService.enrichedEnvironment() now harvests a conservative
   allowlist of AI-provider keys (ANTHROPIC_API_KEY/TOKEN/BASE_URL,
   OPENAI_*, OPENROUTER_*, GEMINI/GOOGLE/GROQ/MISTRAL/XAI API keys,
   CLAUDE_CODE_OAUTH_TOKEN) alongside PATH. Uses one `zsh` probe with
   null-delimited `printf` so values with newlines survive, cached for
   the process lifetime.

2. Two-attempt shell probe catches nvm/asdf/mise PATH.
   Previous `zsh -l` missed `.zshrc`-exported PATH (nvm). New probe
   first tries `zsh -l -i` (login + interactive, sources .zshrc) with
   prompt frameworks defanged (TERM=dumb, empty PS1/PROMPT,
   POWERLEVEL9K_INSTANT_PROMPT=off, STARSHIP_DISABLE=1,
   ZSH_DISABLE_COMPFIX=true) and a 5s timeout; falls back to `zsh -l`
   with 3s; finally to hardcoded defaults.

3. Resolve `hermes` binary across install locations.
   HermesPaths.hermesBinary is now computed, walking pipx
   (~/.local/bin), Apple Silicon brew (/opt/homebrew/bin), Intel brew
   / manual (/usr/local/bin), and ~/.hermes/bin. Returns the first
   executable match or the pipx default for "Expected at …"
   diagnostics. All 10+ callsites (ACPClient, scarfApp, Health /
   Gateway / Tools / Sessions / QuickCommands / Personalities /
   Settings / WhatsAppSetup / OAuthFlow / CredentialPools
   ViewModels) auto-migrate with zero edits.
   HermesFileService.hermesBinaryPath() shares the same candidate
   list as the source of truth.

4. Surface the real failure in the chat UI.
   ACPClient keeps a 50-line ring buffer of subprocess stderr
   (previously only sent to os_log). New ACPErrorHint.classify pattern-
   matches the common fresh-install failures — "No credentials found",
   "No such file or directory: 'npx'", rate-limit — and returns a short
   human hint. ChatView gains an errorBanner between toolbar and chat
   area showing the hint + raw message + a "Show details" disclosure
   with the stderr tail in a selectable monospaced view, plus a
   clipboard-copy button.

5. Preflight credential check.
   HermesFileService.hasAnyAICredential() scans the enriched env and
   ~/.hermes/.env for any known provider key. ChatViewModel exposes
   `missingCredentials`; the banner becomes a pre-emptive warning
   ("No AI provider credentials detected — add ANTHROPIC_API_KEY to
   ~/.hermes/.env or your shell profile") before the user even hits
   Send. HermesFileWatcher already watches ~/.hermes/.env, so edits
   re-trigger preflight automatically.

Incidental cleanup: recordACPFailure(_:client:context:) folds the
per-site `logger.error` calls, removing three `_ = msg` suppressions.
Dead `enrichedPath` alias removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:49:44 -07:00
Alan Wizemann 41ea3aeb83 feat: Sparkle auto-updates + Developer ID notarization pipeline
Adds Sparkle 2 auto-updates and a local release script that produces
signed, notarized, stapled builds for GitHub distribution. App Store
submission was rejected because Scarf spawns the user-installed hermes
binary and reads ~/.hermes/ directly — both forbidden by App Sandbox —
so we commit to the GitHub-release path properly.

- Sparkle SPM dep wired into the app target (link-only; hardened-runtime
  entitlement disable-library-validation lets Sparkle load at runtime).
- Tracked Info.plist with SUFeedURL, SUPublicEDKey, and daily check
  interval; replaces the auto-generated plist so Sparkle keys live in
  version control rather than pbxproj INFOPLIST_KEY_* noise.
- UpdaterService wraps SPUStandardUpdaterController and is injected via
  .environment(). Menu bar, standard app menu (CommandGroup after
  .appInfo), and a new Updates section in Settings → General each call
  updater.checkForUpdates().
- scripts/release.sh runs the full pipeline: version bump → universal
  archive → Developer ID export → notarytool submit (keychain profile
  scarf-notary) → staple → appcast EdDSA sign → gh-pages push → gh
  release → tag. scripts/ExportOptions.plist pins manual Developer ID
  signing for team 3Q6X2L86C4.
- README: removes the right-click-Open workaround (notarized builds
  don't need it), notes Sparkle, adds a Releases section describing
  the pipeline and signing prerequisites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:42:20 -07:00
19 changed files with 1003 additions and 91 deletions
+6
View File
@@ -1,5 +1,6 @@
# Xcode
build/
.gh-pages-worktree/
DerivedData/
*.pbxuser
!default.pbxuser
@@ -46,3 +47,8 @@ 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
+20
View File
@@ -39,6 +39,26 @@ 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.
## 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.
+19 -2
View File
@@ -101,10 +101,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
@@ -178,6 +180,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.
@@ -327,6 +330,20 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene
Each section defines a grid with 14 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)
## Contributing
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
+25
View File
@@ -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.
+13
View File
@@ -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.
+48 -24
View File
@@ -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 */;
@@ -203,6 +219,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
@@ -407,23 +424,20 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 18;
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.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -444,23 +458,20 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 18;
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.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -477,11 +488,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.5.0;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -498,11 +509,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.5.0;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -518,10 +529,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.5.0;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -537,10 +548,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.5.0;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -594,6 +605,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 +624,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" */;
+21 -1
View File
@@ -19,10 +19,30 @@ enum HermesPaths: Sendable {
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"
/// Install locations we look for the `hermes` binary in, in priority order.
/// Checked every access so a user installing via a different method doesn't
/// need to relaunch Scarf.
nonisolated static let hermesBinaryCandidates: [String] = [
userHome + "/.local/bin/hermes", // pipx / pip --user (default)
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
userHome + "/.hermes/bin/hermes" // Some self-install layouts
]
/// Resolved path to the `hermes` executable. Returns the first candidate
/// that exists and is executable; falls back to the pipx default so error
/// messages ("Expected at ") still make sense on a fresh machine.
nonisolated static var hermesBinary: String {
for path in hermesBinaryCandidates
where FileManager.default.isExecutableFile(atPath: path) {
return path
}
return hermesBinaryCandidates[0]
}
}
// MARK: - SQLite Constants
+56 -1
View File
@@ -24,6 +24,27 @@ actor ACPClient {
private(set) var currentSessionId: String?
private(set) var statusMessage = ""
/// 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 }
@@ -398,7 +419,8 @@ actor ACPClient {
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 {
@@ -407,6 +429,7 @@ actor ACPClient {
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
await self?.logger.info("ACP stderr: \(text.prefix(500))")
await self?.appendStderr(text)
}
}
}
@@ -516,3 +539,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
}
}
+199 -49
View File
@@ -1203,58 +1203,66 @@ struct HermesFileService: Sendable {
}
nonisolated func hermesBinaryPath() -> String? {
let candidates = [
("\(NSHomeDirectory())/.local/bin/hermes"),
"/opt/homebrew/bin/hermes",
"/usr/local/bin/hermes"
]
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
// Single source of truth for install-location candidates lives in
// HermesPaths.hermesBinaryCandidates keeps pipx/brew/manual lookups
// consistent across the app.
return HermesPaths.hermesBinaryCandidates
.first { FileManager.default.isExecutableFile(atPath: $0) }
}
/// PATH cobbled together from the user's login shell needed because
/// .app bundles launched from Finder/Dock get a minimal PATH (no Homebrew,
/// no nvm, no asdf, no mise). Without this, MCP servers using `npx`,
/// `node`, `python`, `uv`, etc. fail to launch with `[Errno 2] No such
/// file or directory`. Computed once and cached.
private static let enrichedPath: String = {
let pipe = Pipe()
let errPipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
// -l sources the user's login files (.zprofile, .zshrc via /etc/zshrc
// chain on macOS) so PATH manipulations made there are picked up.
// Skip -i to avoid hangs from interactive prompts.
process.arguments = ["-l", "-c", "echo $PATH"]
process.standardOutput = pipe
process.standardError = errPipe
defer {
try? pipe.fileHandleForReading.close()
try? pipe.fileHandleForWriting.close()
try? errPipe.fileHandleForReading.close()
try? errPipe.fileHandleForWriting.close()
/// Keys queried from the user's login shell. PATH is needed because .app
/// bundles launched from Finder/Dock get a minimal PATH (no Homebrew, no
/// nvm, no asdf, no mise). The credential keys are needed because Hermes
/// resolves AI provider auth by reading env vars a GUI-launched Scarf
/// subprocess sees none of the `export ANTHROPIC_API_KEY=` lines from
/// the user's shell init files.
private static let shellEnvKeys: [String] = [
"PATH",
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "ANTHROPIC_BASE_URL",
"OPENAI_API_KEY", "OPENAI_BASE_URL",
"OPENROUTER_API_KEY",
"GEMINI_API_KEY", "GOOGLE_API_KEY",
"GROQ_API_KEY", "MISTRAL_API_KEY", "XAI_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN"
]
/// Env vars harvested from the user's login shell. Computed once and cached.
///
/// Probing strategy two attempts, best result wins:
/// 1. `zsh -l -i` (login + interactive) sources BOTH `.zprofile` and
/// `.zshrc`, which is required for nvm/asdf/mise PATH on most setups
/// (those tools inject PATH from `.zshrc`, not `.zprofile`).
/// Interactive mode can hang on prompt frameworks (oh-my-zsh,
/// powerlevel10k, starship) so we suppress prompts via env and bound
/// with a 5-second timeout.
/// 2. If that yields no PATH (timed out / prompt framework broke it),
/// fall back to `zsh -l` (login only) with a 3-second timeout.
/// 3. If that also fails, hardcoded sane-default PATH; no credentials.
private static let enrichedShellEnv: [String: String] = {
// Build a shell script that prints `KEY\0VALUE\0` for each key.
// Using printf with \0 as separator lets us unambiguously split the
// output even if a value contains newlines.
let script = shellEnvKeys.map { key in
#"printf '%s\0%s\0' "\#(key)" "$\#(key)""#
}.joined(separator: "; ")
// Attempt 1: login + interactive (covers nvm/asdf/mise in .zshrc).
if let result = runShellProbe(script: script, interactive: true, timeout: 5.0),
result["PATH"] != nil {
return result
}
do {
try process.run()
let deadline = Date().addingTimeInterval(3)
while process.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning { process.terminate() }
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let path = (String(data: data, encoding: .utf8) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if process.terminationStatus == 0 && !path.isEmpty {
return path
}
} catch {
// Fall through to default below.
// Attempt 2: login only (safe fallback if interactive hangs).
if let result = runShellProbe(script: script, interactive: false, timeout: 3.0),
result["PATH"] != nil {
return result
}
// Fallback when the login shell can't be queried (zsh missing,
// sandbox restriction, timeout). Covers Apple Silicon + Intel
// Homebrew plus the standard system paths.
// Homebrew plus the standard system paths. No credential env is
// inferred the user will see the missing-credentials hint instead.
let home = NSHomeDirectory()
return [
let fallbackPath = [
"\(home)/.local/bin",
"/opt/homebrew/bin",
"/usr/local/bin",
@@ -1263,18 +1271,160 @@ struct HermesFileService: Sendable {
"/usr/sbin",
"/sbin"
].joined(separator: ":")
return ["PATH": fallbackPath]
}()
/// Runs a zsh probe with the given script and returns the parsed
/// `KEY\0VALUE\0`-delimited output. Returns nil on timeout/failure.
/// When `interactive` is true, injects env vars that suppress common
/// prompt frameworks so the shell doesn't hang waiting for terminal setup.
private static func runShellProbe(script: String, interactive: Bool, timeout: TimeInterval) -> [String: String]? {
let pipe = Pipe()
let errPipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = interactive ? ["-l", "-i", "-c", script] : ["-l", "-c", script]
process.standardOutput = pipe
process.standardError = errPipe
if interactive {
// Defang prompt frameworks so -i doesn't hang on async prompt init.
// We still inherit the parent env (HOME, USER etc.) so rc files resolve.
var env = ProcessInfo.processInfo.environment
env["TERM"] = "dumb" // disables fancy prompt setup
env["PS1"] = ""
env["PROMPT"] = ""
env["RPROMPT"] = ""
env["POWERLEVEL9K_INSTANT_PROMPT"] = "off" // p10k
env["STARSHIP_DISABLE"] = "1" // starship (some versions)
env["ZSH_DISABLE_COMPFIX"] = "true" // oh-my-zsh compaudit hang
process.environment = env
}
defer {
try? pipe.fileHandleForReading.close()
try? pipe.fileHandleForWriting.close()
try? errPipe.fileHandleForReading.close()
try? errPipe.fileHandleForWriting.close()
}
do {
try process.run()
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning {
process.terminate()
// Brief grace period for SIGTERM to take; then the defer
// cleanup closes the pipes regardless.
Thread.sleep(forTimeInterval: 0.1)
return nil
}
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard process.terminationStatus == 0, !data.isEmpty else { return nil }
var result: [String: String] = [:]
let parts = data.split(separator: 0, omittingEmptySubsequences: false)
var i = 0
while i + 1 < parts.count {
if let key = String(data: Data(parts[i]), encoding: .utf8),
let value = String(data: Data(parts[i + 1]), encoding: .utf8),
!key.isEmpty, !value.isEmpty {
result[key] = value
}
i += 2
}
return result.isEmpty ? nil : result
} catch {
return nil
}
}
/// Environment to hand any subprocess that may itself spawn user-installed
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Identical
/// to ProcessInfo.processInfo.environment but with PATH replaced by the
/// login-shell PATH.
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Starts
/// from ProcessInfo.environment and overlays PATH + allowlisted credential
/// env vars harvested from the user's login shell.
nonisolated static func enrichedEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment
env["PATH"] = enrichedPath
for (key, value) in enrichedShellEnv where !value.isEmpty {
// Shell wins for PATH (we explicitly want the enriched one). For
// credential keys, also let the shell win GUI env rarely has
// them, and if it does, the shell-exported value is usually the
// one the user actually maintains.
env[key] = value
}
return env
}
/// True if any known AI-provider credential is reachable. Hermes itself
/// resolves credentials from four locations at runtime, so the preflight
/// mirrors that set to avoid false "no credentials" warnings:
/// 1. Current process env + login-shell env (queried once at startup)
/// 2. `~/.hermes/.env`
/// 3. `~/.hermes/auth.json` Credential Pools (v1.6+ blessed flow)
/// 4. `~/.hermes/config.yaml` embedded `api_key:` for auxiliary /
/// delegation tasks
/// Used by Chat to warn the user before `hermes acp` fails on send with
/// "No Anthropic credentials found".
nonisolated static func hasAnyAICredential() -> Bool {
let credentialKeys = shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" }
let env = enrichedEnvironment()
for key in credentialKeys {
if let value = env[key], !value.isEmpty {
return true
}
}
// Scan ~/.hermes/.env for KEY= lines. Uses a simple substring check
// good enough for a preflight hint; hermes itself does the real parse.
let envPath = HermesPaths.home + "/.env"
if let data = try? String(contentsOfFile: envPath, encoding: .utf8) {
for line in data.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
for key in credentialKeys where trimmed.hasPrefix("\(key)=") || trimmed.hasPrefix("export \(key)=") {
// Must have a non-empty value after `=`
if let eq = trimmed.firstIndex(of: "="),
trimmed.index(after: eq) < trimmed.endIndex {
let value = trimmed[trimmed.index(after: eq)...]
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
if !value.isEmpty { return true }
}
}
}
}
// Scan ~/.hermes/auth.json the Credential Pools file written by the
// Configure Credential Pools UI. Schema is
// { "credential_pool": { "<provider>": [ { "access_token": "...", ... }, ... ] } }
// Defensive parse: any malformed input falls through to the next check.
let authPath = HermesPaths.home + "/auth.json"
if let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let pool = root["credential_pool"] as? [String: Any] {
for (_, entries) in pool {
guard let list = entries as? [[String: Any]] else { continue }
for cred in list {
if let token = cred["access_token"] as? String, !token.isEmpty {
return true
}
}
}
}
// Scan ~/.hermes/config.yaml for `api_key:` lines with a non-empty
// value. Covers both `auxiliary.<task>.api_key` and `delegation.api_key`
// without needing to parse the YAML structure any leaf `api_key: ...`
// with a value means Hermes has a credential to fall back on.
if let text = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) {
for line in text.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("api_key:") else { continue }
let value = trimmed.dropFirst("api_key:".count)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
if !value.isEmpty { return true }
}
}
return false
}
@discardableResult
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
guard let binary = hermesBinaryPath() else { return (-1, "") }
@@ -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)
}
}
@@ -30,6 +30,14 @@ final class ChatViewModel {
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
var acpStatus: String = ""
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
@@ -39,6 +47,34 @@ final class ChatViewModel {
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
}
/// Re-checks env + `~/.hermes/.env` for AI-provider credentials and
/// updates `missingCredentials`. Cheap safe to call from view `.task`.
func refreshCredentialPreflight() {
missingCredentials = !HermesFileService.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
func startNewSession() {
@@ -157,10 +193,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 +203,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 +227,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,7 +244,7 @@ final class ChatViewModel {
private func startACPSession(resume sessionId: String?) {
stopACP()
acpError = nil
clearACPErrorState()
acpStatus = "Starting..."
let client = ACPClient()
@@ -259,10 +292,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 +364,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 }
@@ -379,7 +410,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 +435,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."
}
+96 -1
View File
@@ -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
)
}
}
@@ -0,0 +1,51 @@
import SwiftUI
/// Updates section for the General tab. Wraps the Sparkle-backed `UpdaterService`
/// in the same row idioms used elsewhere in Settings (per CLAUDE.md guidance
/// extract sections so individual tab bodies stay small).
struct UpdatesSection: View {
@Environment(UpdaterService.self) private var updater
var body: some View {
SettingsSection(title: "Updates", icon: "arrow.down.circle") {
ReadOnlyRow(label: "Current Version", value: versionString)
ToggleRow(
label: "Check Automatically",
isOn: updater.automaticallyChecksForUpdates
) { newValue in
updater.automaticallyChecksForUpdates = newValue
}
ReadOnlyRow(label: "Last Checked", value: lastCheckedString)
checkNowRow
}
}
private var versionString: String {
let info = Bundle.main.infoDictionary
let short = info?["CFBundleShortVersionString"] as? String ?? "?"
let build = info?["CFBundleVersion"] as? String ?? "?"
return "\(short) (\(build))"
}
private var lastCheckedString: String {
guard let date = updater.lastUpdateCheckDate else { return "Never" }
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
private var checkNowRow: some View {
HStack {
Text("Check Now")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Button("Check for Updates…") { updater.checkForUpdates() }
.controlSize(.small)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
@@ -39,6 +39,8 @@ struct GeneralTab: View {
SettingsSection(title: "Locale", icon: "globe.americas") {
EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) }
}
UpdatesSection()
}
/// Breadcrumb-style row that points users to the Credential Pools sidebar
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Scarf</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>NSMicrophoneUsageDescription</key>
<string>Scarf uses the microphone for Hermes voice chat.</string>
<key>SUFeedURL</key>
<string>https://awizemann.github.io/scarf/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>sxHR0OGLmx9I4Fyx1GdPANR9WUiVAz/rI38x3cLYnMU=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>SUEnableInstallerLauncherService</key>
<false/>
</dict>
</plist>
+2
View File
@@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+11 -1
View File
@@ -6,6 +6,7 @@ struct ScarfApp: App {
@State private var fileWatcher = HermesFileWatcher()
@State private var menuBarStatus = MenuBarStatus()
@State private var chatViewModel = ChatViewModel()
@State private var updater = UpdaterService()
var body: some Scene {
WindowGroup {
@@ -13,6 +14,7 @@ struct ScarfApp: App {
.environment(coordinator)
.environment(fileWatcher)
.environment(chatViewModel)
.environment(updater)
.onAppear {
fileWatcher.startWatching()
menuBarStatus.startPolling()
@@ -23,9 +25,14 @@ struct ScarfApp: App {
}
}
.defaultSize(width: 1100, height: 700)
.commands {
CommandGroup(after: .appInfo) {
Button("Check for Updates…") { updater.checkForUpdates() }
}
}
MenuBarExtra("Scarf", systemImage: menuBarStatus.icon) {
MenuBarMenu(status: menuBarStatus, coordinator: coordinator)
MenuBarMenu(status: menuBarStatus, coordinator: coordinator, updater: updater)
}
}
}
@@ -90,6 +97,7 @@ final class MenuBarStatus {
struct MenuBarMenu: View {
let status: MenuBarStatus
let coordinator: AppCoordinator
let updater: UpdaterService
var body: some View {
VStack {
@@ -116,6 +124,8 @@ struct MenuBarMenu: View {
NSApplication.shared.activate()
}
Divider()
Button("Check for Updates…") { updater.checkForUpdates() }
Divider()
Button("Quit Scarf") {
NSApplication.shared.terminate(nil)
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>teamID</key>
<string>3Q6X2L86C4</string>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Developer ID Application</string>
<key>destination</key>
<string>export</string>
<key>stripSwiftSymbols</key>
<true/>
</dict>
</plist>
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env bash
#
# Scarf release pipeline — local, manual, repeatable.
#
# Usage:
# ./scripts/release.sh 1.7.0 # full release: build, sign, notarize,
# # appcast push, GitHub release, tag
# ./scripts/release.sh 1.7.0 --draft # everything builds + notarizes, but the
# # GitHub release is created as draft, the
# # appcast is NOT updated, and main is NOT
# # tagged. Promote later with --promote.
#
# Release notes:
# If `releases/v<VERSION>/RELEASE_NOTES.md` exists, it is committed alongside the
# version bump and used as the GitHub release body. Otherwise a minimal autogenerated
# note is used.
#
# Prerequisites (one-time setup):
# 1. Developer ID Application cert installed in login Keychain.
# security find-identity -v -p codesigning | grep "Developer ID Application"
# 2. App Store Connect API key stored for notarytool as profile "scarf-notary":
# xcrun notarytool store-credentials "scarf-notary" \
# --key ~/.private/AuthKey_XXXX.p8 --key-id <KEY_ID> --issuer <ISSUER_ID>
# 3. Sparkle EdDSA keypair generated (private key in Keychain item "https://sparkle-project.org"):
# ./scripts/sparkle/generate_keys # or similar, from Sparkle SPM artifacts
# 4. gh-pages branch exists with an appcast.xml and GitHub Pages enabled.
# 5. gh CLI authed: `gh auth status`.
# 6. GH_PAGES_WORKTREE env var pointing at a gh-pages checkout, OR let the
# script create one automatically at .gh-pages-worktree/ via `git worktree add`.
#
set -euo pipefail
# ---------- arg parsing ----------
VERSION=""
DRAFT=0
for arg in "$@"; do
case "$arg" in
--draft) DRAFT=1 ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
-*) printf '[ERR] unknown flag: %s\n' "$arg" >&2; exit 1 ;;
*) [[ -z "$VERSION" ]] && VERSION="$arg" || { printf '[ERR] unexpected arg: %s\n' "$arg" >&2; exit 1; } ;;
esac
done
[[ -n "$VERSION" ]] || { printf 'usage: ./scripts/release.sh <marketing-version> [--draft]\n' >&2; exit 1; }
# ---------- config ----------
TEAM_ID="3Q6X2L86C4"
BUNDLE_ID="com.scarf.app"
SCHEME="scarf"
PROJECT="scarf/scarf.xcodeproj"
NOTARY_PROFILE="scarf-notary"
SIGNING_IDENTITY="Developer ID Application"
APPCAST_URL="https://awizemann.github.io/scarf/appcast.xml"
DOWNLOAD_URL_BASE="https://github.com/awizemann/scarf/releases/download"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="$REPO_ROOT/build"
EXPORT_OPTIONS="$REPO_ROOT/scripts/ExportOptions.plist"
RELEASE_DIR="$REPO_ROOT/releases/v${VERSION}"
GH_PAGES_WORKTREE="${GH_PAGES_WORKTREE:-$REPO_ROOT/.gh-pages-worktree}"
# ---------- helpers ----------
log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31m[ERR] %s\033[0m\n' "$*" >&2; exit 1; }
require_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
# ---------- preflight ----------
log "Preflight checks"
require_cmd xcodebuild
require_cmd xcrun
require_cmd ditto
require_cmd gh
cd "$REPO_ROOT"
# git must be clean and on main. The one exception: the release dir
# (releases/v<VERSION>/) may already exist and be untracked — the user may
# have written RELEASE_NOTES.md there ahead of time, and the rest of the dir
# is auto-populated + gitignored anyway. Git status abbreviates to the dir
# path when all contents are untracked, so the whitelist matches both forms.
ALLOW="^\?\? releases/v${VERSION}/"
DIRTY="$(git status --porcelain | grep -Ev "$ALLOW" || true)"
if [[ -n "$DIRTY" ]]; then
die "working tree not clean — commit or stash first"
fi
CUR_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
[[ "$CUR_BRANCH" == "main" ]] || die "not on main (on $CUR_BRANCH)"
# identity present
security find-identity -v -p codesigning | grep -q "$SIGNING_IDENTITY" \
|| die "'$SIGNING_IDENTITY' certificate not in Keychain — create at developer.apple.com"
# notary profile present (can't list, only test by dry-running submit help)
xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" --output-format json >/dev/null 2>&1 \
|| die "notarytool profile '$NOTARY_PROFILE' not set up — see script header"
# locate sign_update (ships with Sparkle SPM artifacts)
SIGN_UPDATE="$(find ~/Library/Developer/Xcode/DerivedData -name sign_update -type f -perm +111 2>/dev/null | head -n1 || true)"
[[ -x "${SIGN_UPDATE:-}" ]] || die "sign_update not found — build the project once in Xcode so Sparkle artifacts resolve, then re-run"
# ---------- bump version ----------
log "Bumping version to $VERSION"
PBXPROJ="$PROJECT/project.pbxproj"
# CURRENT_PROJECT_VERSION (build number) bumps by 1 from existing
CUR_BUILD="$(awk -F'= ' '/CURRENT_PROJECT_VERSION/ {gsub(/[; ]/,"",$2); print $2; exit}' "$PBXPROJ")"
NEW_BUILD=$((CUR_BUILD + 1))
sed -i '' -E "s/MARKETING_VERSION = [0-9]+\.[0-9]+\.[0-9]+;/MARKETING_VERSION = ${VERSION};/g" "$PBXPROJ"
sed -i '' -E "s/CURRENT_PROJECT_VERSION = [0-9]+;/CURRENT_PROJECT_VERSION = ${NEW_BUILD};/g" "$PBXPROJ"
git add "$PBXPROJ"
# Include release notes in the bump commit if user prepared them ahead of time.
NOTES_FILE="$RELEASE_DIR/RELEASE_NOTES.md"
if [[ -f "$NOTES_FILE" ]]; then
git add "$NOTES_FILE"
fi
git commit -m "chore: Bump version to ${VERSION}"
# ---------- build variants ----------
# Each release produces two zips: a Universal binary (recommended — works on
# both Apple Silicon and Intel) and an ARM64-only variant (smaller download for
# users who know they're on M-series silicon). Each variant is independently
# notarized and stapled. The appcast only references the Universal zip since
# it works everywhere; ARM64 is an alternative manual download.
log "Clean build directory"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR" "$RELEASE_DIR"
# build_variant <label> <archs> <output_zip>
# label e.g. "Universal" or "ARM64" (used as subdir name + log prefix)
# archs e.g. "arm64 x86_64" or "arm64" (space-separated ARCHS value)
# output_zip absolute path where the stapled, distribution-ready zip is written
build_variant() {
local label="$1"
local archs="$2"
local out_zip="$3"
local variant_dir="$BUILD_DIR/$label"
local archive_path="$variant_dir/scarf.xcarchive"
local export_dir="$variant_dir/export"
local app_path="$export_dir/Scarf.app"
local notarize_zip="$variant_dir/Scarf-notarize.zip"
mkdir -p "$variant_dir"
log "[$label] Archive (archs: $archs)"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-archivePath "$archive_path" \
-destination "generic/platform=macOS" \
ONLY_ACTIVE_ARCH=NO \
ARCHS="$archs" \
archive
log "[$label] Export signed .app"
xcodebuild \
-exportArchive \
-archivePath "$archive_path" \
-exportPath "$export_dir" \
-exportOptionsPlist "$EXPORT_OPTIONS"
# Xcode exports as scarf.app (PRODUCT_NAME = $TARGET_NAME = "scarf"). Rename so
# users see properly-cased Scarf.app in /Applications. Renaming the bundle
# wrapper does NOT invalidate the signature — codesign signs contents, not the
# wrapper folder name.
if [[ -d "$export_dir/scarf.app" && ! -d "$app_path" ]]; then
mv "$export_dir/scarf.app" "$app_path"
fi
[[ -d "$app_path" ]] || die "[$label] exported app not found at $app_path"
log "[$label] Verify signature"
codesign --verify --deep --strict --verbose=2 "$app_path"
log "[$label] Zip for notarization"
ditto -c -k --keepParent "$app_path" "$notarize_zip"
log "[$label] Submit to notarytool (blocking)"
xcrun notarytool submit "$notarize_zip" \
--keychain-profile "$NOTARY_PROFILE" \
--wait \
--timeout 30m
log "[$label] Staple + validate"
xcrun stapler staple "$app_path"
xcrun stapler validate "$app_path"
spctl --assess --type execute --verbose "$app_path"
log "[$label] Package $(basename "$out_zip")"
ditto -c -k --keepParent "$app_path" "$out_zip"
}
UNIVERSAL_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-Universal.zip"
ARM64_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-ARM64.zip"
build_variant "Universal" "arm64 x86_64" "$UNIVERSAL_ZIP"
build_variant "ARM64" "arm64" "$ARM64_ZIP"
# ---------- sign appcast entry ----------
log "Sign appcast entry with EdDSA"
# sign_update prints: sparkle:edSignature="..." length="..."
SIG_OUTPUT="$("$SIGN_UPDATE" "$UNIVERSAL_ZIP")"
ED_SIGNATURE="$(echo "$SIG_OUTPUT" | sed -nE 's/.*sparkle:edSignature="([^"]+)".*/\1/p')"
FILE_LENGTH="$(echo "$SIG_OUTPUT" | sed -nE 's/.*length="([^"]+)".*/\1/p')"
[[ -n "$ED_SIGNATURE" && -n "$FILE_LENGTH" ]] || die "sign_update did not produce signature: $SIG_OUTPUT"
DOWNLOAD_URL="$DOWNLOAD_URL_BASE/v${VERSION}/Scarf-v${VERSION}-Universal.zip"
PUB_DATE="$(LC_TIME=en_US.UTF-8 date -u +"%a, %d %b %Y %H:%M:%S +0000")"
APPCAST_ITEM=$(cat <<EOF
<item>
<title>Version ${VERSION}</title>
<sparkle:version>${NEW_BUILD}</sparkle:version>
<sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
<pubDate>${PUB_DATE}</pubDate>
<enclosure url="${DOWNLOAD_URL}"
sparkle:edSignature="${ED_SIGNATURE}"
length="${FILE_LENGTH}"
type="application/octet-stream" />
</item>
EOF
)
# ---------- update appcast on gh-pages (skipped for drafts) ----------
if [[ $DRAFT -eq 0 ]]; then
log "Update appcast.xml on gh-pages worktree"
if [[ ! -d "$GH_PAGES_WORKTREE" ]]; then
git worktree add "$GH_PAGES_WORKTREE" gh-pages
fi
(
cd "$GH_PAGES_WORKTREE"
git pull --ff-only origin gh-pages
# Insert new item after <language>en</language> line
python3 - "$APPCAST_ITEM" <<'PY'
import sys, pathlib
new_item = sys.argv[1]
p = pathlib.Path("appcast.xml")
xml = p.read_text()
marker = "<language>en</language>"
if marker not in xml:
sys.exit("appcast.xml missing <language>en</language> marker")
xml = xml.replace(marker, marker + "\n" + new_item, 1)
p.write_text(xml)
PY
git add appcast.xml
git commit -m "release: v${VERSION}"
git push origin gh-pages
)
else
log "Draft mode — skipping appcast push. Saving entry to $RELEASE_DIR/appcast-entry.xml for later promotion."
printf '%s\n' "$APPCAST_ITEM" > "$RELEASE_DIR/appcast-entry.xml"
fi
# ---------- github release ----------
log "Create GitHub release and upload artifacts"
GH_FLAGS=()
[[ $DRAFT -eq 1 ]] && GH_FLAGS+=(--draft)
if [[ -f "$NOTES_FILE" ]]; then
GH_FLAGS+=(--notes-file "$NOTES_FILE")
else
GH_FLAGS+=(--notes "Release v${VERSION}. See commit history for details.")
fi
gh release create "v${VERSION}" \
--title "Scarf v${VERSION}" \
"${GH_FLAGS[@]}" \
"$UNIVERSAL_ZIP" \
"$ARM64_ZIP"
# ---------- tag main (skipped for drafts) ----------
if [[ $DRAFT -eq 0 ]]; then
log "Tag main and push"
git tag "v${VERSION}"
git push origin main --tags
else
log "Draft mode — skipping tag. Bump commit is local only; push manually with: git push origin main"
fi
if [[ $DRAFT -eq 1 ]]; then
log "Draft release v${VERSION} ready"
log " Review: https://github.com/awizemann/scarf/releases"
log " Promote: in GitHub UI, edit the draft and uncheck 'Set as a pre-release / draft' → Publish."
log " Then commit + push appcast-entry.xml to gh-pages, and tag main:"
log " git push origin main"
log " git tag v${VERSION} && git push origin v${VERSION}"
log " (manually merge $RELEASE_DIR/appcast-entry.xml into gh-pages branch's appcast.xml after <language>en</language>)"
else
log "Release v${VERSION} complete"
log " Download: $DOWNLOAD_URL"
log " Appcast: $APPCAST_URL"
fi