feat(macos): onboard Claude OAuth + identity
This commit is contained in:
44
apps/macos/Sources/Clawdis/AgentIdentity.swift
Normal file
44
apps/macos/Sources/Clawdis/AgentIdentity.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AgentIdentity: Codable, Equatable {
|
||||||
|
var name: String
|
||||||
|
var theme: String
|
||||||
|
var emoji: String
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
self.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||||
|
self.theme.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||||
|
self.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AgentIdentityEmoji {
|
||||||
|
static func suggest(theme: String) -> String {
|
||||||
|
let normalized = theme.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if normalized.isEmpty { return "🦞" }
|
||||||
|
|
||||||
|
let table: [(needle: String, emoji: String)] = [
|
||||||
|
("lobster", "🦞"),
|
||||||
|
("sloth", "🦥"),
|
||||||
|
("space", "🪐"),
|
||||||
|
("rocket", "🚀"),
|
||||||
|
("astronaut", "🧑🚀"),
|
||||||
|
("octopus", "🐙"),
|
||||||
|
("crab", "🦀"),
|
||||||
|
("shark", "🦈"),
|
||||||
|
("cat", "🐈"),
|
||||||
|
("dog", "🐕"),
|
||||||
|
("owl", "🦉"),
|
||||||
|
("fox", "🦊"),
|
||||||
|
("robot", "🤖"),
|
||||||
|
("wizard", "🧙"),
|
||||||
|
("ninja", "🥷"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for entry in table where normalized.contains(entry.needle) {
|
||||||
|
return entry.emoji
|
||||||
|
}
|
||||||
|
return "🦞"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,8 @@ import OSLog
|
|||||||
enum AgentWorkspace {
|
enum AgentWorkspace {
|
||||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
|
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
|
||||||
static let agentsFilename = "AGENTS.md"
|
static let agentsFilename = "AGENTS.md"
|
||||||
|
static let identityStartMarker = "<!-- clawdis:identity:start -->"
|
||||||
|
static let identityEndMarker = "<!-- clawdis:identity:end -->"
|
||||||
|
|
||||||
static func displayPath(for url: URL) -> String {
|
static func displayPath(for url: URL) -> String {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
@@ -36,6 +38,31 @@ enum AgentWorkspace {
|
|||||||
return agentsURL
|
return agentsURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func upsertIdentity(workspaceURL: URL, identity: AgentIdentity) throws {
|
||||||
|
let agentsURL = try self.bootstrap(workspaceURL: workspaceURL)
|
||||||
|
var content = (try? String(contentsOf: agentsURL, encoding: .utf8)) ?? ""
|
||||||
|
let block = self.identityBlock(identity: identity)
|
||||||
|
|
||||||
|
if let start = content.range(of: self.identityStartMarker),
|
||||||
|
let end = content.range(of: self.identityEndMarker),
|
||||||
|
start.lowerBound < end.upperBound
|
||||||
|
{
|
||||||
|
content.replaceSubrange(
|
||||||
|
start.lowerBound..<end.upperBound,
|
||||||
|
with: block.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
} else if let insert = self.identityInsertRange(in: content) {
|
||||||
|
content.insert(contentsOf: "\n\n## Identity\n\(block)\n", at: insert.upperBound)
|
||||||
|
} else {
|
||||||
|
content = [content.trimmingCharacters(in: .whitespacesAndNewlines), "## Identity\n\(block)"]
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: "\n\n")
|
||||||
|
.appending("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try content.write(to: agentsURL, atomically: true, encoding: .utf8)
|
||||||
|
self.logger.info("Updated identity in \(agentsURL.path, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
static func defaultTemplate() -> String {
|
static func defaultTemplate() -> String {
|
||||||
"""
|
"""
|
||||||
# AGENTS.md — Clawdis Workspace
|
# AGENTS.md — Clawdis Workspace
|
||||||
@@ -51,4 +78,26 @@ enum AgentWorkspace {
|
|||||||
- Add your preferred style, rules, and “memory” here.
|
- Add your preferred style, rules, and “memory” here.
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func identityBlock(identity: AgentIdentity) -> String {
|
||||||
|
let name = identity.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let theme = identity.theme.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let emoji = identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
return """
|
||||||
|
\(self.identityStartMarker)
|
||||||
|
- Name: \(name)
|
||||||
|
- Theme: \(theme)
|
||||||
|
- Emoji: \(emoji)
|
||||||
|
\(self.identityEndMarker)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func identityInsertRange(in content: String) -> Range<String.Index>? {
|
||||||
|
if let firstHeading = content.range(of: "\n") {
|
||||||
|
// Insert after the first line (usually "# AGENTS.md …")
|
||||||
|
return firstHeading
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
164
apps/macos/Sources/Clawdis/AnthropicOAuth.swift
Normal file
164
apps/macos/Sources/Clawdis/AnthropicOAuth.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Security
|
||||||
|
|
||||||
|
struct AnthropicOAuthCredentials: Codable {
|
||||||
|
let type: String
|
||||||
|
let refresh: String
|
||||||
|
let access: String
|
||||||
|
let expires: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnthropicOAuth {
|
||||||
|
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth")
|
||||||
|
|
||||||
|
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
|
||||||
|
private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
|
||||||
|
private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
|
||||||
|
private static let scopes = "org:create_api_key user:profile user:inference"
|
||||||
|
|
||||||
|
struct PKCE {
|
||||||
|
let verifier: String
|
||||||
|
let challenge: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func generatePKCE() throws -> PKCE {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: 32)
|
||||||
|
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||||
|
}
|
||||||
|
let verifier = Data(bytes).base64URLEncodedString()
|
||||||
|
let hash = SHA256.hash(data: Data(verifier.utf8))
|
||||||
|
let challenge = Data(hash).base64URLEncodedString()
|
||||||
|
return PKCE(verifier: verifier, challenge: challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildAuthorizeURL(pkce: PKCE) -> URL {
|
||||||
|
var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "code", value: "true"),
|
||||||
|
URLQueryItem(name: "client_id", value: self.clientId),
|
||||||
|
URLQueryItem(name: "response_type", value: "code"),
|
||||||
|
URLQueryItem(name: "redirect_uri", value: self.redirectURI),
|
||||||
|
URLQueryItem(name: "scope", value: self.scopes),
|
||||||
|
URLQueryItem(name: "code_challenge", value: pkce.challenge),
|
||||||
|
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||||
|
// Match Pi: state is the verifier.
|
||||||
|
URLQueryItem(name: "state", value: pkce.verifier),
|
||||||
|
]
|
||||||
|
return components.url!
|
||||||
|
}
|
||||||
|
|
||||||
|
static func exchangeCode(
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
verifier: String) async throws -> AnthropicOAuthCredentials
|
||||||
|
{
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": self.clientId,
|
||||||
|
"code": code,
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": self.redirectURI,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
]
|
||||||
|
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||||
|
|
||||||
|
var request = URLRequest(url: self.tokenURL)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.httpBody = body
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||||
|
throw NSError(
|
||||||
|
domain: "AnthropicOAuth",
|
||||||
|
code: http.statusCode,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
let access = decoded?["access_token"] as? String
|
||||||
|
let refresh = decoded?["refresh_token"] as? String
|
||||||
|
let expiresIn = decoded?["expires_in"] as? Double
|
||||||
|
guard let access, let refresh, let expiresIn else {
|
||||||
|
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match Pi: expiresAt = now + expires_in - 5 minutes.
|
||||||
|
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
+ Int64(expiresIn * 1000)
|
||||||
|
- Int64(5 * 60 * 1000)
|
||||||
|
|
||||||
|
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||||
|
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PiOAuthStore {
|
||||||
|
static let oauthFilename = "oauth.json"
|
||||||
|
private static let providerKey = "anthropic"
|
||||||
|
|
||||||
|
static func oauthDir() -> URL {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".pi", isDirectory: true)
|
||||||
|
.appendingPathComponent("agent", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func oauthURL() -> URL {
|
||||||
|
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hasAnthropicOAuth() -> Bool {
|
||||||
|
guard let dict = (try? self.loadStorage()) else { return false }
|
||||||
|
return dict[self.providerKey] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
|
||||||
|
var storage = (try? self.loadStorage()) ?? [:]
|
||||||
|
storage[self.providerKey] = creds
|
||||||
|
try self.saveStorage(storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadStorage() throws -> [String: AnthropicOAuthCredentials] {
|
||||||
|
let url = self.oauthURL()
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
return try JSONDecoder().decode([String: AnthropicOAuthCredentials].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saveStorage(_ storage: [String: AnthropicOAuthCredentials]) throws {
|
||||||
|
let dir = self.oauthDir()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: dir,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: [.posixPermissions: 0o700])
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(storage)
|
||||||
|
|
||||||
|
let url = self.oauthURL()
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
func base64URLEncodedString() -> String {
|
||||||
|
self.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -62,4 +62,28 @@ enum ClawdisConfigFile {
|
|||||||
root["inbound"] = inbound
|
root["inbound"] = inbound
|
||||||
self.saveDict(root)
|
self.saveDict(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func loadIdentity() -> AgentIdentity? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
guard let identity = root["identity"] as? [String: Any] else { return nil }
|
||||||
|
let name = identity["name"] as? String ?? ""
|
||||||
|
let theme = identity["theme"] as? String ?? ""
|
||||||
|
let emoji = identity["emoji"] as? String ?? ""
|
||||||
|
let result = AgentIdentity(name: name, theme: theme, emoji: emoji)
|
||||||
|
return result.isEmpty ? nil : result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setIdentity(_ identity: AgentIdentity?) {
|
||||||
|
var root = self.loadDict()
|
||||||
|
if let identity, !identity.isEmpty {
|
||||||
|
root["identity"] = [
|
||||||
|
"name": identity.name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
"theme": identity.theme.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
"emoji": identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
root.removeValue(forKey: "identity")
|
||||||
|
}
|
||||||
|
self.saveDict(root)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
let launchdLabel = "com.steipete.clawdis"
|
let launchdLabel = "com.steipete.clawdis"
|
||||||
let onboardingVersionKey = "clawdis.onboardingVersion"
|
let onboardingVersionKey = "clawdis.onboardingVersion"
|
||||||
let currentOnboardingVersion = 5
|
let currentOnboardingVersion = 6
|
||||||
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
||||||
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
||||||
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ struct OnboardingView: View {
|
|||||||
@State private var workspacePath: String = ""
|
@State private var workspacePath: String = ""
|
||||||
@State private var workspaceStatus: String?
|
@State private var workspaceStatus: String?
|
||||||
@State private var workspaceApplying = false
|
@State private var workspaceApplying = false
|
||||||
|
@State private var anthropicAuthPKCE: AnthropicOAuth.PKCE?
|
||||||
|
@State private var anthropicAuthCode: String = ""
|
||||||
|
@State private var anthropicAuthStatus: String?
|
||||||
|
@State private var anthropicAuthBusy = false
|
||||||
|
@State private var anthropicAuthConnected = false
|
||||||
|
@State private var identityName: String = ""
|
||||||
|
@State private var identityTheme: String = ""
|
||||||
|
@State private var identityEmoji: String = ""
|
||||||
|
@State private var identityStatus: String?
|
||||||
|
@State private var identityApplying = false
|
||||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||||
@State private var gatewayInstalling = false
|
@State private var gatewayInstalling = false
|
||||||
@State private var gatewayInstallMessage: String?
|
@State private var gatewayInstallMessage: String?
|
||||||
@@ -63,14 +73,14 @@ struct OnboardingView: View {
|
|||||||
private let pageWidth: CGFloat = 680
|
private let pageWidth: CGFloat = 680
|
||||||
private let contentHeight: CGFloat = 520
|
private let contentHeight: CGFloat = 520
|
||||||
private let connectionPageIndex = 1
|
private let connectionPageIndex = 1
|
||||||
private let permissionsPageIndex = 3
|
private let permissionsPageIndex = 5
|
||||||
private var pageOrder: [Int] {
|
private var pageOrder: [Int] {
|
||||||
if self.state.connectionMode == .remote {
|
if self.state.connectionMode == .remote {
|
||||||
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
||||||
// and WhatsApp/Telegram setup is optional.
|
// and WhatsApp/Telegram setup is optional.
|
||||||
return [0, 1, 3, 7]
|
return [0, 1, 5, 9]
|
||||||
}
|
}
|
||||||
return [0, 1, 2, 3, 4, 5, 6, 7]
|
return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||||
}
|
}
|
||||||
|
|
||||||
private var pageCount: Int { self.pageOrder.count }
|
private var pageCount: Int { self.pageOrder.count }
|
||||||
@@ -102,6 +112,8 @@ struct OnboardingView: View {
|
|||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
self.welcomePage().frame(width: self.pageWidth)
|
self.welcomePage().frame(width: self.pageWidth)
|
||||||
self.connectionPage().frame(width: self.pageWidth)
|
self.connectionPage().frame(width: self.pageWidth)
|
||||||
|
self.anthropicAuthPage().frame(width: self.pageWidth)
|
||||||
|
self.identityPage().frame(width: self.pageWidth)
|
||||||
self.gatewayPage().frame(width: self.pageWidth)
|
self.gatewayPage().frame(width: self.pageWidth)
|
||||||
self.permissionsPage().frame(width: self.pageWidth)
|
self.permissionsPage().frame(width: self.pageWidth)
|
||||||
self.cliPage().frame(width: self.pageWidth)
|
self.cliPage().frame(width: self.pageWidth)
|
||||||
@@ -143,6 +155,8 @@ struct OnboardingView: View {
|
|||||||
self.refreshCLIStatus()
|
self.refreshCLIStatus()
|
||||||
self.refreshGatewayStatus()
|
self.refreshGatewayStatus()
|
||||||
self.loadWorkspaceDefaults()
|
self.loadWorkspaceDefaults()
|
||||||
|
self.refreshAnthropicOAuthStatus()
|
||||||
|
self.loadIdentityDefaults()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +294,230 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func anthropicAuthPage() -> some View {
|
||||||
|
self.onboardingPage {
|
||||||
|
Text("Connect Claude")
|
||||||
|
.font(.largeTitle.weight(.semibold))
|
||||||
|
Text(
|
||||||
|
"Optional, but recommended: authenticate via Claude (Anthropic) so Pi can answer immediately. " +
|
||||||
|
"Clawdis will always pass --provider/--model when invoking Pi.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 540)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
self.onboardingCard(spacing: 12, padding: 16) {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(self.anthropicAuthConnected ? Color.green : Color.orange)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text(self.anthropicAuthConnected ? "Anthropic OAuth connected" : "Not connected yet")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"This writes Pi-compatible credentials to `~/.pi/agent/oauth.json` (owner-only). " +
|
||||||
|
"You can redo this anytime.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Divider().padding(.vertical, 2)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
self.startAnthropicOAuth()
|
||||||
|
} label: {
|
||||||
|
if self.anthropicAuthBusy {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text("Open Claude login (OAuth)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.anthropicAuthBusy)
|
||||||
|
|
||||||
|
Button("Skip for now") {
|
||||||
|
self.anthropicAuthStatus = "Skipped. The agent may not respond until you authenticate."
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.anthropicAuthBusy)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.anthropicAuthPKCE != nil {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Paste `code#state`")
|
||||||
|
.font(.headline)
|
||||||
|
TextField("code#state", text: self.$anthropicAuthCode)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
|
Button("Finish connection") {
|
||||||
|
Task { await self.finishAnthropicOAuth() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(
|
||||||
|
self.anthropicAuthBusy ||
|
||||||
|
self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onboardingCard(spacing: 8, padding: 12) {
|
||||||
|
Text("API key (advanced)")
|
||||||
|
.font(.headline)
|
||||||
|
Text(
|
||||||
|
"You can also use an Anthropic API key, but this is instructions-only for now " +
|
||||||
|
"(GUI-launched processes don’t automatically inherit your shell env vars).")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.shadow(color: .clear, radius: 0)
|
||||||
|
.background(Color.clear)
|
||||||
|
|
||||||
|
if let status = self.anthropicAuthStatus {
|
||||||
|
Text(status)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAnthropicOAuth() {
|
||||||
|
guard !self.anthropicAuthBusy else { return }
|
||||||
|
self.anthropicAuthBusy = true
|
||||||
|
defer { self.anthropicAuthBusy = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let pkce = try AnthropicOAuth.generatePKCE()
|
||||||
|
self.anthropicAuthPKCE = pkce
|
||||||
|
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
self.anthropicAuthStatus = "Opened browser. After approving, paste the `code#state` here."
|
||||||
|
} catch {
|
||||||
|
self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func finishAnthropicOAuth() async {
|
||||||
|
guard !self.anthropicAuthBusy else { return }
|
||||||
|
guard let pkce = self.anthropicAuthPKCE else { return }
|
||||||
|
self.anthropicAuthBusy = true
|
||||||
|
defer { self.anthropicAuthBusy = false }
|
||||||
|
|
||||||
|
let trimmed = self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
|
||||||
|
let code = splits.first ?? ""
|
||||||
|
let state = splits.count > 1 ? splits[1] : ""
|
||||||
|
|
||||||
|
do {
|
||||||
|
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
|
||||||
|
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||||
|
self.refreshAnthropicOAuthStatus()
|
||||||
|
self.anthropicAuthStatus = "Connected. Pi can now use Claude via Anthropic OAuth."
|
||||||
|
} catch {
|
||||||
|
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAnthropicOAuthStatus() {
|
||||||
|
self.anthropicAuthConnected = PiOAuthStore.hasAnthropicOAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func identityPage() -> some View {
|
||||||
|
self.onboardingPage {
|
||||||
|
Text("Identity")
|
||||||
|
.font(.largeTitle.weight(.semibold))
|
||||||
|
Text("Name your agent, pick a theme, and we’ll suggest an emoji.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 520)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
self.onboardingCard(spacing: 12, padding: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Agent name")
|
||||||
|
.font(.headline)
|
||||||
|
TextField("Samantha", text: self.$identityName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Theme")
|
||||||
|
.font(.headline)
|
||||||
|
TextField("space lobster", text: self.$identityTheme)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Emoji")
|
||||||
|
.font(.headline)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
TextField("🦞", text: self.$identityEmoji)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 120)
|
||||||
|
|
||||||
|
Button("Suggest") {
|
||||||
|
let suggested = AgentIdentityEmoji.suggest(theme: self.identityTheme)
|
||||||
|
self.identityEmoji = suggested
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().padding(.vertical, 2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Workspace")
|
||||||
|
.font(.headline)
|
||||||
|
Text(self.workspacePath.isEmpty ? AgentWorkspace.displayPath(for: ClawdisConfigFile.defaultWorkspaceURL()) : self.workspacePath)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
Task { await self.applyIdentity() }
|
||||||
|
} label: {
|
||||||
|
if self.identityApplying {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text("Save identity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.identityApplying || self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
|
||||||
|
Button("Open workspace") {
|
||||||
|
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.identityApplying)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"This writes your identity to `~/.clawdis/clawdis.json` and into `AGENTS.md` inside the workspace. " +
|
||||||
|
"Treat that workspace as the agent’s “memory” and consider making it a (private) git repo.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
if let status = self.identityStatus {
|
||||||
|
Text(status)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func gatewayPage() -> some View {
|
private func gatewayPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Install the gateway")
|
Text("Install the gateway")
|
||||||
@@ -899,6 +1137,17 @@ struct OnboardingView: View {
|
|||||||
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadIdentityDefaults() {
|
||||||
|
guard self.identityName.isEmpty, self.identityTheme.isEmpty, self.identityEmoji.isEmpty else { return }
|
||||||
|
if let identity = ClawdisConfigFile.loadIdentity() {
|
||||||
|
self.identityName = identity.name
|
||||||
|
self.identityTheme = identity.theme
|
||||||
|
self.identityEmoji = identity.emoji
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.identityEmoji = AgentIdentityEmoji.suggest(theme: "")
|
||||||
|
}
|
||||||
|
|
||||||
private var workspaceBootstrapCommand: String {
|
private var workspaceBootstrapCommand: String {
|
||||||
let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines)
|
let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return """
|
return """
|
||||||
@@ -923,6 +1172,37 @@ struct OnboardingView: View {
|
|||||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyIdentity() async {
|
||||||
|
guard !self.identityApplying else { return }
|
||||||
|
self.identityApplying = true
|
||||||
|
defer { self.identityApplying = false }
|
||||||
|
|
||||||
|
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.identityStatus = "Please enter a name first."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = AgentIdentity(
|
||||||
|
name: self.identityName,
|
||||||
|
theme: self.identityTheme,
|
||||||
|
emoji: self.identityEmoji)
|
||||||
|
|
||||||
|
if identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
identity.emoji = AgentIdentityEmoji.suggest(theme: identity.theme)
|
||||||
|
self.identityEmoji = identity.emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let workspaceURL = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||||
|
try AgentWorkspace.upsertIdentity(workspaceURL: workspaceURL, identity: identity)
|
||||||
|
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: workspaceURL))
|
||||||
|
ClawdisConfigFile.setIdentity(identity)
|
||||||
|
self.identityStatus = "Saved identity to AGENTS.md and ~/.clawdis/clawdis.json"
|
||||||
|
} catch {
|
||||||
|
self.identityStatus = "Failed to save identity: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct GlowingClawdisIcon: View {
|
private struct GlowingClawdisIcon: View {
|
||||||
|
|||||||
Reference in New Issue
Block a user