refactor(macos): remove manual identity onboarding
This commit is contained in:
@@ -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 "🦞"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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 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
|
||||
|
||||
|
||||
@@ -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") == "🦞")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user