feat(skills): Spotify auth flow + sign-in sheet (Phase 3.1)

Hermes v2026.4.23 ships a `spotify` skill that needs OAuth via
`hermes auth spotify`. Mirror the v2.3 Nous Portal in-app sign-in
pattern so users don't have to drop to a shell.

Mac (full sign-in flow):
- SpotifyAuthFlow.swift in Core/Services — @Observable @MainActor,
  five-state machine (idle → starting → waitingForApproval(URL) →
  verifying → success | failure). Spawns `hermes auth spotify` via
  the transport, regex-detects the
  `https://accounts.spotify.com/authorize?...` URL on stdout/stderr,
  auto-opens it via NSWorkspace, and on subprocess exit polls
  `~/.hermes/auth.json` to confirm `providers.spotify.access_token`
  actually landed (exit code alone isn't proof).
- SpotifySignInSheet.swift in Features/Skills/Views — five sub-views
  matching the state machine (starting / waiting / verifying /
  success / failure with retry). Auto-dismisses 1.2s after success.
  Mirrors NousSignInSheet shape.
- SkillsView surfaces a "Sign in to Spotify" row in the skill detail
  pane when the selected skill is the spotify one.

iOS (read-only documentation):
- SkillsListView's SkillDetailView gains a "Authentication" section
  on the spotify skill explaining that OAuth needs to happen from
  Mac (or a shell). The credential lands in ~/.hermes/auth.json and
  ScarfGo picks it up automatically once the agent uses the skill.
  Editor sheet UX deferred to v2.6 — multi-line OAuth flows on iPhone
  are a separate UX problem.

Verified: Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:12:34 +02:00
parent 64bcea35a0
commit 97aa988762
4 changed files with 468 additions and 0 deletions
@@ -89,6 +89,25 @@ private struct SkillDetailView: View {
.textSelection(.enabled)
}
if skill.name.lowercased() == "spotify" {
Section("Authentication") {
Label {
VStack(alignment: .leading, spacing: 4) {
Text("Spotify needs OAuth")
.font(.callout.weight(.medium))
Text("Run `hermes auth spotify` from the Scarf macOS app or a shell — it opens your browser to complete the OAuth flow. Once authorised, this skill picks up the credentials from `~/.hermes/auth.json` automatically.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} icon: {
Image(systemName: "music.note")
.foregroundStyle(.green)
}
.padding(.vertical, 4)
}
}
if !skill.files.isEmpty {
Section("Files") {
ForEach(skill.files, id: \.self) { file in
@@ -0,0 +1,212 @@
import Foundation
import AppKit
import os
import ScarfCore
/// Drives `hermes auth spotify` for the Spotify skill (Hermes v2026.4.23).
///
/// Spotify uses OAuth 2.0 authorization-code flow with a local-callback
/// listener: Hermes prints a `https://accounts.spotify.com/authorize?...`
/// URL, the user approves in their browser, Spotify redirects back to a
/// local server Hermes spun up, Hermes catches the code, exchanges it for
/// a token, and writes the result to `~/.hermes/auth.json`.
///
/// The flow:
///
/// 1. Spawn hermes via `context.makeTransport().makeProcess(...)`.
/// 2. Stream stdout/stderr; regex-detect the auth URL on whatever line
/// Hermes prints it (we're permissive match any `accounts.spotify.com/authorize`
/// so log-format changes between minor versions don't break us).
/// 3. Auto-open the URL in the default browser; transition to
/// `.waitingForApproval` so the sheet can show a manual fallback.
/// 4. On subprocess exit 0, poll `~/.hermes/auth.json` for
/// `providers.spotify.access_token`. The exit code alone isn't
/// proof auth could fail mid-callback and exit 0 anyway.
/// 5. Surface clear errors for cancellation / missing binary / token
/// not landing.
///
/// Mirrors `NousAuthFlow` in shape so future "auth provider X" sheets
/// can lift the pattern without re-deriving the lifecycle handling.
@Observable
@MainActor
final class SpotifyAuthFlow {
enum State: Equatable {
case idle
case starting
case waitingForApproval(authorizeURL: URL)
case verifying
case success
case failure(reason: String)
}
private(set) var state: State = .idle
/// Accumulated stdout/stderr surfaced in the failure UI for bug reports.
private(set) var output: String = ""
let context: ServerContext
private let logger = Logger(subsystem: "com.scarf", category: "SpotifyAuthFlow")
private var process: Process?
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
private var pollTask: Task<Void, Never>?
init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Lifecycle
/// Start the sign-in flow. Cancels any in-flight subprocess first.
func start() {
cancel()
output = ""
state = .starting
let proc = context.makeTransport().makeProcess(
executable: context.paths.hermesBinary,
args: ["auth", "spotify"]
)
if !context.isRemote {
var env = HermesFileService.enrichedEnvironment()
// Force unbuffered Python stdout so the auth URL flushes
// immediately. Same reasoning as NousAuthFlow.
env["PYTHONUNBUFFERED"] = "1"
proc.environment = env
}
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
self.process = proc
self.stdoutPipe = stdoutPipe
self.stderrPipe = stderrPipe
do {
try proc.run()
} catch {
state = .failure(reason: "Couldn't start `hermes auth spotify`: \(error.localizedDescription)")
return
}
// Stream both pipes into `output`; URL detection on every chunk.
stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
Task { @MainActor [weak self] in
self?.absorb(text)
}
}
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
Task { @MainActor [weak self] in
self?.absorb(text)
}
}
proc.terminationHandler = { [weak self] terminated in
Task { @MainActor [weak self] in
self?.handleTermination(exitCode: terminated.terminationStatus)
}
}
}
/// Cancel the in-flight subprocess, if any. Idempotent.
func cancel() {
pollTask?.cancel()
pollTask = nil
if let p = process, p.isRunning {
p.terminate()
}
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
stderrPipe?.fileHandleForReading.readabilityHandler = nil
try? stdoutPipe?.fileHandleForReading.close()
try? stderrPipe?.fileHandleForReading.close()
try? stdoutPipe?.fileHandleForWriting.close()
try? stderrPipe?.fileHandleForWriting.close()
process = nil
stdoutPipe = nil
stderrPipe = nil
}
// MARK: - Output handling
private func absorb(_ text: String) {
output += text
// Detect the OAuth authorize URL on first sight.
if case .starting = state, let url = Self.detectAuthorizeURL(in: output) {
state = .waitingForApproval(authorizeURL: url)
NSWorkspace.shared.open(url)
}
}
/// Match any `accounts.spotify.com/authorize` URL in the buffer.
/// Permissive on purpose log-format changes between Hermes minors
/// shouldn't break us. Returns nil if no match found yet.
nonisolated static func detectAuthorizeURL(in text: String) -> URL? {
let pattern = #"https://accounts\.spotify\.com/authorize\?[^\s)\"']+"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(text.startIndex..., in: text)
guard let match = regex.firstMatch(in: text, range: range),
let urlRange = Range(match.range, in: text)
else { return nil }
return URL(string: String(text[urlRange]))
}
// MARK: - Termination
private func handleTermination(exitCode: Int32) {
guard exitCode == 0 else {
// Cancelled by us, or hermes returned non-zero.
if state == .starting || state == .verifying {
state = .failure(reason: "Spotify auth exited with status \(exitCode). Last log:\n\(Self.tail(output, lines: 6))")
}
return
}
// Verify the token actually landed in auth.json.
state = .verifying
pollTask = Task { @MainActor [weak self] in
guard let self else { return }
let path = context.paths.authJSON
let transport = context.makeTransport()
// Three quick polls auth.json is written synchronously by
// hermes before exit, so this almost always lands on the
// first read; the retries cover NFS / SFTP write barriers.
for _ in 0..<3 {
if Task.isCancelled { return }
if Self.authJSONHasSpotifyToken(path: path, transport: transport) {
state = .success
return
}
try? await Task.sleep(nanoseconds: 250_000_000)
}
state = .failure(reason: "Hermes exited cleanly but no Spotify token landed in \(path).")
}
}
/// Return true when `auth.json` contains a non-empty
/// `providers.spotify.access_token`. False on read failure, parse
/// failure, or absent token caller treats as "not signed in".
nonisolated static func authJSONHasSpotifyToken(
path: String,
transport: any ServerTransport
) -> Bool {
guard let data = try? transport.readFile(path),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let providers = json["providers"] as? [String: Any],
let spotify = providers["spotify"] as? [String: Any],
let token = spotify["access_token"] as? String,
!token.isEmpty
else { return false }
return true
}
/// Last `lines` lines of a string buffer, used in failure messages.
nonisolated static func tail(_ s: String, lines: Int) -> String {
let parts = s.split(separator: "\n", omittingEmptySubsequences: false)
let tail = parts.suffix(lines)
return tail.joined(separator: "\n")
}
}
@@ -3,6 +3,8 @@ import ScarfCore
struct SkillsView: View {
@State private var viewModel: SkillsViewModel
@State private var showSpotifySignIn: Bool = false
@Environment(\.serverContext) private var serverContext
@State private var currentTab: Tab = .installed
init(context: ServerContext) {
@@ -134,6 +136,14 @@ struct SkillsView: View {
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
// v2.5 Spotify auth affordance only when this skill
// is the spotify one. We don't probe auth.json here
// (transport read is async); the button always shows
// and the sheet itself handles the "already signed in?"
// case (token present succeeds immediately on retry).
if skill.name.lowercased() == "spotify" {
spotifyAuthRow
}
Divider()
if !skill.files.isEmpty {
VStack(alignment: .leading, spacing: 4) {
@@ -182,12 +192,44 @@ struct SkillsView: View {
.sheet(isPresented: $viewModel.isEditing) {
skillEditorSheet
}
.sheet(isPresented: $showSpotifySignIn) {
SpotifySignInSheet(onSignedIn: {
// No state to refresh in this view yet chat picks
// up the new token on next session start. Keep the
// hook so a future "auth status" indicator can rebind.
})
}
} else {
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Renders the v2.5 Spotify auth row when the user has the
/// `spotify` skill selected. Tapping opens `SpotifySignInSheet`
/// which drives `hermes auth spotify` end-to-end in-app.
private var spotifyAuthRow: some View {
HStack(spacing: 10) {
Image(systemName: "music.note")
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Sign in to Spotify")
.font(.callout.weight(.medium))
Text("Authorise Hermes to control playback, search, and library actions.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Sign In") { showSpotifySignIn = true }
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.green.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var skillEditorSheet: some View {
VStack(spacing: 0) {
HStack {
@@ -0,0 +1,195 @@
import SwiftUI
import AppKit
/// In-app sign-in sheet for the Spotify skill (Hermes v2026.4.23+).
/// Hosts a `SpotifyAuthFlow` and renders one of five sub-views keyed
/// on `flow.state`. Reached from the Skills sidebar (when the spotify
/// skill is selected and not yet authenticated) and from any future
/// "Auxiliary providers" surface.
///
/// UX contract with the caller:
/// - Sheet presented via `.sheet(isPresented:)`.
/// - Parent owns the binding.
/// - `onSignedIn` fires on `.success` so callers can refresh whatever
/// view was showing the "not authed" affordance.
///
/// Mirrors `NousSignInSheet` (v2.3) in shape same lifecycle, same
/// patience model, same auto-dismiss-on-success.
struct SpotifySignInSheet: View {
@Environment(\.serverContext) private var serverContext
@Environment(\.dismiss) private var dismiss
var onSignedIn: () -> Void = {}
@State private var flow: SpotifyAuthFlow?
@State private var successDismissTask: Task<Void, Never>?
var body: some View {
VStack(spacing: 16) {
header
Divider()
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(20)
.frame(minWidth: 440, idealWidth: 440, minHeight: 320)
.onAppear {
if flow == nil {
let f = SpotifyAuthFlow(context: serverContext)
flow = f
f.start()
}
}
.onDisappear {
successDismissTask?.cancel()
flow?.cancel()
}
.onChange(of: flowState) { _, newValue in
if case .success = newValue {
onSignedIn()
successDismissTask?.cancel()
successDismissTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_200_000_000)
if !Task.isCancelled { dismiss() }
}
}
}
}
// Captures `flow.state` so `.onChange(of:)` works (Equatable) without
// forcing the whole flow into the change closure (it isn't Equatable).
private var flowState: SpotifyAuthFlow.State {
flow?.state ?? .idle
}
// MARK: - Subviews
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "music.note")
.foregroundStyle(.green)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Sign in to Spotify")
.font(.headline)
Text("Authorise Hermes to control your Spotify account.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Cancel") {
flow?.cancel()
dismiss()
}
.keyboardShortcut(.cancelAction)
}
}
@ViewBuilder
private var content: some View {
switch flow?.state ?? .idle {
case .idle, .starting:
startingView
case .waitingForApproval(let url):
waitingView(url: url)
case .verifying:
verifyingView
case .success:
successView
case .failure(let reason):
failureView(reason: reason)
}
}
private var startingView: some View {
VStack(spacing: 12) {
ProgressView()
Text("Starting `hermes auth spotify`…")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func waitingView(url: URL) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Waiting for browser approval…")
.font(.callout)
}
Text("Scarf opened the authorisation URL in your default browser. Sign in with your Spotify account and approve the requested permissions to complete sign-in.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Text(url.absoluteString)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button("Copy") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url.absoluteString, forType: .string)
}
.controlSize(.small)
Button("Open") {
NSWorkspace.shared.open(url)
}
.controlSize(.small)
}
.padding(8)
.background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 6))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var verifyingView: some View {
VStack(spacing: 10) {
ProgressView()
Text("Verifying token…")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var successView: some View {
VStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 36))
.foregroundStyle(.green)
Text("Spotify connected")
.font(.headline)
Text("You can now use the spotify skill from chat.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func failureView(reason: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Sign-in failed")
.font(.headline)
Spacer()
}
Text(reason)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("Try again") {
flow?.start()
}
.buttonStyle(.borderedProminent)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}