Files
scarf/scarf/scarf/Core/Services/NousSubscriptionService.swift
T
Alan Wizemann 19b4ba9995 Merge branch 'main' into scarf-mobile-development (v2.3.0)
Brings the iOS companion branch current with main's v2.2.0, v2.2.1,
and v2.3.0 landings — templates + configuration + catalog (v2.2),
projects folder hierarchy + per-project Sessions sidecar + AGENTS.md
context block + Tool Gateway + Nous Portal OAuth + hermes dashboard
webview (v2.3), and credential-pool OAuth expiry + Nous agent-key
rotation (post-v2.3).

Resolutions:
- ScarfCore Models (HermesConfig, ProjectDashboard, HermesPathSet) —
  forward-ported Tool Gateway's platformToolsets, project-registry v2
  folder/archived fields, and sessionProjectMap path into the moved
  ScarfCore copies. Deleted the old Mac-target paths.
- ScarfCore ModelCatalogService — merged main's overlay-only provider
  support (Nous Portal + OpenAI Codex + Qwen OAuth + …) so iOS and
  macOS pickers see the same provider list. Widened HermesProviderInfo
  / HermesProviderOverlay APIs to public.
- ScarfCore ProjectsViewModel — layered main's v2.3 registry verbs
  (moveProject / renameProject / archive / unarchive / folders) onto
  the M0d-extracted VM, keeping public surface for the Mac target.
- ScarfCore ConnectionStatusViewModel / RichChatViewModel — widened
  `private(set)` to `public private(set)` so Mac views can read
  status, lastSuccess, acp*Tokens, originSessionId, acpCommands,
  quickCommands.
- ScarfCore HermesConfig+YAML — added platform_toolsets parsing to
  the iOS YAML path so config.yaml round-trips the same as macOS.
- RichChatViewModel quick-commands — inlined the Mac-target's
  QuickCommandsViewModel.loadQuickCommands into ScarfCore using the
  existing HermesYAML parser, removing the cross-module dependency.
- HealthViewModel — took main's Tool Gateway + hermes-dashboard
  webview sections wholesale; file stays macOS-only.
- ChatView auto-merge — confirmed resume-session fix (5ae8db2) is
  present; made the PendingPermission.id extension public to satisfy
  Identifiable conformance across module boundary.
- ProjectSessionsViewModel — moved back to the Mac target since it
  depends on SessionAttributionService (also Mac-target). Defer the
  iOS SFTP parity of attribution to M7.
- LocalTransport.runProcess + SSHTransport.runLocal — wrapped the
  Process body in `#if !os(iOS)` with an explicit throw on iOS so
  ScarfCore compiles under the iOS SDK. iOS uses
  CitadelServerTransport (ScarfIOS) as the real implementation.
- CitadelServerTransport — updated `sftp.remove(atPath:)` to
  `sftp.remove(at:)` for the current Citadel API shape.

Cross-module imports: added `import ScarfCore` to 25 Mac-target files
that consumed ScarfCore types (13 v2.3 additions + 12 post-merge
errors caught by MemberImportVisibility: Settings tabs, SidebarView,
MCPServerEditorView, TemplateExportSheet, tests).

Version lockstep: bumped `scarf mobile` target to
MARKETING_VERSION=2.3.0, CURRENT_PROJECT_VERSION=25 to match main.

Builds green for both schemes:
- swift build (ScarfCore standalone)
- xcodebuild scarf -destination platform=macOS
- xcodebuild 'scarf mobile' -destination generic/platform=iOS

Deferred to M7 (iOS SFTP parity):
- NousSubscriptionService auth.json reader
- ProjectAgentContextService AGENTS.md write-before-chat
- SessionAttributionService session_project_map.json read/watch
All currently Mac-target-gated; iOS still builds without them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:53:23 +02:00

87 lines
3.7 KiB
Swift

import Foundation
import os
import ScarfCore
/// Snapshot of the user's Nous Portal subscription state, derived from the
/// `providers.nous` entry in `~/.hermes/auth.json`. Read-only Scarf never
/// writes the subscription record; `hermes model` + `hermes auth` own that
/// path.
struct NousSubscriptionState: Sendable, Hashable {
/// True when `providers.nous` exists and has a usable access token.
/// Mirrors the `nous_auth_present` field on
/// `NousSubscriptionFeatures` in `hermes_cli/nous_subscription.py`.
let present: Bool
/// True when the user's **active provider** is `nous`, i.e. they've not
/// just authed but selected it as the primary model provider. The Tool
/// Gateway only routes tools when this is true auth alone isn't enough.
let providerIsNous: Bool
/// Last update time for the auth record, if known. Useful in the Health
/// view to tell the user when their subscription state was last refreshed.
let updatedAt: Date?
static let absent = NousSubscriptionState(present: false, providerIsNous: false, updatedAt: nil)
/// Overall subscription active for Tool Gateway routing. Both halves have
/// to line up: auth record present *and* `nous` is the active provider.
/// Mirrors `NousSubscriptionFeatures.subscribed` on the Python side.
var subscribed: Bool { present && providerIsNous }
}
/// Reads `auth.json` to detect Nous Portal subscription state. Delegates file
/// I/O to the active `ServerTransport`, so remote installations work the same
/// as local ones.
///
/// The auth-record shape is defined by hermes-agent and is load-bearing. This
/// service parses a small, stable subset and tolerates anything new Hermes
/// adds we only rely on `providers.nous` being a dict with `access_token`
/// and `active_provider` being either `"nous"` or not.
struct NousSubscriptionService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "NousSubscriptionService")
let authJSONPath: String
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.authJSONPath = context.paths.authJSON
self.transport = context.makeTransport()
}
/// Escape hatch for tests point at a fixture `auth.json` without
/// constructing a full `ServerContext`. Uses `LocalTransport` so the
/// fixture must live on the local filesystem.
init(path: String) {
self.authJSONPath = path
self.transport = LocalTransport()
}
/// Load the current subscription state. Returns ``NousSubscriptionState/absent``
/// on any read or parse failure callers treat "absent" and "can't
/// read" the same in UI (show a "not subscribed" CTA).
nonisolated func loadState() -> NousSubscriptionState {
guard let data = try? transport.readFile(authJSONPath) else {
return .absent
}
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
logger.warning("auth.json is not a JSON object; assuming no Nous subscription")
return .absent
}
let providers = root["providers"] as? [String: Any] ?? [:]
let nous = providers["nous"] as? [String: Any]
let token = nous?["access_token"] as? String
let present = (token?.isEmpty == false)
let activeProvider = root["active_provider"] as? String
let providerIsNous = (activeProvider == "nous")
let updatedAt: Date? = {
guard let raw = root["updated_at"] as? String else { return nil }
return ISO8601DateFormatter().date(from: raw)
}()
return NousSubscriptionState(
present: present,
providerIsNous: providerIsNous,
updatedAt: updatedAt
)
}
}