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 →
|
// Scan auth.json. Two shapes need to count as "credential present":
|
||||||
// Credential Pools UI). Schema:
|
//
|
||||||
// { "credential_pool": { "<provider>": [ { "access_token": "...", ... }, ... ] } }
|
// 1. credential_pool.<provider>[].access_token
|
||||||
// Defensive parse: any malformed input falls through to the next check.
|
// — 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),
|
if let data = readFileData(context.paths.authJSON),
|
||||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
let pool = root["credential_pool"] as? [String: Any] {
|
{
|
||||||
for (_, entries) in pool {
|
if let pool = root["credential_pool"] as? [String: Any] {
|
||||||
guard let list = entries as? [[String: Any]] else { continue }
|
for (_, entries) in pool {
|
||||||
for cred in list {
|
guard let list = entries as? [[String: Any]] else { continue }
|
||||||
if let token = cred["access_token"] as? String, !token.isEmpty {
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,21 @@ struct HermesCredentialPool: Identifiable, Sendable {
|
|||||||
let credentials: [HermesCredential]
|
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
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CredentialPoolsViewModel {
|
final class CredentialPoolsViewModel {
|
||||||
@@ -64,6 +79,13 @@ final class CredentialPoolsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pools: [HermesCredentialPool] = []
|
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 isLoading = false
|
||||||
var message: String?
|
var message: String?
|
||||||
|
|
||||||
@@ -101,13 +123,70 @@ final class CredentialPoolsViewModel {
|
|||||||
decodedPools = []
|
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
|
await MainActor.run { [weak self] in
|
||||||
self?.pools = decodedPools
|
self?.pools = decodedPools
|
||||||
|
self?.oauthProviders = oauth
|
||||||
self?.isLoading = false
|
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>`.
|
/// 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.
|
/// Pure-function form so it's safe to call from the detached load task.
|
||||||
nonisolated private static func parseStrategies(from yaml: String) -> [String: String] {
|
nonisolated private static func parseStrategies(from yaml: String) -> [String: String] {
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ struct CredentialPoolsView: View {
|
|||||||
safetyNotice
|
safetyNotice
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
ProgressView().padding()
|
ProgressView().padding()
|
||||||
} else if viewModel.pools.isEmpty {
|
} else if viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
|
if !viewModel.oauthProviders.isEmpty {
|
||||||
|
oauthProvidersSection
|
||||||
|
}
|
||||||
ForEach(viewModel.pools) { pool in
|
ForEach(viewModel.pools) { pool in
|
||||||
poolSection(pool)
|
poolSection(pool)
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,7 @@ struct CredentialPoolsView: View {
|
|||||||
.loadingOverlay(
|
.loadingOverlay(
|
||||||
viewModel.isLoading,
|
viewModel.isLoading,
|
||||||
label: "Loading credentials…",
|
label: "Loading credentials…",
|
||||||
isEmpty: viewModel.pools.isEmpty
|
isEmpty: viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty
|
||||||
)
|
)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
.sheet(isPresented: $showAddSheet) {
|
.sheet(isPresented: $showAddSheet) {
|
||||||
@@ -114,6 +117,97 @@ struct CredentialPoolsView: View {
|
|||||||
.padding(.vertical, 40)
|
.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
|
@ViewBuilder
|
||||||
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
||||||
SettingsSection(title: LocalizedStringKey(pool.provider), icon: "key.horizontal") {
|
SettingsSection(title: LocalizedStringKey(pool.provider), icon: "key.horizontal") {
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ final class DashboardViewModel {
|
|||||||
/// surfaceable error.
|
/// surfaceable error.
|
||||||
var lastReadError: String?
|
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 {
|
func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
// refresh() = close + reopen, forces a fresh remote snapshot. Cheap
|
// refresh() = close + reopen, forces a fresh remote snapshot. Cheap
|
||||||
@@ -110,6 +118,17 @@ final class DashboardViewModel {
|
|||||||
} else {
|
} else {
|
||||||
lastReadError = nil
|
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
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ struct DashboardView: View {
|
|||||||
if let err = viewModel.lastReadError {
|
if let err = viewModel.lastReadError {
|
||||||
readErrorBanner(err)
|
readErrorBanner(err)
|
||||||
}
|
}
|
||||||
|
if !viewModel.hermesShadows.isEmpty {
|
||||||
|
hermesShadowBanner(viewModel.hermesShadows)
|
||||||
|
}
|
||||||
statusRow
|
statusRow
|
||||||
statsSection
|
statsSection
|
||||||
recentTwoColumn
|
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
|
// MARK: - Status row
|
||||||
|
|
||||||
private var statusRow: some View {
|
private var statusRow: some View {
|
||||||
|
|||||||
@@ -3917,6 +3917,14 @@
|
|||||||
"comment" : "A label that shows the name of the active Scarf project, followed by \"Chat\".",
|
"comment" : "A label that shows the name of the active Scarf project, followed by \"Chat\".",
|
||||||
"isCommentAutoGenerated" : true
|
"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 \"%@\"" : {
|
"Chat is scoped to Scarf project \"%@\"" : {
|
||||||
"comment" : "Tooltip for the folder-chip indicator.",
|
"comment" : "Tooltip for the folder-chip indicator.",
|
||||||
"isCommentAutoGenerated" : true
|
"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…" : {
|
"Checking…" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"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." : {
|
"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" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -5367,7 +5387,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Connected — %@" : {
|
||||||
|
"comment" : "A connected state with a reason for",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Connected — can't read Hermes state" : {
|
"Connected — can't read Hermes state" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"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" : {
|
"Copied" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -11499,6 +11528,10 @@
|
|||||||
"comment" : "A description of the logs feature.",
|
"comment" : "A description of the logs feature.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Load earlier messages" : {
|
||||||
|
"comment" : "A button to load older messages.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Loaded" : {
|
"Loaded" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -11547,6 +11580,10 @@
|
|||||||
"comment" : "A message displayed while loading the configuration.",
|
"comment" : "A message displayed while loading the configuration.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Loading earlier…" : {
|
||||||
|
"comment" : "A label displayed while loading older messages.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Loading session…" : {
|
"Loading session…" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"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" : {
|
"Other" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"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" : {
|
"Paths" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -18357,6 +18402,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Run diagnostics" : {
|
||||||
|
"comment" : "A button that runs diagnostics.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Run Diagnostics…" : {
|
"Run Diagnostics…" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -18739,6 +18788,10 @@
|
|||||||
"comment" : "A description of the warning about not switching models.",
|
"comment" : "A description of the warning about not switching models.",
|
||||||
"isCommentAutoGenerated" : true
|
"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:" : {
|
"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" : {
|
"localizations" : {
|
||||||
"de" : {
|
"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." : {
|
"SSH works but %@. Click for diagnostics." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -23942,6 +24000,10 @@
|
|||||||
},
|
},
|
||||||
"value" : {
|
"value" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Verify" : {
|
||||||
|
"comment" : "A button that verifies a project path on a remote server.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Verifying token…" : {
|
"Verifying token…" : {
|
||||||
"comment" : "A label displayed in the Spotify sign-in sheet",
|
"comment" : "A label displayed in the Spotify sign-in sheet",
|
||||||
|
|||||||
Reference in New Issue
Block a user