refactor(macos): remove manual identity onboarding

main
Peter Steinberger 2025-12-21 01:39:50 +00:00
parent fb259e8a50
commit 5b25eeb449
6 changed files with 27 additions and 293 deletions

View File

@ -1,45 +0,0 @@
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", "🦥"),
("octopus", "🐙"),
("crab", "🦀"),
("shark", "🦈"),
("cat", "🐈"),
("dog", "🐕"),
("owl", "🦉"),
("fox", "🦊"),
("otter", "🦦"),
("raccoon", "🦝"),
("badger", "🦡"),
("hedgehog", "🦔"),
("koala", "🐨"),
("penguin", "🐧"),
("frog", "🐸"),
("bear", "🐻"),
]
for entry in table where normalized.contains(entry.needle) {
return entry.emoji
}
return "🦞"
}
}

View File

@ -9,8 +9,6 @@ enum AgentWorkspace {
static let userFilename = "USER.md" static let userFilename = "USER.md"
static let bootstrapFilename = "BOOTSTRAP.md" static let bootstrapFilename = "BOOTSTRAP.md"
private static let templateDirname = "templates" private static let templateDirname = "templates"
static let identityStartMarker = "<!-- clawdis:identity:start -->"
static let identityEndMarker = "<!-- clawdis:identity:end -->"
enum BootstrapSafety: Equatable { enum BootstrapSafety: Equatable {
case safe case safe
case unsafe(reason: String) case unsafe(reason: String)
@ -92,29 +90,15 @@ enum AgentWorkspace {
return agentsURL return agentsURL
} }
static func upsertIdentity(workspaceURL: URL, identity: AgentIdentity) throws { static func needsBootstrap(workspaceURL: URL) -> Bool {
let agentsURL = try self.bootstrap(workspaceURL: workspaceURL) let fm = FileManager.default
var content = (try? String(contentsOf: agentsURL, encoding: .utf8)) ?? "" var isDir: ObjCBool = false
let block = self.identityBlock(identity: identity) if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
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")
} }
guard isDir.boolValue else { return true }
try content.write(to: agentsURL, atomically: true, encoding: .utf8) let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
self.logger.info("Updated identity in \(agentsURL.path, privacy: .public)") return fm.fileExists(atPath: bootstrapURL.path)
} }
static func defaultTemplate() -> String { static func defaultTemplate() -> String {
@ -301,25 +285,5 @@ enum AgentWorkspace {
return trimmed + "\n" return trimmed + "\n"
} }
private static func identityBlock(identity: AgentIdentity) -> String { // Identity is written by the agent during the bootstrap ritual.
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
}
} }

View File

@ -80,27 +80,4 @@ enum ClawdisConfigFile {
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)
}
} }

View File

