fix(mac): guard onboarding workspace bootstrap
This commit is contained in:
@@ -11,6 +11,10 @@ enum AgentWorkspace {
|
|||||||
private static let templateDirname = "templates"
|
private static let templateDirname = "templates"
|
||||||
static let identityStartMarker = "<!-- clawdis:identity:start -->"
|
static let identityStartMarker = "<!-- clawdis:identity:start -->"
|
||||||
static let identityEndMarker = "<!-- clawdis:identity:end -->"
|
static let identityEndMarker = "<!-- clawdis:identity:end -->"
|
||||||
|
enum BootstrapSafety: Equatable {
|
||||||
|
case safe
|
||||||
|
case unsafe(reason: String)
|
||||||
|
}
|
||||||
|
|
||||||
static func displayPath(for url: URL) -> String {
|
static func displayPath(for url: URL) -> String {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
@@ -33,6 +37,31 @@ enum AgentWorkspace {
|
|||||||
workspaceURL.appendingPathComponent(self.agentsFilename)
|
workspaceURL.appendingPathComponent(self.agentsFilename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
|
||||||
|
let fm = FileManager.default
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||||
|
return .safe
|
||||||
|
}
|
||||||
|
if !isDir.boolValue {
|
||||||
|
return .unsafe(reason: "Workspace path points to a file.")
|
||||||
|
}
|
||||||
|
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||||
|
if fm.fileExists(atPath: agentsURL.path) {
|
||||||
|
return .safe
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let contents = try fm.contentsOfDirectory(atPath: workspaceURL.path)
|
||||||
|
let ignored: Set<String> = [".DS_Store", ".git", ".gitignore"]
|
||||||
|
let filtered = contents.filter { !ignored.contains($0) }
|
||||||
|
return filtered.isEmpty
|
||||||
|
? .safe
|
||||||
|
: .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
|
||||||
|
} catch {
|
||||||
|
return .unsafe(reason: "Couldn't inspect the workspace folder.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func bootstrap(workspaceURL: URL) throws -> URL {
|
static func bootstrap(workspaceURL: URL) throws -> URL {
|
||||||
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
||||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||||
|
|||||||
@@ -1378,13 +1378,18 @@ struct OnboardingView: View {
|
|||||||
guard self.state.connectionMode == .local else { return }
|
guard self.state.connectionMode == .local else { return }
|
||||||
let configured = ClawdisConfigFile.inboundWorkspace()
|
let configured = ClawdisConfigFile.inboundWorkspace()
|
||||||
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
||||||
do {
|
switch AgentWorkspace.bootstrapSafety(for: url) {
|
||||||
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
case .safe:
|
||||||
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
do {
|
||||||
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
|
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
||||||
|
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
} catch {
|
case let .unsafe(reason):
|
||||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
self.workspaceStatus = "Workspace not touched: \(reason)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1426,6 +1431,10 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||||
|
if case let .unsafe(reason) = AgentWorkspace.bootstrapSafety(for: url) {
|
||||||
|
self.workspaceStatus = "Workspace not created: \(reason)"
|
||||||
|
return
|
||||||
|
}
|
||||||
_ = 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)"
|
||||||
|
|||||||
@@ -48,4 +48,40 @@ struct AgentWorkspaceTests {
|
|||||||
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||||
#expect(second == agentsURL)
|
#expect(second == agentsURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() 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)
|
||||||
|
|
||||||
|
let result = AgentWorkspace.bootstrapSafety(for: tmp)
|
||||||
|
switch result {
|
||||||
|
case .unsafe:
|
||||||
|
break
|
||||||
|
case .safe:
|
||||||
|
#expect(false, "Expected unsafe bootstrap safety result.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func bootstrapSafetyAllowsExistingAgentsFile() 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 agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
|
||||||
|
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let result = AgentWorkspace.bootstrapSafety(for: tmp)
|
||||||
|
switch result {
|
||||||
|
case .safe:
|
||||||
|
break
|
||||||
|
case .unsafe:
|
||||||
|
#expect(false, "Expected safe bootstrap safety result.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ Once setup is complete, the user can switch to the normal chat (`main`) via the
|
|||||||
|
|
||||||
We no longer collect identity in the onboarding wizard. Instead, the **first agent run** performs a playful bootstrap ritual using files in the workspace:
|
We no longer collect identity in the onboarding wizard. Instead, the **first agent run** performs a playful bootstrap ritual using files in the workspace:
|
||||||
|
|
||||||
- Workspace is created implicitly (default `~/.clawdis/workspace`) when local is selected.
|
- Workspace is created implicitly (default `~/.clawdis/workspace`) when local is selected,
|
||||||
|
but only if the folder is empty or already contains `AGENTS.md`.
|
||||||
- Files are seeded: `AGENTS.md`, `BOOTSTRAP.md`, `IDENTITY.md`, `USER.md`.
|
- Files are seeded: `AGENTS.md`, `BOOTSTRAP.md`, `IDENTITY.md`, `USER.md`.
|
||||||
- `BOOTSTRAP.md` tells the agent to keep it conversational:
|
- `BOOTSTRAP.md` tells the agent to keep it conversational:
|
||||||
- open with a cute hello
|
- open with a cute hello
|
||||||
|
|||||||
Reference in New Issue
Block a user