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