mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?" : {
|
||||
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user