fix(credentials): recognize OAuth providers; warn on project-shadowed Hermes

Three related fixes for the "I authed Nous but Scarf doesn't see it" bug:

1. `hasAnyAICredential()` (HermesFileService) only probed the
   `credential_pool.<provider>` shape in auth.json. OAuth-authed providers
   land under `providers.<name>.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 `<project>/.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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-27 22:48:20 +02:00
parent 4ffd353835
commit 389620059c
7 changed files with 503 additions and 12 deletions
@@ -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"
}
}
@@ -1442,17 +1442,44 @@ struct HermesFileService: Sendable {
}
}
}
// Scan auth.json (Credential Pools file written by the Configure
// Credential Pools UI). Schema:
// { "credential_pool": { "<provider>": [ { "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.<provider>[].access_token
// written by Configure Credential Pools (manual key entry,
// round-robin / least-used routing).
//
// 2. providers.<name>.access_token
// written by `hermes auth add <name>` 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
}
}
@@ -52,6 +52,21 @@ struct HermesCredentialPool: Identifiable, Sendable {
let credentials: [HermesCredential]
}
/// OAuth-authed provider parsed from `auth.json.providers.<name>`. 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.<name>` (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.<name>` 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 `<provider>: <strategy>`.
/// Pure-function form so it's safe to call from the detached load task.
nonisolated private static func parseStrategies(from yaml: String) -> [String: String] {
@@ -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.<name>`) as a
/// single section above the rotation pools. Read-only Hermes owns
/// the write path via `hermes auth add <name>`. 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 <provider>` — 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") {
@@ -33,6 +33,14 @@ final class DashboardViewModel {
/// surfaceable error.
var lastReadError: String?
/// Projects with their own `<project>/.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
}
}
@@ -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 `<project>/.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 {
+62
View File
@@ -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 <name>` 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",