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