diff --git a/apps/macos/Sources/Clawdis/AgentIdentity.swift b/apps/macos/Sources/Clawdis/AgentIdentity.swift new file mode 100644 index 000000000..5799a5dd0 --- /dev/null +++ b/apps/macos/Sources/Clawdis/AgentIdentity.swift @@ -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 "🦞" + } +} + diff --git a/apps/macos/Sources/Clawdis/AgentWorkspace.swift b/apps/macos/Sources/Clawdis/AgentWorkspace.swift index e3f308e1c..34841a6a6 100644 --- a/apps/macos/Sources/Clawdis/AgentWorkspace.swift +++ b/apps/macos/Sources/Clawdis/AgentWorkspace.swift @@ -4,6 +4,8 @@ import OSLog enum AgentWorkspace { private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace") static let agentsFilename = "AGENTS.md" + static let identityStartMarker = "" + static let identityEndMarker = "" static func displayPath(for url: URL) -> String { let home = FileManager.default.homeDirectoryForCurrentUser.path @@ -36,6 +38,31 @@ enum AgentWorkspace { 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.. String { """ # AGENTS.md β€” Clawdis Workspace @@ -51,4 +78,26 @@ enum AgentWorkspace { - 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? { + if let firstHeading = content.range(of: "\n") { + // Insert after the first line (usually "# AGENTS.md …") + return firstHeading + } + return nil + } } diff --git a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift new file mode 100644 index 000000000..8e3068738 --- /dev/null +++ b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift @@ -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) ?? "" + 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: "") + } +} + diff --git a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift index 173cda327..8807f8f49 100644 --- a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift +++ b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift @@ -62,4 +62,28 @@ enum ClawdisConfigFile { root["inbound"] = inbound 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) + } } diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index c6c987d25..4d7b5cb82 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -2,7 +2,7 @@ import Foundation let launchdLabel = "com.steipete.clawdis" let onboardingVersionKey = "clawdis.onboardingVersion" -let currentOnboardingVersion = 5 +let currentOnboardingVersion = 6 let pauseDefaultsKey = "clawdis.pauseEnabled" let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled" let swabbleEnabledKey = "clawdis.swabbleEnabled" diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 6f7d4f2e3..41e51b09b 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -52,6 +52,16 @@ struct OnboardingView: View { @State private var workspacePath: String = "" @State private var workspaceStatus: String? @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 gatewayInstalling = false @State private var gatewayInstallMessage: String? @@ -63,14 +73,14 @@ struct OnboardingView: View { private let pageWidth: CGFloat = 680 private let contentHeight: CGFloat = 520 private let connectionPageIndex = 1 - private let permissionsPageIndex = 3 + private let permissionsPageIndex = 5 private var pageOrder: [Int] { if self.state.connectionMode == .remote { // Remote setup doesn't need local gateway/CLI/workspace setup pages, // 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 } @@ -102,6 +112,8 @@ struct OnboardingView: View { HStack(spacing: 0) { self.welcomePage().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.permissionsPage().frame(width: self.pageWidth) self.cliPage().frame(width: self.pageWidth) @@ -143,6 +155,8 @@ struct OnboardingView: View { self.refreshCLIStatus() self.refreshGatewayStatus() 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 { self.onboardingPage { Text("Install the gateway") @@ -899,6 +1137,17 @@ struct OnboardingView: View { 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 { let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines) return """ @@ -923,6 +1172,37 @@ struct OnboardingView: View { 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 {