From 389620059c60d3009783853d6ad791c4b477eec9 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 27 Apr 2026 22:48:20 +0200 Subject: [PATCH] fix(credentials): recognize OAuth providers; warn on project-shadowed Hermes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes for the "I authed Nous but Scarf doesn't see it" bug: 1. `hasAnyAICredential()` (HermesFileService) only probed the `credential_pool.` shape in auth.json. OAuth-authed providers land under `providers..access_token` instead — Nous, Spotify, GH Copilot ACP, Qwen, Gemini all use that path. The chat banner kept showing "No AI provider credentials" even after a successful Nous sign-in. Now we probe both shapes; refresh-only entries (pre-mint OAuth flows) also count. 2. `CredentialPoolsViewModel` decoded only `credential_pool.*` and ignored `providers.*` entirely. New `oauthProviders` array surfaces them in a parallel "OAuth providers" section above the rotation pools — read-only, with token tail, expiry badge, portal URL, and "managed by `hermes auth add`" footnote so users know where the write path lives. 3. New `ProjectHermesShadowDetector` (ScarfCore) probes each registered project for a `/.hermes/` directory. Hermes' CLI binds to the closest `.hermes/` as `$HERMES_HOME` when run from inside such a project — `hermes auth add nous` lands in the project's auth.json instead of `~/.hermes/auth.json` and Scarf's global probes never see it. Surfaced as a yellow Dashboard banner listing affected projects with badges for `auth.json` / `state.db` presence and a "Copy fix command" button that emits a one-liner consolidating auth.json into the global home. Read-only — no auto-migration; the user decides what to keep. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ProjectHermesShadowDetector.swift | 114 ++++++++++++++++++ .../Core/Services/HermesFileService.swift | 47 ++++++-- .../ViewModels/CredentialPoolsViewModel.swift | 79 ++++++++++++ .../Views/CredentialPoolsView.swift | 98 ++++++++++++++- .../ViewModels/DashboardViewModel.swift | 19 +++ .../Dashboard/Views/DashboardView.swift | 96 +++++++++++++++ scarf/scarf/Localizable.xcstrings | 62 ++++++++++ 7 files changed, 503 insertions(+), 12 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectHermesShadowDetector.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectHermesShadowDetector.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectHermesShadowDetector.swift new file mode 100644 index 0000000..2f63834 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectHermesShadowDetector.swift @@ -0,0 +1,114 @@ +import Foundation +#if canImport(os) +import os +#endif + +/// Detects when a registered project directory contains its own `.hermes/` +/// subdirectory. Hermes' CLI uses the closest `.hermes/` as `$HERMES_HOME` +/// when invoked from inside such a directory, which **shadows** the user's +/// global Hermes home — credentials, config, sessions, skills, memories +/// all bind to the project-local copy without warning. +/// +/// This causes confusing failure modes: the user runs `hermes auth add nous` +/// during setup expecting a global registration, but if their cwd happens to +/// be inside a project that already has a `.hermes/` (e.g. seeded by a +/// previous workflow, copied from another machine, or checked into git), +/// Hermes writes the credentials to the project-local `.hermes/auth.json`. +/// Scarf then reads the global path on every dashboard tick and shows +/// "missing provider" warnings even though the user did sign in successfully. +/// +/// The detector enumerates the registered projects on a given server and +/// reports which ones carry a shadowing `.hermes/`. Views surface a yellow +/// banner so the user can consolidate. +public struct ProjectHermesShadowDetector: Sendable { + public struct Shadow: Sendable, Hashable, Identifiable { + public var id: String { projectPath } + /// Project name from the registry (`ProjectEntry.name`). + public let projectName: String + /// Absolute path to the project on the target server. + public let projectPath: String + /// Absolute path to the shadowing `.hermes/` directory. + public let shadowPath: String + /// `true` when the shadow `.hermes/auth.json` exists. Strong signal + /// that user credentials are landing in the wrong place. + public let hasAuthJSON: Bool + /// `true` when the shadow `.hermes/state.db` exists. Hermes wrote + /// session state to the project-local home — the user's chat + /// history is invisible to Scarf's global Dashboard for this slice. + public let hasStateDB: Bool + + public init( + projectName: String, + projectPath: String, + shadowPath: String, + hasAuthJSON: Bool, + hasStateDB: Bool + ) { + self.projectName = projectName + self.projectPath = projectPath + self.shadowPath = shadowPath + self.hasAuthJSON = hasAuthJSON + self.hasStateDB = hasStateDB + } + } + + #if canImport(os) + private static let logger = Logger(subsystem: "com.scarf", category: "ProjectHermesShadowDetector") + #endif + + private let context: ServerContext + private let transport: any ServerTransport + + public init(context: ServerContext) { + self.context = context + self.transport = context.makeTransport() + } + + /// Probe every project in `projects` for a shadowing `.hermes/`. Skips + /// archived projects and projects whose absolute path equals the + /// resolved Hermes home (rare but possible — a project literally + /// rooted at `~/.hermes` shouldn't trigger a self-warning). + public func detect(in projects: [ProjectEntry]) async -> [Shadow] { + let hermesHome = await context.resolvedUserHome() + "/.hermes" + var found: [Shadow] = [] + for project in projects where !project.archived { + // A project nested inside the Hermes home itself is a weird + // edge case (someone made `~/.hermes/notes` a Scarf project). + // The project is BELOW the Hermes home, so its `.hermes` is + // the same dir as `~/.hermes/.hermes` — almost certainly not + // present and definitely not a shadow. + if project.path.hasPrefix(hermesHome) { continue } + let shadowPath = project.path + "/.hermes" + guard transport.fileExists(shadowPath) else { continue } + // It's only a shadow if the path is a directory; a stray + // `.hermes` file would be filtered out here. + guard transport.stat(shadowPath)?.isDirectory == true else { continue } + let hasAuth = transport.fileExists(shadowPath + "/auth.json") + let hasDB = transport.fileExists(shadowPath + "/state.db") + #if canImport(os) + Self.logger.warning( + "Detected shadow Hermes home at \(shadowPath, privacy: .public) (auth: \(hasAuth), state.db: \(hasDB))" + ) + #endif + found.append(Shadow( + projectName: project.name, + projectPath: project.path, + shadowPath: shadowPath, + hasAuthJSON: hasAuth, + hasStateDB: hasDB + )) + } + return found + } + + /// Suggested shell command the user can copy-paste / run on the remote + /// to consolidate a shadow's auth.json into their global Hermes home. + /// Skips state.db / sessions / skills migration intentionally — those + /// require Hermes to be quiesced and risk data loss; the user should + /// decide what to keep on a case-by-case basis. We give them the + /// load-bearing one-liner (auth) and let them handle the rest. + public static func consolidationCommand(for shadow: Shadow, hermesHome: String) -> String? { + guard shadow.hasAuthJSON else { return nil } + return "cp \(shadow.shadowPath)/auth.json \(hermesHome)/auth.json && chmod 600 \(hermesHome)/auth.json" + } +} diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index d721a15..bbecd57 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -1442,17 +1442,44 @@ struct HermesFileService: Sendable { } } } - // Scan auth.json (Credential Pools file written by the Configure → - // Credential Pools UI). Schema: - // { "credential_pool": { "": [ { "access_token": "...", ... }, ... ] } } - // Defensive parse: any malformed input falls through to the next check. + // Scan auth.json. Two shapes need to count as "credential present": + // + // 1. credential_pool.[].access_token + // — written by Configure → Credential Pools (manual key entry, + // round-robin / least-used routing). + // + // 2. providers..access_token + // — written by `hermes auth add ` for OAuth-authed + // providers (Nous Portal, Spotify, GitHub Copilot ACP, etc.). + // Pre-fix this was ignored, so a user with only Nous OAuth + // kept seeing the "No AI provider credentials" banner even + // after a successful Nous sign-in. + // + // Defensive parse: malformed input falls through to the next check. if let data = readFileData(context.paths.authJSON), - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let pool = root["credential_pool"] as? [String: Any] { - for (_, entries) in pool { - guard let list = entries as? [[String: Any]] else { continue } - for cred in list { - if let token = cred["access_token"] as? String, !token.isEmpty { + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + if let pool = root["credential_pool"] as? [String: Any] { + for (_, entries) in pool { + guard let list = entries as? [[String: Any]] else { continue } + for cred in list { + if let token = cred["access_token"] as? String, !token.isEmpty { + return true + } + } + } + } + if let providers = root["providers"] as? [String: Any] { + for (_, value) in providers { + guard let entry = value as? [String: Any] else { continue } + if let token = entry["access_token"] as? String, !token.isEmpty { + return true + } + // Some auth records (Spotify) carry only a refresh + // token until the first access-token mint — count + // that too so we don't false-negative seconds-old + // OAuth flows. + if let refresh = entry["refresh_token"] as? String, !refresh.isEmpty { return true } } diff --git a/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift index 34d8229..7ac43c2 100644 --- a/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift +++ b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift @@ -52,6 +52,21 @@ struct HermesCredentialPool: Identifiable, Sendable { let credentials: [HermesCredential] } +/// OAuth-authed provider parsed from `auth.json.providers.`. Distinct +/// from `HermesCredentialPool` because OAuth providers don't pool — one +/// active token per provider, refresh handled by Hermes. Nous, Spotify, +/// GitHub Copilot ACP, Qwen, Gemini all land here. +struct HermesOAuthProvider: Identifiable, Sendable, Equatable { + var id: String { provider } + let provider: String // "nous" | "spotify" | ... + let tokenTail: String // last 4 of access_token, never the full token + let hasAccessToken: Bool + let hasRefreshToken: Bool + let expiresAt: Date? + let portalURL: String? // "portal_base_url" — Nous-specific but generic-shaped + let updatedAt: Date? +} + @Observable @MainActor final class CredentialPoolsViewModel { @@ -64,6 +79,13 @@ final class CredentialPoolsViewModel { } var pools: [HermesCredentialPool] = [] + /// OAuth-authed providers from `auth.json.providers.` (Nous, + /// Spotify, etc.). These have a different shape from `credential_pool` + /// entries — one access token per provider, no rotation strategy — + /// so they render in a parallel section rather than as a single-entry + /// pool. Without this, OAuth providers were invisible in the UI even + /// after a successful sign-in. + var oauthProviders: [HermesOAuthProvider] = [] var isLoading = false var message: String? @@ -101,13 +123,70 @@ final class CredentialPoolsViewModel { decodedPools = [] } + // OAuth providers are a parallel surface — different shape, so + // we parse via `JSONSerialization` instead of folding into the + // strict `AuthFile` decoder. A malformed `providers` block is + // a non-fatal shrug: empty list, no banner. + let oauth = Self.parseOAuthProviders(from: authData) + await MainActor.run { [weak self] in self?.pools = decodedPools + self?.oauthProviders = oauth self?.isLoading = false } } } + /// Pull `providers.` entries out of `auth.json` and shape them + /// for the UI. Returns an empty array when the file is missing, + /// unparseable, or has no `providers` key. + nonisolated private static func parseOAuthProviders(from data: Data?) -> [HermesOAuthProvider] { + guard let data, + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let providers = root["providers"] as? [String: Any] + else { return [] } + + return providers.keys.sorted().compactMap { name in + guard let entry = providers[name] as? [String: Any] else { return nil } + let access = entry["access_token"] as? String ?? "" + let refresh = entry["refresh_token"] as? String ?? "" + // Worth surfacing if there's ANY token shape — pre-mint + // refresh-only entries shouldn't be hidden. + guard !access.isEmpty || !refresh.isEmpty else { return nil } + + let expiresAt: Date? = { + if let ms = entry["expires_at_ms"] as? Double, ms > 0 { + return Date(timeIntervalSince1970: ms / 1000.0) + } + if let secs = entry["expires_at"] as? Double, secs > 0 { + // Hermes' Nous flow writes epoch seconds as a Double here. + return Date(timeIntervalSince1970: secs) + } + if let iso = entry["expires_at"] as? String { + return Self.parseISO8601(iso) + } + return nil + }() + + let updatedAt: Date? = { + if let iso = entry["obtained_at"] as? String { + return Self.parseISO8601(iso) + } + return nil + }() + + return HermesOAuthProvider( + provider: name, + tokenTail: Self.tail(of: access.isEmpty ? refresh : access), + hasAccessToken: !access.isEmpty, + hasRefreshToken: !refresh.isEmpty, + expiresAt: expiresAt, + portalURL: entry["portal_base_url"] as? String, + updatedAt: updatedAt + ) + } + } + /// The `credential_pool_strategies:` map lives in config.yaml as `: `. /// Pure-function form so it's safe to call from the detached load task. nonisolated private static func parseStrategies(from yaml: String) -> [String: String] { diff --git a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift index 8b3c06b..79811fc 100644 --- a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift +++ b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift @@ -20,9 +20,12 @@ struct CredentialPoolsView: View { safetyNotice if viewModel.isLoading { ProgressView().padding() - } else if viewModel.pools.isEmpty { + } else if viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty { emptyState } else { + if !viewModel.oauthProviders.isEmpty { + oauthProvidersSection + } ForEach(viewModel.pools) { pool in poolSection(pool) } @@ -37,7 +40,7 @@ struct CredentialPoolsView: View { .loadingOverlay( viewModel.isLoading, label: "Loading credentials…", - isEmpty: viewModel.pools.isEmpty + isEmpty: viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty ) .onAppear { viewModel.load() } .sheet(isPresented: $showAddSheet) { @@ -114,6 +117,97 @@ struct CredentialPoolsView: View { .padding(.vertical, 40) } + /// Render OAuth-authed providers (`auth.json.providers.`) as a + /// single section above the rotation pools. Read-only — Hermes owns + /// the write path via `hermes auth add `. Rendered only when + /// `viewModel.oauthProviders` is non-empty so users without any + /// OAuth-authed providers don't see an empty header. + @ViewBuilder + private var oauthProvidersSection: some View { + SettingsSection(title: LocalizedStringKey("OAuth providers"), icon: "person.badge.key") { + ForEach(viewModel.oauthProviders) { provider in + HStack(spacing: 12) { + Image(systemName: "person.badge.key") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(provider.provider.capitalized) + .font(.system(.body, weight: .medium)) + Text("oauth") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary) + .clipShape(Capsule()) + if !provider.hasAccessToken && provider.hasRefreshToken { + Text("refresh-only") + .font(.caption2) + .foregroundStyle(.orange) + } + oauthExpiryBadge(provider) + } + HStack(spacing: 8) { + Text(provider.tokenTail.isEmpty ? "—" : provider.tokenTail) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + if let updated = provider.updatedAt { + Text("authed · \(Self.relativeAge(updated))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + if let url = provider.portalURL, !url.isEmpty { + Text(url) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) + } + HStack { + Text("Managed by `hermes auth add ` — Scarf is read-only here.") + .font(.caption2) + .foregroundStyle(.tertiary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) + } + } + + @ViewBuilder + private func oauthExpiryBadge(_ provider: HermesOAuthProvider) -> some View { + if let expiresAt = provider.expiresAt { + let secondsRemaining = expiresAt.timeIntervalSinceNow + if secondsRemaining <= 0 { + Text("expired") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.red) + .clipShape(Capsule()) + } else if secondsRemaining < 7 * 86_400 { + let days = max(1, Int(secondsRemaining / 86_400)) + Text("expires in \(days)d") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.orange) + .clipShape(Capsule()) + } + } + } + @ViewBuilder private func poolSection(_ pool: HermesCredentialPool) -> some View { SettingsSection(title: LocalizedStringKey(pool.provider), icon: "key.horizontal") { diff --git a/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift index 8edd59c..5e4d8b0 100644 --- a/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift +++ b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift @@ -33,6 +33,14 @@ final class DashboardViewModel { /// surfaceable error. var lastReadError: String? + /// Projects with their own `/.hermes/` directory shadowing + /// the global Hermes home. Hermes' CLI uses the closest `.hermes/` + /// when invoked from inside such a project, which silently routes + /// `hermes auth add` / setup writes into the project-local copy + /// instead of `~/.hermes/`. Surfaced as a yellow banner so users + /// can consolidate before more state drifts. + var hermesShadows: [ProjectHermesShadowDetector.Shadow] = [] + func load() async { isLoading = true // refresh() = close + reopen, forces a fresh remote snapshot. Cheap @@ -110,6 +118,17 @@ final class DashboardViewModel { } else { lastReadError = nil } + + // Probe for projects with shadow `.hermes/` directories. Read-only + // — we just stat each registered project's path. Detached so the + // SSH round-trips don't block the load completion. + let ctx = context + let detector = ProjectHermesShadowDetector(context: ctx) + let projects = await Task.detached { + ProjectDashboardService(context: ctx).loadRegistry().projects + }.value + hermesShadows = await detector.detect(in: projects) + isLoading = false } } diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift index 8ea60a4..d87085b 100644 --- a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -27,6 +27,9 @@ struct DashboardView: View { if let err = viewModel.lastReadError { readErrorBanner(err) } + if !viewModel.hermesShadows.isEmpty { + hermesShadowBanner(viewModel.hermesShadows) + } statusRow statsSection recentTwoColumn @@ -126,6 +129,99 @@ struct DashboardView: View { ) } + // MARK: - Hermes shadow banner + + /// One row per project that carries its own `/.hermes/` + /// directory. Hermes' CLI binds to that as `$HERMES_HOME` when run + /// from inside, which silently shadows the user's global setup — + /// `hermes auth add nous` lands in the project, not in `~/.hermes/`, + /// and Scarf's global probes show "missing provider" until consolidated. + private func hermesShadowBanner(_ shadows: [ProjectHermesShadowDetector.Shadow]) -> some View { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + HStack(alignment: .top, spacing: ScarfSpace.s2) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ScarfColor.warning) + VStack(alignment: .leading, spacing: 4) { + Text("Project-local Hermes home shadowing global setup") + .scarfStyle(.bodyEmph) + .foregroundStyle(ScarfColor.foregroundPrimary) + Text("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.") + .scarfStyle(.footnote) + .foregroundStyle(ScarfColor.foregroundMuted) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + ForEach(shadows) { shadow in + shadowRow(shadow) + } + } + .padding(ScarfSpace.s3) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous) + .fill(ScarfColor.warning.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous) + .strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1) + ) + ) + } + + private func shadowRow(_ shadow: ProjectHermesShadowDetector.Shadow) -> some View { + HStack(alignment: .top, spacing: ScarfSpace.s2) { + VStack(alignment: .leading, spacing: 2) { + Text(shadow.projectName) + .scarfStyle(.bodyEmph) + Text(shadow.shadowPath) + .font(ScarfFont.monoSmall) + .foregroundStyle(ScarfColor.foregroundMuted) + .textSelection(.enabled) + HStack(spacing: 6) { + if shadow.hasAuthJSON { + Text("auth.json present") + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(ScarfColor.warning.opacity(0.20)) + .clipShape(Capsule()) + } + if shadow.hasStateDB { + Text("state.db present") + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(ScarfColor.warning.opacity(0.20)) + .clipShape(Capsule()) + } + } + } + Spacer() + if shadow.hasAuthJSON { + Button("Copy fix command") { + Task { @MainActor in + let home = await viewModel.context.resolvedUserHome() + "/.hermes" + if let cmd = ProjectHermesShadowDetector.consolidationCommand( + for: shadow, + hermesHome: home + ) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(cmd, forType: .string) + } + } + } + .buttonStyle(ScarfSecondaryButton()) + .controlSize(.small) + .help("Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard.") + } + } + .padding(ScarfSpace.s2) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous) + .fill(ScarfColor.warning.opacity(0.06)) + ) + } + // MARK: - Status row private var statusRow: some View { diff --git a/scarf/scarf/Localizable.xcstrings b/scarf/scarf/Localizable.xcstrings index f36ed09..f40ccc3 100644 --- a/scarf/scarf/Localizable.xcstrings +++ b/scarf/scarf/Localizable.xcstrings @@ -3917,6 +3917,14 @@ "comment" : "A label that shows the name of the active Scarf project, followed by \"Chat\".", "isCommentAutoGenerated" : true }, + "Chat density" : { + "comment" : "Title of a settings section that lets the user configure the chat density (tool calls, reasoning, font size).", + "isCommentAutoGenerated" : true + }, + "Chat font size" : { + "comment" : "A label displayed above a slider that adjusts the font size of chat messages.", + "isCommentAutoGenerated" : true + }, "Chat is scoped to Scarf project \"%@\"" : { "comment" : "Tooltip for the folder-chip indicator.", "isCommentAutoGenerated" : true @@ -4129,6 +4137,10 @@ } } }, + "Checking on %@…" : { + "comment" : "A label indicating that a project is being verified. The argument is the name of the project being verified.", + "isCommentAutoGenerated" : true + }, "Checking…" : { "localizations" : { "de" : { @@ -4670,6 +4682,14 @@ } } }, + "Click to inspect this tool call" : { + "comment" : "A tooltip for a tool call button.", + "isCommentAutoGenerated" : true + }, + "Click to inspect tool calls" : { + "comment" : "A tooltip for the button that opens a list of tool calls.", + "isCommentAutoGenerated" : true + }, "Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next." : { "localizations" : { "de" : { @@ -5367,7 +5387,12 @@ } } }, + "Connected — %@" : { + "comment" : "A connected state with a reason for", + "isCommentAutoGenerated" : true + }, "Connected — can't read Hermes state" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -5574,6 +5599,10 @@ } } }, + "Controls how Scarf renders the chat. Use Output → Show Reasoning to control what Hermes sends." : { + "comment" : "A footnote that describes how the chat is rendered.", + "isCommentAutoGenerated" : true + }, "Copied" : { "localizations" : { "de" : { @@ -11499,6 +11528,10 @@ "comment" : "A description of the logs feature.", "isCommentAutoGenerated" : true }, + "Load earlier messages" : { + "comment" : "A button to load older messages.", + "isCommentAutoGenerated" : true + }, "Loaded" : { "localizations" : { "de" : { @@ -11547,6 +11580,10 @@ "comment" : "A message displayed while loading the configuration.", "isCommentAutoGenerated" : true }, + "Loading earlier…" : { + "comment" : "A label displayed while loading older messages.", + "isCommentAutoGenerated" : true + }, "Loading session…" : { "localizations" : { "de" : { @@ -14936,6 +14973,10 @@ } } }, + "Or run this on the remote to switch back to the default profile:" : { + "comment" : "A hint to the user on how to switch back to the default", + "isCommentAutoGenerated" : true + }, "Other" : { "extractionState" : "stale", "localizations" : { @@ -15194,6 +15235,10 @@ } } }, + "Path on %@ — must already exist on the server. Tool calls run with this directory as their working directory." : { + "comment" : "A label that describes the path of a project on a remote server.", + "isCommentAutoGenerated" : true + }, "Paths" : { "localizations" : { "de" : { @@ -18357,6 +18402,10 @@ } } }, + "Run diagnostics" : { + "comment" : "A button that runs diagnostics.", + "isCommentAutoGenerated" : true + }, "Run Diagnostics…" : { "localizations" : { "de" : { @@ -18739,6 +18788,10 @@ "comment" : "A description of the warning about not switching models.", "isCommentAutoGenerated" : true }, + "Scarf is reading from Hermes profile \"%@\". Switch profiles with `hermes profile use ` and relaunch Scarf." : { + "comment" : "A warning that Scarf is reading from a Hermes profile that is not the default.", + "isCommentAutoGenerated" : true + }, "Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:" : { "localizations" : { "de" : { @@ -20955,7 +21008,12 @@ } } }, + "SSH works but %@. Click for details." : { + "comment" : "A tooltip for a degraded connection status pill. The argument is a reason for the degraded connection.", + "isCommentAutoGenerated" : true + }, "SSH works but %@. Click for diagnostics." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -23942,6 +24000,10 @@ }, "value" : { + }, + "Verify" : { + "comment" : "A button that verifies a project path on a remote server.", + "isCommentAutoGenerated" : true }, "Verifying token…" : { "comment" : "A label displayed in the Spotify sign-in sheet",