From a5b4a01594d86361748fc3922da7cfe8b1ad026e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 21 Dec 2025 01:51:48 +0000 Subject: [PATCH] fix(mac): shrink onboarding + respect existing workspace --- .../Sources/Clawdis/AgentWorkspace.swift | 63 +++++++++++++++++-- apps/macos/Sources/Clawdis/Onboarding.swift | 10 +-- .../ClawdisIPCTests/AgentWorkspaceTests.swift | 36 +++++++++++ 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AgentWorkspace.swift b/apps/macos/Sources/Clawdis/AgentWorkspace.swift index 96018a080..14698881b 100644 --- a/apps/macos/Sources/Clawdis/AgentWorkspace.swift +++ b/apps/macos/Sources/Clawdis/AgentWorkspace.swift @@ -9,6 +9,14 @@ enum AgentWorkspace { static let userFilename = "USER.md" static let bootstrapFilename = "BOOTSTRAP.md" private static let templateDirname = "templates" + private static let ignoredEntries: Set = [".DS_Store", ".git", ".gitignore"] + private static let templateEntries: Set = [ + AgentWorkspace.agentsFilename, + AgentWorkspace.soulFilename, + AgentWorkspace.identityFilename, + AgentWorkspace.userFilename, + AgentWorkspace.bootstrapFilename, + ] enum BootstrapSafety: Equatable { case safe case unsafe(reason: String) @@ -35,6 +43,28 @@ enum AgentWorkspace { workspaceURL.appendingPathComponent(self.agentsFilename) } + static func workspaceEntries(workspaceURL: URL) throws -> [String] { + let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path) + return contents.filter { !self.ignoredEntries.contains($0) } + } + + static func isWorkspaceEmpty(workspaceURL: URL) -> Bool { + let fm = FileManager.default + var isDir: ObjCBool = false + if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { + return true + } + guard isDir.boolValue else { return false } + guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } + return entries.isEmpty + } + + static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool { + guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } + guard !entries.isEmpty else { return true } + return Set(entries).isSubset(of: self.templateEntries) + } + static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety { let fm = FileManager.default var isDir: ObjCBool = false @@ -49,10 +79,8 @@ enum AgentWorkspace { return .safe } do { - let contents = try fm.contentsOfDirectory(atPath: workspaceURL.path) - let ignored: Set = [".DS_Store", ".git", ".gitignore"] - let filtered = contents.filter { !ignored.contains($0) } - return filtered.isEmpty + let entries = try self.workspaceEntries(workspaceURL: workspaceURL) + return entries.isEmpty ? .safe : .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") } catch { @@ -61,6 +89,7 @@ enum AgentWorkspace { } static func bootstrap(workspaceURL: URL) throws -> URL { + let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL) try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) let agentsURL = self.agentsURL(workspaceURL: workspaceURL) if !FileManager.default.fileExists(atPath: agentsURL.path) { @@ -83,7 +112,7 @@ enum AgentWorkspace { self.logger.info("Created USER.md at \(userURL.path, privacy: .public)") } let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) - if !FileManager.default.fileExists(atPath: bootstrapURL.path) { + if shouldSeedBootstrap, !FileManager.default.fileExists(atPath: bootstrapURL.path) { try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8) self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)") } @@ -97,8 +126,30 @@ enum AgentWorkspace { return true } guard isDir.boolValue else { return true } + if self.hasIdentity(workspaceURL: workspaceURL) { + return false + } let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) - return fm.fileExists(atPath: bootstrapURL.path) + guard fm.fileExists(atPath: bootstrapURL.path) else { return false } + return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL) + } + + static func hasIdentity(workspaceURL: URL) -> Bool { + let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) + guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false } + return self.identityLinesHaveValues(contents) + } + + private static func identityLinesHaveValues(_ content: String) -> Bool { + for line in content.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue } + let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return true + } + } + return false } static func defaultTemplate() -> String { diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 77aba90cd..ca6cd97f5 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -24,7 +24,7 @@ final class OnboardingController { let hosting = NSHostingController(rootView: OnboardingView()) let window = NSWindow(contentViewController: hosting) window.title = UIStrings.welcomeTitle - window.setContentSize(NSSize(width: 630, height: 684)) + window.setContentSize(NSSize(width: 630, height: 644)) window.styleMask = [.titled, .closable, .fullSizeContentView] window.titlebarAppearsTransparent = true window.titleVisibility = .hidden @@ -79,7 +79,7 @@ struct OnboardingView: View { private var permissionMonitor: PermissionMonitor private let pageWidth: CGFloat = 630 - private let contentHeight: CGFloat = 520 + private let contentHeight: CGFloat = 420 private let connectionPageIndex = 1 private let anthropicAuthPageIndex = 2 private let onboardingChatPageIndex = 8 @@ -140,9 +140,9 @@ struct OnboardingView: View { var body: some View { VStack(spacing: 0) { - GlowingClawdisIcon(size: 156, glowIntensity: 0.28) - .offset(y: 13) - .frame(height: 181) + GlowingClawdisIcon(size: 130, glowIntensity: 0.28) + .offset(y: 10) + .frame(height: 145) GeometryReader { _ in HStack(spacing: 0) { diff --git a/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift index 507f601b7..b4308163f 100644 --- a/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift @@ -84,4 +84,40 @@ struct AgentWorkspaceTests { #expect(false, "Expected safe bootstrap safety result.") } } + + @Test + func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let marker = tmp.appendingPathComponent("notes.txt") + try "hello".write(to: marker, atomically: true, encoding: .utf8) + + _ = try AgentWorkspace.bootstrap(workspaceURL: tmp) + + let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) + #expect(!FileManager.default.fileExists(atPath: bootstrapURL.path)) + } + + @Test + func needsBootstrapFalseWhenIdentityAlreadySet() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) + try """ + # IDENTITY.md - Agent Identity + + - Name: Clawd + - Creature: Space Lobster + - Vibe: Helpful + - Emoji: lobster + """.write(to: identityURL, atomically: true, encoding: .utf8) + let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) + try "bootstrap".write(to: bootstrapURL, atomically: true, encoding: .utf8) + + #expect(!AgentWorkspace.needsBootstrap(workspaceURL: tmp)) + } }