feat: chat-start preflight, Nous catalog, remote-aware admin sheets

Three feature batches that were in progress on chat-resilience —
all aligned with v2.5.2's remote-context theme.

## Chat-start model preflight

When a chat-start hits a server whose config.yaml has no
model.default / model.provider, the upstream provider returns an
opaque "Model parameter is required" 400 only AFTER the user types
a prompt and hits send. New ModelPreflight in ScarfCore catches the
missing keys before any ACP work; ChatView presents the existing
ModelPickerSheet via a thin ChatModelPreflightSheet wrapper so the
picker / validation / Nous-catalog branch stay single-sourced.
ChatViewModel persists the selection via `hermes config set` and
replays the original startACPSession arguments — the chat the user
originally opened lands without re-clicking the project row.

## Nous Portal live catalog

NousModelCatalogService fetches `GET /v1/models` from
inference-api.nousresearch.com using the bearer token in
`auth.json`, caches to `~/.hermes/scarf/nous_models_cache.json`
(new path on HermesPathSet) with a 24h TTL. Picker's nous-overlay
detail switches from a free-form TextField to a real model list,
with a "Custom…" escape hatch (nousManualEntry) for IDs not yet in
the API response.

## Remote-aware admin sheets (mirror of #54's pattern)

The Add Project sheet got context-aware Verify in v2.5.1 (#54);
this batch extends the same shape to three more sheets:

- Profiles: remote import/export. ProfilesView gains
  showRemoteImportSheet + pendingRemoteExport state; reuses the
  same path-input + verify + run-via-hermes pattern from
  AddProjectSheet. Drives `hermes profile import <zip>` /
  `hermes profile export <name> <zip>` over SSH.
- Backup restore (Settings → Advanced): pickLocalBackupZip + new
  RemoteBackupPathSheet so the Restore action picks a local zip
  on local contexts and verifies a remote path on remote contexts.
- Template install destination: TemplateInstallSheet's parent-
  directory picker now branches on context. ParentDirectoryStep
  with browseLocalDirectory + verifyRemotePath + RemoteVerification
  — same UX vocabulary as AddProjectSheet, applied to where the
  template gets installed.

Plus a `runHermesWithStdin` helper on HermesFileService for the
profile import flow (passing zip bytes through stdin rather than
landing them on the remote disk first), and ProjectTemplateInstaller
gains a remote-path-aware code path for the install destination.

## Localizations

Localizable.xcstrings adds strings for all the new copy across
seven supported locales (en, zh-Hans, de, fr, es, ja, pt-BR).
This commit is contained in:
Alan Wizemann
2026-04-29 13:27:25 +02:00
parent c31dfccb9b
commit 2aab9dac07
17 changed files with 1672 additions and 67 deletions
@@ -81,6 +81,12 @@ public struct HermesPathSet: Sendable, Hashable {
/// Maps Hermes session IDs to the Scarf project path a chat was
/// started for. Scarf-owned; Hermes never touches this file.
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
/// Cached list of available Nous Portal models. Populated by
/// `NousModelCatalogService` from `GET https://inference-api.nousresearch.com/v1/models`
/// using the bearer token in `auth.json`. Refreshed on a 24h TTL or
/// on user request from the model picker. Survives offline runs so
/// the picker still has something to render.
public nonisolated var nousModelsCache: String { scarfDir + "/nous_models_cache.json" }
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution
@@ -25,6 +25,10 @@ public struct SSHConfig: Sendable, Hashable, Codable {
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
/// remote side).
public var remoteHome: String?
/// Override for where Scarf installs new project templates on this host.
/// `nil` uses `~/projects` (unexpanded remote shell resolves it).
/// Created on first install if missing.
public var projectsRoot: 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.
@@ -36,6 +40,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
port: Int? = nil,
identityFile: String? = nil,
remoteHome: String? = nil,
projectsRoot: String? = nil,
hermesBinaryHint: String? = nil
) {
self.host = host
@@ -43,6 +48,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
self.port = port
self.identityFile = identityFile
self.remoteHome = remoteHome
self.projectsRoot = projectsRoot
self.hermesBinaryHint = hermesBinaryHint
}
}
@@ -106,6 +112,27 @@ public struct ServerContext: Sendable, Hashable, Identifiable {
return false
}
/// Default parent directory under which `ProjectTemplateInstaller` lays
/// out new projects. Per-host configurable on `.ssh` via
/// `SSHConfig.projectsRoot`; local always resolves to `~/Projects` on the
/// user's Mac. The remote default is left as an unexpanded `~/projects`
/// the remote shell resolves the tilde, same convention as
/// `HermesPathSet.defaultRemoteHome`. The installer calls
/// `transport.createDirectory(_:)` at install time so a missing dir on a
/// fresh host is bootstrapped on first use rather than treated as an error.
public nonisolated var defaultProjectsRoot: String {
switch kind {
case .local:
return NSHomeDirectory() + "/Projects"
case .ssh(let config):
if let configured = config.projectsRoot,
!configured.trimmingCharacters(in: .whitespaces).isEmpty {
return configured
}
return "~/projects"
}
}
/// Construct the `ServerTransport` for this context. Local contexts get
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
/// from `SSHConfig` by default, OR whatever `sshTransportFactory`
@@ -0,0 +1,56 @@
import Foundation
/// Pre-flight check used before opening an ACP session. Hermes resolves the
/// model+provider from `config.yaml` at session boot; on a fresh install that
/// file is missing or has neither key set, and the chat fails with an opaque
/// "Model parameter is required" 400 from the upstream provider only after the
/// user has typed a prompt and hit send. Catching the missing config here lets
/// the UI surface a real "pick a model" sheet before any ACP work starts.
///
/// `HermesConfig.empty` (returned on read failure) and the YAML parser's
/// missing-key fallback both use the literal string `"unknown"`, so the check
/// has to treat `""` and `"unknown"` as equivalent. Anything else is
/// considered configured we don't try to validate the model against the
/// provider's catalog here; that happens later in `ModelPickerSheet`.
public enum ModelPreflight: Sendable {
public enum Result: Equatable, Sendable {
case configured
case missingModel
case missingProvider
case missingBoth
public var isConfigured: Bool {
self == .configured
}
/// Short user-facing reason. Long enough to be honest, short enough
/// for a sheet header full messaging belongs to the picker UI.
public var reason: String {
switch self {
case .configured: return ""
case .missingModel: return "No primary model is set in this server's config."
case .missingProvider:return "No primary provider is set in this server's config."
case .missingBoth: return "No model is configured on this server yet."
}
}
}
/// Treat `""` and the YAML parser's `"unknown"` fallback as missing.
/// Trim whitespace so a stray newline in a hand-edited config.yaml
/// doesn't read as "configured."
public static func check(_ config: HermesConfig) -> Result {
let modelMissing = isUnset(config.model)
let providerMissing = isUnset(config.provider)
switch (modelMissing, providerMissing) {
case (true, true): return .missingBoth
case (true, false): return .missingModel
case (false, true): return .missingProvider
case (false, false): return .configured
}
}
private static func isUnset(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased()
return trimmed.isEmpty || trimmed == "unknown"
}
}
@@ -0,0 +1,247 @@
import Foundation
import os
/// One Nous Portal model as exposed by `GET /v1/models`. The shape
/// mirrors the OpenAI-compatible response schema Nous's inference
/// API uses the same envelope. Optional fields stay optional because
/// not every entry includes them; `id` is the only field we strictly
/// need (it's what Hermes passes through to the provider).
public struct NousModel: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let owned_by: String?
public let created: Int?
/// Free-text description if the API ships one. Nous's current
/// catalog doesn't include this, but the field is here so future
/// shape changes don't drop user-visible context on the floor.
public let description: String?
public init(id: String, owned_by: String? = nil, created: Int? = nil, description: String? = nil) {
self.id = id
self.owned_by = owned_by
self.created = created
self.description = description
}
}
/// On-disk cache shape. Versioned so a future schema change can lift
/// stale caches gracefully bump `version` and the loader rejects
/// anything older without trying to migrate. Stored as JSON next to
/// the projects registry so a Hermes wipe takes it with the rest of
/// the Scarf-owned state.
public struct NousModelsCache: Codable, Sendable {
public static let currentVersion = 1
public let version: Int
public let fetchedAt: Date
public let models: [NousModel]
public init(version: Int = NousModelsCache.currentVersion, fetchedAt: Date, models: [NousModel]) {
self.version = version
self.fetchedAt = fetchedAt
self.models = models
}
}
/// Result of a `loadModels` call. Distinguishes "fetched fresh from
/// the API" from "cache served, network failed" so the picker UI can
/// surface a "could not refresh" hint without hiding the cached list.
public enum NousModelsLoadResult: Sendable {
case fresh(models: [NousModel], fetchedAt: Date)
case cache(models: [NousModel], fetchedAt: Date, refreshError: String?)
case fallback(models: [NousModel], reason: String)
}
/// Fetches + caches the list of available Nous Portal models. Runs in
/// the Scarf process (not on the remote), authenticated with the
/// bearer token from `~/.hermes/auth.json` on the active server
/// `NousSubscriptionService` reads that file via the active transport,
/// so a remote droplet's token comes back over SSH and the network
/// call to Nous still happens from the user's Mac. That's correct:
/// we want the model list visible whenever the user has subscription
/// credentials, regardless of where Hermes will eventually run the
/// chat from.
public struct NousModelCatalogService: Sendable {
public static let baseURL = URL(string: "https://inference-api.nousresearch.com/v1/models")!
public static let cacheTTL: TimeInterval = 24 * 60 * 60 // 24h
public static let requestTimeout: TimeInterval = 10 // seconds
/// Hard-coded fallback for offline-with-no-cache. Short on purpose
/// only the canonical Hermes models (the family the user is most
/// likely to want) plus a reminder that fresh data is one
/// successful refresh away. Update when Nous releases a new
/// flagship; deliberately not exhaustive the API is the source
/// of truth, this just keeps the picker non-empty.
public static let fallbackModels: [NousModel] = [
NousModel(id: "Hermes-3-Llama-3.1-405B"),
NousModel(id: "Hermes-3-Llama-3.1-70B"),
NousModel(id: "Hermes-3-Llama-3.1-8B"),
NousModel(id: "DeepHermes-3-Llama-3-8B-Preview")
]
private static let logger = Logger(subsystem: "com.scarf", category: "NousModelCatalogService")
public let context: ServerContext
private let session: URLSession
private let cachePath: String
public init(context: ServerContext, session: URLSession = .shared) {
self.context = context
self.session = session
self.cachePath = context.paths.nousModelsCache
}
// MARK: - Cache I/O
/// Read the cache via the active transport (so a remote droplet's
/// cache lands on the droplet, not the user's Mac). Missing or
/// malformed cache nil; the loader treats that as "no cache" and
/// kicks off a fresh fetch.
public func readCache() -> NousModelsCache? {
let transport = context.makeTransport()
guard transport.fileExists(cachePath) else { return nil }
do {
let data = try transport.readFile(cachePath)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let cache = try decoder.decode(NousModelsCache.self, from: data)
guard cache.version == NousModelsCache.currentVersion else {
Self.logger.info("nous models cache schema mismatch (got v\(cache.version), expected v\(NousModelsCache.currentVersion)); ignoring")
return nil
}
return cache
} catch {
Self.logger.warning("couldn't decode nous models cache: \(error.localizedDescription, privacy: .public)")
return nil
}
}
private func writeCache(_ cache: NousModelsCache) {
let transport = context.makeTransport()
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(cache)
// Make sure the parent dir exists fresh remote installs
// may not yet have `~/.hermes/scarf/`. mkdir -p is cheap
// and idempotent on both transports.
let parent = (cachePath as NSString).deletingLastPathComponent
if !parent.isEmpty {
try? transport.createDirectory(parent)
}
try transport.writeFile(cachePath, data: data)
} catch {
Self.logger.warning("couldn't write nous models cache: \(error.localizedDescription, privacy: .public)")
}
}
public func isCacheStale(_ cache: NousModelsCache) -> Bool {
Date().timeIntervalSince(cache.fetchedAt) > Self.cacheTTL
}
// MARK: - Network fetch
/// Read the bearer token from `auth.json` on the active server.
/// Returns nil when the user isn't signed in to Nous, in which
/// case `loadModels` skips the network call and falls through to
/// cache or fallback.
private func bearerToken() -> String? {
// The subscription service already checks for `present`; we
// re-read the raw token here because we need the actual string,
// not just a Bool. Mirrors the SubscriptionService parse path.
let transport = context.makeTransport()
guard transport.fileExists(context.paths.authJSON) else { return nil }
guard let data = try? transport.readFile(context.paths.authJSON) else { return nil }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let providers = root["providers"] as? [String: Any] ?? [:]
let nous = providers["nous"] as? [String: Any]
let token = nous?["access_token"] as? String
guard let token, !token.isEmpty else { return nil }
return token
}
/// Make the API call. Times out after `requestTimeout` so a hung
/// network doesn't block the picker indefinitely. Returns the raw
/// `[NousModel]` on success, throws on any HTTP / decode error so
/// the caller can log + fall back.
public func fetchModels() async throws -> [NousModel] {
guard let token = bearerToken() else {
throw NousModelCatalogError.notAuthenticated
}
var request = URLRequest(url: Self.baseURL)
request.httpMethod = "GET"
request.timeoutInterval = Self.requestTimeout
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw NousModelCatalogError.transport("non-HTTP response")
}
guard (200..<300).contains(http.statusCode) else {
throw NousModelCatalogError.http(status: http.statusCode)
}
struct Envelope: Decodable { let data: [NousModel] }
let envelope = try JSONDecoder().decode(Envelope.self, from: data)
return envelope.data
}
// MARK: - Public entry
/// Top-level "give me models" entry point. Cache-first: serve from
/// cache if fresh, fetch + write through if stale or empty, fall
/// back to the hard-coded list when both fail. The caller renders
/// based on the case so it can show a "could not refresh" hint
/// next to a stale-but-still-useful list.
public func loadModels(forceRefresh: Bool = false) async -> NousModelsLoadResult {
let cached = readCache()
if let cached, !forceRefresh, !isCacheStale(cached) {
return .cache(models: cached.models, fetchedAt: cached.fetchedAt, refreshError: nil)
}
do {
let models = try await fetchModels()
let now = Date()
writeCache(NousModelsCache(fetchedAt: now, models: models))
return .fresh(models: models, fetchedAt: now)
} catch let error as NousModelCatalogError {
// Fetch failed but we may still have *something* useful.
if let cached {
return .cache(
models: cached.models,
fetchedAt: cached.fetchedAt,
refreshError: error.userMessage
)
}
return .fallback(models: Self.fallbackModels, reason: error.userMessage)
} catch {
if let cached {
return .cache(
models: cached.models,
fetchedAt: cached.fetchedAt,
refreshError: error.localizedDescription
)
}
return .fallback(models: Self.fallbackModels, reason: error.localizedDescription)
}
}
}
public enum NousModelCatalogError: Error, Sendable {
case notAuthenticated
case http(status: Int)
case transport(String)
public var userMessage: String {
switch self {
case .notAuthenticated:
return "Sign in to Nous Portal to fetch the latest model list."
case .http(let status) where status == 401:
return "Nous rejected the saved token (401). Sign in again."
case .http(let status):
return "Nous returned HTTP \(status)."
case .transport(let detail):
return "Couldn't reach Nous: \(detail)."
}
}
}
+217 -1
View File
@@ -159,6 +159,21 @@ struct ChatView: View {
connectingOverlay
}
}
.sheet(isPresented: Binding(
get: { controller.modelPreflightReason != nil },
set: { newValue in
if !newValue { controller.cancelModelPreflight() }
}
)) {
IOSModelPreflightSheet(
reason: controller.modelPreflightReason ?? "",
serverDisplayName: controller.context.displayName,
onSelect: { model, provider in
controller.confirmModelPreflight(model: model, provider: provider)
},
onCancel: { controller.cancelModelPreflight() }
)
}
.sheet(item: Binding(
get: { controller.vm.pendingPermission.map(PermissionWrapper.init) },
set: { if $0 == nil { controller.vm.pendingPermission = nil } }
@@ -680,6 +695,28 @@ final class ChatController {
private(set) var state: State = .idle
var vm: RichChatViewModel
var draft: String = ""
/// Set when chat-start is blocked because the active server's
/// `config.yaml` has no `model.default` / `model.provider`. ChatView
/// observes this to present an inline "pick a model" sheet the
/// Mac picker UI doesn't ship on iOS today, so the iOS sheet
/// captures model + provider as text fields and persists them via
/// the same `hermes config set` path. Reset on cancel or after a
/// successful retry.
var modelPreflightReason: String?
/// Stash of the original chat-start intent while we wait for the
/// user to fill in a model. Captured by the gate inside `start`,
/// `startInternal`, `startResuming`; replayed verbatim once
/// `confirmModelPreflight` writes the chosen values to config.yaml
/// so the chat the user originally tried to open lands without
/// them having to click the project row again.
private enum PendingStart {
case fresh
case project(path: String, name: String)
case resume(sessionID: String)
}
private var pendingStartIntent: PendingStart?
/// Display name of the Scarf project this session is scoped to,
/// or nil for "quick chat" / global sessions. Surfaced as a
/// subtitle under the "Chat" title in the nav bar so users can
@@ -694,7 +731,10 @@ final class ChatController {
/// chip on the right side of the project context bar.
private(set) var currentGitBranch: String?
private let context: ServerContext
/// Public so the surrounding `ChatView` can read `displayName`
/// when presenting sheets (e.g., the model preflight). Still
/// `let` set once at init, never mutated after.
let context: ServerContext
private var client: ACPClient?
private var eventTask: Task<Void, Never>?
private var healthMonitorTask: Task<Void, Never>?
@@ -796,11 +836,109 @@ final class ChatController {
self.vm = RichChatViewModel(context: context)
}
/// Pre-flight: returns true when `config.yaml` has both
/// `model.default` and `model.provider`. Returns false and stashes
/// the start intent so the preflight sheet can replay it after the
/// user picks a model. Reads via `context.readText` (transport-
/// aware) and parses with the ScarfCore YAML parser same path
/// `IOSSettingsViewModel.load` uses, just synchronous because the
/// preflight runs before any `state = .connecting` UI transition.
private func passModelPreflight(intent: PendingStart) -> Bool {
let raw = context.readText(context.paths.configYAML) ?? ""
let config = HermesConfig(yaml: raw)
let result = ModelPreflight.check(config)
if result.isConfigured { return true }
pendingStartIntent = intent
modelPreflightReason = result.reason
return false
}
/// User confirmed model + provider in the preflight sheet. Persist
/// to `config.yaml` via `hermes config set` (transport-aware runs
/// over SSH on the active server) and replay the original start
/// intent. iOS picker is a free-form text input today (matches the
/// Mac overlay-provider field for `nous`), so trust the user's
/// input Hermes will surface a runtime error if the model isn't
/// valid for the provider.
func confirmModelPreflight(model: String, provider: String) {
let intent = pendingStartIntent
modelPreflightReason = nil
pendingStartIntent = nil
let trimmedModel = model.trimmingCharacters(in: .whitespaces)
let trimmedProvider = provider.trimmingCharacters(in: .whitespaces)
guard !trimmedProvider.isEmpty else { return }
let ctx = context
Task.detached { [weak self] in
// Same PATH-prefix trick `IOSSettingsViewModel.saveValue`
// uses so non-interactive shells find `hermes` even when
// it's in ~/.local/bin / /opt/homebrew/bin.
let hermes = ctx.paths.hermesBinary
let providerScript = """
PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH" \
\(hermes) config set 'model.provider' '\(Self.escapeShellArg(trimmedProvider))'
"""
let providerOK = (try? ctx.makeTransport().runProcess(
executable: "/bin/sh",
args: ["-c", providerScript],
stdin: nil,
timeout: 15
))?.exitCode == 0
var modelOK = true
if providerOK, !trimmedModel.isEmpty {
let modelScript = """
PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH" \
\(hermes) config set 'model.default' '\(Self.escapeShellArg(trimmedModel))'
"""
modelOK = (try? ctx.makeTransport().runProcess(
executable: "/bin/sh",
args: ["-c", modelScript],
stdin: nil,
timeout: 15
))?.exitCode == 0
}
await MainActor.run { [weak self] in
guard let self else { return }
if providerOK, modelOK, let intent {
Task { @MainActor in
switch intent {
case .fresh:
await self.start()
case .project(let path, let name):
await self.start(projectPath: path, projectName: name)
case .resume(let id):
await self.startResuming(sessionID: id)
}
}
} else if !(providerOK && modelOK) {
self.state = .failed("Couldn't save model+provider to config.yaml.")
}
}
}
}
/// Single-quote escape a shell argument. Handles embedded single
/// quotes via the standard `'"'"'` trick. Mirrors the helper on
/// `IOSSettingsViewModel`. `nonisolated static` so the
/// `Task.detached` body can call it without a `self` capture and
/// without hopping back to the MainActor.
nonisolated private static func escapeShellArg(_ s: String) -> String {
s.replacingOccurrences(of: "'", with: "'\"'\"'")
}
func cancelModelPreflight() {
modelPreflightReason = nil
pendingStartIntent = nil
}
/// Open the SSH exec channel, send ACP `initialize`, then
/// `session/new` so that by the time `state == .ready` the user
/// can type and hit send immediately.
func start() async {
if state == .connecting || state == .ready { return }
guard passModelPreflight(intent: .fresh) else { return }
state = .connecting
vm.reset()
let client = ACPClient.forIOSApp(
@@ -1297,6 +1435,13 @@ final class ChatController {
projectName: String?
) async {
if state == .connecting || state == .ready { return }
let intent: PendingStart
if let projectPath, let projectName {
intent = .project(path: projectPath, name: projectName)
} else {
intent = .fresh
}
guard passModelPreflight(intent: intent) else { return }
state = .connecting
let client = ACPClient.forIOSApp(
context: context,
@@ -1380,6 +1525,7 @@ final class ChatController {
/// to `session/load` if the remote doesn't support `session/resume`
/// (Hermes < 0.9.x).
func startResuming(sessionID: String) async {
guard passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
await stop()
vm.reset()
// Clear eagerly so a lingering project name from a prior
@@ -1981,6 +2127,76 @@ private struct PermissionSheet: View {
}
}
/// iOS preflight sheet for the model + provider on a server whose
/// `config.yaml` is missing them. The Mac picker (`ModelPickerSheet`)
/// doesn't ship in the iOS target the catalog UI is Mac-only today
/// so this is a pair of `TextField`s plus a hint pointing at common
/// formats. Confirms via the same `setModelAndProvider` path the Mac
/// preflight uses, so persistence + replay logic stays single-sourced
/// in `ChatController.confirmModelPreflight`.
private struct IOSModelPreflightSheet: View {
let reason: String
let serverDisplayName: String
let onSelect: (_ model: String, _ provider: String) -> Void
let onCancel: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var model: String = ""
@State private var provider: String = ""
var body: some View {
NavigationStack {
Form {
Section {
Text(reasonLine)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("Provider") {
TextField("e.g. anthropic, nous, openai", text: $provider)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section("Model") {
TextField("e.g. claude-sonnet-4.6, hermes-3", text: $model)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Text("Hermes will pass these through verbatim. Leave model blank if you're using Nous Portal — Hermes picks its default.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Pick a model")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
onCancel()
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save & Start") {
let p = provider.trimmingCharacters(in: .whitespaces)
let m = model.trimmingCharacters(in: .whitespaces)
guard !p.isEmpty else { return }
onSelect(m, p)
dismiss()
}
.disabled(provider.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
private var reasonLine: String {
let suffix = "Scarf will save these to `config.yaml` on \(serverDisplayName) and start the chat."
guard !reason.isEmpty else { return suffix }
return "\(reason) \(suffix)"
}
}
#endif // canImport(SQLite3)
// Empty shim so the file compiles on platforms without SQLite3 the
@@ -1500,6 +1500,42 @@ struct HermesFileService: Sendable {
return false
}
/// Persist the primary model + provider to `config.yaml` in one call.
/// Used by the chat-start preflight when the user picks a model from
/// the picker sheet we need to write both keys before re-attempting
/// `client.start()`. Wraps two `hermes config set` invocations because
/// Hermes doesn't expose a combined "set model" command.
///
/// Returns `true` only if both writes succeed. If the second write
/// fails the first is left in place `model.default` without a
/// matching `model.provider` is no worse than the all-empty state we
/// started in, and the next preflight pass will re-prompt anyway.
@discardableResult
nonisolated func setModelAndProvider(model: String, provider: String) -> Bool {
let trimmedModel = model.trimmingCharacters(in: .whitespaces)
let trimmedProvider = provider.trimmingCharacters(in: .whitespaces)
guard !trimmedProvider.isEmpty else { return false }
let providerResult = runHermesCLI(args: ["config", "set", "model.provider", trimmedProvider], timeout: 30)
guard providerResult.exitCode == 0 else {
Self.logger.warning("hermes config set model.provider failed: \(providerResult.output, privacy: .public)")
return false
}
// Subscription-gated overlay providers (Nous Portal) accept an
// empty model Hermes picks its own default. Skip the model
// write in that case rather than persisting the empty string,
// which Hermes would treat as "unset" and the preflight would
// catch again on the next start.
guard !trimmedModel.isEmpty else { return true }
let modelResult = runHermesCLI(args: ["config", "set", "model.default", trimmedModel], timeout: 30)
guard modelResult.exitCode == 0 else {
Self.logger.warning("hermes config set model.default failed: \(modelResult.output, privacy: .public)")
return false
}
return true
}
@discardableResult
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
// Resolve the executable path for remote, prefer the cached
@@ -21,6 +21,7 @@ struct ProjectTemplateInstaller: Sendable {
/// to the registry so the caller can set `AppCoordinator.selectedProjectName`.
@discardableResult
nonisolated func install(plan: TemplateInstallPlan) throws -> ProjectEntry {
try bootstrapProjectsRoot(plan: plan)
try preflight(plan: plan)
try createProjectFiles(plan: plan)
try createSkillsFiles(plan: plan)
@@ -32,6 +33,24 @@ struct ProjectTemplateInstaller: Sendable {
return entry
}
// MARK: - Bootstrap
/// Idempotently `mkdir -p` the parent directory so a fresh remote
/// host (or a local user with no `~/Projects`) can complete the
/// first install. Runs *before* preflight preflight then checks
/// the project dir itself, which we deliberately don't create
/// here so the "already exists" collision check still fires for
/// repeat installs at the same path.
///
/// Safe on both transports: `LocalTransport.createDirectory` uses
/// `withIntermediateDirectories: true`; `SSHTransport.createDirectory`
/// runs `mkdir -p`. Idempotent for existing dirs in both cases.
nonisolated private func bootstrapProjectsRoot(plan: TemplateInstallPlan) throws {
let parentDir = (plan.projectDir as NSString).deletingLastPathComponent
guard !parentDir.isEmpty, parentDir != "/" else { return }
try context.makeTransport().createDirectory(parentDir)
}
// MARK: - Preflight
nonisolated private func preflight(plan: TemplateInstallPlan) throws {
@@ -142,6 +142,20 @@ final class ChatViewModel {
/// True when `hasAnyAICredential()` returned false at last preflight.
var missingCredentials: Bool = false
/// Set when chat-start is blocked because the active server's
/// `config.yaml` has no `model.default` / `model.provider`. The chat
/// view observes this and presents `ChatModelPreflightSheet`; on
/// successful pick we persist via `setModelAndProvider` and re-attempt
/// the original `startACPSession` call from `pendingStartArgs`.
/// Nil when no preflight is pending.
var modelPreflightReason: String?
/// Stash of the original `startACPSession` arguments while we wait
/// for the user to pick a model. Replayed verbatim once
/// `confirmModelPreflight` writes the chosen model+provider to
/// config.yaml. Cleared on cancel or after replay.
private var pendingStartArgs: (sessionId: String?, projectPath: String?)?
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
@@ -404,6 +418,23 @@ final class ChatViewModel {
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
stopACP()
clearACPErrorState()
// Pre-flight: bail before opening any ACP plumbing if the
// active server's `config.yaml` has no primary model or
// provider. Hermes would otherwise let `session/new` succeed
// and only fail at first prompt with an opaque
// "Model parameter is required" 400. Stashing the start
// arguments here lets `confirmModelPreflight` replay them
// unchanged after the user picks a model.
let preflight = ModelPreflight.check(fileService.loadConfig())
if !preflight.isConfigured {
pendingStartArgs = (sessionId, projectPath)
modelPreflightReason = preflight.reason
acpStatus = ""
hasActiveProcess = false
return
}
acpStatus = "Starting..."
let client = ACPClient.forMacApp(context: context)
@@ -716,6 +747,44 @@ final class ChatViewModel {
isHandlingDisconnect = false
}
// MARK: - Model preflight
/// Called by `ChatModelPreflightSheet` once the user has picked a
/// model in the embedded `ModelPickerSheet`. Persists the choice via
/// `hermes config set` (transport-aware works on remote droplets
/// too) and replays the pending `startACPSession` call so the chat
/// the user originally tried to open finally lands.
@MainActor
func confirmModelPreflight(model: String, provider: String) {
let pending = pendingStartArgs
modelPreflightReason = nil
pendingStartArgs = nil
let svc = fileService
Task.detached { [weak self] in
let ok = svc.setModelAndProvider(model: model, provider: provider)
await MainActor.run { [weak self] in
guard let self else { return }
if ok {
if let pending {
self.startACPSession(resume: pending.sessionId, projectPath: pending.projectPath)
}
} else {
self.acpError = "Couldn't save model+provider to config.yaml. Open Settings to retry."
}
}
}
}
/// User dismissed the preflight sheet without picking a model. Drop
/// the stashed start arguments and leave the chat in its idle state
/// no error banner, since this isn't a failure, just a deferral.
@MainActor
func cancelModelPreflight() {
modelPreflightReason = nil
pendingStartArgs = nil
}
/// Respond to a permission request from the ACP agent.
func respondToPermission(optionId: String) {
guard let client = acpClient,
@@ -0,0 +1,66 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Pre-flight sheet shown when a chat-start hits a server whose
/// `config.yaml` has no `model.default` / `model.provider`. Wraps the
/// existing `ModelPickerSheet` so the picker surface, validation, and
/// Nous-catalog branch all remain in one place.
///
/// The host (`ChatView`) owns persistence + retry: this sheet only
/// captures the user's selection and calls `onSelect`. The
/// `ChatViewModel` writes via `hermes config set` and replays the
/// original `startACPSession` arguments, so the chat the user
/// originally opened lands without them having to click the project
/// row again.
struct ChatModelPreflightSheet: View {
let reason: String
let serverDisplayName: String
let onSelect: (_ model: String, _ provider: String) -> Void
let onCancel: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
ModelPickerSheet(
initialProvider: "",
initialModel: "",
onSelect: { modelID, providerID in
onSelect(modelID, providerID)
dismiss()
},
onCancel: {
onCancel()
dismiss()
}
)
}
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "cpu")
.foregroundStyle(ScarfColor.warning)
.font(.title2)
VStack(alignment: .leading, spacing: 4) {
Text("Pick a model to start chatting")
.scarfStyle(.headline)
Text(detailMessage)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.padding()
}
private var detailMessage: String {
let suffix = "Hermes uses `model.default` + `model.provider` from `config.yaml`. Pick one and Scarf will save it on \(serverDisplayName) before starting the chat."
guard !reason.isEmpty else { return suffix }
return "\(reason) \(suffix)"
}
}
@@ -20,6 +20,16 @@ struct ProfilesView: View {
@State private var renameTarget: HermesProfile?
@State private var renameNewName = ""
@State private var pendingDelete: HermesProfile?
/// Remote-import sheet visibility. Local imports use `NSOpenPanel`
/// inline; remote imports route through `RemoteProfilePathSheet`
/// because the zip the user wants to import lives on the remote
/// host (that's where `hermes profile export` produced it), and
/// `NSOpenPanel` can only browse the local Mac.
@State private var showRemoteImportSheet = false
/// When non-nil, the export button on the named profile presents
/// `RemoteProfilePathSheet` to ask for an output path on the
/// remote host. Local exports continue to use `NSSavePanel`.
@State private var pendingRemoteExport: HermesProfile?
var body: some View {
VStack(spacing: 0) {
@@ -53,6 +63,36 @@ struct ProfilesView: View {
} message: {
Text("This removes the profile directory and all data within it. This cannot be undone.")
}
.sheet(isPresented: $showRemoteImportSheet) {
RemoteProfilePathSheet(
context: viewModel.context,
title: "Import profile",
prompt: "Enter the path to a profile `.zip` on \(viewModel.context.displayName).",
placeholder: "e.g. ~/profiles/my-profile.zip",
confirmLabel: "Import",
mode: .existingFile,
onCancel: { showRemoteImportSheet = false },
onConfirm: { path in
showRemoteImportSheet = false
viewModel.import(from: path)
}
)
}
.sheet(item: $pendingRemoteExport) { profile in
RemoteProfilePathSheet(
context: viewModel.context,
title: "Export profile '\(profile.name)'",
prompt: "Enter the destination path on \(viewModel.context.displayName) where the `.zip` should be written.",
placeholder: "e.g. ~/\(profile.name)-profile.zip",
confirmLabel: "Export",
mode: .writableFile(initialName: "\(profile.name)-profile.zip"),
onCancel: { pendingRemoteExport = nil },
onConfirm: { path in
pendingRemoteExport = nil
viewModel.export(profile, to: path)
}
)
}
}
private var listSection: some View {
@@ -72,13 +112,21 @@ struct ProfilesView: View {
}
.controlSize(.small)
Button {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.zip]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
viewModel.import(from: url.path)
if viewModel.context.isRemote {
// The zip lives on the remote (where `hermes profile
// export` produced it). NSOpenPanel can only browse
// the user's Mac, so route through a remote-path
// input sheet instead.
showRemoteImportSheet = true
} else {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.zip]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
viewModel.import(from: url.path)
}
}
} label: {
Label("Import", systemImage: "square.and.arrow.down")
@@ -119,11 +167,20 @@ struct ProfilesView: View {
renameNewName = profile.name
}
Button("Export…") {
let panel = NSSavePanel()
panel.allowedContentTypes = [.zip]
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
if panel.runModal() == .OK, let url = panel.url {
viewModel.export(profile, to: url.path)
if viewModel.context.isRemote {
// Exporting a remote profile must write to a
// remote path NSSavePanel would write to
// the user's Mac, leaving the remote
// profile zip nowhere on the host where
// anyone can use it.
pendingRemoteExport = profile
} else {
let panel = NSSavePanel()
panel.allowedContentTypes = [.zip]
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
if panel.runModal() == .OK, let url = panel.url {
viewModel.export(profile, to: url.path)
}
}
}
Divider()
@@ -264,3 +321,147 @@ struct ProfilesView: View {
.frame(minWidth: 440, minHeight: 180)
}
}
/// Remote-path picker for profile import + export. Used when the active
/// `ServerContext` is `.ssh` `NSOpenPanel` / `NSSavePanel` would
/// browse the user's Mac, which is the wrong host. The sheet takes a
/// remote path string and verifies it via the active transport before
/// handing it back. The `mode` distinguishes "must already exist" from
/// "we're about to write here," each with appropriate validation.
private struct RemoteProfilePathSheet: View {
enum Mode {
/// Import flow: zip must already exist on the remote.
case existingFile
/// Export flow: we'll be writing to the path. Permissive on
/// non-existence (that's expected); warn on existing dir or
/// non-zip extension.
case writableFile(initialName: String)
}
let context: ServerContext
let title: String
let prompt: String
let placeholder: String
let confirmLabel: String
let mode: Mode
let onCancel: () -> Void
let onConfirm: (String) -> Void
@State private var path: String = ""
@State private var verification: Verification = .idle
private enum Verification: Equatable {
case idle
case verifying
case ok(String)
case warn(String)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(title).font(.headline)
Text(prompt)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
TextField(placeholder, text: $path)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.onChange(of: path) { _, _ in
if verification != .idle { verification = .idle }
}
Button("Verify") { Task { await verify() } }
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty
|| verification == .verifying)
}
verificationBadge
HStack {
Button("Cancel") { onCancel() }
.keyboardShortcut(.cancelAction)
Spacer()
Button(confirmLabel) {
let trimmed = path.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
onConfirm(trimmed)
}
.keyboardShortcut(.defaultAction)
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding(20)
.frame(width: 520)
.onAppear {
if case .writableFile(let initialName) = mode, path.isEmpty {
path = "~/" + initialName
}
}
}
@ViewBuilder
private var verificationBadge: some View {
switch verification {
case .idle:
EmptyView()
case .verifying:
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Checking on \(context.displayName)")
.font(.caption)
.foregroundStyle(.secondary)
}
case .ok(let detail):
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(detail).font(.caption)
}
case .warn(let detail):
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(detail).font(.caption)
}
}
}
private func verify() async {
let trimmed = path.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
verification = .verifying
let snapshot = context
let snapshotMode = mode
let result: Verification = await Task.detached {
let transport = snapshot.makeTransport()
let exists = transport.fileExists(trimmed)
switch snapshotMode {
case .existingFile:
guard exists else {
return .warn("Path doesn't exist on \(snapshot.displayName).")
}
guard let stat = transport.stat(trimmed) else {
return .warn("Found, but couldn't stat — check permissions.")
}
if stat.isDirectory {
return .warn("Path is a directory, not a file.")
}
if !trimmed.lowercased().hasSuffix(".zip") {
return .warn("File found, but extension isn't `.zip`. Profile import expects a zip archive.")
}
return .ok("File found on \(snapshot.displayName).")
case .writableFile:
if exists {
if let stat = transport.stat(trimmed), stat.isDirectory {
return .warn("Path is a directory. Choose a file path that doesn't yet exist.")
}
return .warn("File already exists on \(snapshot.displayName) — export will overwrite it.")
}
if !trimmed.lowercased().hasSuffix(".zip") {
return .warn("Extension isn't `.zip`. The export command writes a zip archive.")
}
return .ok("Path is available on \(snapshot.displayName).")
}
}.value
verification = result
}
}
@@ -17,6 +17,10 @@ final class AddServerViewModel {
var identityFile: String = ""
/// Override for `~/.hermes` on the remote. Empty = default.
var remoteHome: String = ""
/// Override for the parent dir under which template installs land on
/// this host. Empty = default (`~/projects`). Created on first install
/// if missing.
var projectsRoot: String = ""
var isTesting: Bool = false
/// Outcome of the most recent Test Connection run. `nil` = not yet run.
@@ -44,6 +48,7 @@ final class AddServerViewModel {
port: Int(port),
identityFile: nonEmpty(identityFile),
remoteHome: nonEmpty(remoteHome),
projectsRoot: nonEmpty(projectsRoot),
hermesBinaryHint: nil
)
}
@@ -93,6 +93,16 @@ struct AddServerSheet: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
LabeledField("Projects directory") {
TextField("Default: ~/projects", text: $viewModel.projectsRoot)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
}
Text("Where Scarf installs new project templates on this host. Created on first install if missing.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text("Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.")
.font(.caption)
.foregroundStyle(.secondary)
@@ -271,10 +271,16 @@ final class SettingsViewModel {
}
}
func runRestore(from url: URL) {
/// Restore from a backup `.zip`. The path may be local (the user picked
/// it via `NSOpenPanel` on a local context) or remote (the user typed it
/// in the remote-path sheet). Either way, the call goes through
/// `fileService.runHermesCLI`, which is transport-aware for an SSH
/// context the `hermes import <path>` command runs on the remote shell
/// where `<path>` is a remote filesystem path.
func runRestore(fromPath path: String) {
backupInProgress = true
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["import", url.path], timeout: 300)
let result = fileService.runHermesCLI(args: ["import", path], timeout: 300)
await MainActor.run {
self.backupInProgress = false
self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed"
@@ -299,17 +305,6 @@ final class SettingsViewModel {
return String(output[r])
}
func presentRestorePicker() -> URL? {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.zip]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.message = "Choose a Hermes backup archive to restore"
guard panel.runModal() == .OK, let url = panel.url else { return nil }
return url
}
func openConfigInEditor() {
// No-op for remote contexts the file is on the remote host, not
// this Mac. The Settings tab's in-app editor is the supported way
@@ -47,6 +47,20 @@ struct ModelPickerSheet: View {
/// "Sign in to Nous Portal" button in the subscription summary.
@State private var showNousSignIn: Bool = false
/// Cached + freshly-fetched Nous model list for the picker's
/// nous-overlay branch. Populated on appear (cache-first) and
/// refreshed when the user signs in or hits the Refresh button.
@State private var nousModels: [NousModel] = []
@State private var nousFetchedAt: Date?
@State private var nousRefreshError: String?
@State private var nousIsRefreshing: Bool = false
/// When true, render the Nous detail with the original free-form
/// TextField + manual hint instead of the model list. Used when
/// the user explicitly wants to type a model not in the catalog
/// the API list is comprehensive but not infallible, so always
/// keep the escape hatch reachable.
@State private var nousManualEntry: Bool = false
/// Validation failure surfaced on Select when the typed / selected
/// model isn't in the chosen provider's catalog. Pass-1 M7 #5
/// cross-platform fix previously Scarf let you save any string
@@ -107,6 +121,10 @@ struct ModelPickerSheet: View {
// status row flips to "active" without waiting for the
// picker to be re-opened.
subscription = subscriptionService.loadState()
// Sign-in unlocked the bearer token kick a fresh
// model-list fetch so the picker populates without the
// user needing to hit Refresh manually.
Task { await refreshNousModels(forceRefresh: true) }
}
}
.alert(item: $validationIssue) { issue in
@@ -189,8 +207,14 @@ struct ModelPickerSheet: View {
@ViewBuilder
private var modelColumn: some View {
if let selected = providers.first(where: { $0.providerID == selectedProviderID }), selected.isOverlay {
overlayProviderDetail(selected)
if let selected = providers.first(where: { $0.providerID == selectedProviderID }) {
if selected.providerID == "nous" {
nousOverlayDetail(selected)
} else if selected.isOverlay {
overlayProviderDetail(selected)
} else {
cachedModelList
}
} else {
cachedModelList
}
@@ -241,6 +265,147 @@ struct ModelPickerSheet: View {
}
}
/// Right-column detail for Nous Portal same overlay shape as
/// `overlayProviderDetail` but with a live model list fetched from
/// Nous's OpenAI-compatible `/v1/models` endpoint. The list is
/// cache-first so opening the sheet feels instant; refresh runs
/// in the background. Falls back to a hard-coded short list when
/// the user has no token AND no cache (offline first-run on a
/// fresh remote install). The "Custom" button below the list
/// flips to the original free-form TextField Nous occasionally
/// adds a model before our cache hits 24h, and we don't want
/// users locked out of the latest releases.
@ViewBuilder
private func nousOverlayDetail(_ provider: HermesProviderInfo) -> some View {
let overlay = catalog.overlayMetadata(for: provider.providerID)
ScrollView {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(provider.providerName).font(.title3.bold())
if provider.subscriptionGated {
capsuleTag("Subscription", tint: .accentColor)
}
}
if provider.subscriptionGated {
subscriptionSummary(provider: provider, overlay: overlay)
}
Divider()
if nousManualEntry {
nousManualEntryBlock(provider: provider)
} else {
nousModelListBlock
}
if let docURL = overlay?.docURL, let url = URL(string: docURL) {
Link(destination: url) {
Label("Setup documentation", systemImage: "book")
.font(.caption)
}
}
Spacer(minLength: 0)
}
.padding()
}
}
@ViewBuilder
private var nousModelListBlock: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Available models")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if nousIsRefreshing {
HStack(spacing: 4) {
ProgressView().controlSize(.mini)
Text("Refreshing…").font(.caption2).foregroundStyle(.tertiary)
}
} else {
Button {
Task { await refreshNousModels(forceRefresh: true) }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help(nousFetchedAtTooltip)
}
Button("Custom…") { nousManualEntry = true }
.controlSize(.small)
}
if let err = nousRefreshError, !nousIsRefreshing {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(err)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
List(selection: $overlayModelID) {
ForEach(nousModels) { model in
VStack(alignment: .leading, spacing: 2) {
Text(model.id)
.font(.system(.body, design: .monospaced))
if let owner = model.owned_by, !owner.isEmpty {
Text(owner)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.tag(model.id)
}
}
.listStyle(.inset)
.frame(minHeight: 220)
.overlay {
if nousModels.isEmpty && !nousIsRefreshing {
ContentUnavailableView(
"No models loaded",
systemImage: "cpu",
description: Text("Sign in to Nous Portal to load the catalog, or enter a model ID manually.")
)
}
}
if nousFetchedAt == nil && !nousModels.isEmpty {
Text("Showing built-in fallback list — couldn't reach Nous to refresh.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
Text("Leave blank in config to let Hermes pick the default Nous model. Picking one above writes it explicitly.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
@ViewBuilder
private func nousManualEntryBlock(provider: HermesProviderInfo) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Model ID").font(.caption).foregroundStyle(.secondary)
Spacer()
Button("Use list") { nousManualEntry = false }
.controlSize(.small)
}
TextField(modelIDPlaceholder(for: provider), text: $overlayModelID)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Text("Type a model ID exactly as Nous expects it. Leave blank to use Hermes's default.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
private var nousFetchedAtTooltip: String {
guard let date = nousFetchedAt else {
return "Fetch the latest model list from Nous."
}
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return "Last refreshed \(formatter.localizedString(for: date, relativeTo: Date()))"
}
/// Right-column detail for overlay-only providers (Nous Portal, OpenAI
/// Codex, Qwen OAuth, ). models.dev has no catalog for them, so the user
/// either trusts Hermes's default (subscription providers) or types a
@@ -467,6 +632,53 @@ struct ModelPickerSheet: View {
if !models.contains(where: { $0.modelID == selectedModelID }) {
selectedModelID = models.first?.modelID ?? ""
}
// Cache-first kick for the Nous catalog. Renders from cache
// immediately, fires a background refresh if stale or empty.
if selectedProviderID == "nous" {
Task { await refreshNousModels(forceRefresh: false) }
}
}
/// Cache-first load of the Nous model list. Updates the four
/// `@State` vars the detail view reads. Force-refresh skips the
/// TTL check so the user-tapped Refresh button always hits the
/// network the cache write keeps the next sheet-open instant.
private func refreshNousModels(forceRefresh: Bool) async {
let service = NousModelCatalogService(context: serverContext)
// Render from cache immediately on the first pass so the user
// doesn't see an empty list while the network call is in
// flight. The async load below overwrites with fresh data
// when it returns.
if !forceRefresh, let cache = service.readCache(), !cache.models.isEmpty, nousModels.isEmpty {
nousModels = cache.models
nousFetchedAt = cache.fetchedAt
nousRefreshError = nil
}
nousIsRefreshing = true
let result = await service.loadModels(forceRefresh: forceRefresh)
nousIsRefreshing = false
switch result {
case .fresh(let models, let fetchedAt):
nousModels = models
nousFetchedAt = fetchedAt
nousRefreshError = nil
case .cache(let models, let fetchedAt, let refreshError):
nousModels = models
nousFetchedAt = fetchedAt
nousRefreshError = refreshError
case .fallback(let models, let reason):
nousModels = models
nousFetchedAt = nil
nousRefreshError = reason
}
// Pre-fill `overlayModelID` with the user's previously chosen
// model when it's in the freshly-loaded list otherwise the
// selection state highlights nothing on first paint.
if !overlayModelID.isEmpty,
!nousModels.contains(where: { $0.id == overlayModelID }) {
// Leave overlayModelID alone it's a user-typed value
// that may legitimately not be in the catalog.
}
}
/// When the user enters a custom model ID without explicitly naming a
@@ -1,5 +1,7 @@
import AppKit
import SwiftUI
import ScarfCore
import UniformTypeIdentifiers
/// Advanced tab network, compression, checkpoints, logging, delegation, file read cap,
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
@@ -7,7 +9,8 @@ struct AdvancedTab: View {
@Bindable var viewModel: SettingsViewModel
@State private var showRawConfig = false
@State private var showRestoreConfirm = false
@State private var pendingRestoreURL: URL?
@State private var pendingRestorePath: String?
@State private var showRemoteRestoreSheet = false
@State private var diagnosticsOutput: String = ""
@State private var showDiagnostics = false
@@ -111,9 +114,17 @@ struct AdvancedTab: View {
.controlSize(.small)
.disabled(viewModel.backupInProgress)
Button {
if let url = viewModel.presentRestorePicker() {
pendingRestoreURL = url
showRestoreConfirm = true
if viewModel.context.isRemote {
// The backup zip lives on the remote (that's where
// `hermes backup` ran). NSOpenPanel can only browse
// the user's Mac, so present a remote-path input
// sheet instead.
showRemoteRestoreSheet = true
} else {
if let path = pickLocalBackupZip() {
pendingRestorePath = path
showRestoreConfirm = true
}
}
} label: {
Label("Restore…", systemImage: "arrow.up.doc")
@@ -131,15 +142,40 @@ struct AdvancedTab: View {
}
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
Button("Restore", role: .destructive) {
if let url = pendingRestoreURL {
viewModel.runRestore(from: url)
if let path = pendingRestorePath {
viewModel.runRestore(fromPath: path)
}
pendingRestoreURL = nil
pendingRestorePath = nil
}
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
Button("Cancel", role: .cancel) { pendingRestorePath = nil }
} message: {
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
Text("This will overwrite files under \(viewModel.context.paths.home) with the archive contents.")
}
.sheet(isPresented: $showRemoteRestoreSheet) {
RemoteBackupPathSheet(
context: viewModel.context,
onCancel: { showRemoteRestoreSheet = false },
onConfirm: { path in
showRemoteRestoreSheet = false
pendingRestorePath = path
showRestoreConfirm = true
}
)
}
}
/// NSOpenPanel for local backup zip. Lifted from
/// `SettingsViewModel.presentRestorePicker` kept in the view layer
/// because it's a UI concern that has no business on the VM.
private func pickLocalBackupZip() -> String? {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.zip]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.message = "Choose a Hermes backup archive to restore"
guard panel.runModal() == .OK, let url = panel.url else { return nil }
return url.path
}
private var pathsSection: some View {
@@ -178,3 +214,115 @@ struct AdvancedTab: View {
}
}
}
/// Remote-backup-path picker. NSOpenPanel can only browse the user's
/// Mac, which is the wrong host for a remote restore `hermes backup`
/// produced the zip on the remote, so the path the user wants is on
/// the remote too. This sheet takes a remote path string + verifies
/// it via `transport.fileExists` before handing it back to the
/// caller. Future iteration: add an "Upload local zip first" path so
/// users can restore from a backup that lives on this Mac.
private struct RemoteBackupPathSheet: View {
let context: ServerContext
let onCancel: () -> Void
let onConfirm: (String) -> Void
@State private var path: String = ""
@State private var verification: Verification = .idle
private enum Verification: Equatable {
case idle
case verifying
case ok
case warn(String)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Restore from remote backup")
.font(.headline)
Text("Enter the path to a Hermes backup `.zip` on \(context.displayName). Hermes ran the backup there, so the file lives on the remote — Scarf can't browse the remote from a local file picker.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
TextField("e.g. ~/.hermes-backups/hermes-2026-04-28.zip", text: $path)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.onChange(of: path) { _, _ in
if verification != .idle { verification = .idle }
}
Button("Verify") { Task { await verify() } }
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty
|| verification == .verifying)
}
verificationBadge
HStack {
Button("Cancel") { onCancel() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Restore…") {
let trimmed = path.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
onConfirm(trimmed)
}
.keyboardShortcut(.defaultAction)
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding(20)
.frame(width: 520)
}
@ViewBuilder
private var verificationBadge: some View {
switch verification {
case .idle:
EmptyView()
case .verifying:
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Checking on \(context.displayName)")
.font(.caption)
.foregroundStyle(.secondary)
}
case .ok:
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("File found on \(context.displayName).")
.font(.caption)
}
case .warn(let detail):
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(detail).font(.caption)
}
}
}
private func verify() async {
let trimmed = path.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
verification = .verifying
let snapshot = context
let result: Verification = await Task.detached {
let transport = snapshot.makeTransport()
guard transport.fileExists(trimmed) else {
return .warn("Path doesn't exist on \(snapshot.displayName).")
}
guard let stat = transport.stat(trimmed) else {
return .warn("Found, but couldn't stat — check permissions.")
}
if stat.isDirectory {
return .warn("Path is a directory, not a file. Restore expects a `.zip` archive.")
}
if !trimmed.lowercased().hasSuffix(".zip") {
return .warn("File found, but extension isn't `.zip`. Restore expects a Hermes backup archive.")
}
return .ok
}.value
verification = result
}
}
@@ -65,28 +65,36 @@ struct TemplateInstallSheet: View {
}
private var pickParentView: some View {
VStack(alignment: .leading, spacing: 12) {
if let manifest = viewModel.inspection?.manifest {
ParentDirectoryStep(
context: viewModel.context,
templateID: viewModel.inspection?.manifest.id,
header: parentStepHeader(),
onCancel: {
viewModel.cancel()
dismiss()
},
onContinue: { parentDir in
viewModel.pickParentDirectory(parentDir)
}
)
}
/// Builds the manifest banner that sits above the parent-directory
/// picker. Returned as `AnyView` so `ParentDirectoryStep` can stay
/// non-generic and `pickParentView` doesn't have to bubble its
/// generics back up the stack. Empty when inspection is still in
/// flight.
private func parentStepHeader() -> AnyView {
guard let manifest = viewModel.inspection?.manifest else {
return AnyView(EmptyView())
}
return AnyView(
VStack(alignment: .leading, spacing: 0) {
manifestHeader(manifest)
Divider()
.padding(.top, 8)
}
Text("Where should this project live?")
.scarfStyle(.headline)
Text("Scarf will create a new folder inside the directory you pick, named after the template id.")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
HStack {
Button("Cancel") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Button("Choose Folder…") { chooseParentDirectory() }
.keyboardShortcut(.defaultAction)
}
}
)
}
/// Configure step for schemaful templates. Inlines
@@ -417,17 +425,191 @@ struct TemplateInstallSheet: View {
.padding()
}
// MARK: - Actions
}
private func chooseParentDirectory() {
/// Parent-directory picker step. Uses the active `ServerContext` so a
/// remote install never opens an `NSOpenPanel` against the local Mac
/// filesystem the panel's choices are useless when the project lives
/// on the remote host. Mirrors the `AddProjectSheet` pattern in
/// `ProjectsView`: text input + Verify (remote) or Browse (local), an
/// idle/verifying/ok/warn badge for remote feedback, and a Continue
/// button that hands the chosen path back via `onContinue`.
///
/// **Bootstrap.** The path is allowed to not yet exist the installer
/// runs `transport.createDirectory(_:)` on the parent dir at install
/// time (`mkdir -p` / `withIntermediateDirectories: true`). The Verify
/// badge surfaces "doesn't exist" as a warn rather than blocking
/// Continue, so a fresh remote host with no `~/projects` still
/// completes the install.
private struct ParentDirectoryStep: View {
let context: ServerContext
let templateID: String?
let header: AnyView
let onCancel: () -> Void
let onContinue: (String) -> Void
@State private var parentPath: String
@State private var remoteVerification: RemoteVerification = .idle
init(
context: ServerContext,
templateID: String?,
header: AnyView,
onCancel: @escaping () -> Void,
onContinue: @escaping (String) -> Void
) {
self.context = context
self.templateID = templateID
self.header = header
self.onCancel = onCancel
self.onContinue = onContinue
self._parentPath = State(initialValue: context.defaultProjectsRoot)
}
private enum RemoteVerification: Equatable {
case idle
case verifying
case ok(String)
case warn(String)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
Text("Where should this project live?")
.scarfStyle(.headline)
Text(installPreviewCaption)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
pathInputRow
if context.isRemote {
Text("Path on \(context.displayName) — Scarf creates it on first install if missing.")
.font(.caption)
.foregroundStyle(.secondary)
verificationBadge
}
Spacer()
footer
}
}
private var installPreviewCaption: String {
let trimmedPath = parentPath.trimmingCharacters(in: .whitespaces)
let parentDisplay = trimmedPath.isEmpty ? "<parent>" : trimmedPath
let slug = templateID ?? "<template-id>"
return "Project will be installed at \(parentDisplay)/\(slug) on \(context.displayName)."
}
@ViewBuilder
private var pathInputRow: some View {
HStack {
TextField("Parent directory", text: $parentPath)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.onChange(of: parentPath) { _, _ in
if remoteVerification != .idle {
remoteVerification = .idle
}
}
if context.isRemote {
Button("Verify") { Task { await verifyRemotePath() } }
.disabled(parentPath.trimmingCharacters(in: .whitespaces).isEmpty
|| remoteVerification == .verifying)
} else {
Button("Browse…") { browseLocalDirectory() }
}
}
}
@ViewBuilder
private var verificationBadge: some View {
switch remoteVerification {
case .idle:
EmptyView()
case .verifying:
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Checking on \(context.displayName)")
.font(.caption)
.foregroundStyle(.secondary)
}
case .ok(let detail):
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(ScarfColor.success)
Text(detail)
.font(.caption)
.foregroundStyle(.primary)
}
case .warn(let detail):
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
Text(detail)
.font(.caption)
.foregroundStyle(.primary)
}
}
}
private var footer: some View {
HStack {
Button("Cancel") { onCancel() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Continue") {
let trimmed = parentPath.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
onContinue(trimmed)
}
.keyboardShortcut(.defaultAction)
.disabled(parentPath.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
private func browseLocalDirectory() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.prompt = String(localized: "Choose Parent Folder")
let trimmed = parentPath.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty {
let expanded = (trimmed as NSString).expandingTildeInPath
if FileManager.default.fileExists(atPath: expanded) {
panel.directoryURL = URL(fileURLWithPath: expanded)
}
}
if panel.runModal() == .OK, let url = panel.url {
viewModel.pickParentDirectory(url.path)
parentPath = url.path
}
}
/// Verify the entered path on the remote via the SSH transport's
/// `stat`. Mirrors `AddProjectSheet.verifyRemotePath`. A missing
/// directory is reported as a *warn*, not an error Continue is
/// still enabled because the installer's `mkdir -p` creates the
/// parent on first install.
private func verifyRemotePath() async {
let path = parentPath.trimmingCharacters(in: .whitespaces)
guard !path.isEmpty, context.isRemote else { return }
remoteVerification = .verifying
let snapshot = context
let result: RemoteVerification = await Task.detached {
let transport = snapshot.makeTransport()
guard transport.fileExists(path) else {
return .warn("Path doesn't exist on \(snapshot.displayName) — Scarf will create it on install.")
}
guard let stat = transport.stat(path) else {
return .warn("Found, but couldn't stat — check parent directory permissions.")
}
if stat.isDirectory {
return .ok("Directory exists on \(snapshot.displayName).")
} else {
return .warn("Path is a file, not a directory. Project paths must be directories.")
}
}.value
remoteVerification = result
}
}
+118 -8
View File
@@ -2841,6 +2841,13 @@
}
}
},
"auth.json present" : {
},
"authed · %@" : {
"comment" : "A label that shows when a provider's access token is still valid. The argument is a relative date string.",
"isCommentAutoGenerated" : true
},
"Authentication" : {
"localizations" : {
"de" : {
@@ -3097,6 +3104,10 @@
}
}
},
"Available models" : {
"comment" : "A label for the list of available models.",
"isCommentAutoGenerated" : true
},
"Back" : {
"localizations" : {
"de" : {
@@ -3543,6 +3554,10 @@
}
}
},
"Browse…" : {
"comment" : "A button that opens a file browser to select a directory.",
"isCommentAutoGenerated" : true
},
"Browser" : {
"localizations" : {
"de" : {
@@ -4464,10 +4479,6 @@
}
}
},
"Choose Folder…" : {
"comment" : "A button that opens a dialog for choosing a folder.",
"isCommentAutoGenerated" : true
},
"Choose Parent Folder" : {
},
@@ -5643,6 +5654,10 @@
}
}
},
"Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard." : {
"comment" : "A tooltip for the \"Copy fix command\" button.",
"isCommentAutoGenerated" : true
},
"Copy" : {
"localizations" : {
"de" : {
@@ -5806,6 +5821,10 @@
}
}
},
"Copy fix command" : {
"comment" : "A button that copies a one-liner that consolidates a project's auth.json into your global ~/.hermes/.",
"isCommentAutoGenerated" : true
},
"Copy Full Report" : {
"localizations" : {
"de" : {
@@ -6755,6 +6774,10 @@
}
}
},
"Default: ~/projects" : {
"comment" : "A description of the default location of the user's projects directory.",
"isCommentAutoGenerated" : true
},
"Defaults to ~/.ssh/config or current user" : {
"localizations" : {
"de" : {
@@ -7473,6 +7496,10 @@
},
"Duplicate" : {
},
"e.g. ~/.hermes-backups/hermes-2026-04-28.zip" : {
"comment" : "A placeholder for a remote backup path.",
"isCommentAutoGenerated" : true
},
"e.g. anthropic" : {
"localizations" : {
@@ -8254,6 +8281,10 @@
}
}
},
"Enter the path to a Hermes backup `.zip` on %@. Hermes ran the backup there, so the file lives on the remote — Scarf can't browse the remote from a local file picker." : {
"comment" : "A label at the top of the remote backup path picker.",
"isCommentAutoGenerated" : true
},
"Entity Filters (config.yaml only)" : {
"localizations" : {
"de" : {
@@ -9021,6 +9052,10 @@
}
}
},
"File found on %@." : {
"comment" : "A label indicating that a file was found at the path provided by the user.",
"isCommentAutoGenerated" : true
},
"Files" : {
"localizations" : {
"de" : {
@@ -11280,6 +11315,10 @@
}
}
},
"Leave blank in config to let Hermes pick the default Nous model. Picking one above writes it explicitly." : {
"comment" : "A description of the default model selection.",
"isCommentAutoGenerated" : true
},
"Leave blank to infer from the model ID's prefix (\"openai/...\" → openai)." : {
"localizations" : {
"de" : {
@@ -11989,6 +12028,10 @@
}
}
},
"Managed by `hermes auth add <provider>` — Scarf is read-only here." : {
"comment" : "A footer describing how OAuth providers are managed.",
"isCommentAutoGenerated" : true
},
"Mark as seen" : {
"comment" : "A button that marks the current skill set as seen and dismisses the \"What's New\" pill.",
"isCommentAutoGenerated" : true
@@ -13649,6 +13692,10 @@
}
}
},
"No models loaded" : {
"comment" : "A message that appears when the user is not logged in to Nous Portal.",
"isCommentAutoGenerated" : true
},
"No output yet." : {
"comment" : "A message displayed when a tool call has not yet produced output.",
"isCommentAutoGenerated" : true
@@ -14302,6 +14349,10 @@
},
"npx" : {
},
"oauth" : {
"comment" : "A label for OAuth tokens.",
"isCommentAutoGenerated" : true
},
"OAuth" : {
@@ -14349,6 +14400,10 @@
}
}
},
"OAuth providers" : {
"comment" : "Title of a section in the credential pools view that lists OAuth-authed providers.",
"isCommentAutoGenerated" : true
},
"OK" : {
"localizations" : {
"de" : {
@@ -15190,6 +15245,9 @@
}
}
}
},
"Parent directory" : {
},
"Paste an https URL pointing at a .scarftemplate file." : {
"comment" : "A description of the URL field in the template installation prompt.",
@@ -15239,6 +15297,10 @@
"comment" : "A label that describes the path of a project on a remote server.",
"isCommentAutoGenerated" : true
},
"Path on %@ — Scarf creates it on first install if missing." : {
"comment" : "A label that describes a warning about a project's path on a remote host. The argument is the name of the host.",
"isCommentAutoGenerated" : true
},
"Paths" : {
"localizations" : {
"de" : {
@@ -15523,6 +15585,10 @@
}
}
},
"Pick a model to start chatting" : {
"comment" : "A heading for the chat model picker sheet.",
"isCommentAutoGenerated" : true
},
"Pick an MCP server to add." : {
"localizations" : {
"de" : {
@@ -16115,6 +16181,9 @@
}
}
}
},
"Project-local Hermes home shadowing global setup" : {
},
"Project's current git branch" : {
"comment" : "A tooltip for the git branch of the project.",
@@ -16959,6 +17028,14 @@
}
}
},
"refresh-only" : {
"comment" : "A label for a refresh-only OAuth provider.",
"isCommentAutoGenerated" : true
},
"Refreshing…" : {
"comment" : "A message that appears when the app is refreshing",
"isCommentAutoGenerated" : true
},
"Reload" : {
"localizations" : {
"de" : {
@@ -17987,6 +18064,10 @@
}
}
},
"Restore from remote backup" : {
"comment" : "A heading for a sheet that lets the user restore from a remote backup.",
"isCommentAutoGenerated" : true
},
"Restore…" : {
"localizations" : {
"de" : {
@@ -18916,10 +18997,6 @@
}
}
},
"Scarf will create a new folder inside the directory you pick, named after the template id." : {
"comment" : "A description of how a template will be installed.",
"isCommentAutoGenerated" : true
},
"scarf-default" : {
"comment" : "A tool gateway policy applied at run time.",
"isCommentAutoGenerated" : true
@@ -20509,6 +20586,10 @@
}
}
},
"Showing built-in fallback list — couldn't reach Nous to refresh." : {
"comment" : "A message that appears when the user has selected a",
"isCommentAutoGenerated" : true
},
"Shown as the subtitle in the chat slash menu." : {
"comment" : "A description of a field that describes a slash command.",
"isCommentAutoGenerated" : true
@@ -20523,6 +20604,10 @@
},
"Sign in to Nous Portal" : {
},
"Sign in to Nous Portal to load the catalog, or enter a model ID manually." : {
"comment" : "A description of the error message shown when the",
"isCommentAutoGenerated" : true
},
"Sign in to Spotify" : {
"comment" : "A label for a Spotify sign-in button.",
@@ -21416,6 +21501,10 @@
}
}
},
"state.db present" : {
"comment" : "A label indicating that a project has a state.db file.",
"isCommentAutoGenerated" : true
},
"state.db readable" : {
"localizations" : {
"de" : {
@@ -22424,6 +22513,10 @@
}
}
},
"These projects carry their own `.hermes/` directory. Hermes' CLI uses the closest one as `$HERMES_HOME` when run from inside the project, so credentials and config written there don't show up in your global Hermes setup. Consolidate to clear this warning." : {
"comment" : "A description of the warning that appears when a project's Hermes home shadows the user's global Hermes setup.",
"isCommentAutoGenerated" : true
},
"This is the prompt Hermes will receive. The user sees the literal `/%@` they typed in their own bubble; the expanded body goes to the agent with a `<!-- scarf-slash:<name> -->` marker." : {
"comment" : "A description of what the preview pane shows.",
"isCommentAutoGenerated" : true
@@ -22686,7 +22779,12 @@
}
}
},
"This will overwrite files under %@ with the archive contents." : {
"comment" : "A message in the confirmation dialog for restoring from a backup.",
"isCommentAutoGenerated" : true
},
"This will overwrite files under ~/.hermes/ with the archive contents." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -23400,6 +23498,10 @@
}
}
},
"Type a model ID exactly as Nous expects it. Leave blank to use Hermes's default." : {
"comment" : "A description of how to enter a model ID for a",
"isCommentAutoGenerated" : true
},
"Unarchive" : {
"comment" : "A button that unarchives a project.",
"isCommentAutoGenerated" : true
@@ -23911,6 +24013,10 @@
}
}
},
"Use list" : {
"comment" : "A button that lets users cancel entering a custom model ID.",
"isCommentAutoGenerated" : true
},
"Use this" : {
"localizations" : {
"de" : {
@@ -24774,6 +24880,10 @@
}
}
},
"Where Scarf installs new project templates on this host. Created on first install if missing." : {
"comment" : "A description of the location of the projects directory.",
"isCommentAutoGenerated" : true
},
"Where should this project live?" : {
},