diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift index d27d8bb..004dd8a 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift @@ -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 diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift index 667a2d3..f7939c0 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift @@ -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` diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift new file mode 100644 index 0000000..56efaa6 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift @@ -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" + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/NousModelCatalogService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/NousModelCatalogService.swift new file mode 100644 index 0000000..fe42959 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/NousModelCatalogService.swift @@ -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)." + } + } +} diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index fb10dfe..d9ae223 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -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? private var healthMonitorTask: Task? @@ -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 diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index bbecd57..5e36541 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -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 diff --git a/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift b/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift index 49ecda5..87d1c76 100644 --- a/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift +++ b/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift @@ -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 { diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index f174538..287eb2b 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -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, diff --git a/scarf/scarf/Features/Chat/Views/ChatModelPreflightSheet.swift b/scarf/scarf/Features/Chat/Views/ChatModelPreflightSheet.swift new file mode 100644 index 0000000..8541d9a --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/ChatModelPreflightSheet.swift @@ -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)" + } +} diff --git a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift index 2d9478c..d51275e 100644 --- a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift +++ b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift @@ -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 + } +} diff --git a/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift b/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift index df0cc58..2082801 100644 --- a/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift +++ b/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift @@ -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 ) } diff --git a/scarf/scarf/Features/Servers/Views/AddServerSheet.swift b/scarf/scarf/Features/Servers/Views/AddServerSheet.swift index 396db86..458a37d 100644 --- a/scarf/scarf/Features/Servers/Views/AddServerSheet.swift +++ b/scarf/scarf/Features/Servers/Views/AddServerSheet.swift @@ -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) diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index e3a4245..919a9d0 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -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 ` command runs on the remote shell + /// where `` 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 diff --git a/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift index 2c55d81..5f919d3 100644 --- a/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift +++ b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift @@ -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 diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift index 680b0f4..3d940d6 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift @@ -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 + } +} diff --git a/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift b/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift index 085533f..48e7816 100644 --- a/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift +++ b/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift @@ -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 ? "" : trimmedPath + let slug = templateID ?? "" + 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 + } } diff --git a/scarf/scarf/Localizable.xcstrings b/scarf/scarf/Localizable.xcstrings index f40ccc3..2cda375 100644 --- a/scarf/scarf/Localizable.xcstrings +++ b/scarf/scarf/Localizable.xcstrings @@ -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 ` — 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 `` 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?" : { },