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 {
|
||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
|
||||
static let agentsFilename = "AGENTS.md"
|
||||
static let identityStartMarker = "<!-- clawdis:identity:start -->"
|
||||
static let identityEndMarker = "<!-- clawdis:identity:end -->"
|
||||
|
||||
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..<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 {
|
||||
"""
|
||||
# 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<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
|
||||
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 onboardingVersionKey = "clawdis.onboardingVersion"
|
||||
let currentOnboardingVersion = 5
|
||||
let currentOnboardingVersion = 6
|
||||
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
||||
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
||||
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user