refactor(macos): remove manual identity onboarding

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

View File

@@ -80,27 +80,4 @@ enum ClawdisConfigFile {
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 monitoringAuth = false
@State private var authMonitorTask: Task<Void, Never>?
@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 hasIdentity = false
@State private var needsBootstrap = false
@State private var didAutoKickoff = false
@State private var showAdvancedConnection = false
@State private var preferredGatewayID: String?
@@ -93,22 +88,22 @@ struct OnboardingView: View {
private let permissionsPageIndex = 5
static func pageOrder(
for mode: AppState.ConnectionMode,
hasIdentity: Bool) -> [Int]
needsBootstrap: Bool) -> [Int]
{
switch mode {
case .remote:
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
// 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:
hasIdentity ? [0, 1, 9] : [0, 1, 8, 9]
needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
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] {
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 }
@@ -182,7 +177,7 @@ struct OnboardingView: View {
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
self.updateDiscoveryMonitoring(for: self.activePageIndex)
}
.onChange(of: self.hasIdentity) { _, _ in
.onChange(of: self.needsBootstrap) { _, _ in
if self.currentPage >= self.pageOrder.count {
self.currentPage = max(0, self.pageOrder.count - 1)
}
@@ -198,7 +193,7 @@ struct OnboardingView: View {
self.loadWorkspaceDefaults()
self.ensureDefaultWorkspace()
self.refreshAnthropicOAuthStatus()
self.loadIdentityDefaults()
self.refreshBootstrapStatus()
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
}
}
@@ -230,8 +225,6 @@ struct OnboardingView: View {
self.connectionPage()
case 2:
self.anthropicAuthPage()
case 3:
self.identityPage()
case 5:
self.permissionsPage()
case 6:
@@ -731,99 +724,6 @@ struct OnboardingView: View {
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 {
self.onboardingPage {
Text("Grant permissions")
@@ -1372,6 +1272,7 @@ struct OnboardingView: View {
let configured = ClawdisConfigFile.inboundWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
self.workspacePath = AgentWorkspace.displayPath(for: url)
self.refreshBootstrapStatus()
}
private func ensureDefaultWorkspace() {
@@ -1391,26 +1292,14 @@ struct OnboardingView: View {
case let .unsafe(reason):
self.workspaceStatus = "Workspace not touched: \(reason)"
}
self.refreshBootstrapStatus()
}
private func loadIdentityDefaults() {
if let identity = ClawdisConfigFile.loadIdentity() {
self.identityName = identity.name
self.identityTheme = identity.theme
self.identityEmoji = identity.emoji
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)
private func refreshBootstrapStatus() {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url)
if self.needsBootstrap {
self.didAutoKickoff = false
}
}
@@ -1438,45 +1327,15 @@ struct OnboardingView: View {
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
self.workspacePath = AgentWorkspace.displayPath(for: url)
self.workspaceStatus = "Workspace ready at \(self.workspacePath)"
self.refreshBootstrapStatus()
} catch {
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) {
guard pageIndex == self.onboardingChatPageIndex else { return }
guard !self.hasIdentity else { return }
guard self.needsBootstrap else { return }
guard !self.didAutoKickoff else { return }
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)
- 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.