@ -68,12 +68,7 @@ struct OnboardingView: View {
@State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount @State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
@State private var monitoringAuth = false @State private var monitoringAuth = false
@State private var authMonitorTask: Task<Void, Never>? @State private var authMonitorTask: Task<Void, Never>?
@State private var identityName: String = "" @State private var needsBootstrap = false
@State private var identityTheme: String = ""
@State private var identityEmoji: String = ""
@State private var identityStatus: String?
@State private var identityApplying = false
@State private var hasIdentity = false
@State private var didAutoKickoff = false @State private var didAutoKickoff = false
@State private var showAdvancedConnection = false @State private var showAdvancedConnection = false
@State private var preferredGatewayID: String? @State private var preferredGatewayID: String?
@ -93,22 +88,22 @@ struct OnboardingView: View {
private let permissionsPageIndex = 5 private let permissionsPageIndex = 5
static func pageOrder( static func pageOrder(
for mode: AppState.ConnectionMode, for mode: AppState.ConnectionMode,
hasIdentity: Bool) -> [Int] needsBootstrap: Bool) -> [Int]
{ {
switch mode { switch mode {
case .remote: case .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.
hasIdentity ? [0, 1, 5, 9] : [0, 1, 5, 8, 9] needsBootstrap ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
case .unconfigured: case .unconfigured:
hasIdentity ? [0, 1, 9] : [0, 1, 8, 9] needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
case .local: case .local:
hasIdentity ? [0, 1, 2, 5, 6, 9] : [0, 1, 2, 5, 6, 8, 9] needsBootstrap ? [0, 1, 2, 5, 6, 8, 9] : [0, 1, 2, 5, 6, 9]
} }
} }
private var pageOrder: [Int] { private var pageOrder: [Int] {
Self.pageOrder(for: self.state.connectionMode, hasIdentity: self.hasIdentity) Self.pageOrder(for: self.state.connectionMode, needsBootstrap: self.needsBootstrap)
} }
private var pageCount: Int { self.pageOrder.count } private var pageCount: Int { self.pageOrder.count }
@ -182,7 +177,7 @@ struct OnboardingView: View {
self.reconcilePageForModeChange(previousActivePageIndex: oldActive) self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
self.updateDiscoveryMonitoring(for: self.activePageIndex) self.updateDiscoveryMonitoring(for: self.activePageIndex)
} }
.onChange(of: self.hasIdentity) { _, _ in .onChange(of: self.needsBootstrap) { _, _ in
if self.currentPage >= self.pageOrder.count { if self.currentPage >= self.pageOrder.count {
self.currentPage = max(0, self.pageOrder.count - 1) self.currentPage = max(0, self.pageOrder.count - 1)
} }
@ -198,7 +193,7 @@ struct OnboardingView: View {
self.loadWorkspaceDefaults() self.loadWorkspaceDefaults()
self.ensureDefaultWorkspace() self.ensureDefaultWorkspace()
self.refreshAnthropicOAuthStatus() self.refreshAnthropicOAuthStatus()
self.loadIdentityDefaults() self.refreshBootstrapStatus()
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID() self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
} }
} }
@ -230,8 +225,6 @@ struct OnboardingView: View {
self.connectionPage() self.connectionPage()
case 2: case 2:
self.anthropicAuthPage() self.anthropicAuthPage()
case 3:
self.identityPage()
case 5: case 5:
self.permissionsPage() self.permissionsPage()
case 6: case 6:
@ -731,99 +724,6 @@ struct OnboardingView: View {
self.anthropicAuthConnected = status.isConnected self.anthropicAuthConnected = status.isConnected
} }
private func identityPage() -> some View {
self.onboardingPage {
Text("Identity")
.font(.largeTitle.weight(.semibold))
Text("Name your agent, pick a vibe, and choose 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("Clawd", text: self.$identityName)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 10) {
Text("Theme")
.font(.headline)
TextField("helpful 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 agents “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 permissionsPage() -> some View { private func permissionsPage() -> some View {
self.onboardingPage { self.onboardingPage {
Text("Grant permissions") Text("Grant permissions")
@ -1372,6 +1272,7 @@ struct OnboardingView: View {
let configured = ClawdisConfigFile.inboundWorkspace() let configured = ClawdisConfigFile.inboundWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured) let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
self.workspacePath = AgentWorkspace.displayPath(for: url) self.workspacePath = AgentWorkspace.displayPath(for: url)
self.refreshBootstrapStatus()
} }
private func ensureDefaultWorkspace() { private func ensureDefaultWorkspace() {
@ -1391,26 +1292,14 @@ struct OnboardingView: View {
case let .unsafe(reason): case let .unsafe(reason):
self.workspaceStatus = "Workspace not touched: \(reason)" self.workspaceStatus = "Workspace not touched: \(reason)"
} }
self.refreshBootstrapStatus()
} }
private func loadIdentityDefaults() { private func refreshBootstrapStatus() {
if let identity = ClawdisConfigFile.loadIdentity() { let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
self.identityName = identity.name self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url)
self.identityTheme = identity.theme if self.needsBootstrap {
self.identityEmoji = identity.emoji self.didAutoKickoff = false
self.hasIdentity = !identity.isEmpty
return
}
self.hasIdentity = false
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityName = "Clawd"
}
if self.identityTheme.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityTheme = "helpful space lobster"
}
if self.identityEmoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityEmoji = AgentIdentityEmoji.suggest(theme: self.identityTheme)
} }
} }
@ -1438,45 +1327,15 @@ struct OnboardingView: View {
_ = try AgentWorkspace.bootstrap(workspaceURL: url) _ = try AgentWorkspace.bootstrap(workspaceURL: url)
self.workspacePath = AgentWorkspace.displayPath(for: url) self.workspacePath = AgentWorkspace.displayPath(for: url)
self.workspaceStatus = "Workspace ready at \(self.workspacePath)" self.workspaceStatus = "Workspace ready at \(self.workspacePath)"
self.refreshBootstrapStatus()
} catch { } catch {
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 func maybeKickoffOnboardingChat(for pageIndex: Int) { private func maybeKickoffOnboardingChat(for pageIndex: Int) {
guard pageIndex == self.onboardingChatPageIndex else { return } guard pageIndex == self.onboardingChatPageIndex else { return }
guard !self.hasIdentity else { return } guard self.needsBootstrap else { return }
guard !self.didAutoKickoff else { return } guard !self.didAutoKickoff else { return }
self.didAutoKickoff = true self.didAutoKickoff = true

View File

@ -1,21 +0,0 @@
import Foundation
import Testing
@testable import Clawdis
@Suite
struct AgentIdentityTests {
@Test
func isEmptyTreatsWhitespaceAsEmpty() {
#expect(AgentIdentity(name: " ", theme: "\n", emoji: "\t").isEmpty == true)
#expect(AgentIdentity(name: "Pi", theme: "", emoji: "").isEmpty == false)
}
@Test
func emojiSuggestMatchesKnownThemes() {
#expect(AgentIdentityEmoji.suggest(theme: "") == "🦞")
#expect(AgentIdentityEmoji.suggest(theme: "shark") == "🦈")
#expect(AgentIdentityEmoji.suggest(theme: " Octopus helper ") == "🐙")
#expect(AgentIdentityEmoji.suggest(theme: "unknown") == "🦞")
}
}

View File

@ -86,7 +86,7 @@ This onboarding chat is where the agent:
- asks how the user wants to talk (web-only / WhatsApp / Telegram) - asks how the user wants to talk (web-only / WhatsApp / Telegram)
- guides linking steps (including showing a QR inline for WhatsApp via the `whatsapp_login` tool) - guides linking steps (including showing a QR inline for WhatsApp via the `whatsapp_login` tool)
If the agent identity already exists in `~/.clawdis/clawdis.json`, the onboarding chat step is skipped. If the workspace bootstrap is already complete (BOOTSTRAP.md removed), the onboarding chat step is skipped.
Once setup is complete, the user can switch to the normal chat (`main`) via the menu bar panel. Once setup is complete, the user can switch to the normal chat (`main`) via the menu bar panel.